50167 sc high retroactive reward drain via incomplete reward debt reset
Submitted on Jul 22nd 2025 at 08:24:50 UTC by @BeastBoy for Attackathon | Plume Network
Report ID: #50167
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol
Impacts: Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
In the full-unstake path, the contract correctly advances every token’s “paid” pointer by iterating over all historical tokens:
address[] storage historicalTokens = $.historicalRewardTokens;
for (…) {
updateRewardsForValidatorAndToken($, user, validatorId, historicalTokens[i]);
}This ensures that when a user fully withdraws, their userValidatorRewardPerTokenPaid for every token moves to the current cumulative index. By contrast, the re-stake initialization only touches the active tokens:
address[] memory rewardTokens = $.rewardTokens;
for (…) {
updateRewardPerTokenForValidator($, token, validatorId);
$.userValidatorRewardPerTokenPaid[user][validatorId][token] =
$.validatorRewardPerTokenCumulative[validatorId][token];
$.userValidatorRewardPerTokenPaidTimestamp[user][validatorId][token] = block.timestamp;
}Any token that was later removed never has its paid pointer updated on re-entry. Its global cumulative index continued to climb (until removal), so when the user finally calls claim, the delta between the stale pointer and the frozen cumulative index is paid out—even though the user held zero stake during that period.
Impact
Users can siphon unearned rewards for removed tokens, draining the reward pool and causing financial loss to other stakers.
Recommendation
Change the restake initialization to reset pointers over $.historicalRewardTokens rather than $.rewardTokens.
Proof of Concept
Initial Stake & Unstake
At time T₀ Alice stakes 1 000 PLUME on validator 42. At time T₁ she fully unstakes. The call path enters:
updateRewardsForValidator($, Alice, 42) // uses $.historicalRewardTokenswhich advances every token’s userValidatorRewardPerTokenPaid to validatorRewardPerTokenCumulative[42][token] at T₁.
Claim & Drain
Alice invokes claim(PLUME, 42). The code computes:
pending = (validatorRewardPerTokenCumulative[42][PLUME] – userValidatorRewardPerTokenPaid[Alice][42][PLUME]) × stake / PRECISION
= (C₂ – C₁) × 1_000 / 1e18and transfers that amount, even though Alice held zero stake during [T₁, T₂]. She thereby withdraws unearned PLUME from the pool.
Was this helpful?