ZS Pair Burn Drain
Exploit Transactions
Victim Addresses
0x12b3b6b1055b8ad1ae8f60a0b6c79a9825bcb4bcBSC0x162888d39cfb0990699ad1ea252521b2982ad690BSCLoss Breakdown
Similar Incidents
CS Pair Balance Burn Drain
54%APE2 Pair Burn Exploit
46%SafeMoon LP Burn Drain
44%UN Burn-Skim Exploit
44%LinkdaoDex USDT Pair Drain
42%T3913 Pair-Skim Referral Drain
42%Root Cause Analysis
ZS Pair Burn Drain
1. Incident Overview TL;DR
ZS 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.
2. Key Background
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"
}
3. Vulnerability Analysis & Root Cause Summary
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.
4. Detailed Root Cause Analysis
4.1 Code-Level Breakpoints
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:
- Every sell increases a global burn counter, but no matching inventory is isolated for later destruction.
- Any caller can later burn
Burnamountstraight 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.
4.2 Why the Direct pair.swap Buy Bypasses ZS Buy Protections
During 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:
- The public ZS/USDT pair must hold drainable USDT liquidity.
Burnamountmust be non-zero; at block32429591it was already267056754175384170415736.- The attacker must be able to call the public
ZS.destory_pair_amount()hook afterBurnamountis populated. - The attacker must accumulate enough ZS before the burn; the observed path uses a public Pancake V3 flash loan and a direct pair swap that bypasses normal buy protections because of the misclassification bug.
The violated security principles are equally explicit:
- Token logic must not arbitrarily mutate LP-owned AMM reserves.
- Trade-type detection must not rely on transient pre-swap balances that are invalid during pair callbacks.
- Publicly callable hooks must not perform irreversible reserve destruction.
4.3 Public Pair Burn and Reserve Collapse
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.
4.4 Final Cash-Out and Profit Realization
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:
- Pair USDT balance:
22185786397892010993403 -> 2 - Pair ZS balance:
196442977815494639869682754 -> 196266441577596769299234125 - Attacker EOA USDT balance:
98808492904090704053225 -> 112835248269138578430485 - Attacker EOA USDT profit delta:
14026755365047874377260 - Flash-pool USDT fee received:
8169591879108981398395
The 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.
5. Adversary Flow Analysis
5.1 Priming Deployment Transaction
Transaction 0xe2e87090f47c82eed3697297763edfad8e9689d2da7a4325541087d77432f54f at block 32429592 was sent by EOA 0x7ccf451d3c48c8bb747f42f29a0cde4209ff863e and deployed helper contract 0xa905ff8853edc498a2acddfdfac4a56c2c599930.
The priming trace shows the helper:
- swapping
0.1BNB into USDT, - swapping half that USDT into ZS,
- transferring
1raw USDT and1e18raw ZS into the pair, and - calling
sync().
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:
- Helper USDT:
0 -> 10560846264844782254 - Helper ZS:
0 -> 90520516277513599967107 - Pair USDT:
22175225551627166211147 -> 22185786397892010993403 - Pair ZS:
196536297966296200282019977 -> 196442977815494639869682754
5.2 Flash-Loan-Funded Accumulation
Two 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.
5.3 Reserve Burn and Dust-State Pair
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:
- Pair ZS balance:
1 - Pair USDT balance:
16360380105462119652564191
This 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.
5.4 Cash-Out, Repayment, and Profit
The helper then:
- transferred
1raw USDT to the pair, - transferred
196266441577596769299234124raw ZS to the pair, - swapped out
16360380105462119652564190raw USDT, - repaid the flash pool
16347353350097071778186929raw USDT, and - transferred
14026755365047874377260raw 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.
6. Impact & Losses
The direct victimized public components are ZS and the ZS/USDT Pancake pair. The measured impact recorded in root_cause.json is:
- USDT loss from the pair:
22185786397892010993401raw units (decimal = 18) - ZS destroyed from the pair:
176536237897870570448629raw 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:
- attacker EOA USDT before exploit tx:
98808492904090704053225 - attacker EOA USDT after exploit tx:
112835248269138578430485 - attacker EOA USDT delta:
14026755365047874377260 - flash-loan fee paid in USDT:
8169591879108981398395
These values come directly from the seed balance diff for transaction 0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e72192.
7. References
- Verified ZS source: contract
0x12b3b6b1055b8ad1ae8f60a0b6c79a9825bcb4bc - ZS/USDT Pancake pair:
0x162888d39cfb0990699ad1ea252521b2982ad690 - Flash pool:
0x4f31fa980a675570939b737ebdde0471a4be40eb - Priming transaction:
0xe2e87090f47c82eed3697297763edfad8e9689d2da7a4325541087d77432f54f - Exploit transaction:
0xbc88aa6057f9da6f88e28bc908baad111ae7545e69fb0c90fbdfd485c9e72192 - State snapshot at blocks
32429591,32429593, and32429594 - Seed balance diffs for both attacker-crafted transactions
- Seed traces for both attacker-crafted transactions