Reproduced Exploit

Suzaku counts one curator's share twice and locks 1,000 of the epoch's reward tokens

curatorShares[epoch][curator] already stores that curator's total. claimUndistributedRewards adds it again for every vault the curator owns. Two vaults turn 1,000 bp into 2,000 bp, so the summed shares are 8,000 instead of 7,000. Of 10,000 reward tokens, the distributor is paid 2,000 rather than 3,…

Jul 2025•Ethereum•logic•4 min read

Loss

1,000 reward tokens left in the contract. The distributor is paid 2,000 instead of 3,000

Chain

Ethereum

Category

logic

Date

Jul 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: 61233-incorrect-summation-of-curator-shares-in-claimundistributedr. Standalone Foundry PoC and full write-up: 61233-incorrect-summation-of-curator-shares-in-claimundistributedr_exp in the evm-hack-registry mirror.


Vulnerability classes: vuln/logic/reward-calculation · vuln/dos/frozen-funds

Key info#

FieldValue
Loss1,000 reward tokens left in the contract. The distributor is paid 2,000 instead of 3,000
Vulnerable contractRewards.claimUndistributedRewards
Attacker EOA0x0000000000000000000000000000000000061233 (the rewards distributor)
Attack contractExploit
Attack txExploit.run()
Chain / block / dateEthereum model · block 0 · 2025-07
Compilersolc 0.8.27, cancun, via IR
Bug classCurator total is added once per vault the curator owns

TL;DR#

curatorShares[epoch][curator] already stores that curator's total. claimUndistributedRewards adds it again for every vault the curator owns. Two vaults turn 1,000 bp into 2,000 bp, so the summed shares are 8,000 instead of 7,000. Of 10,000 reward tokens, the distributor is paid 2,000 rather than 3,000. After the operator, both vaults, and the curator take their real shares, 1,000 tokens have no claimant. Counting the curator once pays the distributor 3,000 and leaves a zero balance.

Background#

Cyfrin's July 2025 Suzaku Core review. Undistributed rewards are whatever basis points were not assigned to operators, vaults, or curators. The curator figure is accumulated once, across every vault that curator owns, and then the claim loop walks vaults again.

The vulnerable code#

SOLIDITY
// @> VULN: curatorShares already stores the curator's total, but it is added again for every vault they own
for (uint256 i = 0; i < vaults.length; i++) {
    address curatorOwner = VaultTokenized(vaults[i]).owner();
    totalDistributedShares += curatorShares[epoch][curatorOwner];
}

Both vaults return the same owner. The stored share is 1,000 bp. The loop adds 2,000 bp. Operator 2,000 plus two vaults at 2,000 plus that double count is 8,000 bp.

Root cause#

The mapping is a total, and the loop treats it as a per-vault value. The second add does not correspond to any second claim the curator can make.

Preconditions#

  • One curator owns two vaults that were active in the epoch.
  • Stored shares are operator 2,000 bp, each vault 2,000 bp, curator 1,000 bp.
  • The epoch is funded with 10,000 reward tokens.
  • The distributor calls claimUndistributedRewards. Individual claimants then take their stored shares once.

Attack walkthrough#

  1. 10,000 reward tokens are minted into the rewards contract.
  2. The distributor claims. The curator loop runs twice.
  3. totalDistributedShares is 8,000. Undistributed rewards are 2,000, not 3,000.
  4. The operator claims 2,000, each vault claims 2,000, and the curator claims 1,000.
  5. 1,000 tokens are still in the contract. No remaining share points at them.
  6. The control adds the curator once. The distributor receives 3,000 and the contract balance is 0.

Diagrams#

flowchart TD A["One curator owns two vaults and has 1,000 bp stored once"] --> B["claimUndistributedRewards walks every vault"] B --> C["VULN: the same 1,000 bp is added for vault 1 and vault 2"] C --> D["Summed shares are 8,000 bp instead of 7,000"] D --> E["Distributor is paid 2,000. After real claims, 1,000 tokens have no owner"] B --> F["Control adds the curator once"] F --> G["Control pays the distributor 3,000 and leaves a zero balance"]

Impact#

1,000 of the 10,000 reward tokens stay in the contract after every party with a stored share has claimed. The distributor is short 1,000 tokens relative to the fair undistributed amount. The control pays that 1,000 to the distributor and empties the contract.

Remediation#

Iterate the unique curator addresses for the epoch, and add curatorShares[epoch][curator] once per curator.

How to reproduce#

BASH
cd 61233-incorrect-summation-of-curator-shares-in-claimundistributedr_exp
forge test -vv

test_exploit_double_counts_curator_and_locks_rewards asserts an 8,000 bp sum, a 2,000 token distributor payment, and 1,000 tokens left behind. test_control_curator_is_counted_once asserts a 3,000 token payment and a zero balance. Captured output is output.txt.

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.