All incidents

FarmZAP Fee-Free Arbitrary-Farm Abuse

Share
May 28, 2023 02:11 UTCAttackLoss: 437.12 WBNBPending manual check1 exploit txWindow: Atomic

Root Cause Analysis

FarmZAP Fee-Free Arbitrary-Farm Abuse

1. Incident Overview TL;DR

On BNB Smart Chain block 28593355, transaction 0x098e7394a1733320e0887f0de22b18f5c71ee18d48a0f6d30c76890fb5c85375 let an unprivileged adversary turn FarmZAP at 0x451583B6DA479eAA04366443262848e27706f762 into a fee-free routing inventory for CoinToken at 0xc748673057861a797275CD8A068AbB95A902e8de. The attacker flash-loaned 80,000 WBNB, made FarmZAP buy CoinToken into FarmZAP itself, pulled those tokens out with an attacker-controlled allowance, forced CoinToken's public swapAndLiquify at a chosen moment, unwound back into WBNB, repaid the flash loan, and kept 437.123093519995276290 BNB at the attacker contract.

The root cause is an application bug, not a private-key compromise or privileged path. FarmZAP's public buyTokensAndDepositOnBehalf function trusted an arbitrary caller-supplied IFarm, approved that arbitrary contract over FarmZAP-held assets, and never verified that depositOnBehalf actually transferred custody. CoinToken's fee-exemption for FarmZAP then made those FarmZAP-held assets usable as a tax-free trading route, which turned the approval bug into a deterministic ACT exploit.

2. Key Background

FarmZAP is a public helper that swaps input assets into a farm stake token and then approves and calls the supplied farm contract. At the incident block it was reachable by any unprivileged caller; there was no allowlist restricting which IFarm implementation could be passed to buyTokensAndDepositOnBehalf.

CoinToken is a reflective token that charges transfer fees unless either endpoint is fee-exempt. Its _transfer logic also checks whether the token contract's own balance has reached numTokensSellToAddToLiquidity; once the threshold is met, a non-pair transfer triggers swapAndLiquify before the user transfer finishes.

The validator independently reconfirmed the critical pre-state at block 28593354:

  • CoinToken.isExcludedFromFee(FarmZAP) == true
  • CoinToken.numTokensSellToAddToLiquidity() == 210000000000000000000000
  • CoinToken.balanceOf(CoinToken) == 206103023928631485488355
  • aWBNB.POOL() == 0xd50Cf00b6e600Dd036Ba8eF475677d816d6c4281

That means the token contract balance started just below the public auto-liquidity threshold, and FarmZAP already had the special fee status needed for the exploit.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK category ACT exploit. The broken invariant is that a public zap should never grant unrestricted spend rights over zap-held assets to an arbitrary external contract unless the zap can prove those assets were actually deposited into the intended farm. FarmZAP violated that invariant by using only farm.stakeToken() as authorization, then approving the farm for the full zap-held output balance. Its _approveIfRequired helper further amplified the issue by upgrading allowance to type(uint256).max whenever the current allowance was below the requested amount.

CoinToken supplied the second half of the exploit. Because fees are disabled whenever either side of a transfer is fee-exempt, any FarmZAP-held CoinToken could be moved or sold without CoinToken's normal transfer tax. CoinToken also exposed a public, threshold-based swapAndLiquify path, so the attacker could push the contract balance to the threshold and then trigger the forced sale with a 1-unit transfer. The result was a deterministic sequence: obtain FarmZAP allowance, route CoinToken tax-free, force CoinToken to sell into Pancake liquidity, buy back cheaper tokens, then reuse the same FarmZAP approval flaw on the WBNB leg.

The critical vulnerable components were:

  • FarmZAP buyTokensAndDepositOnBehalf(IFarm,uint256,uint256,address[])
  • FarmZAP _approveIfRequired(address,address,uint256)
  • CoinToken _transfer(address,address,uint256)
  • CoinToken isExcludedFromFee(address) and numTokensSellToAddToLiquidity()

4. Detailed Root Cause Analysis

4.1 Code-Level Breakpoint

The verified FarmZAP source on BscScan shows the exact authorization flaw:

function buyTokensAndDepositOnBehalf(
    IFarm farm,
    uint256 amountIn,
    uint256 amountOutMin,
    address[] calldata path
) external payable returns(uint256) {
    ...
    address tokenOut = path[path.length - 1];
    require(tokenOut == farm.stakeToken(), "Not a stake token");
    ...
    uint256 received = IERC20(tokenOut).balanceOf(address(this));

    _approveIfRequired(tokenOut, address(farm), received);
    farm.depositOnBehalf(received, msg.sender);
}

function _approveIfRequired(
    address token,
    address spender,
    uint256 minAmount
) private {
    if (IERC20(token).allowance(address(this), spender) < minAmount) {
        IERC20(token).approve(spender, type(uint256).max);
    }
}

This logic never authenticates the farm contract beyond farm.stakeToken(), and it never checks whether depositOnBehalf consumed the approved tokens. An attacker can therefore deploy any contract that implements stakeToken() and depositOnBehalf(), return the desired token address from stakeToken(), and make depositOnBehalf() a no-op.

CoinToken's verified source explains why FarmZAP's balance was uniquely valuable:

bool overMinTokenBalance = contractTokenBalance >= numTokensSellToAddToLiquidity;
if (
    overMinTokenBalance &&
    !inSwapAndLiquify &&
    from != uniswapV2Pair &&
    swapAndLiquifyEnabled
) {
    contractTokenBalance = numTokensSellToAddToLiquidity;
    swapAndLiquify(contractTokenBalance);
}

bool takeFee = true;
if(_isExcludedFromFee[from] || _isExcludedFromFee[to]){
    takeFee = false;
}

Because FarmZAP was fee-exempt, any transfer involving FarmZAP bypassed CoinToken fees. Because swapAndLiquify was public and threshold-based, the attacker could deterministically trigger it after topping up the token contract balance.

4.2 Trace-Proven Exploit Mechanics

The seed trace for 0x098e7394a1733320e0887f0de22b18f5c71ee18d48a0f6d30c76890fb5c85375 shows the exploit unfolding inside one transaction:

flashLoan(... [80000000000000000000000], ...)
0x51873a...::executeOperation(...)
0x451583B6...::buyTokensAndDepositOnBehalf{value: 80000000000000000000000}(
  0x51873a..., 80000000000000000000000, 0,
  [WBNB, CoinToken]
)
CoinToken::approve(0x51873a..., 1157920892...639935)
0x51873a...::depositOnBehalf(3529864186667202773252839, 0x51873a...)

This proves the first half of the bug:

  1. The attacker contract received a public flash loan of 80,000 WBNB.
  2. It called FarmZAP as a malicious IFarm with stakeToken() == CoinToken.
  3. FarmZAP bought 3529864186667202773252839 CoinToken into FarmZAP itself.
  4. FarmZAP approved the attacker contract for the full CoinToken balance with a max allowance.
  5. depositOnBehalf returned without consuming the tokens.

The next trace segment shows the fee-free routing and forced CoinToken sale:

CoinToken::transferFrom(FarmZAP, PancakePair, 3525967210595834258741193)
CoinToken::transferFrom(FarmZAP, CoinToken, 3896976071368514511645)
CoinToken::transferFrom(FarmZAP, 0x51873a..., 1)
CoinToken::transferFrom(CoinToken, PancakePair, 105000000000000000000000)
WBNB::withdraw(219470602145517302825)

Those calls show that the attacker:

  1. Pulled CoinToken out of FarmZAP without paying CoinToken tax.
  2. Sold most of it into the Pancake pair.
  3. Sent the exact threshold gap 3896976071368514511645 into CoinToken itself.
  4. Triggered swapAndLiquify with a 1-unit transfer.
  5. Caused CoinToken to sell 105000000000000000000000 raw units into Pancake liquidity at the attacker's chosen moment.

After buying back CoinToken into FarmZAP and refreshing the CoinToken allowance with a dust re-zap, the attacker repeated the same approval abuse on the unwind leg:

0x451583B6...::buyTokensAndDepositOnBehalf(
  0x51873a..., 3607312221474062399420035, 0,
  [CoinToken, WBNB]
)
WBNB::approve(0x51873a..., 1157920892...639935)
0x51873a...::depositOnBehalf(80509123093519995276290, 0x51873a...)
WBNB::transferFrom(FarmZAP, 0x51873a..., 80509123093519995276290)

That second FarmZAP call proves the bug was reusable with a different stakeToken(). FarmZAP sold the recovered CoinToken into WBNB, held the WBNB itself, approved the attacker contract, and again failed to verify custody transfer. The attacker then withdrew 80509123093519995276290 WBNB from FarmZAP, repaid the lending pool 80072000000000000000000 WBNB, and kept the remainder.

4.3 Why the Opportunity Was ACT

The exploit required only public, permissionless components:

  • the lending pool flashLoan entrypoint at 0xd50Cf00b6e600Dd036Ba8eF475677d816d6c4281
  • FarmZAP's public buyTokensAndDepositOnBehalf
  • CoinToken's public transfer and liquidity logic
  • on-chain liquidity in the CoinToken/WBNB pools

No private keys, governance actions, or privileged contracts were required. A fresh attacker contract implementing the small IFarm surface was enough.

5. Adversary Flow Analysis

The adversary cluster consisted of EOA 0xcbc0d0c1049eb011d7c7cfc4ff556d281f0afebb and contract 0x51873a0b615a51115f2cfbc2e24d9db4bfa2e6e2. The EOA sent the exploit transaction and paid gas; the contract executed the on-chain strategy and retained profit.

The end-to-end flow was:

  1. Borrow 80,000 WBNB via public flash loan.
  2. Unwrap to native BNB and use FarmZAP with a malicious IFarm returning CoinToken as the stake token.
  3. Leave the bought CoinToken inside FarmZAP while receiving spend rights over it.
  4. Drain CoinToken from FarmZAP tax-free, short it on Pancake, and push the CoinToken contract balance to the public liquidity threshold.
  5. Trigger swapAndLiquify with a 1-unit transfer, forcing CoinToken to dump inventory into Pancake.
  6. Buy back CoinToken at the distorted price into FarmZAP, refresh the approval with a dust re-zap, and pull the CoinToken back out.
  7. Reuse FarmZAP with the malicious IFarm now returning WBNB as the stake token, causing FarmZAP to sell CoinToken into WBNB and approve the attacker over the WBNB output.
  8. Pull WBNB out of FarmZAP, repay 80,072 WBNB to the lending pool, and retain the residual BNB.

This was a single adversary-crafted transaction, so the seed transaction and the ACT realization transaction are the same transaction.

6. Impact & Losses

The measured immediate profit landed at the attacker contract as native BNB:

  • 437123093519995276290 wei (437.123093519995276290 BNB)

The sender EOA paid 77855880000000000 wei in gas, so the adversary cluster's net balance increase was 437045237639995276290 wei. The value came from the affected CoinToken trading ecosystem and liquidity pools after the forced Pancake price distortion and tax-free round trip through FarmZAP.

The root cause also implies broader exposure while the configuration remains unchanged:

  • any caller can supply an arbitrary IFarm
  • FarmZAP approvals can be reused across output assets
  • fee-exempt operational contracts can be weaponized as public inventory if allowances are granted to arbitrary contracts

7. References

  1. Exploit transaction: 0x098e7394a1733320e0887f0de22b18f5c71ee18d48a0f6d30c76890fb5c85375
  2. Attacker contract: 0x51873a0b615a51115f2cfbc2e24d9db4bfa2e6e2
  3. Sender EOA: 0xcbc0d0c1049eb011d7c7cfc4ff556d281f0afebb
  4. FarmZAP verified source: 0x451583B6DA479eAA04366443262848e27706f762 on BscScan
  5. CoinToken verified source collected in the session artifacts
  6. Seed execution trace for the exploit transaction
  7. Seed balance-diff artifact for the exploit transaction
  8. Validator challenge result in artifacts/validator/root_cause_challenge_result.json