We do not have a reliable USD price for the recorded assets yet.
0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa0xb3fb1d01b07a706736ca175f827e4f56021b85deEthereumAn 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.
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:
0x89767960b76b009416bc7ff4a4b79051eed0a9ee0x2d85f5c295760b0afe0b271b94254a8c58b513c5Both 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.
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:
_totalSupply = _totalSupply - amount = 0_balances[0x2d85...] = 0 - amount = 2^256 - amountamount LP tokens from StakingRewards to the helperThis 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.
The intended safety properties of StakingRewards can be expressed as:
u: 0 <= _balances[u] and any withdrawal by u satisfies amount <= _balances[u].stakingToken.balanceOf(address(this)) and _totalSupply should 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....
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 - 8792873290680252648282This exactly matches the underflow pattern expected from calling _withdraw(totalSupply) for a user with zero recorded stake.
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:
0x2d85... calls StakingRewards.withdraw(totalSupply)._withdraw performs unchecked subtraction on _totalSupply and _balances[0x2d85...]._totalSupply becomes zero, _balances[0x2d85...] underflows to a huge value, and the staking contract is left with no LP tokens backing its recorded balances.This exploit is an Anyone-Can-Take (ACT) opportunity under the standard adversary model:
withdraw(uint256) function with no access control._totalSupply and individual _balances[u] values are fully observable via public view functions or direct storage reads.14421983, there exist addresses (including the helper) with _balances[u] = 0, while _totalSupply and the contract’s LP balance are non-zero.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.
The adversary-related accounts are:
0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0 (unprivileged EOA)
0x3347... and the earlier failed call 0x85db....0x8976... and helper 0x2d85... at block 14421957.0x89767960b76b009416bc7ff4a4b79051eed0a9ee
0x85db... and 0x3347....0x2d85f5c295760b0afe0b271b94254a8c58b513c5
_balances[0x2d85...] = 0 before the exploit and 2^256 - amount after, consistent with being the user argument to _withdraw.Victim contracts:
0xb3fb1d01b07a706736ca175f827e4f56021b85de (verified)0xb1bbeea2da2905e6b0a30203aef55c399c53d042 (verified UniswapV2Pair)Adversary contract deployment
0x681809ebe7a55219ce5df42ac018f708249799a5a305c7ca1ae0b77e4671157e (block 14421957)
0x1751... deploys helper 0x2d85....0x0fdd9a2e32b8c36bc4827fb5fe5a2d5bb4bb49919a5c311cb0d5b5d37b98a3b2 (block 14421957)
0x1751... deploys orchestrator 0x8976....artifacts/root_cause/data_collector/iter_2/address/1/0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0/txlist_0_to_0xdc0fe0.json.Failed orchestrated call
0x85db310041b359cae8183d3cd9f21bb95e46812aadef4263c0bbfeb36c5b681c (block 14421979)
0x1751... to orchestrator 0x8976....0x74a97af6 with structured calldata matching the later exploit transaction.isError = "1" and txreceipt_status = "0" in the Etherscan-style txlist, indicating the call reverted and no LP tokens moved.artifacts/root_cause/data_collector/iter_2/address/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/txlist_0_to_0xdc0fe0.json.Exploit execution
0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa (block 14421984)
0x1751... to orchestrator 0x8976... with method 0x74a97af6 and the same calldata structure as the reverted call.0x2d85....StakingRewards.withdraw(8792873290680252648282) with selector 0x2e1a7d4d and argument equal to the pre-state _totalSupply.0x2d85..., sets _totalSupply to 0, and underflows _balances[0x2d85...].artifacts/root_cause/data_collector/iter_1/tx/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/tx_trace.jsonartifacts/root_cause/seed/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/balance_diff.jsonartifacts/root_cause/data_collector/iter_2/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/storage_slots_0xdc0fdf_0xdc0fe0.jsonThe ACT opportunity at pre-state block 14421983 is:
8792873290680252648282 UNI‑V2 LP tokens and reports _totalSupply equal to that amount; helper _balances[0x2d85...] = 0.StakingRewards.withdraw(totalSupply) from an address with _balances[user] = 0, either directly or via a helper/orchestrator.0xb1bb...) increases from 0 to 8792873290680252648282 units, while the victim contract’s LP balance drops to 0.Any unprivileged adversary can:
_totalSupply and identify an address u with _balances[u] = 0.withdraw(_totalSupply) and submit a type-2 transaction to call the victim contract from u (or from a helper acting on behalf of u).Therefore, the exploit is not tied to private keys or whitelisted roles beyond the adversary cluster; it is a publicly reproducible ACT opportunity.
The immediate on-chain impact is a complete drain of the UNI‑V2 LP tokens staked in StakingRewards:
0xb3fb1d01b07a706736ca175f827e4f56021b85de loses all 8792873290680252648282 UNI‑V2 LP tokens (0xb1bbeea2da2905e6b0a30203aef55c399c53d042) in transaction 0x3347....0x2d85f5c295760b0afe0b271b94254a8c58b513c5’s LP balance increases from 0 to 8792873290680252648282.From the perspective of liquidity providers who staked in this farm:
_balances mapping reflects a large positive balance for 0x2d85... and non-zero balances for any remaining stakers.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 = 87928732906802526482820xb3fb1d01b07a706736ca175f827e4f56021b85de): artifacts/root_cause/data_collector/iter_1/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/source/src/staking/StakingRewards.sol0xb1bbeea2da2905e6b0a30203aef55c399c53d042): artifacts/root_cause/seed/1/0xb1bbeea2da2905e6b0a30203aef55c399c53d042/src/Contract.sol0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa): artifacts/root_cause/data_collector/iter_1/tx/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4faartifacts/root_cause/seed/1/0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa/balance_diff.jsonartifacts/root_cause/data_collector/iter_2/contract/1/0xb3fb1d01b07a706736ca175f827e4f56021b85de/storage_slots_0xdc0fdf_0xdc0fe0.json0x89767960b76b009416bc7ff4a4b79051eed0a9ee): artifacts/root_cause/data_collector/iter_2/contract/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/decompileartifacts/root_cause/data_collector/iter_2/address/1/0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0/txlist_0_to_0xdc0fe0.json and artifacts/root_cause/data_collector/iter_2/address/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/txlist_0_to_0xdc0fe0.json