Calculated from recorded token losses using historical USD prices at the incident time.
0x098e7394a1733320e0887f0de22b18f5c71ee18d48a0f6d30c76890fb5c853750x451583B6DA479eAA04366443262848e27706f762BSC0xc748673057861a797275CD8A068AbB95A902e8deBSCOn 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.
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) == trueCoinToken.numTokensSellToAddToLiquidity() == 210000000000000000000000CoinToken.balanceOf(CoinToken) == 206103023928631485488355aWBNB.POOL() == 0xd50Cf00b6e600Dd036Ba8eF475677d816d6c4281That 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.
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:
buyTokensAndDepositOnBehalf(IFarm,uint256,uint256,address[])_approveIfRequired(address,address,uint256)_transfer(address,address,uint256)isExcludedFromFee(address) and numTokensSellToAddToLiquidity()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.
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:
80,000 WBNB.IFarm with stakeToken() == CoinToken.3529864186667202773252839 CoinToken into FarmZAP itself.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:
3896976071368514511645 into CoinToken itself.swapAndLiquify with a 1-unit transfer.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.
The exploit required only public, permissionless components:
flashLoan entrypoint at 0xd50Cf00b6e600Dd036Ba8eF475677d816d6c4281buyTokensAndDepositOnBehalfNo private keys, governance actions, or privileged contracts were required. A fresh attacker contract implementing the small IFarm surface was enough.
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:
80,000 WBNB via public flash loan.IFarm returning CoinToken as the stake token.swapAndLiquify with a 1-unit transfer, forcing CoinToken to dump inventory into Pancake.IFarm now returning WBNB as the stake token, causing FarmZAP to sell CoinToken into WBNB and approve the attacker over the WBNB output.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.
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:
IFarm0x098e7394a1733320e0887f0de22b18f5c71ee18d48a0f6d30c76890fb5c853750x51873a0b615a51115f2cfbc2e24d9db4bfa2e6e20xcbc0d0c1049eb011d7c7cfc4ff556d281f0afebb0x451583B6DA479eAA04366443262848e27706f762 on BscScanartifacts/validator/root_cause_challenge_result.json