This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0x70ca72bb4a1386439a2a51476f2335a31005ebe8BSC0x8ebeb2bf3c6ca7d5c6b345515e368e94eda0ab26BSC0x5a25b8576b14699bbb15947111f5811e58b39a82BSC0x88b3eb62e363d9f153beab49c5c2ef2e785a375aBSCOn BNB Smart Chain, an unprivileged adversary cluster used two public transactions to turn BCT's referral system into a treasury-drain loop. In setup transaction 0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250, the attacker created a five-level inviter chain with dust-sized BCT transfers. In exploit transaction 0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103, the attacker flash-borrowed 20 WBNB, repeatedly sold BCT into BCT's hardcoded BCT/USDT pair, reclaimed the post-fee principal with skim(), harvested treasury-funded referral rewards from payTokenAddress, converted the drained inventory through public pairs, repaid the flash swap, and exited with 10.154043154622408863 WBNB.
The root cause is a protocol bug in BCT. The token recognizes only one AMM pair, the constructor-created BCT/USDT pair, while all other BCT pairs are treated as ordinary transfers. That design sends 50% of non-designated-pair transfers to payTokenAddress, then later pays up to 40% of each sell into the hardcoded pair out of that same treasury via promoteReward, without requiring any irreversible economic loss by the seller. Because direct pair transfers leave excess token balances that can be reclaimed with skim(), the same capital can repeatedly harvest treasury-funded rewards.
BCT is the contract at 0x70ca72bb4a1386439a2a51476f2335a31005ebe8. Its constructor creates exactly one tracked PancakeSwap pair against USDT, stores that address in uniswapV2Pair, and sets the referral treasury to .
payTokenAddress0x8ebeb2bf3c6ca7d5c6b345515e368e94eda0ab26The inviter graph is not access-controlled. bindInviter records relationship state using two exact transfer sizes:
1e15 BCT marks beinvited[to] = from5e14 BCT finalizes inviter[to] = frompromoteReward then walks up to five inviter levels and transfers treasury BCT from payTokenAddress if each inviter holds at least 30 * 10**18 BCT.
The critical market structure is:
0x5a25b8576b14699bbb15947111f5811e58b39a820x88b3eb62e363d9f153beab49c5c2ef2e785a375a0x1b96b92314c44b159149f7e0303511fb2fc4774fBecause BCT only recognizes the USDT pair, trades that involve the WBNB pair are processed by the generic transfer branch and continuously refill payTokenAddress.
The vulnerability class is an application-level economic attack caused by inconsistent pair classification and treasury-funded referral payouts. The constructor binds fee logic to a single USDT pair, but BCT is actively traded in a separate WBNB pair. As a result, BCT/WBNB transfers hit the generic else branch in takeAllFee, which diverts 50% of every such transfer to payTokenAddress. Once that treasury is large, any user can build a five-address inviter chain and qualify each inviter with at least 30 BCT. After qualification, every sell into the hardcoded USDT pair transfers 1% to payTokenAddress, 14% to the token contract, and up to 40% of the nominal sell amount from payTokenAddress to attacker-controlled inviters. The seller does not need to give up the full nominal amount permanently, because direct transfers into the pair leave reclaimable excess balances that can be pulled back with skim(). The exploit therefore converts a reusable trading balance into repeated treasury withdrawals until the attacker can dump the harvested BCT back into public liquidity and exit in WBNB.
The vulnerable components are concentrated in one contract:
Contract.sol lines 1379-1400Contract.sol lines 1473-1505Contract.sol lines 1507-1599The security principles violated are also concrete:
sync()Victim token code excerpt:
address public uniswapV2Pair;
address private _baseToken = 0x55d398326f99059fF775485246999027B3197955;
constructor(TokenWarp _warp) ERC20(_name, _symbol) {
address _uniswapV2Pair =
IUniswapV2Factory(_uniswapV2Router.factory()).createPair(address(this), address(_baseToken));
uniswapV2Pair = _uniswapV2Pair;
payTokenAddress = 0x8eBeb2bf3C6CA7d5C6b345515e368E94Eda0aB26;
}
function bindInviter(address from, address to, uint256 amount) internal {
if (inviter[to] == address(0) && amount == 10**15) beinvited[to] = from;
if (inviter[from] == address(0) && beinvited[from] == to && amount == 5 * 10**14) {
inviter[to] = from;
}
}
function takeAllFee(address from, address to, uint256 amount) private returns (uint256 amountAfter) {
if (to == uniswapV2Pair) {
transferToken(from, payTokenAddress, amount * 1 / 100);
transferToken(from, address(this), amount * 14 / 100);
promoteReward(from, amount);
} else {
transferToken(from, payTokenAddress, amount * 50 / 100);
}
}
That excerpt is sufficient to explain the invariant break: a sell should only pay referral rewards from value actually consumed by the sell, and all live BCT pairs should be handled consistently. BCT violates both conditions.
The verified BCT source fixes uniswapV2Pair to the constructor-created USDT pair. The external BCT/WBNB pair is therefore invisible to the token's pair-specific logic. Whenever BCT moves through BCT/WBNB, _transfer falls through to the generic branch in takeAllFee and moves 50% of the amount into payTokenAddress. The exploit balance diff for tx 0xdae0... shows that this treasury was already heavily funded before the attack and lost 1130499359007577072673681 raw BCT units during the exploit.
The ACT exploit conditions were all public and satisfied on-chain before the exploit:
1e15 / 5e14 transfer patternpayTokenAddress already held enough BCT inventory to fund repeated rewardsThe attacker first sent a tiny setup transaction to the primary attacker contract 0xe9616ff20ad519bce0e3d61353a37232f0c27a50, passing five helper addresses. The setup trace shows the exact 1e15 / 5e14 pattern required by bindInviter:
emit Transfer(from: 0xe9616f..., to: 0xcc1378..., value: 500000000000000)
Token::transfer(0xe9616f..., 500000000000000)
emit Transfer(from: 0xcc1378..., to: 0xe9616f..., value: 250000000000000)
...
emit Transfer(from: 0x64b200..., to: 0x3b132c..., value: 500000000000000)
Token::transfer(0x64b200..., 500000000000000)
emit Transfer(from: 0x3b132c..., to: 0x64b200..., value: 250000000000000)
Those transfers correspond to the attacker-controlled chain:
0xe9616f...0xcc1378...0xf5eb1f...0x23d167...0x64b200...0x3b132c...The setup balance diff confirms that each helper ended the transaction with a small positive BCT balance, and the exploit transaction later tops each helper up above the 30 BCT threshold.
In tx 0xdae0..., the attacker contract flash-borrowed 20 WBNB from the WBNB/BUSD pair. The exploit trace shows pancakeCall, then five buys from the BCT/WBNB pair that each deliver 60 BCT gross to a helper. That satisfies the balanceOf(inviter) >= 30 * 10**18 gate in promoteReward.
Immediately after qualification, the attacker starts the core loop:
skim() on BCT/USDT to reclaim the excess BCT balance that now sits above reserves.promoteReward(from, amount) because to == uniswapV2Pair.payTokenAddress across the five inviter levels.The first drain cycle is explicit in the trace:
PancakePair::skim(0xe9616f...)
Token::transfer(0xe9616f..., 194045828222693844822)
emit Transfer(from: 0x8eBeb2..., to: 0xcc1378..., value: 29106874233404076723)
emit Transfer(from: 0x8eBeb2..., to: 0xf5Eb1F..., value: 19404582822269384482)
emit Transfer(from: 0x8eBeb2..., to: 0x23D167..., value: 9702291411134692241)
emit Transfer(from: 0x8eBeb2..., to: 0x64B200..., value: 9702291411134692241)
emit Transfer(from: 0x8eBeb2..., to: 0x3B132C..., value: 9702291411134692241)
emit Transfer(from: PancakePair: [0x5A25...], to: 0xe9616f..., value: 164938953989289768099)
The exploit trace contains 278 skim() calls, which matches the root-cause claim that the attacker repeatedly recycled reclaimable principal into new treasury-funded rewards.
After materially draining payTokenAddress, the attacker used the remaining USDT and BCT flows to re-enter the BCT/WBNB pair, converted the recovered inventory back into WBNB, repaid the flash swap with fee, and sent the surplus to the profit EOA 0x9c66b0c68c144ffe33e7084fe8ce36ebc44ad21e.
End-of-trace excerpt:
WBNB::transfer(0x1B96B92314C44b159149f7E0303511fB2Fc4774f, 20050000000000000001)
WBNB::balanceOf(0xe9616f...) -> 10154043154622408863
WBNB::transfer(0x9c66B0c68c144Ffe33E7084FE8cE36EBC44aD21e, 10154043154622408863)
That final transfer amount matches the profit figure reported in root_cause.json.
The attack is a clean two-transaction ACT sequence.
0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250
The EOA 0x9c66b0c68c144ffe33e7084fe8ce36ebc44ad21e sends 0.00065 BNB to the attacker contract 0xe9616f..., passing five helper addresses. The contract buys dust BCT, executes the 1e15 / 5e14 transfer handshake five times, and builds the inviter ladder.0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103
The same EOA calls the same attacker contract. The contract flash-borrows 20 WBNB, buys enough BCT for each helper to cross the 30 BCT gate, repeatedly harvests payTokenAddress by selling into the hardcoded BCT/USDT pair and reclaiming principal with skim(), dumps helper balances through the same pair, converts the resulting inventory via BCT/WBNB, repays 20.05 WBNB to the flash pair, and transfers the remaining WBNB to the EOA.The decision points are fully public and permissionless:
No privileged key, whitelist, or private infrastructure is required.
The measurable impacts are:
payTokenAddress lost 1130499359007577072673681 raw BCT units.10154043154622408863 raw WBNB units after flash-loan repayment.The setup transaction cost the attacker 4291523000000000 wei in gas-equivalent native value. The exploit transaction cost 89147673000000000 wei in native value. Those costs do not offset the positive WBNB result shown in the exploit trace and balance diff.
0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 metadata: metadata.json0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 trace: trace.cast.log0xd4c19d575ea5b3a415cc288ce09942299ca3a3b49ef9718cda17e4033dd4c250 balance diff: balance_diff.json0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 metadata: metadata.json0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 trace: trace.cast.log0xdae0b85e01670e6b6b317657a72fb560fc388664cf8bfdd9e1b0ae88e0679103 balance diff: balance_diff.json