51987 sc high validators will be able to steal more commission from users that isn t the commission to be charged
Submitted on Aug 7th 2025 at 04:01:22 UTC by @oxrex for Attackathon | Plume Network
Report ID: #51987
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol
Impacts: Theft of unclaimed yield
Description
Validators can cause users to be charged a commission rate that is higher than the rate that should apply for a given historical period. Due to how commission checkpoints and fallback logic are implemented, a validator can set a new commission and immediately benefit from that new (higher) rate when computing commissions for prior user reward intervals, causing theft of unclaimed yield.
Vulnerability Details
Below are the two relevant functions involved in selecting the commission rate for a timestamp and falling back to the validator's current commission:
function findCommissionCheckpointIndexAtOrBefore(
PlumeStakingStorage.Layout storage $,
uint16 validatorId,
uint256 timestamp
) internal view returns (uint256) {
PlumeStakingStorage.RateCheckpoint[] storage checkpoints = $.validatorCommissionCheckpoints[validatorId];
uint256 len = checkpoints.length;
if (len == 0) {
return 0; // No checkpoints, caller uses current validator.commission
}
uint256 low = 0;
uint256 high = len - 1;
uint256 ans = 0;
bool foundSuitable = false;
while (low <= high) {
uint256 mid = low + (high - low) / 2;
if (checkpoints[mid].timestamp <= timestamp) {
ans = mid;
foundSuitable = true;
low = mid + 1;
} else {
if (mid == 0) {
break;
}
high = mid - 1;
}
}
return ans;
}function getEffectiveCommissionRateAt(
PlumeStakingStorage.Layout storage $,
uint16 validatorId,
uint256 timestamp
) internal view returns (uint256) {
PlumeStakingStorage.RateCheckpoint[] storage checkpoints = $.validatorCommissionCheckpoints[validatorId];
uint256 chkCount = checkpoints.length;
if (chkCount > 0) {
uint256 idx = findCommissionCheckpointIndexAtOrBefore($, validatorId, timestamp);
if (idx < chkCount && checkpoints[idx].timestamp <= timestamp) {
// Similar to above, ensure this is the latest one.
return checkpoints[idx].rate;
}
}
// Fallback to the current commission rate stored directly in ValidatorInfo
// This is important if no checkpoints exist or all are in the future.
uint256 fallbackComm = $.validators[validatorId].commission;
return fallbackComm;
}Briefly, how these functions are used:
Fallback logic issue
If the index returned does not point to a checkpoint with timestamp <=
timestamp,getEffectiveCommissionRateAt()falls back to the current validator commission stored in$.validators[validatorId].commission. That current commission may have been updated at the present block.timestamp when a validator calledsetValidatorCommission().
The problem arises because setValidatorCommission() sets the validator's current commission immediately (used as fallback) and then creates the checkpoint with the same effective timestamp. This allows a newly set commission to be used as fallback for queries about earlier timestamps when the binary search returns an index that doesn't satisfy the timestamp check.
Example snippet from setValidatorCommission():
function setValidatorCommission(
uint16 validatorId,
uint256 newCommission
) external onlyValidatorAdmin(validatorId) {
...
// Now update the validator's commission rate to the new rate.
validator.commission = newCommission;
// Create a checkpoint for the new commission rate.
// This records the new rate effective from this block.timestamp.
...
}A more concrete flow of the logic that the contract intended:
At t0 validator commission: 5e16
At t1 user stakes
At t2 validator updates to 5.5e16 (checkpoint at t2)
If user claims now, the segment covering [t1, t2) should use 5e16
At t3 validator updates to 5.8e16 (checkpoint at t3)
Next segment [t2, t3) should use 5.5e16
At t4 validator updates to 6e16 (checkpoint at t4)
Next segment [t3, t4) should use 5.8e16
However, due to the fallback logic described above, when calculating commission for the earliest segment [t1, t2) the system may instead apply the current commission (6e16) immediately, reversing and corrupting the intended historical sequence.
Where the wrong commission is applied in reward calculation
Relevant snippet from reward calculation where commission is determined for each time segment:
// inside _calculateRewardsCore(...)
uint256[] memory distinctTimestamps =
getDistinctTimestamps($, validatorId, token, lastUserRewardUpdateTime, effectiveEndTime);
for (uint256 k = 0; k < distinctTimestamps.length - 1; ++k) {
...
// Commission rate effective at the START of this segment
uint256 effectiveCommissionRate = getEffectiveCommissionRateAt($, validatorId, segmentStartTime);
// Use ceiling division for commission charged to user to ensure rounding up
uint256 commissionForThisSegment =
_ceilDiv(grossRewardForSegment * effectiveCommissionRate, PlumeStakingStorage.REWARD_PRECISION);
...
totalCommissionAmountDelta += commissionForThisSegment;
}Because segmentStartTime can be earlier than the earliest checkpoint timestamp and findCommissionCheckpointIndexAtOrBefore() returns 0 (which doesn't satisfy the checkpoint timestamp <= segmentStartTime check), getEffectiveCommissionRateAt() falls back to $.validators[validatorId].commission — which may have been set to a later/higher rate — causing overcharging.
Impact Details
Validators can extract excessive commission from delegators by repeatedly setting higher commission rates. A validator who increases commission shortly after delegations can cause the delegators' historical reward segments to be calculated using the new higher commission immediately (via the fallback), rather than the commission that was effective during the original segment. This enables stealing a portion of rewards — the larger the delegated stakes, the larger the stolen amount.
Example attack scenario:
Validator sets modest increases several times and then sets a very large commission (e.g., near the 50% cap).
Delegators who claim rewards for prior periods may end up being charged the large commission for earlier intervals, not the historically correct lower rates.
Suggested Mitigation
One approach suggested by the reporter:
Ensure the first commission rate for a validator is pushed into
validatorCommissionCheckpointsduringaddValidator()(similar to how global reward rate is checkpointed) so that there is always an initial checkpoint to be found by the binary search instead of falling back tovalidator.commission.Alternatively, modify the
getEffectiveCommissionRateAt()/findCommissionCheckpointIndexAtOrBefore()logic so that it cannot fall back to the currentvalidator.commissionif there exist checkpoints but none with timestamp <= requested timestamp. Instead, return the earliest checkpoint rate (or otherwise ensure the correct historical rate is used).
Note: Do not change any external behavior beyond preserving the invariant that historical segments must use the commission rate that was effective at that time.
References
https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol#L675-L706
https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol#L605-L624
https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/lib/PlumeRewardLogic.sol#L212-L360
https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/ValidatorFacet.sol#L317-L352
Proof of Concept
-- End of report.
Was this helpful?