#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

  1. Suppose the totalSupply is 1000 tokens stacked by innocent users.

  2. Suppose there are 100 tokens as accumulated rewards. So the stakingToken.balanceOf(address(this)) is 1100.

  3. An attacker stakes another 1000 tokens.

  4. Now the totalSupply is 2000 tokens and the stakingToken.balanceOf(address(this)) is 2100.

  5. The attacker starts the unstake process for 500 tokens.

  6. Now the totalSupply is 1500 tokens and the stakingToken.balanceOf(address(this)) is still 2100.

  7. The attacker waits for the moment when the contract owner distributes excess rewards (100) and locked (500) to the vault.

  8. Now the totalSupply is 1500 tokens and the stakingToken.balanceOf(address(this)) is 1500.

  9. Then the attacker finalizes the unstake process for the first 500 tokens.

  10. Now the totalSupply is 1500 tokens and the stakingToken.balanceOf(address(this)) is 1000.

  11. Then the attacker claims rewards and receives about 500 / 1500 part of distributed rewards (600), i.e. ~200 tokens.

  12. Then the attacker starts the unstake process for another 500 tokens and finalizes it after the VESTING_PERIOD.

  13. Now the totalSupply is 1000 tokens and the stakingToken.balanceOf(address(this)) is 500.

  14. The attacker has profit and the totalSupply is undercollateralized.

Was this helpful?