StakingRewards withdraw underflow drains all staked Uniswap V2 LP
Exploit Transactions
0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4faVictim Addresses
0xb3fb1d01b07a706736ca175f827e4f56021b85deEthereumLoss Breakdown
Similar Incidents
JokInTheBoxStaking unstake replay bug drains staked JOK repeatedly
36%SorraV2 staking withdraw bug enables repeated SOR reward drain
36%PumpToken removeLiquidityWhenKIncreases Uniswap LP Drain
34%AnyswapV4Router WETH9 permit misuse drains WETH to ETH
32%RewardsHypervisor reentrant deposit mints unbacked vVISR and drains VISR
32%Unauthorized WETH drain via unprotected Uniswap V3 callback
32%Root Cause Analysis
StakingRewards withdraw underflow drains all staked Uniswap V2 LP
1. Incident Overview TL;DR
An adversary-controlled helper contract at 0x2d85f5c295760b0afe0b271b94254a8c58b513c5 drained the full pool of Uniswap V2 LP tokens from the StakingRewards contract 0xb3fb1d01b07a706736ca175f827e4f56021b85de in a single transaction. In Ethereum mainnet transaction 0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa (block 14421984), the helper invoked StakingRewards.withdraw(totalSupply) from a zero recorded stake balance, causing the contract to transfer all 8792873290680252648282 UNI‑V2 LP tokens (0xb1bbeea2da2905e6b0a30203aef55c399c53d042) to the helper.
The root cause is a missing per‑user balance check in StakingRewards._withdraw. The function subtracts amount from both _totalSupply and _balances[user] using unchecked arithmetic and then transfers amount staking tokens to recipient without enforcing amount <= _balances[user]. Calling withdraw(totalSupply) from an address whose _balances[user] is zero underflows that user’s balance and moves the entire recorded stake supply to the attacker-controlled address while leaving the staking contract with no LP tokens.
2. Key Background
StakingRewards (0xb3fb1d01b07a706736ca175f827e4f56021b85de) is a single-sided farming contract that accepts an ERC20 staking token and pays rewards in a separate rewards token. It exposes standard methods including stake, withdraw, exit, and getReward, and maintains internal accounting via _totalSupply and _balances[address] mappings.
The staking token in this incident is a Uniswap V2 LP token with symbol UNI‑V2 at address 0xb1bbeea2da2905e6b0a30203aef55c399c53d042. The LP token follows the standard UniswapV2Pair/UniswapV2ERC20 behavior: balances are tracked in an ERC20 balances mapping, and transfers emit standard Transfer events. A local copy of the Uniswap V2 pair implementation is available in the seed project under artifacts/root_cause/seed/1/0xb1bbeea2da2905e6b0a30203aef55c399c53d042/src/Contract.sol.
The verified StakingRewards implementation (from artifacts/root_cause/data_collector/iter_1/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/source/src/staking/StakingRewards.sol) uses Solidity 0.7.5 without SafeMath for its internal supply and balance bookkeeping:
// SPDX-License-Identifier: MIT
pragma solidity 0.7.5;
contract StakingRewards is IStakingRewards, RewardsDistributionRecipient, ReentrancyGuard, Pausable {
// ...
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
// ...
function withdraw(uint256 amount) override public {
_withdraw(amount, msg.sender, msg.sender);
}
// ...
function _withdraw(uint256 amount, address user, address recipient) internal nonReentrant updateReward(user) {
require(amount != 0, "Cannot withdraw 0");
// not using safe math, because there is no way to overflow if stake tokens not overflow
_totalSupply = _totalSupply - amount;
_balances[user] = _balances[user] - amount;
// not using safe transfer, because we working with trusted tokens
require(stakingToken.transfer(recipient, amount), "token transfer failed");
emit Withdrawn(user, amount);
}
}
This design assumes the caller will only request withdrawals up to their recorded stake. There is no on-chain enforcement of amount <= _balances[user], and subtraction is unchecked, creating a latent underflow vulnerability for any address that can call withdraw.
Two adversary-owned contracts are central to the exploit:
- Orchestrator
0x89767960b76b009416bc7ff4a4b79051eed0a9ee - Helper
0x2d85f5c295760b0afe0b271b94254a8c58b513c5
Both share runtime bytecode that implements origin-locked control. The decompiled orchestrator code (from artifacts/root_cause/data_collector/iter_2/contract/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/decompile/0x89767960b76b009416bc7ff4a4b79051eed0a9ee-decompiled.sol) shows repeated checks tying tx.origin to internal storage and helper-style logic that routes calls into other contracts:
/// Extract from decompiled orchestrator 0x8976...
contract DecompiledContract {
uint256 public constant CALLER_ROLE = 5981...6880;
bytes32 store_a;
// ... multiple functions elided ...
function Unresolved_bcfe82da(uint256 arg0, uint256 arg1, uint256 arg2) public returns (uint256) {
// ...
require(address(store_a ^ 0xe9cf47d8a30dd87b8e7b0e4a0ccd71d6221e0e01ebb45170d96ab33cf07c1cd5) == address(tx.origin));
// ...
}
}
These origin checks indicate that the orchestrator and helper are private tools for EOA 0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0, not part of the victim protocol governance or upgrade path.
3. Vulnerability Analysis & Root Cause Summary
The vulnerability is a missing per-user balance check combined with unchecked arithmetic in StakingRewards._withdraw. The contract allows any caller to invoke withdraw(amount) without verifying that amount is less than or equal to their recorded _balances[msg.sender]. Because Solidity 0.7.5 does not automatically revert on arithmetic underflow, subtracting a larger amount from a smaller balance wraps the result modulo 2^256.
At the chosen pre-state (Ethereum mainnet block 14421983), StakingRewards holds 8792873290680252648282 UNI‑V2 LP tokens, _totalSupply equals that same value, and the adversary helper 0x2d85... has _balances[0x2d85...] = 0. In the exploit transaction at block 14421984, the helper calls withdraw(8792873290680252648282). The internal logic:
- Sets
_totalSupply = _totalSupply - amount = 0 - Sets
_balances[0x2d85...] = 0 - amount = 2^256 - amount - Transfers
amountLP tokens from StakingRewards to the helper
This violates the intended invariant that user balances are non-negative and that _totalSupply and the contract’s LP balance reflect the true staked supply. After the call, the staking contract holds zero LP tokens but still records a large positive balance for the attacker address.
4. Detailed Root Cause Analysis
Intended invariant and breakpoint
The intended safety properties of StakingRewards can be expressed as:
- For every user
u:0 <= _balances[u]and any withdrawal byusatisfiesamount <= _balances[u]. - Globally:
stakingToken.balanceOf(address(this))and_totalSupplyshould match the aggregate of user balances (modulo reward-distribution and minor rounding effects), so the contract always holds enough staking tokens to honor withdrawals.
The concrete breakpoint where this invariant is violated is the unchecked subtraction in _withdraw:
function _withdraw(uint256 amount, address user, address recipient) internal nonReentrant updateReward(user) {
require(amount != 0, "Cannot withdraw 0");
// Breakpoint: no check that amount <= _balances[user]
_totalSupply = _totalSupply - amount;
_balances[user] = _balances[user] - amount;
require(stakingToken.transfer(recipient, amount), "token transfer failed");
emit Withdrawn(user, amount);
}
When user has _balances[user] = 0 and amount is set to the full _totalSupply, the arithmetic operations execute as:
_totalSupply' = 8792873290680252648282 - 8792873290680252648282 = 0_balances[user]' = 0 - 8792873290680252648282 = 2^256 - 8792873290680252648282(modulo underflow)
The subsequent token transfer sends all staked LP tokens to recipient, which in this incident is the adversary helper 0x2d85....
Pre-state evidence
Storage snapshots (from artifacts/root_cause/data_collector/iter_2/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/storage_slots_0xdc0fdf_0xdc0fe0.json) capture the relevant StakingRewards state around the exploit:
{
"rpc_url": "<REDACTED_QUICKNODE_URL>",
"contract": "0xb3fb1d01b07a706736ca175f827e4f56021b85de",
"results": {
"0xdc0fdf": {
"totalSupply_pre_tx": {
"result": "0x0000000000000000000000000000000000000000000001dca9a1373c007e4b5a"
},
"balance_0x2d85..._pre_tx": {
"result": "0x0000000000000000000000000000000000000000000000000000000000000000"
}
},
"0xdc0fe0": {
"totalSupply_post_tx": {
"result": "0x0000000000000000000000000000000000000000000000000000000000000000"
},
"balance_0x2d85..._post_tx": {
"result": "0xfffffffffffffffffffffffffffffffffffffffffffffe23565ec8c3ff81b4a6"
}
}
}
}
Interpreting these values:
totalSupply_pre_tx = 0x1dca9a1373c007e4b5a = 8792873290680252648282balance_0x2d85..._pre_tx = 0totalSupply_post_tx = 0balance_0x2d85..._post_tx = 2^256 - 8792873290680252648282
This exactly matches the underflow pattern expected from calling _withdraw(totalSupply) for a user with zero recorded stake.
On-chain trace and balance diffs
The exploit transaction trace (from artifacts/root_cause/data_collector/iter_1/tx/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/tx_trace.json) shows the call stack:
{
"result": {
"from": "0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0",
"to": "0x89767960b76b009416bc7ff4a4b79051eed0a9ee",
"input": "0x74a97af6...",
"calls": [
{
"from": "0x89767960b76b009416bc7ff4a4b79051eed0a9ee",
"to": "0x2d85f5c295760b0afe0b271b94254a8c58b513c5",
"input": "0x74a97af6...",
"calls": [
{
"from": "0x2d85f5c295760b0afe0b271b94254a8c58b513c5",
"to": "0xb3fb1d01b07a706736ca175f827e4f56021b85de",
"type": "CALL",
"input": "0x2e1a7d4d0000000000000000000000000000000000000000000001dca9a1373c007e4b5a"
}
]
}
]
}
}
Selector 0x2e1a7d4d corresponds to withdraw(uint256). The argument 0x1dca9a1373c007e4b5a equals the pre-state _totalSupply value, so the helper is explicitly calling withdraw(totalSupply).
Token balance diffs (from artifacts/root_cause/seed/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/balance_diff.json) confirm that the LP tokens move from StakingRewards to the helper:
{
"erc20_balance_deltas": [
{
"token": "0xb1bbeea2da2905e6b0a30203aef55c399c53d042",
"holder": "0xb3fb1d01b07a706736ca175f827e4f56021b85de",
"before": "8792873290680252648282",
"after": "0",
"delta": "-8792873290680252648282"
},
{
"token": "0xb1bbeea2da2905e6b0a30203aef55c399c53d042",
"holder": "0x2d85f5c295760b0afe0b271b94254a8c58b513c5",
"before": "0",
"after": "8792873290680252648282",
"delta": "8792873290680252648282"
}
]
}
Combining the trace, storage snapshots, and balance diffs, the exploit path is:
- Helper
0x2d85...callsStakingRewards.withdraw(totalSupply). _withdrawperforms unchecked subtraction on_totalSupplyand_balances[0x2d85...].- The LP tokens are transferred from StakingRewards to the helper.
_totalSupplybecomes zero,_balances[0x2d85...]underflows to a huge value, and the staking contract is left with no LP tokens backing its recorded balances.
ACT exploit conditions
This exploit is an Anyone-Can-Take (ACT) opportunity under the standard adversary model:
- The victim contract is deployed on Ethereum mainnet and exposes a public
withdraw(uint256)function with no access control. _totalSupplyand individual_balances[u]values are fully observable via public view functions or direct storage reads.- At block
14421983, there exist addresses (including the helper) with_balances[u] = 0, while_totalSupplyand the contract’s LP balance are non-zero. - Any unprivileged account can submit a transaction to call
withdraw(_totalSupply)from an address with_balances[u] = 0, reproducing the same underflow and pool drain.
The adversary in this incident chose to realize the opportunity via a two-contract helper/orchestrator structure, but the vulnerability is inherent to the public interface of StakingRewards and does not rely on private roles or whitelists.
5. Adversary Flow Analysis
Adversary-related cluster
The adversary-related accounts are:
- EOA
0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0(unprivileged EOA)- Sends the exploit transaction
0x3347...and the earlier failed call0x85db.... - Deploys both the orchestrator
0x8976...and helper0x2d85...at block14421957.
- Sends the exploit transaction
- Orchestrator contract
0x89767960b76b009416bc7ff4a4b79051eed0a9ee- Receives calls from the EOA in
0x85db...and0x3347.... - Forwards structured calldata into the helper and other contracts.
- Decompilation reveals origin-locked logic and helper-style routing rather than protocol governance behavior.
- Receives calls from the EOA in
- Helper contract
0x2d85f5c295760b0afe0b271b94254a8c58b513c5- Receives the full LP token transfer from StakingRewards in the exploit transaction.
- Has
_balances[0x2d85...] = 0before the exploit and2^256 - amountafter, consistent with being theuserargument to_withdraw.
Victim contracts:
- StakingRewards:
0xb3fb1d01b07a706736ca175f827e4f56021b85de(verified) - UNI‑V2 LP token:
0xb1bbeea2da2905e6b0a30203aef55c399c53d042(verified UniswapV2Pair)
Lifecycle stages
-
Adversary contract deployment
- Transaction
0x681809ebe7a55219ce5df42ac018f708249799a5a305c7ca1ae0b77e4671157e(block14421957)- EOA
0x1751...deploys helper0x2d85....
- EOA
- Transaction
0x0fdd9a2e32b8c36bc4827fb5fe5a2d5bb4bb49919a5c311cb0d5b5d37b98a3b2(block14421957)- EOA
0x1751...deploys orchestrator0x8976....
- EOA
- Evidence:
artifacts/root_cause/data_collector/iter_2/address/1/0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0/txlist_0_to_0xdc0fe0.json.
- Transaction
-
Failed orchestrated call
- Transaction
0x85db310041b359cae8183d3cd9f21bb95e46812aadef4263c0bbfeb36c5b681c(block14421979)- From
0x1751...to orchestrator0x8976.... - Uses method selector
0x74a97af6with structured calldata matching the later exploit transaction. isError = "1"andtxreceipt_status = "0"in the Etherscan-style txlist, indicating the call reverted and no LP tokens moved.
- From
- Evidence:
artifacts/root_cause/data_collector/iter_2/address/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/txlist_0_to_0xdc0fe0.json.
- Transaction
-
Exploit execution
- Transaction
0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa(block14421984)- From EOA
0x1751...to orchestrator0x8976...with method0x74a97af6and the same calldata structure as the reverted call. - Orchestrator forwards a call to helper
0x2d85.... - Helper calls
StakingRewards.withdraw(8792873290680252648282)with selector0x2e1a7d4dand argument equal to the pre-state_totalSupply. - StakingRewards transfers all LP tokens from its balance to
0x2d85..., sets_totalSupplyto0, and underflows_balances[0x2d85...].
- From EOA
- Evidence:
- Trace:
artifacts/root_cause/data_collector/iter_1/tx/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/tx_trace.json - Balance diffs:
artifacts/root_cause/seed/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/balance_diff.json - Storage snapshots:
artifacts/root_cause/data_collector/iter_2/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/storage_slots_0xdc0fdf_0xdc0fe0.json
- Trace:
- Transaction
ACT opportunity and reproducibility
The ACT opportunity at pre-state block 14421983 is:
- Pre-state σ_B: StakingRewards holds
8792873290680252648282UNI‑V2 LP tokens and reports_totalSupplyequal to that amount; helper_balances[0x2d85...] = 0. - Transaction sequence b: A single adversary-crafted mainnet transaction calling
StakingRewards.withdraw(totalSupply)from an address with_balances[user] = 0, either directly or via a helper/orchestrator. - Success predicate: The adversary’s portfolio in the reference asset (UNI‑V2 LP token
0xb1bb...) increases from0to8792873290680252648282units, while the victim contract’s LP balance drops to0.
Any unprivileged adversary can:
- Observe
_totalSupplyand identify an addressuwith_balances[u] = 0. - Craft calldata for
withdraw(_totalSupply)and submit a type-2 transaction to call the victim contract fromu(or from a helper acting on behalf ofu). - Pay gas in ETH to include the transaction under standard Ethereum rules.
Therefore, the exploit is not tied to private keys or whitelisted roles beyond the adversary cluster; it is a publicly reproducible ACT opportunity.
6. Impact & Losses
The immediate on-chain impact is a complete drain of the UNI‑V2 LP tokens staked in StakingRewards:
- StakingRewards
0xb3fb1d01b07a706736ca175f827e4f56021b85deloses all8792873290680252648282UNI‑V2 LP tokens (0xb1bbeea2da2905e6b0a30203aef55c399c53d042) in transaction0x3347.... - Adversary helper
0x2d85f5c295760b0afe0b271b94254a8c58b513c5’s LP balance increases from0to8792873290680252648282.
From the perspective of liquidity providers who staked in this farm:
- The staking contract no longer holds LP tokens to honor withdrawals, even though its internal
_balancesmapping reflects a large positive balance for0x2d85...and non-zero balances for any remaining stakers. - Without external recapitalization or administrative intervention, stakers cannot recover their LP tokens from the farming contract.
The success predicate for the adversary in the chosen reference asset (UNI‑V2 LP token) is:
value_before_in_reference_asset = 0(helper LP balance before tx)value_after_in_reference_asset = 8792873290680252648282fees_paid_in_reference_asset = 0(gas is paid in ETH by the EOA and not in UNI‑V2 units)value_delta_in_reference_asset = 8792873290680252648282
7. References
- StakingRewards contract source (Ethereum mainnet,
0xb3fb1d01b07a706736ca175f827e4f56021b85de):artifacts/root_cause/data_collector/iter_1/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/source/src/staking/StakingRewards.sol - Uniswap V2 LP token implementation (UNI‑V2,
0xb1bbeea2da2905e6b0a30203aef55c399c53d042):artifacts/root_cause/seed/1/0xb1bbeea2da2905e6b0a30203aef55c399c53d042/src/Contract.sol - Exploit transaction metadata, receipt, and trace (
0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa):artifacts/root_cause/data_collector/iter_1/tx/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa - Exploit transaction balance diffs:
artifacts/root_cause/seed/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/balance_diff.json - StakingRewards storage snapshots around the exploit:
artifacts/root_cause/data_collector/iter_2/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/storage_slots_0xdc0fdf_0xdc0fe0.json - Adversary orchestrator decompiled source and ABI (
0x89767960b76b009416bc7ff4a4b79051eed0a9ee):artifacts/root_cause/data_collector/iter_2/contract/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/decompile - Adversary address txlists (EOA and contracts):
artifacts/root_cause/data_collector/iter_2/address/1/0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0/txlist_0_to_0xdc0fe0.jsonandartifacts/root_cause/data_collector/iter_2/address/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/txlist_0_to_0xdc0fe0.json