Reproduced Exploit

A removed operator still claims 1,000 rewards because removal does not touch the balance

The operator has 1,000 unclaimed rewards. Governance removes them. isOperator becomes false and operatorRewards is still 1,000. claim transfers the 1,000. The control removal zeroes the balance, sends the 1,000 to the pool, and the same claim pays 0.

Aug 2023•Ethereum•logic•3 min read

Loss

The removed operator claims 1,000 rewards. Seizing on removal sends that 1,000 to the pool and the later clai…

Chain

Ethereum

Category

logic

Date

Aug 2023

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: 26461-c-02-operator-can-still-claim-rewards-after-being-removed-fr. Standalone Foundry PoC and full write-up: 26461-c-02-operator-can-still-claim-rewards-after-being-removed-fr_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/reward-calculation · vuln/access-control/broken-logic

Reproduction: self-contained synthetic Foundry reduction; see output.txt.

Key info#

FieldValue
LossThe removed operator claims 1,000 rewards. Seizing on removal sends that 1,000 to the pool and the later claim pays 0
Vulnerable contractPoolGovernance26461.deleteOperators (reduction of Smoothly PoolGovernance)
Attacker EOA0x1111111111111111111111111111111111111111
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2023-08
Compilersolc 0.8.27, cancun, via IR
Bug classdeleteOperators clears isOperator and leaves operatorRewards unchanged

TL;DR#

The operator has 1,000 unclaimed rewards. Governance removes them. isOperator becomes false and operatorRewards is still 1,000. claim transfers the 1,000. The control removal zeroes the balance, sends the 1,000 to the pool, and the same claim pays 0.

Background#

Pashov, Smoothly, C-02, 1 August 2023. deleteOperators is the path used when an operator is slashed or has gone inactive. It removes the account from PoolGovernance and does not move operatorRewards. A malicious operator who was removed can still claim. An inactive operator's balance stays in the contract with no one to sweep it. The report's fix is to transfer the rewards to a chosen account, for example SmoothlyPool, as part of removal.

The vulnerable code#

SOLIDITY
function deleteOperators(address op) external {
    // @> VULN: the operator is removed from governance but operatorRewards[op] is left intact, so the removed operator can still claim.
    isOperator[op] = false;
}

claim then pays operatorRewards[op] and zeroes it. Removal never reaches that mapping.

Root cause#

The operator flag and the reward balance are separate, and only the flag is updated. Claim does not check isOperator. Whoever is still in the mapping gets paid.

Preconditions#

  • The operator has a non-zero operatorRewards balance. Here it is 1,000, and the governance contract holds the tokens.
  • Governance calls deleteOperators. That is the intended slash path, not a malicious admin. The harm is the removed operator calling claim afterwards.

Attack walkthrough#

  1. Accrued rewards are 1,000 (output.txt:9).
  2. After removal the balance on the books is still 1,000 (output.txt:11).
  3. The removed operator claims 1,000 (output.txt:13).
  4. Control seizes 1,000 to the pool (output.txt:15). The later claim pays 0 (output.txt:17).
  5. [PASS] (output.txt:6).

Diagrams#

flowchart TD accrue["Operator has 1000 unclaimed rewards"] --> remove["deleteOperators clears only the operator flag"] remove --> still["operatorRewards is still 1000"] still --> claim["The removed operator claims 1000"] claim --> ctrl["Control sends the 1000 to the pool and the claim pays 0"]

Impact#

Rewards that were supposed to be slashed are paid to the removed operator. The pool is short 1,000. On the control path the pool receives the 1,000 and the operator receives 0.

Remediation#

On removal, read operatorRewards[op], set it to zero, and transfer that amount to the pool before the operator can claim.

How to reproduce#

BASH
cd 26461-c-02-operator-can-still-claim-rewards-after-being-removed-fr_exp
forge test -vv

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.