Reproduced Exploit

Roots Staker — the wrong getReward selector leaves 5 collateral stuck

_fetchRewards calls IGauge.getReward(address). The reward vault implements getReward(address account, address recipient). The selector does not match, so the call reverts. withdraw fetches rewards before it transfers the stake, so the user's 5 collateral cannot leave the staker. Calling getReward(a…

Feb 2025•Other•dependency•2 min read

Chain

Other

Category

dependency

Date

Feb 2025

Source

AuditVault

EVM Playground

Source-level debugger — step opcodes and Solidity in sync

evm-hack-analyzer

The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.

Loading fork state…

Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 55110-c-01-incorrect-interface-used-for-gauge-contract-in-staker-p. Standalone Foundry PoC and full write-up: 55110-c-01-incorrect-interface-used-for-gauge-contract-in-staker-p_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/dependency/unsafe-external-call · vuln/dos/frozen-funds

Key info#

ProtocolRoots Staker
FindingPashov C-01, AuditVault 55110
Vulnerable codegauge.getReward(address(this)) against a vault whose function is getReward(address, address)
Harm5 collateral stays in the staker. Withdraw reverts.
Chainlocal synthetic

TL;DR#

_fetchRewards calls IGauge.getReward(address). The reward vault implements getReward(address account, address recipient). The selector does not match, so the call reverts. withdraw fetches rewards before it transfers the stake, so the user's 5 collateral cannot leave the staker. Calling getReward(account, recipient) succeeds, and the fixed withdraw pays the user the 5 stake plus the 5 reward.

The vulnerable code#

SOLIDITY
function _fetchRewards() internal {
    // @> VULN: RewardVault.getReward takes (account, recipient). This selector does not exist.
    gauge.getReward(address(this));
}

The fix is gauge.getReward(address(this), address(this)).

Root cause#

The interface was copied from a gauge. The deployed reward vault is Bera's RewardVault, whose getReward takes the account and the recipient. A one-argument call hits no function and reverts. Every action that updates rewards, including withdraw, reverts with it.

Preconditions#

The staker holds 5 of the user's collateral. The gauge address is a RewardVault that only implements the two-argument getReward. The user calls withdraw.

Attack walkthrough#

  1. 5 collateral is minted to the staker and recorded as locked.
  2. withdraw calls _fetchRewards.
  3. getReward(address) reverts. The stake is not transferred.
  4. The staker still holds 5. The user holds 0.

The control calls getReward(account, recipient) and then transfers the stake. The user receives 10: the 5 stake and the 5 reward.

Diagrams#

flowchart TD A["Staker holds 5 collateral"] --> B["withdraw calls getReward with one argument"] B --> C["VULN: RewardVault has no such selector"] C --> D["Call reverts and the 5 stays"] D --> E["Two-arg getReward pays the user"]

Impact#

The user cannot withdraw. 5 collateral remains in the staker.

Remediation#

Call getReward(address account, address recipient) with the staker as both account and recipient.

Sources#


Sources & further analysis#

Reproductions & code

Alerts & third-party analyses

  • Web3Sec X hacked database: search.
  • Rekt leaderboard: search.
  • Solodit incident search: search.

These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.