PLNTOKEN transferFrom burn hook drains WETH reserves
Exploit Transactions
0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5Victim Addresses
0x2b818dd5134cd1761decdeaa157683a83d32c849Ethereum0xe0c218e1633a5c76d57ff4f11149f07bfff16aeaEthereumLoss Breakdown
Similar Incidents
SBR reserve desynchronization exploit drains WETH from UniswapV2 pair
37%OMPxContract bonding-curve loop exploit drains ETH reserves
35%SchnoodleV9 reflection allowance bug drains SNOOD/WETH liquidity
35%AnyswapV4Router WETH9 permit misuse drains WETH to ETH
34%WETH Drain via Unprotected 0xfa461e33 Callback on 0x03f9-62c0
34%WBTC Drain via Insecure Router transferFrom Path
34%Root Cause Analysis
PLNTOKEN transferFrom burn hook drains WETH reserves
1. Incident Overview TL;DR
On Ethereum mainnet block 20681143, attacker EOA 0x67404bcd629e920100c594d62f3678340f40d95a used helper contract 0xbe01c53ad466ef011e3f8a67f6e23c34e2e9976c to execute a single transaction on the WETH/PLNTOKEN Uniswap V2 pair 0x2b818dd5134cd1761decdeaa157683a83d32c849. The helper first swapped 0.9 WETH for approximately 2.704 million PLNTOKEN, then called PLNTOKEN.transferFrom(teamWallet, deadAddress, 0) to trigger liqBurnAt() and pair.sync(), burning nearly the entire PLNTOKEN balance from the pool while leaving its WETH reserve effectively unchanged. The helper then swapped the same PLNTOKEN amount back to WETH at the manipulated price and returned 165.893847285453536788 ETH to the attacker, yielding a net profit of 164.99038684235936323 ETH after gas. PLNTOKEN’s transferFrom and _transfer logic allow any caller to invoke transferFrom(teamWallet, deadAddress, 0) after trading is enabled, which in turn executes liqBurnAt() and pair.sync() to burn almost all PLNTOKEN from the Uniswap V2 pair and update reserves. This owner-designed liquidity-burn hook is reachable by arbitrary EOAs using only public information, creating a deterministic ACT opportunity to misprice the pool and extract ETH.
2. Key Background
- PLNTOKEN (0xe0c218e1633a5c76d57ff4f11149f07bfff16aea) is an ERC-20 token on Ethereum with a Uniswap V2 liquidity pool against WETH (0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2) at pair address 0x2b818dd5134cd1761decdeaa157683a83d32c849, using the canonical Uniswap V2 router 0x7a250d5630b4cf539739df2c5dacb4c659f2488d.
- The verified PLNTOKEN Contract.sol shows an owner-configured teamWallet, exemptions from fees for certain addresses, an automatedMarketMakerPair/uniswapV2Pair pointing at the WETH/PLNTOKEN pool, and a minimumTokens parameter set to 100 × 10^9 token units to keep a small PLNTOKEN balance in the pair after liquidity burns.
- Uniswap V2 pairs track token0 and token1 reserves and expect these reserves to change only through swap, mint, and burn operations; manual token transfers to or from the pair followed by sync() effectively reset reserves to match the token balances, so any external token hook that can move large balances out of the pair and call sync() can break constant-product pricing assumptions.
- The incident transaction uses a helper contract that wraps ETH into WETH, interacts with the Uniswap V2 router and PLNTOKEN, and then unwraps WETH back into ETH, but the helper itself is unprivileged and callable by any EOA.
3. Vulnerability Analysis & Root Cause Summary
The core vulnerability is that PLNTOKEN exposes an owner-intended liquidity-burn hook, liqBurnAt(), behind a transferFrom path that any caller can reach with amount = 0 and a fee-exempt sender. Calling transferFrom(teamWallet, deadAddress, 0) therefore allows arbitrary EOAs to burn almost all PLNTOKEN from the Uniswap V2 pair and force a reserve sync, collapsing the PLNTOKEN reserve while leaving WETH untouched and enabling a guaranteed profit cycle via buy-burn-sell. This vulnerability is categorized as an ATTACK, where a permissionless adversary exploits an owner-designed liquidity-burn hook in PLNTOKEN to forcibly rebalance the WETH/PLN Uniswap V2 pool in their favor, then executes a buy-burn-sell cycle within a single transaction to extract ETH. The invariant for the WETH/PLNTOKEN pair is that reserves change only via standard AMM operations (swap/mint/burn) and cannot be driven from a deep liquidity state to the minimumTokens floor without LP share accounting. The breakpoint is the adversary-triggered call to PLNTOKEN.transferFrom(teamWallet, deadAddress, 0), which executes _transfer in the fee-exempt branch, calls liqBurnAt(), transfers liquidityPairBalance - minimumTokens from the pair to deadAddress, and then invokes pair.sync(), collapsing the PLNTOKEN reserve while leaving WETH nearly unchanged.
4. Detailed Root Cause Analysis
The PLNTOKEN contract implements standard ERC-20 functions with additional launch and fee logic, including a hard-coded teamWallet, a deadAddress, and a minimumTokens parameter used to retain a small PLNTOKEN balance in the Uniswap V2 pool after liquidity burns. The internal function liqBurnAt() reads the PLNTOKEN balance of the WETH/PLNTOKEN pair, computes amountToBurn = liquidityPairBalance - minimumTokens, transfers amountToBurn from the pair to deadAddress via _basicTransfer, and then calls UniswapV2Pair.sync() to reset reserves to the new balances. Key parts of PLNTOKEN's implementation (from verified Contract.sol) are:
function liqBurnAt() internal returns (bool) {
uint256 liquidityPairBalance = _balances[uniswapV2Pair];
uint256 amountToBurn = liquidityPairBalance.sub(minimumTokens);
if (amountToBurn > 0) { _basicTransfer(uniswapV2Pair, deadAddress, amountToBurn); }
IUniswapV2Pair pair = IUniswapV2Pair(uniswapV2Pair);
pair.sync();
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
_transfer(sender, recipient, amount);
_approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));
return true;
}
function _transfer(address sender, address recipient, uint256 amount) private returns (bool) {
if (inSwapAndLiquify) { return _basicTransfer(sender, recipient, amount); }
_balances[sender] = _balances[sender].sub(amount, "Insufficient Balance");
uint256 finalAmount = 0;
if (isExcludedFromFee[sender] || isExcludedFromFee[recipient]) {
finalAmount = amount;
if (sender != address(this) && recipient != address(this)) {
if (recipient == address(0xdead)) liqBurnAt();
}
} else {
finalAmount = takeTxFees(sender, recipient, amount);
}
_balances[recipient] = _balances[recipient].add(finalAmount);
emit Transfer(sender, recipient, finalAmount);
return true;
}
Because transferFrom forwards directly to _transfer and then subtracts 'amount' from the allowance, a call with amount = 0 never reverts on the allowance check, even when no prior approval exists. Once tradingActive is enabled and teamWallet is marked fee-exempt, any caller can invoke transferFrom(teamWallet, deadAddress, 0). In that case, amount = 0 so teamWallet's balance is unchanged, the fee-exempt branch is taken, and recipient == deadAddress triggers liqBurnAt(), burning liquidityPairBalance - minimumTokens from the pool and forcing a sync. This behavior is visible in the incident trace: the helper contract calls PLNTOKEN.transferFrom(0x3f5a63B89773986Fd436a65884fcD321DE77B832, 0x000000000000000000000000000000000000dEaD, 0), and the subsequent internal calls move PLNTOKEN from the pair to deadAddress followed by a sync() on the pair.
... ::transferFrom(0x3f5a63B89773986Fd436a65884fcD321DE77B832, 0x000000000000000000000000000000000000dEaD, 0)
├─ internal: _transfer(...) -> liqBurnAt() -> UniswapV2Pair.sync()
After liqBurnAt() executes, the Uniswap V2 pair's PLNTOKEN reserve is reduced to minimumTokens, while its WETH reserve still includes the 0.9 WETH that the helper deposited during the initial buy. This severely distorts the price in favor of selling PLNTOKEN back into WETH.
5. Adversary Flow Analysis
The adversary uses a single transaction with a helper contract to implement a buy-burn-sell loop on the WETH/PLNTOKEN Uniswap V2 pair: buy PLNTOKEN at the honest price, trigger a forced liquidity burn via zero-amount transferFrom(teamWallet, deadAddress, 0) to misprice the pool, then immediately sell the same PLNTOKEN back to WETH at the inflated price and withdraw ETH to the attacker EOA. Adversary-related cluster accounts:
- 0x67404bcd629e920100c594d62f3678340f40d95a (chainid 1), is_eoa=true, is_contract=false: Sender of transaction 0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5, payer of gas, and final recipient of 165.893847285453536788 ETH from the helper contract, with a net ETH balance increase of 164.99038684235936323 in balance_diff.json.
- 0xbe01c53ad466ef011e3f8a67f6e23c34e2e9976c (chainid 1), is_eoa=false, is_contract=true: Called directly by the attacker EOA in the seed transaction; decompiled source shows that it wraps ETH into WETH, interacts with Uniswap V2 router 0x7a250d5630b4cf539739df2c5dacb4c659f2488d and the WETH/PLNTOKEN pair, calls PLNTOKEN.transferFrom(teamWallet, deadAddress, 0), and then unwraps WETH back to ETH and returns it to the EOA.
- 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97 (chainid 1), is_eoa=true, is_contract=false: QuickNode eth_getCode at block 20681143 returns result 0x for this address, proving it has no deployed bytecode, and block 20681143 lists it as block.coinbase; balance_diff.json shows it receiving 0.000218091369997686 ETH from the incident transaction as the miner/validator capture of the extracted value. Victim candidates:
- PLNTOKEN at 0xe0c218e1633a5c76d57ff4f11149f07bfff16aea on chainid 1, is_verified=true
- UniswapV2Pair WETH/PLNTOKEN at 0x2b818dd5134cd1761decdeaa157683a83d32c849 on chainid 1, is_verified=true
- WETH9 at 0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2 on chainid 1, is_verified=true
- UniswapV2Router02 at 0x7a250d5630b4cf539739df2c5dacb4c659f2488d on chainid 1, is_verified=true
- PLNTOKEN team wallet at 0x3f5a63b89773986fd436a65884fcd321de77b832 on chainid 1, is_verified=false
Pre-incident state: PLNTOKEN deployment and pool configuration
By the time of block 20681143, PLNTOKEN has been deployed, the WETH/PLNTOKEN Uniswap V2 pair has been created via launchLPs(), tradingActive has been enabled, teamWallet has been marked fee-exempt, and minimumTokens has been set so that liqBurnAt() can burn nearly all PLNTOKEN from the pair while leaving a small residue. Evidence: PLNTOKEN Contract.sol and its normal-tx history around deployment and launch in etherscan_normal_txs_20670000-20682000.json show owner-level calls that configure uniswapV2Pair, enable trading, and set fee exemptions; no additional priming swaps in blocks 20681140–20681143 modify pool reserves before the adversary transaction.
Stage 1: Attacker acquires PLNTOKEN at honest reserves
- Tx 0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5 on Ethereum (chainid 1), block 20681143, mechanism=swap The helper contract receives 0.9 ETH from the attacker EOA, wraps it into WETH, approves the Uniswap V2 router, and swaps 0.9 WETH for 2,704,383.977316086 PLNTOKEN on the WETH/PLNTOKEN pair at pre-exploit reserves. Evidence: trace.cast.log and the receipt logs for transaction 0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5 show the WETH deposit into the pair and the first Swap event with amount0In = 900000000000000000 and amount1Out = 2704383977316086.
Stage 2: Zero-amount transferFrom triggers liquidity burn and reserve sync
- Tx 0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5 on Ethereum (chainid 1), block 20681143, mechanism=other Within the same transaction, the helper calls PLNTOKEN.transferFrom(0x3f5a63B89773986Fd436a65884fcD321DE77B832, 0x000000000000000000000000000000000000dEaD, 0). PLNTOKEN executes _transfer in the fee-exempt branch for teamWallet, calls liqBurnAt(), transfers liquidityPairBalance - minimumTokens from the WETH/PLNTOKEN pair to deadAddress, and then invokes pair.sync(), which updates the pair’s reserves to reflect a PLNTOKEN balance equal to minimumTokens and a WETH balance still holding the 0.9 WETH deposit. Evidence: Contract.sol shows transferFrom, _transfer, and liqBurnAt implementing this path, and trace.cast.log for the incident transaction shows the call to transferFrom followed by internal calls that move PLNTOKEN from the pair to deadAddress and call sync() on the pair.
Stage 3: Attacker swaps out and realizes ETH profit
- Tx 0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5 on Ethereum (chainid 1), block 20681143, mechanism=swap After the reserves have been distorted by the liquidity burn and sync, the helper swaps the same 2,704,383.977316086 PLNTOKEN back to WETH through the Uniswap V2 router and WETH/PLNTOKEN pair, receiving 165.893847285453536788 WETH, unwraps it to ETH, and transfers 165.893847285453536788 ETH to the attacker EOA in the same transaction. Evidence: The second Swap event in the receipt for transaction 0xcc36283cee837a8a0d4af0357d1957dc561913e44ad293ea9da8acf15d874ed5 shows amount1In = 2704383977316086 and amount0Out = 165893847285453536788, and balance_diff.json records the attacker’s net ETH gain of 164.99038684235936323 after gas.
The adversary's profit predicate is purely monetary:
- Reference asset: ETH
- Adversary address: 0x67404bcd629e920100c594d62f3678340f40d95a
- Value after: 164.99038684235936323 ETH
- Net gain after gas: 164.99038684235936323 ETH
- Fees paid in ETH: 0.003460443094173558
6. Impact & Losses
- Lost 164.99038684235936323 ETH from the WETH/PLNTOKEN pool. The WETH/PLNTOKEN Uniswap V2 pool loses 164.993847285453536788 WETH of underlying, of which 164.99038684235936323 ETH accrues as net profit to the attacker EOA after gas and a small fraction flows to the miner/validator address. PLNTOKEN liquidity in the pool is burned down to the minimumTokens floor and sent to deadAddress, leaving the pool with a tiny PLNTOKEN balance and a large WETH reserve, which severely distorts price and deprives remaining PLNTOKEN holders of deep on-chain liquidity.
7. References
- [1] Seed transaction trace.cast.log: <REDACTED_LOCAL_PATH>
- [2] Seed transaction balance_diff.json: <REDACTED_LOCAL_PATH>
- [3] Seed transaction receipt.json: <REDACTED_LOCAL_PATH>
- [4] PLNTOKEN Contract.sol: <REDACTED_LOCAL_PATH>
- [5] QuickNode blocks 20681140–20681143 priming_candidates_summary.json: <REDACTED_LOCAL_PATH>
- [6] Etherscan v2 contract.getsourcecode for PLNTOKEN: <REDACTED_LOCAL_PATH>
- [7] Etherscan v2 contract.getsourcecode for WETH/PLNTOKEN pair: <REDACTED_LOCAL_PATH>
- [8] Etherscan v2 contract.getsourcecode for WETH9: <REDACTED_LOCAL_PATH>
- [9] Etherscan v2 contract.getsourcecode for UniswapV2Router02: <REDACTED_LOCAL_PATH>
- [10] QuickNode eth_getCode for 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97: <REDACTED_LOCAL_PATH>