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:
PancakeSwapRouter.swapExactTokensForTokensSupportingFeeOnTransferTokens(feeAmount, 0, [VictimToken, DebtToken], DISTRIBUTOR, block.timestamp + 300)– TAC offset0x2c10(selector0x5c11d795), with path containing0xd3c304697f63b279cd314f92c19cdbe5e5b1631a(DebtToken), routed through PancakeSwap Router0x10ed43c718714eb63d5aa57b78b54704e256024e.PancakeSwapRouter.addLiquidity(VictimToken, DebtToken, ...)– TAC offset0x2cfc(selector0xe8e33700), 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:
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_transfercaller intended.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. EachaddLiquiditycall 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 whatreserve1tracks, because the VictimToken’s own fee-on-transfer behavior during the Router’stransferFromcalls deducts additional tokens.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.
Distributor deploys ExploitContract (
0x1e7e4e41defde022e78add6f6e406a7520b63c70) via CREATE2. The constructor approvestype(uint256).maxallowance of DebtToken (0xd3c304) and VictimToken (0x02739be6) to the PancakeSwap V2 Router (0x10ed43c718714eb63d5aa57b78b54704e256024e).Distributor calls
ExploitContract.0x020346cb()(the main exploit entry point).ExploitContract queries FlashLoanSourcePair (
0x12dabfce) reserves viagetReserves()(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.ExploitContract calls
FlashLoanSourcePair.swap(0, flashLoanAmount, exploitContract, nonEmptyData)(0x022c0d9f). FlashLoanSourcePair sends ~99,789,278 DebtToken to ExploitContract and invokes the standard PancakeSwap callbackExploitContract.pancakeCall(sender, 0, flashLoanAmount, data)(selector0x84800812).Inside the
pancakeCallcallback, ExploitContract executes the main exploitation loop (~83 iterations):- Transfers DebtToken to VictimPair (
0xe3cba5c0) viatransfer()(0xa9059cbb) to provide swap input. - Calls
VictimPair.swap(amountVictimTokenOut, 0, exploitContract, "")(0x022c0d9f) to receive VictimToken. VictimPair’sswap()updates reserves via_update(). - Transfers the received VictimToken back to VictimPair via
VictimToken.transfer(victimPair, amount)(0xa9059cbb). This transfer triggers VictimToken’sswapAndLiquifyfee 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, callingmint()which updates reserves.
- Calls
- 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 whatreserve1reflects.
- Transfers DebtToken to VictimPair (
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).
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.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 awithdraw()function, selector0x3ccfd60b, that unwraps WBNB to BNB and selfdestructs, sending funds to0x242a916ffd95b6174f490bb27f876b3005cc1bd6).
Impact Assessment
| Asset | Lost | Who 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-chaintoken0()/token1()calls. - FlashLoanSourcePair token ordering:
token0= WBNB (0xbb4cdb9c),token1= DebtToken (0xd3c304) – confirmed via on-chain calls. - TAC at
0x02739be6.../contract.tac, offset0x2c10:CONST 0x5c11d795(selector forswapExactTokensForTokensSupportingFeeOnTransferTokens), called with Router0x10ed43c718714eb63d5aa57b78b54704e256024eand path including DebtToken0xd3c304697f63b279cd314f92c19cdbe5e5b1631a. - TAC at
0x02739be6.../contract.tac, offset0x2cfc:CONST 0xe8e33700(selector foraddLiquidity), called with Router0x10ed43c718714eb63d5aa57b78b54704e256024e. - 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), value0x5eedafa56d348822= 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.
Related URLs:
BSCScan transaction: https://bscscan.com/tx/0x4848bae0fe22f781a94b4613596e7640f70d443db03b6a18fdaffcd30de718d0