51813 sc high malicious user can grief victims by staking them across many validators leading to fund freezing
Submitted on Aug 5th 2025 at 22:40:03 UTC by @ihtishamsudo for Attackathon | Plume Network
Report ID: #51813
Report Type: Smart Contract
Report severity: High
Target: https://github.com/immunefi-team/attackathon-plume-network/blob/main/plume/src/facets/StakingFacet.sol
Impacts:
Temporary freezing of funds for at least 24 hours
Description
Brief/Intro
The Plume staking system contains a high-severity vulnerability in the stakeOnBehalf function that enables attackers to perform economical griefing attacks against users. By staking minimal amounts on behalf of victims across many validators, attackers can force victims into gas-expensive operations that make their funds practically inaccessible. This creates a temporary fund freeze lasting at least 24 hours and potentially much longer due to prohibitive recovery costs, allowing a 100 PLUME attack to effectively trap thousands of PLUME in victim accounts.
Vulnerability Details
The vulnerability involves a multi-step call flow that allows attackers to manipulate victim's validator arrays.
Attacker will execute stakeOnBehalf of victim with minimal stake amount which is 0.1 Plume as used in deployment scripts.
function stakeOnBehalf(uint16 validatorId, address staker) external payable returns (uint256) {
//... SNIP
uint256 stakeAmount = msg.value;
bool isNewStake = _performStakeSetup(staker, validatorId, stakeAmount);
}_performStakeSetup internally calls PlumeValidatorLogic.addStakerToValidator()
function _performStakeSetup(address user, uint16 validatorId, uint256 amount) internal returns (bool isNewStake) {
///... SNIP
if (isNewStake) {
PlumeValidatorLogic.addStakerToValidator($, user, validatorId); // <-- Adds validatorId to user's array
}
which pushes the validatorId to victim's $.userValidators[user] array:
function addStakerToValidator(
PlumeStakingStorage.Layout storage $,
address user,
uint16 validatorId
) internal {
// Unbounded array push without limits
$.userValidators[user].push(validatorId); // <-- ARRAY GROWS INDEFINITELY
$.validatorStakers[validatorId].push(user);
//...SNIP
}Once the attacker has populated the victim's $.userValidators[user] array with many validators, all subsequent operations become gas-expensive:
function _calculateActivelyCoolingAmount(address user) internal view returns (uint256 activelyCoolingAmount) {
PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();
uint16[] storage userAssociatedValidators = $.userValidators[user]; // UNBOUNDED ARRAY
activelyCoolingAmount = 0;
// GAS SCALES LINEARLY WITH ARRAY SIZE
for (uint256 i = 0; i < userAssociatedValidators.length; i++) {
uint16 validatorId = userAssociatedValidators[i];
if ($.validatorExists[validatorId]) {
PlumeStakingStorage.CooldownEntry storage cooldownEntry = $.userValidatorCooldowns[user][validatorId];
if (cooldownEntry.amount > 0 && block.timestamp < cooldownEntry.cooldownEndTime) {
activelyCoolingAmount += cooldownEntry.amount;
}
}
}
return activelyCoolingAmount;
}Impacted functions
amountCooling()- Iterates over entire validator array to calculate cooling amountsamountWithdrawable()- Iterates over entire validator array to calculate withdrawable amountsgetUserCooldowns()- Iterates over entire validator array to return cooldown datawithdraw()- Calls_processMaturedCooldowns()which iterates over all user validatorsunstake()- Can trigger expensive operations when combined with many validators_processMaturedCooldowns()- Processes cooldowns across all validators for a user
Attack path
Stake on behalf of victim across many validators
For each validator, attacker calls:
stakingContract.stakeOnBehalf{value: 0.1 ether}(validatorId, victimAddress);Each call triggers
_performStakeSetup(victimAddress, validatorId, 0.1 ether)→addStakerToValidator()→$.userValidators[victim].push(validatorId).Victim's validator array grows from 0 to 1000+ entries.
Victim attempts normal operations
Victim calls view/state functions which must iterate over the very large
userValidatorsarray.Gas usage increases dramatically:
amountCooling(): from ~7,100 gas → 300,000+ gas (example 43x increase)withdraw(): may exceed block gas limitsAll view and critical functions become prohibitively expensive
Impact Details
HIGH - Temporary freezing of funds for at least 24 hours. Economical: low attacker cost (example: 100 PLUME) to cause significant disruption. Based on the provided example cost calculations, the attacker's total cost can be very low relative to victim loss/time locked.
References
Gas-expensive view functions.
Recommended Mitigation
1. Implement Maximum Validator Limits
Add a per-user max validator limit and check in _performStakeSetup before adding a new validator:
uint256 public constant MAX_VALIDATORS_PER_USER = 50;
function _performStakeSetup(address user, uint16 validatorId, uint256 amount) internal {
PlumeStakingStorage.Layout storage $ = PlumeStakingStorage.layout();
// Check if this would exceed validator limit
bool isNewStake = $.userValidatorStakes[user][validatorId].staked == 0;
if (isNewStake && $.userValidators[user].length >= MAX_VALIDATORS_PER_USER) {
revert TooManyValidators(MAX_VALIDATORS_PER_USER);
}
// ... existing logic
}Proof of Concept
Was this helpful?