All incidents

StakingRewards withdraw underflow drains all staked Uniswap V2 LP

Share
Mar 20, 2022 07:48 UTCAttackLoss: 8,792.87 UNI-V2Manually checked1 exploit txWindow: Atomic

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 amount LP 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 by u satisfies amount <= _balances[u].
  • Globally: 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....

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 = 8792873290680252648282
  • balance_0x2d85..._pre_tx = 0
  • totalSupply_post_tx = 0
  • balance_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:

  1. Helper 0x2d85... calls StakingRewards.withdraw(totalSupply).
  2. _withdraw performs unchecked subtraction on _totalSupply and _balances[0x2d85...].
  3. The LP tokens are transferred from StakingRewards to the helper.
  4. _totalSupply becomes 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.
  • _totalSupply and 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 _totalSupply and 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 call 0x85db....
    • Deploys both the orchestrator 0x8976... and helper 0x2d85... at block 14421957.
  • Orchestrator contract 0x89767960b76b009416bc7ff4a4b79051eed0a9ee
    • Receives calls from the EOA in 0x85db... and 0x3347....
    • Forwards structured calldata into the helper and other contracts.
    • Decompilation reveals origin-locked logic and helper-style routing rather than protocol governance behavior.
  • Helper contract 0x2d85f5c295760b0afe0b271b94254a8c58b513c5
    • Receives the full LP token transfer from StakingRewards in the exploit transaction.
    • Has _balances[0x2d85...] = 0 before the exploit and 2^256 - amount after, consistent with being the user argument to _withdraw.

Victim contracts:

  • StakingRewards: 0xb3fb1d01b07a706736ca175f827e4f56021b85de (verified)
  • UNI‑V2 LP token: 0xb1bbeea2da2905e6b0a30203aef55c399c53d042 (verified UniswapV2Pair)

Lifecycle stages

  1. Adversary contract deployment

    • Transaction 0x681809ebe7a55219ce5df42ac018f708249799a5a305c7ca1ae0b77e4671157e (block 14421957)
      • EOA 0x1751... deploys helper 0x2d85....
    • Transaction 0x0fdd9a2e32b8c36bc4827fb5fe5a2d5bb4bb49919a5c311cb0d5b5d37b98a3b2 (block 14421957)
      • EOA 0x1751... deploys orchestrator 0x8976....
    • Evidence: artifacts/root_cause/data_collector/iter_2/address/1/0x1751e3e1aaf1a3e7b973c889b7531f43fc59f7d0/txlist_0_to_0xdc0fe0.json.
  2. Failed orchestrated call

    • Transaction 0x85db310041b359cae8183d3cd9f21bb95e46812aadef4263c0bbfeb36c5b681c (block 14421979)
      • From 0x1751... to orchestrator 0x8976....
      • Uses method selector 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.
    • Evidence: artifacts/root_cause/data_collector/iter_2/address/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/txlist_0_to_0xdc0fe0.json.
  3. Exploit execution

    • Transaction 0x33479bcfbc792aa0f8103ab0d7a3784788b5b0e1467c81ffbed1b7682660b4fa (block 14421984)
      • From EOA 0x1751... to orchestrator 0x8976... with method 0x74a97af6 and the same calldata structure as the reverted call.
      • Orchestrator forwards a call to helper 0x2d85....
      • Helper calls StakingRewards.withdraw(8792873290680252648282) with selector 0x2e1a7d4d and argument equal to the pre-state _totalSupply.
      • StakingRewards transfers all LP tokens from its balance to 0x2d85..., sets _totalSupply to 0, and underflows _balances[0x2d85...].
    • 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

ACT opportunity and reproducibility

The ACT opportunity at pre-state block 14421983 is:

  • Pre-state σ_B: StakingRewards holds 8792873290680252648282 UNI‑V2 LP tokens and reports _totalSupply equal 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 from 0 to 8792873290680252648282 units, while the victim contract’s LP balance drops to 0.

Any unprivileged adversary can:

  1. Observe _totalSupply and identify an address u with _balances[u] = 0.
  2. Craft calldata for withdraw(_totalSupply) and submit a type-2 transaction to call the victim contract from u (or from a helper acting on behalf of u).
  3. 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 0xb3fb1d01b07a706736ca175f827e4f56021b85de loses all 8792873290680252648282 UNI‑V2 LP tokens (0xb1bbeea2da2905e6b0a30203aef55c399c53d042) in transaction 0x3347....
  • Adversary helper 0x2d85f5c295760b0afe0b271b94254a8c58b513c5’s LP balance increases from 0 to 8792873290680252648282.

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 _balances mapping reflects a large positive balance for 0x2d85... 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 = 8792873290680252648282
  • fees_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.json and artifacts/root_cause/data_collector/iter_2/address/1/0x89767960b76b009416bc7ff4a4b79051eed0a9ee/txlist_0_to_0xdc0fe0.json
StakingRewards withdraw underflow drains all staked Uniswap V2 LP | Clara