All incidents

FPC Pair Drain via LP Self-Debit

Share
Jul 02, 2025 14:25 UTCAttackLoss: 4,673,883.53 USDTPending manual check1 exploit txWindow: Atomic
Estimated Impact
4,673,883.53 USDT
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Jul 02, 2025 14:25 UTC → Jul 02, 2025 14:25 UTC

Exploit Transactions

TX 1BSC
0x3a9dd216fb6314c013fa8c4f85bfbbe0ed0a73209f54c57c1aab02ba989f5937
Jul 02, 2025 14:25 UTCExplorer

Victim Addresses

0xb192d4a737430aa61cea4ce9bfb6432f7d42592fBSC
0xa1e08e10eb09857a8c6f2ef6cca297c1a081ed6bBSC

Loss Breakdown

4,673,883.53USDT

Similar Incidents

Root Cause Analysis

FPC Pair Drain via LP Self-Debit

1. Incident Overview TL;DR

On BNB Chain block 52624701, transaction 0x3a9dd216fb6314c013fa8c4f85bfbbe0ed0a73209f54c57c1aab02ba989f5937 used public flash liquidity and public Pancake swap paths to drain the FPC/USDT pair at 0xa1e08e10eb09857a8c6f2ef6cca297c1a081ed6b. The attacker first acquired an oversized FPC position, then sold through the token's transfer hook so the FPC token contract itself debited pair-held FPC to third parties and called sync() while the swap was still in progress. That reserve rewrite let the attacker pull out nearly the entire USDT side of the pool, repay the flash loan, and keep the remainder as profit.

The root cause is in the FPC token contract at 0xb192d4a737430aa61cea4ce9bfb6432f7d42592f: Token._isLiquidity() misclassifies an in-flight buy as liquidity removal based on transient reserve/balance deltas, and Token.burnLpToken() directly transfers tokens out of the pair and forces sync() during a sell. Those two behaviors break Pancake V2 reserve-accounting assumptions and together make the exploit deterministic.

2. Key Background

FPC is a custom token that wraps AMM-facing transfers in extra buy and sell logic by overriding ERC20 _update(). The collected verified source shows that transfers involving the configured USDT pool are not passive token transfers; they are routed through fee logic, liquidity heuristics, and optional LP burn behavior.

Pancake V2 pairs assume token contracts do not mutate pair inventory behind the pair's back while a swap is being settled. The pair computes swap outputs from observed balances and then updates stored reserves through its own accounting. If the token contract removes assets from the pair and calls sync() mid-flow, the pair's invariant is no longer tied to normal swap semantics.

The FPC token also computes buy-vs-liquidity-removal state by comparing live pair balances against stored reserves during transfer execution. During a swap, those values are intentionally transient. Using them as a liquidity oracle makes ordinary swaps look like LP add/remove events.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is an on-chain accounting bug in the victim token, not a privileged-access issue. In Token._update(), sells into the pair invoke burnLpToken() after calculating fees, and burnLpToken() moves pair-owned FPC to treasury and reward addresses before calling IUniswapV2Pair(usdtPool).sync(). That means a user-triggered sell can arbitrarily rewrite the pair's effective reserves from inside the token contract.

Separately, Token._isLiquidity() decides whether a transfer is adding or removing liquidity by comparing pair balances with stored reserves during the same transaction. On a normal buy, the pair's USDT balance is temporarily below the stored reserve after the pair sends out USDT, so the function flags the transfer as liquidity removal instead of a buy. That bypasses the token's maxBuyRate limit and lets the adversary accumulate far more FPC than the intended 10% cap.

The exploit sequence therefore has two necessary stages. First, the attacker uses the broken _isLiquidity() heuristic to acquire 790178970489172772916652 FPC in one step while the contract emits LiquidityRemoved rather than enforcing the max-buy guard. Second, the attacker sells a large tranche back into the pool; during that sell, burnLpToken() strips FPC from the pair, syncs the manipulated balances, and leaves the pair willing to output 27693883527140201011205321 USDT against only 235069112228082917501404 FPC in.

4. Detailed Root Cause Analysis

The victim-side breakpoint is visible directly in the verified FPC source:

function _update(address from, address to, uint256 value) internal override {
    (bool isAdd,bool isDel) = _isLiquidity(from, to);

    if (isPool[from] || isPool[to]) {
        if (isPool[from] || isDel) {
            super._update(from, to, value);
            if (isDel) {
                emit LiquidityRemoved(to, value);
            } else {
                require(value <= _maxBuyAmount(), "Exceeds max buy amount");
            }
            return;
        }

        if (isPool[to] || isAdd) {
            uint burnPoolAmount = (value * 65) / 100;
            burnLpToken(burnPoolAmount);
        }
    }
    super._update(from, to, value);
}

function _isLiquidity(address from,address to) internal view returns(bool isAdd,bool isDel) {
    (uint reserve0, uint reserve1, ) = pair.getReserves();
    uint balance0 = IERC20(token0).balanceOf(address(pair));
    uint balance1 = IERC20(token1).balanceOf(address(pair));
    if (isPool[from]) {
        if (token0 == address(this) && balance1 < reserve1) {
            isDel = true;
        } else if (token1 == address(this) && balance0 < reserve0) {
            isDel = true;
        }
    }
}

function burnLpToken(uint256 burnAmount) internal {
    uint256 treasuryAmount = (burnAmount * 10) / 65;
    super._update(usdtPool, treasuryAddress, treasuryAmount);
    uint256 rewardAmount = (burnAmount * 55) / 65;
    super._update(usdtPool, rewardPoolAddress, rewardAmount);
    IUniswapV2Pair(usdtPool).sync();
}

In the collected source this behavior appears at lines 119-213 of the verified token contract. The invariant that fails is: pair reserves must only change through the pair's own swap and reserve-update logic, not via arbitrary token-side withdrawals plus forced sync().

The seed trace shows the first half of the exploit during the flash-loan-funded buy:

0x92b7807b...::flash(..., 23020000000000000000000000, ...)
PancakePair::swap(1000000000000000000, 790178970489172772916652, attacker, 0x00)
Token::transfer(attacker, 790178970489172772916652)
emit LiquidityRemoved(to: attacker, amount: 790178970489172772916652)

That event should not occur on a plain buy. Its presence proves _isLiquidity() misread the transient reserve delta and skipped the normal require(value <= _maxBuyAmount(), "Exceeds max buy amount") check.

The second half appears during the sell leg:

PancakePair::getReserves() -> (27693883534817151619771979, 160836761063161996185171, ...)
PancakePair::sync()
emit Sync(reserve0: 27693883534817151619771979, reserve1: 65000000000002)
PancakePair::swap(27693883527140201011205321, 0, attacker, 0x)
emit Transfer(from: PancakePair, to: attacker, value: 27693883527140201011205321)

The reserve on the FPC side collapses from 160836761063161996185171 to 65000000000002 before the draining swap. After that manipulated sync(), the pair outputs essentially all of its USDT. The balance-diff artifact confirms the effect at state level: the pair's USDT balance drops from 4673883534817151619771979 to 7676950608566658, a loss of 4673883527140201011205321.

5. Adversary Flow Analysis

The adversary sequence is a single transaction using only permissionless components.

  1. The entry EOA 0x18dd258631b23777c101440380bf053c79db3d9d triggers attacker contract 0xbf6e706d505e81ad1f73bbc0babfe2b414ba3eb3.
  2. That contract borrows 23020000000000000000000000 USDT from public flash pool 0x92b7807bf19b7dddf89b706143896d05228f3121.
  3. Using that liquidity, it performs a flash-swap-style buy from the FPC/USDT pair and receives 790178970489172772916652 FPC while the token emits LiquidityRemoved, proving the max-buy bypass.
  4. The attacker transfers 247441170766403071054109 FPC to helper contract 0xc2a81942627f6929521397eef6173f271d1fb456, which approves the Pancake router and sells back into the pair.
  5. During that sell, FPC's token hook runs burnLpToken(), transfers pair-owned FPC to treasury and reward destinations, and forces sync() on the manipulated pool balances.
  6. With reserves rewritten, the pair transfers 27693883527140201011205321 USDT to the attacker contract.
  7. The attacker repays the flash loan plus 2302000000000000000000 USDT fee, converts part of the proceeds to BNB, and forwards 4171581527140201011205321 USDT plus 731533618044520680054 BNB to profit EOA 0x421fa2f1fe768d9f7c95be7949bee96d3e3d6fe2.

No privileged role, private key compromise, or private-orderflow assumption is needed. The execution depends only on public contracts and the victim token's broken transfer-hook logic.

6. Impact & Losses

The measurable pool loss is the USDT drained from the FPC/USDT pair:

{
  "holder": "0xa1e08e10eb09857a8c6f2ef6cca297c1a081ed6b",
  "before": "4673883534817151619771979",
  "after": "7676950608566658",
  "delta": "-4673883527140201011205321"
}

The attacker cluster's final realized USDT profit recipient balance is:

{
  "holder": "0x421fa2f1fe768d9f7c95be7949bee96d3e3d6fe2",
  "before": "0",
  "after": "4171581527140201011205321",
  "delta": "4171581527140201011205321"
}

In addition, the same transaction forwards 731533618044520680054 BNB to that recipient after swapping part of the stolen USDT. The pool itself is left with only 7676950608566658 USDT, which is effectively a full drain of the liquid side of the market.

7. References

  1. Seed transaction metadata: 0x3a9dd216fb6314c013fa8c4f85bfbbe0ed0a73209f54c57c1aab02ba989f5937
  2. Seed transaction full trace showing flash borrow, misclassified buy, manipulated sync(), and draining swap
  3. Seed transaction balance diff confirming pair depletion and attacker profit
  4. Verified FPC token source at 0xb192d4a737430aa61cea4ce9bfb6432f7d42592f
  5. Victim pair address 0xa1e08e10eb09857a8c6f2ef6cca297c1a081ed6b