#41938 [SC-Critical] Unstake process manipulation and reward distribution vulnerability
Submitted on Mar 19th 2025 at 14:07:25 UTC by @pontifex for Audit Comp | Yeet
Report ID: #41938
Report Type: Smart Contract
Report severity: Critical
Target: https://github.com/immunefi-team/audit-comp-yeet/blob/main/src/StakeV2.sol
Impacts:
Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
The StakeV2.accumulatedDeptRewardsYeet function wrongly considers tokens locked for the VESTING_PERIOD as the accumulated rewards. So locked tokens can be distributed to the vault and then claimed as rewards. This causes that part of users can't unstake the full amount of their staked tokens.
Vulnerability Details
The StakeV2 tracks the virtual amount of staked tokens in the totalSupply variable:
function stake(uint256 amount) external {
require(amount > 0, "Amount must be greater than 0");
_updateRewards(msg.sender);
stakingToken.transferFrom(msg.sender, address(this), amount);
balanceOf[msg.sender] += amount;
>> totalSupply += amount;
emit Stake(msg.sender, amount);
}The contract owner can distribute excess rewards to the vault via the executeRewardDistributionYeet function. This function calculates the distributed amount as a difference between the contract balance an the totalSupply variable:
function accumulatedDeptRewardsYeet() public view returns (uint256) {
>> return stakingToken.balanceOf(address(this)) - totalSupply;
}The problem is that when users start the unstake process the totalSupply variable is also decreased by the unStakeAmount, but the contract balance is not changed.
function startUnstake(uint256 unStakeAmount) external {
require(unStakeAmount > 0, "Amount must be greater than 0");
require(stakedTimes[msg.sender] < STAKING_LIMIT, "Amount must be less then the STAKING_LIMIT constant"); // DOS protection https://github.com/Enigma-Dark/Yeet/issues/12
_updateRewards(msg.sender);
uint256 amount = balanceOf[msg.sender];
require(amount >= unStakeAmount, "Insufficient balance");
balanceOf[msg.sender] -= unStakeAmount;
>> totalSupply -= unStakeAmount;
uint256 start = block.timestamp;
uint256 end = start + VESTING_PERIOD;
vestings[msg.sender].push(Vesting(unStakeAmount, start, end));
stakedTimes[msg.sender]++;
emit VestingStarted(msg.sender, unStakeAmount, vestings[msg.sender].length - 1);
}This way the unStakeAmount value can be distributed via the executeRewardDistributionYeet function.
When users finalize the unstake process the contract balance becomes less than the totalSupply and users' stakes becomes undercollateralized.
function _unstake(uint256 index) private {
Vesting memory vesting = vestings[msg.sender][index];
(uint256 unlockedAmount, uint256 lockedAmount) = calculateVesting(vesting);
require(unlockedAmount != 0, "No unlocked amount");
>> stakingToken.transfer(msg.sender, unlockedAmount);
>> stakingToken.transfer(address(0x000000dead), lockedAmount);
_remove(msg.sender, index);
if (lockedAmount > 0) {
emit RageQuit(msg.sender, unlockedAmount, lockedAmount, index);
} else {
emit Unstake(msg.sender, unlockedAmount, index);
}
stakedTimes[msg.sender]--;
}I suggest tracking locked amounts in a separate variable and taking it into account in the accumulatedDeptRewardsYeet function.
Impact Details
Loss of user funds: Users may not be able to retrieve their staked tokens, resulting in a loss of funds. Undercollateralization: The contract's balance becomes less than the totalSupply, causing users' stakes to become undercollateralized.
References
https://github.com/immunefi-team/audit-comp-yeet/blob/da15231cdefd8f385fcdb85c27258b5f0d0cc270/src/StakeV2.sol#L149 https://github.com/immunefi-team/audit-comp-yeet/blob/da15231cdefd8f385fcdb85c27258b5f0d0cc270/src/StakeV2.sol#L255
Proof of Concept
Proof of Concept
Suppose the
totalSupplyis 1000 tokens stacked by innocent users.Suppose there are 100 tokens as accumulated rewards. So the
stakingToken.balanceOf(address(this))is 1100.An attacker stakes another 1000 tokens.
Now the
totalSupplyis 2000 tokens and thestakingToken.balanceOf(address(this))is 2100.The attacker starts the unstake process for 500 tokens.
Now the
totalSupplyis 1500 tokens and thestakingToken.balanceOf(address(this))is still 2100.The attacker waits for the moment when the contract owner distributes excess rewards (100) and locked (500) to the vault.
Now the
totalSupplyis 1500 tokens and thestakingToken.balanceOf(address(this))is 1500.Then the attacker finalizes the unstake process for the first 500 tokens.
Now the
totalSupplyis 1500 tokens and thestakingToken.balanceOf(address(this))is 1000.Then the attacker claims rewards and receives about 500 / 1500 part of distributed rewards (600), i.e. ~200 tokens.
Then the attacker starts the unstake process for another 500 tokens and finalizes it after the
VESTING_PERIOD.Now the
totalSupplyis 1000 tokens and thestakingToken.balanceOf(address(this))is 500.The attacker has profit and the
totalSupplyis undercollateralized.
Was this helpful?