DEV Community

ohmygod
ohmygod

Posted on

The $679K BCE Burn Exploit: How a Defective Burn Mechanism Drained a PancakeSwap Pool

A technical breakdown of the BCE/USDT liquidity pool exploit on PancakeSwap, March 2026


The 90-Second Version

On March 23, 2026, attackers deployed two malicious contracts on BNB Chain that exploited a flaw in the BCE token's burn mechanism to drain $679,000 from a PancakeSwap BCE/USDT pool. The attack didn't touch PancakeSwap's router — the vulnerability lived entirely inside the token's custom transfer logic.

Root cause: BCE's _transfer() function triggered automatic burns that modified the pool's reserve ratio without going through the AMM's swap path. Attackers manipulated this to create artificial arbitrage, then drained the pool.

Loss: ~$679,000 in USDT
Chain: BNB Chain (BSC)
Protocol: PancakeSwap V2 (BCE/USDT pool)
Flaw class: Defective fee-on-transfer / burn-on-transfer token logic


Why This Matters Beyond BCE

Custom token transfer logic — burns, reflections, taxes, rebases — is the single most common source of AMM pool exploits in 2026. The pattern is always the same:

  1. Token modifies its own supply during transfer()
  2. AMM pool's reserve0/reserve1 ratio shifts without a corresponding swap
  3. Attacker arbitrages the desynchronized price

BCE is a textbook case. Understanding it prevents the next one.


The Attack: Step by Step

Phase 1: Reconnaissance

The attacker identified that BCE's _transfer() function contained an automatic burn mechanism:

// Simplified reconstruction of BCE's vulnerable transfer logic
function _transfer(address from, address to, uint256 amount) internal {
    uint256 burnAmount = amount * burnRate / 100;
    uint256 transferAmount = amount - burnAmount;

    _balances[from] -= amount;
    _balances[to] += transferAmount;
    _totalSupply -= burnAmount;  // ← Supply reduced but pool doesn't know

    // Burns from totalSupply but doesn't call pool.sync()
    emit Transfer(from, to, transferAmount);
    emit Transfer(from, address(0), burnAmount);
}
Enter fullscreen mode Exit fullscreen mode

The critical flaw: when tokens are burned during a transfer involving the liquidity pool, the pool's cached reserves (reserve0, reserve1) become stale. The pool thinks it holds X tokens, but it actually holds X minus the burned amount.

Phase 2: Deploying the Attack Infrastructure

The attacker deployed two contracts:

  • Contract A: Bypassed BCE's per-transaction buy/sell limits by splitting operations into many sub-limit transactions
  • Contract B: Orchestrated the actual drain by triggering burns at precise moments
// Attacker's limit-bypass pattern (reconstructed)
contract LimitBypasser {
    IBEP20 bce = IBEP20(BCE_ADDRESS);
    IRouter router = IRouter(PANCAKE_ROUTER);

    function fragmentedBuy(uint256 totalAmount, uint256 chunks) external {
        uint256 perChunk = totalAmount / chunks;
        for (uint i = 0; i < chunks; i++) {
            // Each chunk stays under the per-tx limit
            router.swapExactTokensForTokens(
                perChunk, 0,
                path, address(this), block.timestamp
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Phase 3: The Burn-Drain Cycle

The attacker executed a repeated cycle:

  1. Buy BCE through Contract A (fragmented to stay under limits)
  2. Transfer BCE between Contract A and Contract B — each transfer triggers burns
  3. Burns reduce total supply but pool reserves stay cached at old values
  4. Pool's getAmountOut() now returns inflated values because it uses stale reserves
  5. Sell BCE back to pool at the inflated rate
  6. Repeat until pool is drained
Cycle 1: Buy 1000 BCE → Transfer (burns 50) → Pool thinks 1000, actually 950
Cycle 2: Buy 1000 BCE → Transfer (burns 50) → Pool thinks 2000, actually 1900
...
Cycle N: Accumulated reserve desync → massive arbitrage → drain
Enter fullscreen mode Exit fullscreen mode

Phase 4: Extraction

After accumulating sufficient reserve desynchronization, the attacker executed a final large swap that extracted $679,000 in USDT from the pool at a price that didn't reflect the actual BCE supply.


The Root Cause: Token Supply Changes Outside AMM Awareness

PancakeSwap (and Uniswap V2 forks) use the constant product formula:

x * y = k
Enter fullscreen mode Exit fullscreen mode

This formula assumes that reserves only change through:

  1. Swaps (which call swap())
  2. Liquidity additions/removals (which call mint()/burn())
  3. Explicit syncs (which call sync())

BCE's burn mechanism violated this assumption. It changed the token balance inside the pool without calling sync(), creating a persistent gap between cached reserves and actual balances.

// How PancakeSwap V2 tracks reserves
function getReserves() public view returns (uint112 _reserve0, uint112 _reserve1) {
    _reserve0 = reserve0;  // Cached value — NOT live balanceOf()
    _reserve1 = reserve1;  // Only updated on swap/mint/burn/sync
}
Enter fullscreen mode Exit fullscreen mode

Every burn widened the gap. Every gap was profit for the attacker.


The Broader Pattern: Fee-on-Transfer Token Attacks in 2026

BCE isn't alone. Here are the token mechanics that create the same class of vulnerability:

  • Burn-on-transfer: Reduces supply without pool sync → BCE ($679K)
  • Reflection tokens: Redistributes to holders including pool → SafeMoon clones
  • Transfer tax: Recipient gets less than amount → Multiple BSC tokens
  • Rebase tokens: Changes all balances simultaneously → Ampleforth-style
  • Max-tx limits: Creates predictable trading patterns → BCE (exploited to fragment)

4 Burn-Safe Patterns for Token Developers

Pattern 1: Exempt Pool Addresses from Burns

The simplest fix — don't burn tokens when the sender or receiver is a known liquidity pool:

mapping(address => bool) public isLiquidityPool;

function _transfer(address from, address to, uint256 amount) internal {
    uint256 burnAmount = 0;

    // Don't burn on pool interactions
    if (!isLiquidityPool[from] && !isLiquidityPool[to]) {
        burnAmount = amount * burnRate / 100;
    }

    uint256 transferAmount = amount - burnAmount;
    _balances[from] -= amount;
    _balances[to] += transferAmount;

    if (burnAmount > 0) {
        _totalSupply -= burnAmount;
        emit Transfer(from, address(0), burnAmount);
    }

    emit Transfer(from, to, transferAmount);
}
Enter fullscreen mode Exit fullscreen mode

Trade-off: Requires maintaining an allowlist of pool addresses.

Pattern 2: Auto-Sync After Burns

If you must burn on pool transfers, force a sync:

function _transfer(address from, address to, uint256 amount) internal {
    uint256 burnAmount = amount * burnRate / 100;
    uint256 transferAmount = amount - burnAmount;

    _balances[from] -= amount;
    _balances[to] += transferAmount;
    _totalSupply -= burnAmount;

    emit Transfer(from, to, transferAmount);
    emit Transfer(from, address(0), burnAmount);

    // Force pool to recognize the balance change
    if (isLiquidityPool[from] || isLiquidityPool[to]) {
        IPancakePair(poolAddress).sync();  // ← Critical
    }
}
Enter fullscreen mode Exit fullscreen mode

Pattern 3: Burn-to-Dead-Address Instead of Supply Reduction

Instead of reducing totalSupply, send burned tokens to a dead address. This keeps balanceOf(pool) accurate:

address constant DEAD = 0x000000000000000000000000000000000000dEaD;

function _transfer(address from, address to, uint256 amount) internal {
    uint256 burnAmount = amount * burnRate / 100;
    uint256 transferAmount = amount - burnAmount;

    _balances[from] -= amount;
    _balances[to] += transferAmount;
    _balances[DEAD] += burnAmount;  // No supply change, no pool desync

    emit Transfer(from, to, transferAmount);
    emit Transfer(from, DEAD, burnAmount);
}
Enter fullscreen mode Exit fullscreen mode

Why it works: The pool's balanceOf() remains consistent with its cached reserves.

Pattern 4: Per-Block Volume Limits

BCE's per-transaction limits were bypassed by splitting into fragments. Use per-block cumulative tracking:

mapping(address => mapping(uint256 => uint256)) private _blockVolume;
uint256 public maxVolumePerBlock;

function _transfer(address from, address to, uint256 amount) internal {
    _blockVolume[from][block.number] += amount;
    require(
        _blockVolume[from][block.number] <= maxVolumePerBlock,
        "Block volume limit exceeded"
    );
    // ... rest of transfer logic
}
Enter fullscreen mode Exit fullscreen mode

Detection: Semgrep Rule for Vulnerable Burn Logic

rules:
  - id: burn-without-pool-sync
    message: >
      Token burns inside _transfer() without calling pool.sync().
      This can desynchronize AMM reserves and enable drain attacks.
    severity: ERROR
    languages: [solidity]
    patterns:
      - pattern: |
          function _transfer(...) {
            ...
            _totalSupply -= $BURN;
            ...
          }
      - pattern-not: |
          function _transfer(...) {
            ...
            $POOL.sync();
            ...
          }
Enter fullscreen mode Exit fullscreen mode

Detection: Foundry Invariant Test

function invariant_poolReservesMatchBalances() public {
    (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
    uint256 actualBalance0 = token0.balanceOf(address(pair));
    uint256 actualBalance1 = token1.balanceOf(address(pair));

    assertLe(uint256(reserve0), actualBalance0, "Reserve0 > actual balance");
    assertLe(uint256(reserve1), actualBalance1, "Reserve1 > actual balance");

    if (actualBalance0 > 0) {
        uint256 desync = (uint256(reserve0) - actualBalance0) * 10000 / actualBalance0;
        assertLe(desync, 10, "Reserve0 desync > 0.1%");
    }
}
Enter fullscreen mode Exit fullscreen mode

Solana Parallel: SPL Token-2022 Transfer Fees

Solana's Token-2022 program includes a transfer fee extension that functions similarly to burn-on-transfer. The withheld fees accumulate in the recipient's token account, creating the same reserve desync risk for AMM pools:

// Anchor: Check for transfer fee extension before trusting amounts
use anchor_spl::token_2022::spl_token_2022::extension::transfer_fee::TransferFeeConfig;

pub fn safe_swap(ctx: Context<Swap>, amount_in: u64) -> Result<()> {
    let mint = &ctx.accounts.token_mint;

    if let Ok(fee_config) = mint.get_extension::<TransferFeeConfig>() {
        let fee = fee_config
            .calculate_epoch_fee(Clock::get()?.epoch, amount_in)
            .ok_or(ErrorCode::FeeCalculationFailed)?;

        let actual_received = amount_in - fee;
        update_reserves(ctx.accounts.pool, actual_received)?;
    }
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Audit Checklist: Token-AMM Interaction Safety

Before deploying any token with custom transfer logic to an AMM:

  • [ ] Burns: Does _transfer() reduce totalSupply? If yes, are pool addresses exempted or is sync() called?
  • [ ] Fees/taxes: Does the recipient receive less than amount? Is the AMM pool aware?
  • [ ] Reflections: Do holder balances change outside of transfers?
  • [ ] Rebases: Does balanceOf(pool) change between blocks without transfers?
  • [ ] Transaction limits: Per-transaction or per-block cumulative? Can they be bypassed by splitting?
  • [ ] Pool sync: After any supply-modifying operation involving a pool, is pair.sync() called?
  • [ ] Invariant tests: Is there a test that reserve ≤ balanceOf(pool) holds after every operation?

Key Takeaway

The BCE exploit reveals a fundamental tension in tokenomics design: custom transfer logic and AMM invariants are often incompatible by default. Every token that modifies balances, burns supply, or redistributes during transfer() is potentially breaking the x*y=k assumption that AMM pools depend on.

The fix isn't to avoid custom token mechanics — it's to ensure that every supply-modifying operation either (a) excludes pool addresses, (b) calls sync() immediately after, or (c) uses dead-address burns that don't affect balanceOf().

$679K is a cheap lesson. The next burn-mechanism exploit targeting a larger pool won't be.


DeFi Security Research — DreamWork Security

Top comments (0)