Root Cause
NemTokenV2 implemented a fee/recycle mechanism intended to burn a portion of LP sell pressure back into the pool. The transfer hook tracked incoming transfers to the PancakeSwap pair address via a pendingLpSellRecycle counter — but it counted any token transfer to the pair as sell volume, without checking whether an actual swap had settled. The flushLpSellRecycle() function would then burn the tracked amount directly from the pair's token balance and call sync(), reducing the pool's NEM reserve while leaving USDT untouched. Combined with the pair's standard skim() function (which sends excess tokens above reserves to an arbitrary address), this created a loop that progressively siphoned NEM out of the pool.
// NemTokenV2 transfer hook (reconstructed from SlowMist / CertiK analysis)
function _transfer(address from, address to, uint256 amount) internal override {
// BUG: any transfer to the pair increments sell counter
if (to == pancakeswapPair) {
pendingLpSellRecycle += amount; // should only count confirmed swaps
}
super._transfer(from, to, amount);
}
function flushLpSellRecycle() external {
uint256 toRecycle = pendingLpSellRecycle;
pendingLpSellRecycle = 0;
// Burns from the pair, deflating NEM reserves without affecting USDT
_burn(pancakeswapPair, toRecycle);
IPancakePair(pancakeswapPair).sync();
}
The PancakeSwap V2 skim() function sends any token balance above the stored reserves to msg.sender. Because the attacker first transfers tokens into the pair (inflating the pair's actual balance above reserves), skim() returns those tokens to the attacker. The subsequent flush burns from the pair's reserve, moving the K invariant downward.
Attack Steps
| # | Action | Effect |
|---|---|---|
| 1 | Transfer small NEM amount into pair | pendingLpSellRecycle += amount; actual balance > reserves |
| 2 | Call skim() on pair | Excess NEM returned to attacker |
| 3 | Call flushLpSellRecycle() | Burns NEM from pair reserves; sync() updates reserves downward |
| 4 | Repeat steps 1–3 × 42 | NEM reserves progressively depleted |
| 5 | Sell accumulated NEM at favourable USDT rate | Extact USDT from pool |
| 6 | Bridge $821,920 USDT proceeds to Ethereum | Funds leave BNB Chain |
Impact
- ~$821,920 USDT drained from the NEM/USDT PancakeSwap V2 pool on BNB Chain
- NEM token price effectively zeroed; LP fully drained
- Attack detected by both CertiK and SlowMist on October 22, 2026
- Proceeds bridged to Ethereum; no recovery reported
Lessons for Auditors
- Never count AMM deposits as swap events. Token transfer hooks that fire on
to == pairtrigger on both swaps and direct transfers. Use afrom == paircheck (for buys) or the swap callback pattern to distinguish real swaps from manual deposits. skim()is an attack surface when paired with custom burn logic. The combination of an inflatable pair balance (via direct transfer) and a burn-from-pair function creates a guaranteed-profit loop. Any protocol that burns from an AMM pair must protect against this.- Mirror accounting: burning from a pair must update reserves atomically.
_burn(pair, n)followed bysync()is not atomic —skim()can be called in between. Require callers to invokesync()beforeskim()is possible, or use a mutex. - Fee-on-transfer tokens and AMMs require extra care. Protocols that embed recycle/burn mechanics into ERC-20 transfer hooks should be audited for interactions with every AMM integration, not just the primary pair.