BOBO Dual-Market Drain
Exploit Transactions
0xfb14292a531411f852993e5a3ba4e7eb63ed548220267b9b3f4aacc5572d3a58Victim Addresses
0x700ee24c350739e323dcf6a50ae3e7a3329c86aeBSC0x7cafdaaa0ba0f471c800dbaca94bdb943311939dBSCLoss Breakdown
Similar Incidents
KEKESANTA Pair-to-Router Double-Credit Liquidity Drain
39%FIREDRAKE Reflection Drain on PancakeSwap
36%StarlinkCoin Pair Drain
36%OceanLife Reflection Drain on PancakeSwap
36%CS Pair Balance Burn Drain
35%BUNN Reflection Drain via PancakePair
35%Root Cause Analysis
BOBO Dual-Market Drain
1. Incident Overview TL;DR
On BSC transaction 0xfb14292a531411f852993e5a3ba4e7eb63ed548220267b9b3f4aacc5572d3a58 at block 34428628, an unprivileged attacker used a public 100 WBNB DODO flash loan to drain nearly all WBNB from the BOBO/WBNB PancakeSwap pair at 0x7cafdaaa0ba0f471c800dbaca94bdb943311939d. The exploit required no privileged role, no private keys, and no private orderflow.
The root cause is BOBO token 0x700ee24c350739e323dcf6a50ae3e7a3329c86ae. Its _transfer logic executes both market branches when both endpoints are market addresses, but it reuses one cached sender balance. Pair-to-router and pair-to-pair transfers therefore violate balance conservation, create inflated BOBO balances inside PancakeSwap flows, and let the attacker swap that fake BOBO input back out for real WBNB.
2. Key Background
The victim-side token marks both the Pancake router 0x10ed43c718714eb63d5aa57b78b54704e256024e and the BOBO/WBNB pair 0x7cafdaaa0ba0f471c800dbaca94bdb943311939d as market addresses. That classification matters because BOBO applies special fee logic whenever either the sender or the recipient is a market address.
PancakePair does not know about BOBO-specific fee semantics. Its swap, burn, and skim functions trust raw token balanceOf(pair) values and plain ERC20 transfers when deciding how much input arrived and how much output can be released. Once BOBO reports inflated balances during pair/router flows, PancakePair treats those balances as genuine inventory.
The attacker helper contract 0x0fe1983b8972630c866fe77ad873a66ec598b685 is only an execution vehicle. The exploitability is public because every primitive it uses is a public function on BOBO, PancakeSwap, WBNB, and the DODO flash-loan pool 0x81917eb96b397dfb1c6000d28a5bc08c0f05fc1d.
3. Vulnerability Analysis & Root Cause Summary
BOBO violates a basic transfer-conservation invariant: sender debit must equal recipient credit plus any explicit fee, and at most one fee path should run for a single transfer. Instead, BOBO caches fromBalance once, then separately executes a buy-side branch when isMarket(from) is true and a sell-side branch when isMarket(to) is true. When the pair sends BOBO to the router, both conditions are true, so the pair is effectively debited once while the router is credited twice. The same structural flaw also affects skim(pair), because a pair-to-pair BOBO transfer still runs market logic and leaves most of the nominally skimmed surplus inside the pair. PancakePair then uses those inflated BOBO balances in swap, burn, and skim accounting, releasing real WBNB against fake BOBO input. The issue is therefore a victim-token accounting bug that is realized through standard AMM balance-based accounting.
Origin: BOBO token _transfer
function _transfer(address from, address to, uint256 amount) internal virtual {
uint256 fromBalance = _balances[from];
require(fromBalance >= amount, "ERC20: transfer amount exceeds balance");
if (isExcludedFromFee[from] || isExcludedFromFee[to]) {
_balances[from] = fromBalance - amount;
_balances[to] += amount;
} else {
if (isMarket(from)){
uint fee = takeAFee(amount, buyFee);
_balances[from] = fromBalance - amount;
_balances[to] += amount - fee;
_balances[marketWallet] += fee;
}
if (isMarket(to)){
uint fee = takeAFee(amount, sellFee);
_balances[from] = fromBalance - amount;
_balances[to] += amount - fee;
_balances[marketWallet] += fee;
}
}
}
Origin: PancakePair accounting path
function burn(address to) external lock returns (uint amount0, uint amount1) {
uint balance0 = IERC20(_token0).balanceOf(address(this));
uint balance1 = IERC20(_token1).balanceOf(address(this));
uint liquidity = balanceOf[address(this)];
amount0 = liquidity.mul(balance0) / _totalSupply;
amount1 = liquidity.mul(balance1) / _totalSupply;
_safeTransfer(_token0, to, amount0);
_safeTransfer(_token1, to, amount1);
}
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
}
4. Detailed Root Cause Analysis
BOBO’s isMarket helper returns true for both the router and the pair. That means any pair-to-router transfer sits in the intersection of the two fee branches, and any pair-to-pair transfer triggered by skim(pair) still counts as a market transfer. Because _transfer reuses the same cached fromBalance, the second branch overwrites the sender debit computed by the first branch instead of applying an additional debit that matches the second credit.
In the incident trace, the attacker first used a public flash loan to buy a small BOBO position, mint a minimal LP position, and then buy almost all remaining BOBO in the pool. That setup gave the attacker LP tokens plus BOBO inventory for the later loop. The exploit loop then repeatedly transferred BOBO into the pair, called skim(pair) to preserve inflated BOBO surplus inside the pair, withdrew large WBNB amounts directly from the pair, and immediately forced a pair-to-router exact-output BOBO transfer that over-credited the router.
The ACT preconditions are narrow but fully public: the BOBO pair and router must remain non-excluded market addresses, the BOBO/WBNB pair must still have public liquidity, and the attacker needs enough temporary working capital to start the cycle. The seed transaction satisfied that capital requirement with a public DODO flash loan. The violated security principles are straightforward: ERC20 balance updates must conserve value across all sender/recipient combinations, mutually exclusive fee paths must actually be exclusive, and AMMs cannot safely integrate tokens whose transfer logic breaks canonical accounting semantics.
The first exact-output call in the trace is:
PancakeRouter::swapTokensForExactTokens(
583351767343425570674136209055,
99770992561631968151,
[WBNB, BOBO],
PancakeRouter,
1702803679
)
That call causes the pair to transfer BOBO to the router. Under correct ERC20 semantics, the router should receive the requested BOBO amount. Under BOBO’s broken logic, the router is credited by both market branches and ends up with more BOBO than requested. The loop then burns a fixed slice of LP tokens through removeLiquidityETHSupportingFeeOnTransferTokens, causing another pair-to-router BOBO transfer in PancakePair::burn, and finally swaps the attacker-held BOBO back to WBNB.
The trace tail shows the sequence closing exactly as the analysis claims: the helper repays 100 WBNB to the public DODO pool, unwraps the residual WBNB, and forwards 3014911194134545218 wei to the sender EOA. Independent RPC balance checks and the receipt establish the attacker-cluster accounting deterministically:
- before the transaction: sender EOA
480798860900000000 wei, helper0 wei, helper WBNB0 - after the transaction: sender EOA
3484590525034545218 wei, helper0 wei, helper WBNB0 - gas paid:
3706510 * 3000000000 = 11119530000000000 wei - net profit after gas:
3003791664134545218 wei
5. Adversary Flow Analysis
- The sender EOA
0xcb733f075ae67a83a9c5f38a0864596e338a0106calls helper contract0x0fe1983b8972630c866fe77ad873a66ec598b685. - The helper borrows
100 WBNBfrom the public DODO flash-loan pool. - The helper buys a dust amount of BOBO, quotes the matching WBNB amount, and mints a minimal LP position.
- The helper spends the remaining borrowed WBNB to buy almost all BOBO from the pair.
- The helper enters a loop:
- sends all held BOBO to the pair,
- calls
skim(pair)to preserve inflated BOBO surplus inside the pair, - calls
swap(0, amountOut, helper, ...)to extract WBNB, - calls
swapTokensForExactTokens(..., to=router)so the pair transfers BOBO to the router and triggers the over-credit bug, - burns
1 LPthroughremoveLiquidityETHSupportingFeeOnTransferTokens, which triggers another pair-to-router BOBO transfer, - swaps the attacker-held BOBO back into WBNB.
- After nine iterations, the pair is left with only
28141250203402503 weiWBNB. - The helper repays the
100 WBNBflash loan, unwraps the remainder, and forwards the final profit to the sender EOA.
Origin: trace tail
WBNB::transfer(0x81917eb96b397dFb1C6000d28A5bc08c0f05fC1d, 100000000000000000000)
WBNB::withdraw(3014818844900880704)
0xcb733F075ae67A83A9C5f38A0864596E338A0106::fallback{value: 3014911194134545218}()
6. Impact & Losses
The directly measured victim-side loss is 3014911194134545218 raw WBNB units (3.014911194134545218 WBNB) from the BOBO/WBNB Pancake pair. The pair’s WBNB balance dropped from 3043052444337947721 wei before the incident to 28141250203402503 wei after the incident. The attacker realized 3003791664134545218 wei net BNB profit after gas, while the gross extracted value plus gas matches the pair’s WBNB loss.
Affected public components:
- BOBO token
0x700ee24c350739e323dcf6a50ae3e7a3329c86ae - BOBO/WBNB Pancake pair
0x7cafdaaa0ba0f471c800dbaca94bdb943311939d - Pancake router
0x10ed43c718714eb63d5aa57b78b54704e256024e
7. References
- Seed exploit transaction:
0xfb14292a531411f852993e5a3ba4e7eb63ed548220267b9b3f4aacc5572d3a58 - BOBO token source, especially
_transferandisMarket - PancakePair source, especially
swap,burn, andskim - Full decoded transaction trace for the incident transaction
- Seed balance-diff artifact showing pair loss and attacker profit
- Adversary reference-asset accounting artifact confirming BNB before/after balances and gas fees