Reproduced Exploit

Zero Staking: full-exit unstake `delete`s the Staker struct and wipes non-zero `owedRewards`

Jan 1970Otheruntagged4 min read

Chain

Other

Category

untagged

Date

Jan 1970

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: 59358-loss-of-pending-reward-when-unstaking-quantstamp-zero-stakin. The historical source/toolchain is unavailable; this entry is documentation only and claims no executable Forge PoC.


Vulnerability classes: reward-accounting, reward-theft, staking-exit

Reproduction: A faithful minimal reproduction of the vulnerable StakingERC20.unstake path, with the vulnerable function reproduced VERBATIM (marked @>). Local deploy, no fork.

The harm is immediate and self-funded: a user who unstakes their full balance with exit = true gets their staked principal back, but the very same call deletes their Staker struct — zeroing an owedRewards balance that was never paid. In the reproduction the user forfeits 100 reward tokens of already-accrued rewards.

Root cause#

On a full-balance unstake, the contract cleans up by deleting the caller's Staker record. When exit = true, it takes the branch that skips the owed-reward payout and falls straight through to the delete, which zeroes owedRewards even when it is non-zero:

SOLIDITY
if (staker.amountStaked - amount == 0) {
    if (!exit) {
        // Non-exit full unstake pays out owed rewards first.
        uint256 owed = staker.owedRewards;
        if (owed > 0) rewardsToken.transfer(msg.sender, owed);
    }
    delete stakers[msg.sender]; // @> wipes owedRewards even when exit=true and owedRewards>0
} else {
    staker.amountStaked -= amount;
}

The original finding shows the same root cause in StakingERC20.sol (and identically in StakingERC721.sol):

SOLIDITY
// In StakingERC20.sol
if (staker.amountStaked - amount == 0) {
    delete stakers[msg.sender];
}

delete stakers[msg.sender] resets every field of the struct — including owedRewards — with no transfer of those rewards. The accounting of what the protocol owed the user is destroyed rather than settled.

Why it's exploitable here#

  • Attacker-controlled input: exit is a plain caller-supplied bool. The user picks the exact flag value that routes them past the payout and into the delete.
  • No guard: the full-exit branch has no check that owedRewards == 0 before deleting, and no unconditional settlement of pending rewards.
  • Who funds the loss: the user funds their own loss — accrued rewards they had already earned are silently discarded on exit; the protocol keeps the un-disbursed reward tokens.
  • Systemic reach: the identical pattern exists in both StakingERC20.sol and StakingERC721.sol, so every staker of either contract who fully exits with exit = true while owed rewards is affected.

Attack path#

flowchart TD A["User stakes 1000 tokens"] --> B["100 seconds elapse, owedRewards accrues to 100 tokens"] B --> C["User calls unstake(1000, exit=true)"] C --> D["_accrue credits owedRewards = 100"] D --> E["remaining stake is 0 and exit=true, payout branch skipped"] E --> F["delete stakers[msg.sender] zeroes owedRewards"] F --> G["User receives 1000 principal but 0 rewards, 100 tokens lost"]

Marked-line walkthrough (Playground)#

  1. Line 115_accrue(msg.sender) runs inside unstake, crediting the staker's owedRewards for the elapsed period (100 seconds × 1e18 = 100 tokens). At this point the struct correctly records the debt the protocol owes.
  2. Line 118stakingToken.transfer(msg.sender, amount) returns the full staked principal (1000 tokens) to the user. Principal is settled; only rewards remain outstanding.
  3. Line 120 (VULN) — the full-exit branch if (staker.amountStaked - amount == 0) is entered with exit = true. The code skips the owed-reward payout (line 124) and falls through to delete stakers[msg.sender] (line 126), wiping the non-zero owedRewards — the 100 tokens of rewards are lost.

PoC#

BASH
cd 59358-loss-of-pending-reward-when-unstaking-quantstamp-zero-stak_exp
forge test -vv

The exploit test asserts that after the vulnerable full-exit unstake the user gets 1000 principal tokens back but receives 0 reward tokens, leaving 100e18 of accrued owedRewards permanently lost; the fixed-variant control (StakingERC20Fixed, which pays owed rewards before delete) runs the same sequence and asserts the user is credited the full 100 reward tokens. Served at /hacks/59358-loss-of-pending-reward-when-unstaking-quantstamp-zero-stak/.

Remediation#

Settle owedRewards unconditionally before deleting the struct (or keep a separate owed-rewards record that the delete cannot touch):

DIFF
 if (staker.amountStaked - amount == 0) {
-    if (!exit) {
-        // Non-exit full unstake pays out owed rewards first.
-        uint256 owed = staker.owedRewards;
-        if (owed > 0) rewardsToken.transfer(msg.sender, owed);
-    }
-    delete stakers[msg.sender];
+    // Always pay out owed rewards before deleting the struct.
+    uint256 owed = staker.owedRewards;
+    if (owed > 0) rewardsToken.transfer(msg.sender, owed);
+    delete stakers[msg.sender];
 } else {
     staker.amountStaked -= amount;
 }

Apply the same fix to StakingERC721.sol. If immediate transfer is undesirable (e.g. unlockTime has not passed), persist owedRewards in a mapping that survives the Staker deletion so it can be claimed later.

References#


Sources & further analysis#

Reproductions & code

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.