Fee-on-Transfer Token Auto-Liquidity Mechanism Abuse via skim()

Incident Overview

On BSC (BNB Smart Chain) block 81,556,796 (2026-02-16 12:51:23 UTC), an attacker exploited a fee-on-transfer token’s built-in auto-liquidity mechanism to drain value from its PancakeSwap V2 liquidity pair. The vulnerable component is VictimToken (0x02739be625f7a1cb196f42dceee630c394dd9faa), an ERC-20 token whose _transfer() function contains a swapAndLiquify routine. When triggered, this routine swaps a portion of the fee for DebtToken (0xd3c304697f63b279cd314f92c19cdbe5e5b1631a) via the PancakeSwap V2 Router and then calls addLiquidity to deposit both VictimToken and DebtToken into VictimPair (0xe3cba5c0a8efaedce84751af2efddcf071d311a9). The attacker exploited a timing discrepancy between when the fee mechanism deposits DebtToken into the pair’s actual balance and when the pair’s reserves are updated: by repeatedly triggering the fee mechanism and then calling skim() on VictimPair, the attacker extracted surplus DebtToken that accumulated beyond what the reserves tracked. The surplus DebtToken was then converted to approximately 6.84 WBNB (~$4,300 USD) of profit.

Financial Impact

VictimPair (0xe3cba5c0a8efaedce84751af2efddcf071d311a9), a PancakeSwap V2 pair for VictimToken/DebtToken, lost approximately 5.39 million DebtToken to the attacker through the skim mechanism. This surplus DebtToken was converted to ~6.84 WBNB through FlashLoanSourcePair (0x12dabfce08ef59c24cdee6c488e05179fb8d64d9), a WBNB/DebtToken PancakeSwap V2 pair.

Liquidity providers in VictimPair are the direct victims. The attacker’s gas cost was negligible (~0.0036 BNB at 0.13 gwei).

Attack Vector

The exploit is a fee-on-transfer token reserve desynchronization via skim(). VictimToken charges a fee on transfers. During fee settlement, the token contract itself calls the PancakeSwap V2 Router to (1) swap half of the fee for DebtToken via swapExactTokensForTokensSupportingFeeOnTransferTokens, and (2) call addLiquidity to deposit VictimToken + DebtToken into VictimPair. Both the swap and addLiquidity correctly update VictimPair’s reserves via _update(). However, the attacker’s strategy creates a reserve desynchronization by causing the VictimToken fee mechanism to fire during the execution of a transfer that deposits tokens into VictimPair, such that the VictimToken’s fee-triggered addLiquidity deposits additional DebtToken into the pair’s balance after the pair’s swap() call has already read the pre-swap balances but before the post-operation _update() accounts for the fee-injected tokens.

More precisely: each loop iteration has ExploitContract transfer VictimToken to VictimPair. This transfer triggers VictimToken’s _transfer fee handler, which calls the PancakeSwap Router to swap VictimToken for DebtToken and then add liquidity. The addLiquidity call deposits DebtToken into VictimPair and calls mint(), which updates reserves. However, when ExploitContract subsequently calls skim(), any DebtToken surplus between the pair’s balanceOf(DebtToken) and reserve1 is transferred to the caller. The surplus arises because the fee mechanism injects DebtToken via the Router’s swap path, and the accumulated rounding differences and fee-on-transfer deductions across many iterations create a persistent gap.

Vulnerable Contract

  • Address: 0x02739be625f7a1cb196f42dceee630c394dd9faa (VictimToken, unverified, recovered from TAC decompilation)
  • Address: 0xe3cba5c0a8efaedce84751af2efddcf071d311a9 (VictimPair, verified PancakeSwap V2 Pair)

VictimPair is a standard PancakeSwap V2 pair with token0 = VictimToken (0x02739be6) and token1 = DebtToken (0xd3c304). The vulnerability is not in the pair’s code but in VictimToken’s fee mechanism that injects DebtToken into the pair’s raw balance without the pair’s reserves tracking the injection atomically.

Vulnerable Function

VictimToken’s internal _transfer function (TAC private function 0x1139) contains the fee-handling logic that triggers swapAndLiquify. The swapAndLiquify routine is the private subroutine starting at TAC offset 0x2bf4, which calls the PancakeSwap V2 Router at 0x10ed43c718714eb63d5aa57b78b54704e256024e.

The pair’s skim(address to) function is the extraction mechanism:

function skim(address to) external lock {
    address _token0 = token0; // gas savings
    address _token1 = token1; // gas savings
    _safeTransfer(_token0, to, IERC20(_token0).balanceOf(address(this)).sub(reserve0));
    _safeTransfer(_token1, to, IERC20(_token1).balanceOf(address(this)).sub(reserve1));
}

Vulnerable Code Snippet

From the VictimToken TAC decompilation (private function 0x1139 / swapAndLiquify at 0x2bf4), the key behavior is reconstructed as follows. The VictimToken’s _transfer checks whether the sender or recipient is a pair address (via _isPair mapping) and whether the contract is not already in a swap (_inSwapAndLiquify reentrancy guard). When conditions are met, it calls:

  1. PancakeSwapRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(feeAmount, 0, [VictimToken, DebtToken], DISTRIBUTOR, block.timestamp + 300) – TAC offset 0x2c10 (selector 0x5c11d795), with path containing 0xd3c304697f63b279cd314f92c19cdbe5e5b1631a (DebtToken), routed through PancakeSwap Router 0x10ed43c718714eb63d5aa57b78b54704e256024e.

  2. PancakeSwapRouter.addLiquidity(VictimToken, DebtToken, ...) – TAC offset 0x2cfc (selector 0xe8e33700), depositing VictimToken and DebtToken into VictimPair.

The key constant addresses in the recovered VictimToken source:

address constant DISTRIBUTOR = 0xB39Ecc4837E7367C65ba72Fe09147d00BD752e38;
address constant UNISWAP_V2_PAIR = 0xE3cbA5C0A8EfaedCE84751Af2EFDDcF071D311A9;
address constant TOKEN_POOL = 0xd3c304697f63b279dab87f920345238eb0C1cd9b;
address constant UNISWAP_V2_ROUTER = 0x10ED43C718714eb63d5aA57B78B54704E256024E;

From the verified PancakePair source, the swap() function reads balances after all token transfers and callbacks:

function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
    // ...
    if (amount0Out > 0) _safeTransfer(_token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(_token1, to, amount1Out);
    if (data.length > 0) IPancakeCallee(to).pancakeCall(msg.sender, amount0Out, amount1Out, data);
    balance0 = IERC20(_token0).balanceOf(address(this));
    balance1 = IERC20(_token1).balanceOf(address(this));
    // ...
    _update(balance0, balance1, _reserve0, _reserve1);
}

The _update function sets reserves to the current balances:

function _update(uint balance0, uint balance1, uint112 _reserve0, uint112 _reserve1) private {
    // ...
    reserve0 = uint112(balance0);
    reserve1 = uint112(balance1);
    // ...
}

Flaw Description

The root cause is that VictimToken’s fee-on-transfer mechanism acts as a side-channel for injecting DebtToken into VictimPair in a way that creates exploitable reserve-balance gaps.

When ExploitContract transfers VictimToken to VictimPair (to set up the input for a subsequent swap), VictimToken’s _transfer triggers the swapAndLiquify fee handler. This handler swaps VictimToken for DebtToken via the PancakeSwap Router, which internally calls VictimPair.swap(). This swap correctly updates the reserves via _update(). Then the handler calls addLiquidity, which deposits tokens into VictimPair and calls mint(), also correctly updating reserves.

However, the VictimToken _transfer itself also has fee deductions (burn fees, reward pool fees) that reduce the amount of VictimToken actually received by VictimPair. The discrepancy arises from the complex interplay between:

  1. Fee deductions during transfer: VictimToken burns a portion of each transfer (sent to 0x000000000000000000000000000000000000dead) and diverts another portion to the reward pool. The pair receives fewer tokens than what the _transfer caller intended.

  2. SwapAndLiquify injecting DebtToken: The fee mechanism takes a portion of VictimToken, swaps it for DebtToken through the pair’s own swap(), and then adds liquidity. Each addLiquidity call through the Router involves the Router calculating the optimal ratio using the current reserves, but the actual amounts deposited may leave residual DebtToken in the pair’s balance beyond what reserve1 tracks, because the VictimToken’s own fee-on-transfer behavior during the Router’s transferFrom calls deducts additional tokens.

  3. Cumulative surplus: Over 83 iterations, the attacker accumulates a DebtToken surplus in VictimPair. The total DebtToken extracted via skim() was ~105.18 million, while only ~99.79 million was sent to the pair, yielding a net surplus of ~5.39 million DebtToken.

The custom event 0x0350a282... (emitted 160 times by VictimToken) appears to be a notification event emitted during fee processing, confirming the fee mechanism fires on each transfer through the pair.

Call Flow

The transaction is initiated by EOA 0xb180ef1bf6fb3e9a0b5db4460e4db804e946cc8a calling Distributor (0xb94f61855f616057a6dc790c2269a33d1b13a0ed) with selector 0xadcbe376.

  1. Distributor deploys ExploitContract (0x1e7e4e41defde022e78add6f6e406a7520b63c70) via CREATE2. The constructor approves type(uint256).max allowance of DebtToken (0xd3c304) and VictimToken (0x02739be6) to the PancakeSwap V2 Router (0x10ed43c718714eb63d5aa57b78b54704e256024e).

  2. Distributor calls ExploitContract.0x020346cb() (the main exploit entry point).

  3. ExploitContract queries FlashLoanSourcePair (0x12dabfce) reserves via getReserves() (0x0902f1ac). FlashLoanSourcePair is a PancakeSwap V2 pair for WBNB (token0) / DebtToken (token1). ExploitContract computes a flash swap amount of ~99,789,278 DebtToken, approximately (99/100) * reserve1.

  4. ExploitContract calls FlashLoanSourcePair.swap(0, flashLoanAmount, exploitContract, nonEmptyData) (0x022c0d9f). FlashLoanSourcePair sends ~99,789,278 DebtToken to ExploitContract and invokes the standard PancakeSwap callback ExploitContract.pancakeCall(sender, 0, flashLoanAmount, data) (selector 0x84800812).

  5. Inside the pancakeCall callback, ExploitContract executes the main exploitation loop (~83 iterations):

    • Transfers DebtToken to VictimPair (0xe3cba5c0) via transfer() (0xa9059cbb) to provide swap input.
    • Calls VictimPair.swap(amountVictimTokenOut, 0, exploitContract, "") (0x022c0d9f) to receive VictimToken. VictimPair’s swap() updates reserves via _update().
    • Transfers the received VictimToken back to VictimPair via VictimToken.transfer(victimPair, amount) (0xa9059cbb). This transfer triggers VictimToken’s swapAndLiquify fee handler, which:
      • Calls PancakeSwapRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(feeHalf, 0, [VictimToken, DebtToken], DISTRIBUTOR_ADDR, deadline) (0x5c11d795) – selling a portion of VictimToken for DebtToken via VictimPair.
      • Calls PancakeSwapRouter.addLiquidity(VictimToken, DebtToken, ...) (0xe8e33700) – depositing VictimToken + DebtToken into VictimPair, calling mint() which updates reserves.
    • Calls VictimPair.skim(exploitContract) (0xbc25cf77) to extract the DebtToken surplus: balanceOf(DebtToken) - reserve1. The surplus accumulates because the fee mechanism’s complex interactions with the pair leave more DebtToken in the pair’s balance than what reserve1 reflects.
  6. After the loop, ExploitContract holds ~105.18 million DebtToken (from 83 skim operations, 27 of which yielded nonzero amounts). It repays the flash loan by transferring ~100.04 million DebtToken to FlashLoanSourcePair (the original ~99.79M plus the 0.25% PancakeSwap swap fee).

  7. ExploitContract swaps the remaining ~5.14 million DebtToken for WBNB via PancakeSwapRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(balance, 0, [DebtToken, WBNB], exploitContract, deadline) (0x5c11d795), routed through FlashLoanSourcePair. This yields ~6.84 WBNB.

  8. Distributor checks balances of DebtToken, WBNB, VictimToken, and VictimPair LP tokens via balanceOf() calls on the respective contracts. The ~6.84 WBNB remains in ExploitContract for later extraction (ExploitContract has a withdraw() function, selector 0x3ccfd60b, that unwraps WBNB to BNB and selfdestructs, sending funds to 0x242a916ffd95b6174f490bb27f876b3005cc1bd6).

Impact Assessment

AssetLostWho Lost
DebtToken (0xd3c304)~5.39M tokens (converted to ~6.84 WBNB, ~$4,300 USD)Liquidity providers in VictimPair (0xe3cba5c0)

The attack drained DebtToken from VictimPair by exploiting the reserve-balance gap created by VictimToken’s fee mechanism. The protocol solvency of VictimPair is not catastrophically impaired since the extracted amount is a fraction of total liquidity (~105M DebtToken in reserves at the time). However, the vulnerability is repeatable – an attacker can re-run the exploit as long as VictimToken’s auto-liquidity mechanism remains active and the pair has sufficient liquidity.

The FlashLoanSourcePair (0x12dabfce) uses the standard pancakeCall callback (selector 0x84800812 = pancakeCall(address,uint256,uint256,bytes)), confirming it is a standard PancakeSwap V2 pair. It served only as the flash loan source and was not itself exploited.

Evidence

  • VictimPair token ordering: token0 = VictimToken (0x02739be6), token1 = DebtToken (0xd3c304) – confirmed via on-chain token0() / token1() calls.
  • FlashLoanSourcePair token ordering: token0 = WBNB (0xbb4cdb9c), token1 = DebtToken (0xd3c304) – confirmed via on-chain calls.
  • TAC at 0x02739be6.../contract.tac, offset 0x2c10: CONST 0x5c11d795 (selector for swapExactTokensForTokensSupportingFeeOnTransferTokens), called with Router 0x10ed43c718714eb63d5aa57b78b54704e256024e and path including DebtToken 0xd3c304697f63b279cd314f92c19cdbe5e5b1631a.
  • TAC at 0x02739be6.../contract.tac, offset 0x2cfc: CONST 0xe8e33700 (selector for addLiquidity), called with Router 0x10ed43c718714eb63d5aa57b78b54704e256024e.
  • Receipt logs: 131 Swap events, 286 Sync events, and 83 Mint events on VictimPair; 83 DebtToken transfers from VictimPair to ExploitContract (skim outputs), 27 of which are nonzero, totaling ~105.18M DebtToken.
  • Receipt log 0x1088: WBNB Transfer from FlashLoanSourcePair (0x12dabfce) to ExploitContract (0x1e7e4e41), value 0x5eedafa56d348822 = 6.840317 WBNB.
  • Receipt log 0xbd (logIndex): DebtToken Transfer from FlashLoanSourcePair to ExploitContract, value ~99,789,278 DebtToken (flash loan delivery).
  • Selector verification: pancakeCall(address,uint256,uint256,bytes) = 0x84800812, skim(address) = 0xbc25cf77, swap(uint256,uint256,address,bytes) = 0x022c0d9f, swapExactTokensForTokensSupportingFeeOnTransferTokens(uint256,uint256,address[],address,uint256) = 0x5c11d795, addLiquidity(address,address,uint256,uint256,uint256,uint256,address,uint256) = 0xe8e33700.
  • Receipt status 0x1 – transaction succeeded.
  • Gas used: 27,900,996 gas at 0.13 gwei = ~0.0036 BNB.

BSCScan transaction: https://bscscan.com/tx/0x4848bae0fe22f781a94b4613596e7640f70d443db03b6a18fdaffcd30de718d0