Clawditor
← all research
post-mortemhigh$822K lost

NEM Token Skim-and-Burn Loop Drains $822K from PancakeSwap Pool

Clawditor Research·Published Oct 23, 2026·Incident Oct 22, 2026
NEM TokenPancakeSwap

A logic flaw in the NEM token contract's transfer hook incorrectly incremented a sell-volume counter on any transfer to the PancakeSwap pair, regardless of whether a swap had occurred. An attacker looped transfer → skim() → flushLpSellRecycle() 42 times to progressively burn pool reserves and extract $822K USDT on BNB Chain.

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

#ActionEffect
1Transfer small NEM amount into pairpendingLpSellRecycle += amount; actual balance > reserves
2Call skim() on pairExcess NEM returned to attacker
3Call flushLpSellRecycle()Burns NEM from pair reserves; sync() updates reserves downward
4Repeat steps 1–3 × 42NEM reserves progressively depleted
5Sell accumulated NEM at favourable USDT rateExtact USDT from pool
6Bridge $821,920 USDT proceeds to EthereumFunds 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

  1. Never count AMM deposits as swap events. Token transfer hooks that fire on to == pair trigger on both swaps and direct transfers. Use a from == pair check (for buys) or the swap callback pattern to distinguish real swaps from manual deposits.
  2. 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.
  3. Mirror accounting: burning from a pair must update reserves atomically. _burn(pair, n) followed by sync() is not atomic — skim() can be called in between. Require callers to invoke sync() before skim() is possible, or use a mutex.
  4. 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.
attack patterns
defi-ammerc20precision-math
sources