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

1

Step: Setup

  1. Set pUSD rate to 1e18.

  2. user1 stakes 10 ETH to validator v0; user3 stakes 10 ETH to v0.

  3. Advance time vm.warp(+1000).

2

Step: Slash validator

  1. voteToSlashValidator(v0, block.timestamp + 1 days) → validator is auto-slashed at Tslash.

  2. user1 immediately claim(pUSD, v0) → receives pre-slash rewards (e.g. ~9500e18).

3

Step: Advance last update past cap

  1. vm.warp(+10). Anyone calls forceSettleValidatorCommission(v0) → this invokes updateRewardPerTokenForValidator which (in slashed branch) writes validatorLastUpdateTimes[v0][pUSD] = now (post-cap).

4

Step: Victim claim

  1. user3 calls claim(pUSD, v0) → returns 0 because the pre-slash segment is skipped.

  2. Outcome: user3 receives 0 rewards while user1 already received their payout; under-accrued tokens remain.

Flows that cause the bug

1

Validator gets slashed

  • ValidatorFacet.voteToSlashValidator(vId, expiry) sets slashedAtTimestamp = Tslash.

  • Later, admin calls ValidatorFacet.forceSettleValidatorCommission(vId) (or RewardsFacet.removeRewardToken(token) / RewardsFacet.setRewardRates(tokens, rates)).

  • Those call PlumeRewardLogic.updateRewardPerTokenForValidator($, token, vId) → slashed branch writes validatorLastUpdateTimes[vId][token] = now.

  • Subsequent RewardsFacet.claim(token, vId)calculateRewardsWithCheckpoints computes effectiveEndTime = Tslash <= lastUpdateTime → final pre-slash accrual skipped.

2

Validator set inactive

  • Admin calls ValidatorFacet.setValidatorStatus(vId, false) → records slashedAtTimestamp = Tinactive and pushes 0-rate checkpoints.

  • Any later admin op calls updateRewardPerTokenForValidator($, token, vId), which (inactive branch) writes validatorLastUpdateTimes[vId][token] = now.

  • Subsequent RewardsFacet.claim(token, vId) → final pre-inactive accrual skipped.

3

Token removal during/after slash/inactive

  • Admin calls RewardsFacet.removeRewardToken(token).

  • Loop calls PlumeRewardLogic.updateRewardPerTokenForValidator($, token, vId) then createRewardRateCheckpoint($, token, vId, 0).

  • For slashed/inactive validators, updater writes validatorLastUpdateTimes[vId][token] = now (> cap).

  • Later claims skip the pre-cap delta.

4

Set reward rates after slash/inactive

  • Admin calls RewardsFacet.setRewardRates(tokens, rates) (or setMaxRewardRate(token, newMax)).

  • createRewardRateCheckpoint first calls updateRewardPerTokenForValidator.

  • 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.

  • In updateRewardPerTokenForValidator, do not advance validatorLastUpdateTimes in slashed/inactive branches.

  • Alternatively, when transitioning a validator to slashed/inactive, immediately settle rewards up to the cap and set validatorLastUpdateTimes to 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 lastUpdateTimes beyond it.

References / Relevant code excerpts

  • The slashed and inactive branches currently set validatorLastUpdateTimes[validatorId][token] = block.timestamp and return without settling to the cap time — the root cause.

  • In calculateRewardsWithCheckpoints, the slashed path sets effectiveEndTime = validator.slashedAtTimestamp and only accrues a final segment if effectiveEndTime > validatorLastUpdateTime. Advancing validatorLastUpdateTimes to 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):

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?