Reproduced Exploit
Zero Staking: reentrancy in `stake()` re-credits a stale reward timestamp to drain the reward pool
Chain
Other
Category
untagged
Date
Jan 1970
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: 59357-malicious-user-can-drain-rewards-through-reentrancy-in-staki. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.
Vulnerability classes:
reentrancy-single-function,stale-timestamp-accounting,reward-theftReproduction: A faithful minimal reproduction. The vulnerable
stake()/_checkRewards()ordering is reproduced VERBATIM (marked@>), with the reward token, the ERC721 receipt-mint callback, and a settable clock as minimal doubles. Local deploy, no fork, no cheatcodes.
Root cause#
StakingERC721.stake() accrues pending rewards from the elapsed window (now - staker.lastUpdatedTimestamp), but only writes staker.lastUpdatedTimestamp at the very end of the call. In between, it calls _safeMint(), which invokes onERC721Received() on the recipient. A malicious contract staker re-enters stake() during that callback: because lastUpdatedTimestamp is still the stale pre-call value, _checkRewards() credits the same elapsed window again on every reentry.
function _checkRewards(Staker storage staker) internal view returns (uint256) {
return ((currentTime - staker.lastUpdatedTimestamp) * REWARDS_PER_SEC * staker.amountStaked) / 1e18; // @> uses STALE lastUpdatedTimestamp — re-credited on every reentry
}
function stake(uint256 amount) external {
Staker storage staker = stakers[msg.sender];
staker.owedRewards += _checkRewards(staker); // @> accrue against the not-yet-updated timestamp
staker.amountStaked += amount;
_safeMint(msg.sender, nextTokenId++); // @> external onERC721Received callback fires BEFORE the timestamp is updated
staker.lastUpdatedTimestamp = currentTime; // @> lastUpdatedTimestamp written only at the END of stake()
}
The state update that would close the accrual window happens after the untrusted external call instead of before it — a checks-effects-interactions violation on the reward accounting. The same risk exists in StakingERC20.stake() when ERC777 tokens (with tokensReceived hooks) are accepted.
Why it's exploitable here#
- Attacker-controlled callback: the staker is a contract implementing
onERC721Received, so it fully controls the reentrant call that fires mid-stake(). - No guard: neither
stake()norunstake()carries anonReentrantmodifier, and the timestamp is not finalized before the mint, so nothing blocks re-entry into the same accrual window. - Who funds the loss: the inflated
owedRewardsis paid out in real reward tokens from the staking contract's shared reward pool — the excess comes directly out of other honest stakers' rewards. - Systemic reach: the depth of reentry is attacker-chosen, so pending rewards inflate to an arbitrary multiple of the honest amount; the same pattern applies to the ERC20/ERC777 staking path.
Attack path#
Marked-line walkthrough (Playground)#
- Line 97 —
staker.owedRewards += _checkRewards(staker)credits pending rewards using the still-stalelastUpdatedTimestamp, before_safeMinthands control to the attacker's receiver. - Line 102 —
staker.lastUpdatedTimestamp = currentTimeruns only at the end ofstake(), after_safeMint(line 100), so during theonERC721Receivedcallback the timestamp is still stale and the reentry window stays open. - Line 91 (VULN) —
_checkRewardsmultiplies(currentTime - stale lastUpdatedTimestamp)by the stake; re-entered during the mint callback it re-credits the same window every time, inflating pending rewards to 5x the honest amount.
PoC#
cd 59357-malicious-user-can-drain-rewards-through-reentrancy-in-sta_exp
forge test -vv
The exploit stakes 1 token, advances 1000 seconds (honest accrual = 1,000 RWD), then re-enters stake(0) four times during the mint callback and claims 5,000 RWD (5x fair, 4,000 RWD stolen from the shared pool); the fixed-variant control that finalizes the timestamp before _safeMint sees a zero elapsed window on each reentry and claims exactly the fair 1,000 RWD. Served at /hacks/59357-malicious-user-can-drain-rewards-through-reentrancy-in-sta/.
Remediation#
Finalize lastUpdatedTimestamp before any external call, so a reentrant stake() sees a zero elapsed window and accrues nothing:
function stake(uint256 amount) external {
Staker storage staker = stakers[msg.sender];
staker.owedRewards += _checkRewards(staker);
staker.amountStaked += amount;
+ staker.lastUpdatedTimestamp = currentTime; // finalize timestamp BEFORE the external callback
- _safeMint(msg.sender, nextTokenId++);
-
- staker.lastUpdatedTimestamp = currentTime;
+ _safeMint(msg.sender, nextTokenId++);
}
Equivalently, add OpenZeppelin's nonReentrant modifier to stake() and unstake() (and mirror the fix in the ERC20/ERC777 staking path). Reordering the state write is preferred as defense-in-depth even with a guard in place.
References#
- AuditVault finding: https://github.com/Auditware/AuditVault/blob/main/findings/59357-malicious-user-can-drain-rewards-through-reentrancy-in-staki.md
- Quantstamp report (Zero Staking): https://certificate.quantstamp.com/full/zero-staking/40ffa176-7b8d-43ec-a7e2-29732c12f21e/index.html
- Client fix commit:
0a9a94bb49ec54fce5e6bd8859be3f981fbbac4a
Sources & further analysis#
Reproductions & code
- No executable Forge reproduction is claimed; the historical source/toolchain was unavailable for this finding.
- AuditVault finding: 59357-malicious-user-can-drain-rewards-through-reentrancy-in-staki.
- Upstream DeFiHackLabs PoC directory: src/test.
Alerts & third-party analyses
- DeFiHackLabs incident explorer: search "Zero Staking: reentrancy in
stake()re-credits a stale reward timestamp to drain the reward pool". - 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.