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
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.
Was this helpful?