This is a lower bound: only assets with reliable historical USD prices are counted, so the actual loss may be higher.
0x12b3b6b1055b8ad1ae8f60a0b6c79a9825bcb4bcBSC0x162888d39cfb0990699ad1ea252521b2982ad690BSCZS on BSC was drained across two attacker-crafted transactions at blocks 32429592 and 32429594 after an unprivileged adversary deployed a helper contract, primed it with ZS and USDT, then used a public Pancake V3 flash loan to attack the public ZS/USDT Pancake pair at 0x162888d39cfb0990699ad1ea252521b2982ad690. In the exploit transaction 0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e72192, the helper bought almost all pair-held ZS, called the public ZS.destory_pair_amount() hook, then sold the accumulated ZS back into the distorted pool to drain USDT and repay the flash loan.
The root cause is a composition of two deterministic token-logic flaws in ZS at 0x12b3b6b1055b8ad1ae8f60a0b6c79a9825bcb4bc. First, sells add the full sold amount into a global Burnamount counter, but no tokens are escrowed; a later public destory_pair_amount() call burns that amount directly from LP-owned pair inventory and syncs reserves. Second, the token’s isOrLiquidity(2) heuristic treats reserve >= balance as remove-liquidity, so a direct pair.swap output transfer is misclassified during the interval where the pair has sent out ZS but has not yet received callback-delivered USDT. That misclassification disables both the buy fee and the buy-side !to.isContract() guard, allowing a contract-based flash-loan strategy to accumulate ZS and realize the ACT path.
ZS is a custom ERC-20 whose _transfer logic branches on whether a transfer touches the configured Pancake pair, whether the move is inferred to be liquidity add/remove, and whether fees should be taken. The verified source sets pancakePair to the public ZS/USDT Pancake pair and stores a public Burnamount counter.
Pancake V2 pairs send output tokens before they receive callback-delivered input tokens. Any token contract that infers trade type from transient pair balances during that window can misclassify a swap.
Pancake V3 flash liquidity is permissionless. Any unprivileged contract can borrow from the public USDT/USDC pool at 0x4f31fa980a675570939b737ebdde0471a4be40eb as long as it repays principal plus fee in the callback.
The validated ACT pre-state is BSC mainnet immediately before block 32429592, where the public Burnamount was already 267056754175384170415736, the ZS/USDT pair had material reserves, and the flash pool had enough USDT liquidity for the exploit. The auditor state snapshot records:
{
"block_number": 32429591,
"burn_amount": "267056754175384170415736",
"pair_reserve_zs": "196536297966296200282019977",
"pair_reserve_usdt": "22175225551627166211147"
}
This incident is an ATTACK-category ACT exploit caused by broken token-side reserve accounting and a faulty liquidity-classification heuristic. The first flaw is that ZS records sell sizes in Burnamount and later lets any caller destroy that amount from the pair itself via destory_pair_amount(). That breaks the basic invariant that LP-owned reserves should change only through economically matched swaps or genuine liquidity updates. The second flaw is that isOrLiquidity(2) uses reserve >= balance as a remove-liquidity predicate, which becomes true during a direct pair.swap output transfer because the pair has not yet received the callback-delivered USDT input. When that branch fires, ZS disables the buy fee and the buy-side contract-recipient restriction even though the action is a buy. The two flaws compose cleanly: the attacker can use a contract-based flash loan to buy almost all ZS, collapse the remaining ZS reserve to a one-unit stub with the public burn hook, then dump the accumulated ZS back into a severely distorted pool and withdraw essentially all USDT.
The verified ZS source shows the exact breakpoint operations:
if (from == pancakePair) { // buy
require(!to.isContract(), "ERC20: Transfer to contract address is not allowed");
totalFee = amount.mul(buyFee).div(1000);
} else {
require(!from.isContract(), "ERC20: Transfer to contract address is not allowed");
...
Burnamount += amount;
}
function isOrLiquidity(uint8 _type) internal view returns (bool isTrue){
(uint r0,uint256 r1,) = IPancakePair(pancakePair).getReserves();
...
uint bal = IERC20(tokenOther).balanceOf(address(pancakePair));
if(_type == 1) {
isTrue = bal > r;
} else {
isTrue = r >= bal;
}
}
function destory_pair_amount() public {
if (Burnamount > 0){
if(_balances[pancakePair] >= 0){
_burn(pancakePair, Burnamount);
Burnamount = 0;
IPancakePair(pancakePair).sync();
}
}
}
This creates two unsafe properties:
Burnamount straight from the LP pair and force a reserve sync.The validated vulnerable components are the verified ZS _transfer sell and buy branches around lines 1415-1518, isOrLiquidity() at line 1519, and destory_pair_amount() at line 1553.
pair.swap Buy Bypasses ZS Buy ProtectionsDuring the exploit transaction, the helper contract 0xa905ff8853edc498a2acddfdfac4a56c2c599930 called the pair directly rather than routing through a normal router buy path. The trace for transaction 0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e72192 shows the pair transferring ZS to the helper contract before the callback sends USDT back:
0x162888...::swap(196175921061319255699267017, 0, 0xa905FF..., ...)
0x12b3B6...::transfer(0xa905FF..., 196175921061319255699267017)
BEP20USDT::balanceOf(0x162888...) -> 22185786397892010993403
emit Transfer(from: 0x162888..., to: 0xa905FF..., value: 196175921061319255699267017)
0xa905FF...::pancakeCall(...)
BEP20USDT::transfer(0x162888..., 16338194319064227641570788)
That transfer to a contract helper is decisive evidence. ZS normally rejects buys into contracts with require(!to.isContract()), so this transfer can only succeed if the token has already concluded that the action is remove-liquidity and disabled the buy-side guard. The only code path that does that is the isOrLiquidity(2) branch, where r >= bal becomes true because the pair has not yet received its callback-delivered USDT.
The exploit conditions validated from the root-cause artifact and the collected evidence are:
Burnamount must be non-zero; at block 32429591 it was already 267056754175384170415736.ZS.destory_pair_amount() hook after Burnamount is populated.The violated security principles are equally explicit:
Once the helper had reduced the pair to Burnamount + 1 ZS units, it invoked the public burn hook. The exploit trace records the burn and forced sync:
0x12b3B6...::destory_pair_amount()
emit Transfer(from: 0x162888..., to: 0x0000000000000000000000000000000000000000, value: 267056754175384170415736)
0x162888...::sync()
0x12b3B6...::balanceOf(0x162888...) -> 1
BEP20USDT::balanceOf(0x162888...) -> 16360380105462119652564191
emit Sync(: 1, : 16360380105462119652564191)
This is the core invariant violation. LP-owned ZS inventory was destroyed out-of-band by a public token hook rather than through a swap or legitimate liquidity operation.
After the reserve collapse, the helper deposited 1 raw USDT and all accumulated ZS back into the pair, then swapped out almost all USDT:
emit Transfer(from: 0xa905FF..., to: 0x162888..., value: 196266441577596769299234124)
0x162888...::swap(0, 16360380105462119652564190, 0xa905FF..., 0x)
emit Transfer(from: 0x162888..., to: 0xa905FF..., value: 16360380105462119652564190)
...
emit Transfer(from: 0xa905FF..., to: 0x4f31Fa..., value: 16347353350097071778186929)
emit Transfer(from: 0xa905FF..., to: 0x7Ccf451D3c48C8bb747f42F29A0CdE4209FF863e, value: 14026755365047874377260)
The collected balance diff confirms the same result:
22185786397892010993403 -> 2196442977815494639869682754 -> 19626644157759676929923412598808492904090704053225 -> 112835248269138578430485140267553650478743772608169591879108981398395The exploit therefore satisfies the ACT success predicate from root_cause.json: an unprivileged adversary can use public liquidity, public pair interactions, and the public burn hook to end with strong positive USDT profit.
Transaction 0xe2e87090f47c82eed3697297763edfad8e9689d2da7a4325541087d77432f54f at block 32429592 was sent by EOA 0x7ccf451d3c48c8bb747f42f29a0cde4209ff863e and deployed helper contract 0xa905ff8853edc498a2acddfdfac4a56c2c599930.
The priming trace shows the helper:
0.1 BNB into USDT,1 raw USDT and 1e18 raw ZS into the pair, andsync().Representative trace excerpt:
0x16b9a828...::swap(21121692529689564510, 0, 0xa905FF..., 0x)
emit Transfer(... to: 0xa905FF..., value: 21121692529689564510)
...
PancakePair::swap(93321150801560412337223, 0, 0xa905FF..., 0x)
emit Transfer(from: PancakePair, to: 0xa905FF..., value: 90521516277513599967107)
...
BEP20USDT::transfer(PancakePair, 1)
ZS::transfer(PancakePair, 1000000000000000000)
PancakePair::sync()
The seed balance diff for this transaction records the resulting helper balances:
0 -> 105608462648447822540 -> 9052051627751359996710722175225551627166211147 -> 22185786397892010993403196536297966296200282019977 -> 196442977815494639869682754Two blocks later, transaction 0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e72192 borrowed 16339183758217962796788534 raw USDT from the public Pancake V3 flash pool at 0x4f31fa980a675570939b737ebdde0471a4be40eb. The helper then direct-called pair.swap to buy the pair down to Burnamount + 1, which is why the post-swap pair ZS balance was 267056754175384170415737.
At this stage the exploit relies on the misclassification bug. Because the pair had sent ZS out before receiving callback-delivered USDT, the helper contract received a buy transfer that would revert under the normal buy guard absent the misclassification.
After accumulation, the helper called ZS.destory_pair_amount(). This burned exactly 267056754175384170415736 raw ZS from the pair and synced reserves to a one-unit ZS stub. The validated post-burn state in the trace is:
116360380105462119652564191This state is also consistent with the auditor’s state snapshot, which records that by block 32429594 the pair reserve was reduced to 2 raw USDT after the later cash-out and the burn counter had reset to 0.
The helper then:
1 raw USDT to the pair,196266441577596769299234124 raw ZS to the pair,16360380105462119652564190 raw USDT,16347353350097071778186929 raw USDT, and14026755365047874377260 raw USDT profit to the originating EOA.The adversary-related accounts are therefore conclusively identified as:
0x7ccf451d3c48c8bb747f42f29a0cde4209ff863e: funding EOA, deployer, final profit recipient.0xa905ff8853edc498a2acddfdfac4a56c2c599930: helper contract that held working balances, executed the flash loan, called the burn hook, interacted with the pair, and repaid the flash pool.The direct victimized public components are ZS and the ZS/USDT Pancake pair. The measured impact recorded in root_cause.json is:
22185786397892010993401 raw units (decimal = 18)176536237897870570448629 raw units (decimal = 18)The exploit left the pair with only 2 raw USDT units and 196266441577596769299234125 raw ZS units, materially impairing market integrity and liquidity. On the attacker side, the observed EOA received 14026755365047874377260 raw USDT profit while the flash pool received 8169591879108981398395 raw USDT as flash-loan fee. Gas was paid in BNB and is separate from the USDT-denominated profit accounting.
The success predicate is purely profit-based; there is no separate non-monetary oracle. The profit predicate from the root-cause artifact is:
98808492904090704053225112835248269138578430485140267553650478743772608169591879108981398395These values come directly from the seed balance diff for transaction 0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e72192.
0x12b3b6b1055b8ad1ae8f60a0b6c79a9825bcb4bc0x162888d39cfb0990699ad1ea252521b2982ad6900x4f31fa980a675570939b737ebdde0471a4be40eb0xe2e87090f47c82eed3697297763edfad8e9689d2da7a4325541087d77432f54f0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e7219232429591, 32429593, and 32429594