Reproduced Exploit
Surge — a claimant takes rewards funded after they unstaked
stake records cycleStart as the current cycle and unstake records cycleEnd as the current cycle, whatever fraction of that cycle the user actually spent staked. claimRewardsToOwed then pays every cycle in that closed window from the live _rewardPoolRewards balance. A user who stakes in cycle 1, rol…
Chain
Other
Category
logic
Date
Dec 2024
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
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.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 55143-h-04-users-can-take-their-share-of-rewards-in-cycles-they-st. Standalone Foundry PoC and full write-up: 55143-h-04-users-can-take-their-share-of-rewards-in-cycles-they-st_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/logic/reward-calculation · vuln/logic/missing-check
Key info#
| Protocol | Surge (StakingVault) |
| Finding | Shieldify H-04, AuditVault 55143 |
| Vulnerable code | unstake sets r.cycleEnd = cycleId |
| Harm | The claimant takes 1000 reward tokens that were accepted after the unstake. The control pays 0. |
| Chain | local synthetic |
TL;DR#
stake records cycleStart as the current cycle and unstake records cycleEnd as the current cycle, whatever fraction of that cycle the user actually spent staked. claimRewardsToOwed then pays every cycle in that closed window from the live _rewardPoolRewards balance. A user who stakes in cycle 1, rolls into cycle 2, and unstakes before any reward is accepted still has cycle 2 inside the window. The next acceptPositionManagerRewards(1000) lands in cycle 2. Once cycle 2 ends, the claim pays all 1000. Freezing the owed amount at unstake pays 0, and the 1000 stays in the cycle.
The vulnerable code#
require(r.cycleStart != 0, "No claimable reward");
require(r.cycleEnd == 0, "Claimable reward is not active");
require(r.cycleStart <= cycleId, "Claimable reward is start cycle invalid");
// @> VULN: cycleEnd is the in-progress cycle, so rewards funded after this unstake stay inside the claim window.
r.cycleEnd = cycleId;
The reward deposit is the report's line:
_rewardPoolRewards[poolId][cycleId] += amountPerPool;
claimRewardsToOwed then does reward * r.shares / shares for every cycle from cycleStart through cycleEnd, with no snapshot of the reward balance at unstake.
- r.cycleEnd = cycleId;
- // later claim reads the live cycle reward
+ uint256 owed = _rewardPoolRewards[poolId][cycleId] * r.shares / _rewardPoolShares[poolId][cycleId];
+ r.cycleEnd = cycleId;
+ // claim pays `owed` only, so a later acceptPositionManagerRewards is not included
Root cause#
The claim window is a pair of cycle indexes, not a record of rewards that accrued while the user was staked. Setting cycleEnd to the in-progress cycle keeps that cycle claimable after the user has left. Rewards accepted into it afterwards are paid in full on the next cycle boundary. The report's example is the same shape: a user who leaves during pool 4's cycle 3 comes back when cycle 4 starts and takes that cycle's rewards, including deposits made after the unstake.
Preconditions#
One reward pool. The user stakes 100 shares in cycle 1. The cycle rolls forward, copying those shares onto cycle 2, and the user unstakes immediately. No reward has been accepted yet, so a fair freeze is 0. acceptPositionManagerRewards then mints 1000 into cycle 2. The cycle index advances once more so the claim is allowed. No block.timestamp change is required.
Attack walkthrough#
- Stake 100.
cycleStartis 1 and the pool records 100 shares on cycle 1. - End cycle 1. The 100 shares are copied onto cycle 2.
- Unstake.
r.cycleEndbecomes 2, the cycle that is still open. - Accept 1000 rewards. They are added to cycle 2, after the user has left.
- End cycle 2.
currentCycleIdis 3, so cycle 2 is claimable. claimRewardsToOwedpays1000 * 100 / 100. The claimant receives 1000 and cycle 2's reward balance is 0.
The control vault computes the owed amount inside unstake, before the 1000 is accepted. That amount is 0. The later claim transfers nothing and leaves the 1000 in cycle 2.
Diagrams#
Impact#
The claimant receives 1000 reward tokens for cycle 2 even though every one of those tokens was accepted after unstake. A staker who was actually in the pool for that deposit is paid nothing from it, because the balance is zeroed by this claim. The fixed path pays 0 to the departed user and leaves the 1000 in the cycle for whoever was staked when it arrived.
Remediation#
Snapshot the user's share of _rewardPoolRewards at unstake and pay only that snapshot. Do not pay the live balance of a cycle the user has already left.
How to reproduce#
cd 55143-h-04-users-can-take-their-share-of-rewards-in-cycles-they-st_exp
forge test -vv
Sources#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/55143-h-04-users-can-take-their-share-of-rewards-in-cycles-they-st.md
- Shieldify report: https://github.com/shieldify-security/audits-portfolio-md/blob/main/Surge-Security-Review.md
- Reduced from
StakingVault.solas quoted in that review (r.cycleEnd = cycleId,_rewardPoolRewards[poolId][cycleId] += amountPerPool,claimRewardsToOwed). The report lists the repository assurge-contractswith commit N/A.
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 55143-h-04-users-can-take-their-share-of-rewards-in-cycles-they-st_exp (evm-hack-registry mirror).
- AuditVault finding: 55143-h-04-users-can-take-their-share-of-rewards-in-cycles-they-st.
Alerts & third-party analyses
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.