#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
totalSupply
is 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
totalSupply
is 2000 tokens and thestakingToken.balanceOf(address(this))
is 2100.The attacker starts the unstake process for 500 tokens.
Now the
totalSupply
is 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
totalSupply
is 1500 tokens and thestakingToken.balanceOf(address(this))
is 1500.Then the attacker finalizes the unstake process for the first 500 tokens.
Now the
totalSupply
is 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
totalSupply
is 1000 tokens and thestakingToken.balanceOf(address(this))
is 500.The attacker has profit and the
totalSupply
is undercollateralized.
Was this helpful?