52849 sc high claimers who claim after slash inactive updaterewardpertokenforvalidator which advances validatorlastupdatetimes to be more than slashtimestamp will lose rewards for a segment
- Submitted on: Aug 13th 2025 at 16:07:25 UTC by @IronsideSec for Attackathon | Plume Network
- Report ID: #52849
- Report Type: Smart Contract
- Severity: High
- Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol
- Impact summary: Theft of unclaimed yield — underpayment of users and validator commission; unaccrued tokens remain in treasury.
Brief / Intro
When a validator becomes slashed or inactive, later admin operations can advance validatorLastUpdateTimes to a time greater than or equal to the effective cap (the slash/inactive timestamp). Because updateRewardPerTokenForValidator's slashed/inactive branches write validatorLastUpdateTimes[validatorId][token] = block.timestamp and return without first settling rewards up to the cap, subsequent calls to claim skip the final pre-cap reward segment. Result: some users (and validator commission) are underpaid; the under-accrued tokens remain in the treasury.
Root cause: in updateRewardPerTokenForValidator, the slashed and inactive branches advance validatorLastUpdateTimes to block.timestamp without settling to the cap time.
Vulnerability details
PoC attack flow
Step: Setup
- Set pUSD rate to
1e18. - user1 stakes 10 ETH to validator v0; user3 stakes 10 ETH to v0.
- Advance time
vm.warp(+1000).
Step: Slash validator
voteToSlashValidator(v0, block.timestamp + 1 days)→ validator is auto-slashed atTslash.- user1 immediately
claim(pUSD, v0)→ receives pre-slash rewards (e.g. ~9500e18).
Step: Advance last update past cap
vm.warp(+10). Anyone callsforceSettleValidatorCommission(v0)→ this invokesupdateRewardPerTokenForValidatorwhich (in slashed branch) writesvalidatorLastUpdateTimes[v0][pUSD] = now(post-cap).
Step: Victim claim
- user3 calls
claim(pUSD, v0)→ returns 0 because the pre-slash segment is skipped. - Outcome: user3 receives 0 rewards while user1 already received their payout; under-accrued tokens remain.
Flows that cause the bug
Validator gets slashed
ValidatorFacet.voteToSlashValidator(vId, expiry)setsslashedAtTimestamp = Tslash.- Later, admin calls
ValidatorFacet.forceSettleValidatorCommission(vId)(orRewardsFacet.removeRewardToken(token)/RewardsFacet.setRewardRates(tokens, rates)). - Those call
PlumeRewardLogic.updateRewardPerTokenForValidator($, token, vId)→ slashed branch writesvalidatorLastUpdateTimes[vId][token] = now. - Subsequent
RewardsFacet.claim(token, vId)→calculateRewardsWithCheckpointscomputeseffectiveEndTime = Tslash <= lastUpdateTime→ final pre-slash accrual skipped.
Validator set inactive
- Admin calls
ValidatorFacet.setValidatorStatus(vId, false)→ recordsslashedAtTimestamp = Tinactiveand pushes 0-rate checkpoints. - Any later admin op calls
updateRewardPerTokenForValidator($, token, vId), which (inactive branch) writesvalidatorLastUpdateTimes[vId][token] = now. - Subsequent
RewardsFacet.claim(token, vId)→ final pre-inactive accrual skipped.
Token removal during/after slash/inactive
- Admin calls
RewardsFacet.removeRewardToken(token). - Loop calls
PlumeRewardLogic.updateRewardPerTokenForValidator($, token, vId)thencreateRewardRateCheckpoint($, token, vId, 0). - For slashed/inactive validators, updater writes
validatorLastUpdateTimes[vId][token] = now(> cap). - Later claims skip the pre-cap delta.
Set reward rates after slash/inactive
- Admin calls
RewardsFacet.setRewardRates(tokens, rates)(orsetMaxRewardRate(token, newMax)). createRewardRateCheckpointfirst callsupdateRewardPerTokenForValidator.- Updater on slashed/inactive writes
validatorLastUpdateTimes[vId][token] = now. - Later claims skip the last pre-cap segment.
Impact details
- Users are underpaid for the final pre-cap time segment.
- Validator commission for that segment is under-accrued.
- Treasury retains more tokens than intended.
- Magnitude: missed_time_segment × applicable_rate × stake / precision, aggregated over affected users/tokens.
- Likelihood and impact are high because common admin actions (settlement, rate updates, token removal) occur often and call the updater.
Recommended remediation
- In
updateRewardPerTokenForValidator, do not advancevalidatorLastUpdateTimesin slashed/inactive branches. - Alternatively, when transitioning a validator to slashed/inactive, immediately settle rewards up to the cap and set
validatorLastUpdateTimesto exactly that cap time; thereafter prevent any advancement past the cap while the validator remains slashed/inactive. - Ensure checkpoint creation paths that invoke the updater either skip calling it when slashed/inactive, or use updater logic that respects the cap and never moves
lastUpdateTimesbeyond it.
References / Relevant code excerpts
- The slashed and inactive branches currently set
validatorLastUpdateTimes[validatorId][token] = block.timestampand return without settling to the cap time — the root cause. - In
calculateRewardsWithCheckpoints, the slashed path setseffectiveEndTime = validator.slashedAtTimestampand only accrues a final segment ifeffectiveEndTime > validatorLastUpdateTime. AdvancingvalidatorLastUpdateTimesto now (≥ cap) causes that condition to be false and the pre-cap segment to be skipped. - Common admin flows that call the updater after slash/inactive will systematically cause underpayment.
Code excerpts (lines referenced in original report):
File: attackathon-plume-network/plume/src/lib/PlumeRewardLogic.sol
135:
136: function updateRewardPerTokenForValidator(
137: PlumeStakingStorage.Layout storage $, address token, uint16 validatorId
140: ) internal {
141: PlumeStakingStorage.ValidatorInfo storage validator = $.validators[validatorId]; // Get validator info
142:
143: // --- REORDERED SLASHED/INACTIVE CHECKS ---
144: // Check for slashed state FIRST since slashed validators are also inactive
145: if (validator.slashed) {
146: // For slashed validators, no further rewards or commission accrue.
147: // We just update the timestamp to the current time to mark that the state is "settled" up to now.
148: >>> $.validatorLastUpdateTimes[validatorId][token] = block.timestamp;
149:
150: // Add a defensive check: A slashed validator should never have any stake. If it does, something is
151: // wrong with the slashing logic itself.
152: if ($.validatorTotalStaked[validatorId] > 0) {
153: revert InternalInconsistency("Slashed validator has non-zero totalStaked");
154: }
155: return;
156: } else if (!validator.active) {
157: // For inactive (but not slashed) validators, no further rewards or commission accrue.
158: // We just update the timestamp to the current time to mark that the state is "settled" up to now.
159: >>> $.validatorLastUpdateTimes[validatorId][token] = block.timestamp;
160: return;
161: }
164: // --- END REORDERED CHECKS ---
...
203: // Update last global update time for this validator/token AFTER all calculations for the segment
204: $.validatorLastUpdateTimes[validatorId][token] = block.timestamp;
205: }
400: function calculateRewardsWithCheckpoints(
401: PlumeStakingStorage.Layout storage $,
402: address user,
403: uint16 validatorId,
404: address token,
405: uint256 userStakedAmount
406: ) internal returns (uint256 totalUserRewardDelta, uint256 totalCommissionAmountDelta, uint256 effectiveTimeDelta) {
407: PlumeStakingStorage.ValidatorInfo storage validator = $.validators[validatorId];
408:
411: if (!validator.slashed) {
412: // Normal case: update and use the updated cumulative.
413: updateRewardPerTokenForValidator($, token, validatorId);
414: uint256 finalCumulativeRewardPerToken = $.validatorRewardPerTokenCumulative[validatorId][token];
415: return _calculateRewardsCore($, user, validatorId, token, userStakedAmount, finalCumulativeRewardPerToken);
416: } else {
417: // Slashed validator case: calculate what cumulative should be up to the slash timestamp.
418: // We DO NOT call updateRewardPerTokenForValidator here because its logic is incorrect for slashed validators.
419: uint256 currentCumulativeRewardPerToken = $.validatorRewardPerTokenCumulative[validatorId][token];
420: >>> uint256 effectiveEndTime = validator.slashedAtTimestamp;
421:
422: uint256 tokenRemovalTime = $.tokenRemovalTimestamps[token];
423: if (tokenRemovalTime > 0 && tokenRemovalTime < effectiveEndTime) {
424: effectiveEndTime = tokenRemovalTime;
425: }
426:
427: >>> uint256 validatorLastUpdateTime = $.validatorLastUpdateTimes[validatorId][token];
428:
429: >>> if (effectiveEndTime > validatorLastUpdateTime) {
430: uint256 timeSinceLastUpdate = effectiveEndTime - validatorLastUpdateTime;
431:
432: if (userStakedAmount > 0) {
433: PlumeStakingStorage.RateCheckpoint memory effectiveRewardRateChk =
434: getEffectiveRewardRateAt($, token, validatorId, validatorLastUpdateTime); // Use rate at start of segment
435: uint256 effectiveRewardRate = effectiveRewardRateChk.rate;
436:
437: if (effectiveRewardRate > 0) {
438: uint256 rewardPerTokenIncrease = timeSinceLastUpdate * effectiveRewardRate;
439: currentCumulativeRewardPerToken += rewardPerTokenIncrease;
440: }
441: }
442: }
443:
444: return _calculateRewardsCore($, user, validatorId, token, userStakedAmount, currentCumulativeRewardPerToken);
445: }
446: }
Proof of Concept
Follow the steps in the provided gist: https://gist.github.com/IronsideSec/8b8eb5d9c902d7fd91ef9190f2bbecce
(Original report PoC steps included above.)
If you want, I can prepare a suggested patch diff that implements the recommended fix (e.g., settling to the cap and avoiding advancing validatorLastUpdateTimes in slashed/inactive branches) based strictly on the code excerpts.