sherlock-audit / 2024-05-gamma-staking-judging

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

HChang26 - `earlyExitById()` and `exitLateById()` calls near the end of `lockPeriod` are vulnerable to attacks.

sherlock-admin2 opened this issue · comments

HChang26

medium

earlyExitById() and exitLateById() calls near the end of lockPeriod are vulnerable to attacks.

Summary

earlyExitById() and exitLateById() calls near the end of lockPeriod are vulnerable to attacks.

Vulnerability Detail

Locks are automatically re-locked at the end of the lockPeriod for the lesser of lockPeriod or defaultRelockTime.

The protocol offers 2 methods for stakers to unstake, earlyExitById() and exitLateById(). Both methods use calcRemainUnlockPeriod() to calculate unlockTime.

    function calcRemainUnlockPeriod(LockedBalance memory userLock) public view returns (uint256) {
        uint256 lockTime = userLock.lockTime;
        uint256 lockPeriod = userLock.lockPeriod;

        if (lockPeriod <= defaultRelockTime || (block.timestamp - lockTime) < lockPeriod) {
            return lockPeriod - (block.timestamp - lockTime) % lockPeriod;
        } else {
            return defaultRelockTime - (block.timestamp - lockTime) % defaultRelockTime;
        }
    }

exitLateById() is the default method, where the lock id stops accumulating rewards immediately. The unlockTime/cool-down period is calculated by calcRemainUnlockPeriod(). Funds are only available for withdrawal after unlockTime.

For example:
The unlockTime is dictated by modulo logic in function calcRemainUnlockPeriod(). So if the lock time were 30 days, and the user staked for 50 days, he would have been deemed to lock for a full cycle of 30 days, followed by 20 days into the second cycle of 30 days, and thus will have 10 days left before he can withdraw his funds.

Based on this design, it is in stakers' best interest to invoke exitLateById() towards the end of a lockPeriod when there are only a few seconds left before it auto re-locks for another 30 days. This maximizes rewards and minimizes the cool-down period.

If a staker invokes exitLateById() 1 minute before auto re-lock, two scenarios can occur depending on when the transaction is mined:

  1. If the transaction is mined before the auto re-lock, the staker's funds become available after a 1-minute cool-down period.
  2. If the transaction is mined after the auto re-lock, the staker's funds will not be available for another 30 days.

Attackers can grief honest stakers by front-running exitLateById() with a series of dummy transactions to fill up the block. If exitLateById() is mined after the auto re-lock, the staker must wait another 30 days. The only way to avoid this is to call exitLateById() well before the end of the lockPeriod when block stuffing is not feasible, reducing the potential reward earned by the staker.

    function exitLateById(uint256 id) external {
        _updateReward(msg.sender);

        LockedBalance memory lockedBalance = locklist.getLockById(msg.sender, id); // Retrieves the lock details from the lock list as a storage reference to modify.

     ->uint256 coolDownSecs = calcRemainUnlockPeriod(lockedBalance);
        locklist.updateUnlockTime(msg.sender, id, block.timestamp + coolDownSecs);

        uint256 multiplierBalance = lockedBalance.amount * lockedBalance.multiplier;
        lockedSupplyWithMultiplier -= multiplierBalance;
        lockedSupply -= lockedBalance.amount;
        Balances storage bal = balances[msg.sender];
        bal.lockedWithMultiplier -= multiplierBalance;
        bal.locked -= lockedBalance.amount;

        locklist.setExitedLateToTrue(msg.sender, id);

        _updateRewardDebt(msg.sender); // Recalculates reward debt after changing the locked balance.

        emit ExitLateById(id, msg.sender, lockedBalance.amount); // Emits an event logging the details of the late exit.
    }

earlyExitById() is the second method to unstake. In this function, the staker pays a penalty but can access funds immediately. The penalty consists of a base penalty plus a time penalty, which decreases linearly over time. The current configuration sets the minimum penalty at 15% and the maximum penalty at 50%.

    function earlyExitById(uint256 lockId) external whenNotPaused {
        if (isEarlyExitDisabled) {
            revert EarlyExitDisabled();
        }
        _updateReward(msg.sender);
        LockedBalance memory lock = locklist.getLockById(msg.sender, lockId);

        if (lock.unlockTime != 0)
            revert InvalidLockId();

        uint256 coolDownSecs = calcRemainUnlockPeriod(lock);
        lock.unlockTime = block.timestamp + coolDownSecs;
        uint256 penaltyAmount = calcPenaltyAmount(lock);
        locklist.removeFromList(msg.sender, lockId);
        Balances storage bal = balances[msg.sender];
        lockedSupplyWithMultiplier -= lock.amount * lock.multiplier;
        lockedSupply -= lock.amount;
        bal.locked -= lock.amount;
        bal.lockedWithMultiplier -= lock.amount * lock.multiplier;
        _updateRewardDebt(msg.sender);

        if (lock.amount > penaltyAmount) {
            IERC20(stakingToken).safeTransfer(msg.sender, lock.amount - penaltyAmount);
            IERC20(stakingToken).safeTransfer(treasury, penaltyAmount);
            emit EarlyExitById(lockId, msg.sender, lock.amount - penaltyAmount, penaltyAmount);
        } else {
            IERC20(stakingToken).safeTransfer(treasury, lock.amount);
        emit EarlyExitById(lockId, msg.sender, 0, penaltyAmount);
        }
    }

The penalty is calculated in calcPenaltyAmount(), using unlockTime from calcRemainUnlockPeriod().

    function calcPenaltyAmount(LockedBalance memory userLock) public view returns (uint256 penaltyAmount) {
        if (userLock.amount == 0) return 0; // Return zero if there is no amount locked to avoid unnecessary calculations.
        uint256 unlockTime = userLock.unlockTime;
        uint256 lockPeriod = userLock.lockPeriod;
        uint256 penaltyFactor;


        if (lockPeriod <= defaultRelockTime || (block.timestamp - userLock.lockTime) < lockPeriod) {

            penaltyFactor = (unlockTime - block.timestamp) * timePenaltyFraction / lockPeriod + basePenaltyPercentage;
        }
        else {
            penaltyFactor = (unlockTime - block.timestamp) * timePenaltyFraction / defaultRelockTime + basePenaltyPercentage;
        }

        // Apply the calculated penalty factor to the locked amount.
        penaltyAmount = userLock.amount * penaltyFactor / WHOLE;
    }

Using the sample example above, if a user invokes earlyExitById() on day 50, the penalty is as follows:

Time penalty = (10 / 30) * 35%
Base penalty = 15%
Total penalty = 26.66%

However, an issue arises when earlyExitById() is triggered near the end of the lockPeriod. Depending on when the transaction is mined, two scenarios can occur(extreme numbers were used to demonstrate impact):

  1. If earlyExitById() is mined at 59 days, 23 hours, 59 minutes, and 59 seconds, the time penalty is essentially 0%.
  2. If earlyExitById() is mined exactly at 60 days, this results in 100% of the time penalty.

The 1-second difference can mean the difference between the minimum penalty and the maximum penalty. An unexpectedly high penalty can occur if the transaction is sent with lower-than-average gas, causing it not to be picked up immediately. Attackers can cause stakers to incur the maximum penalty by block stuffing and delaying their transaction.

Impact

Funds may be locked for longer than expected in exitLateById()
Penalty may be greater than expected in earlyExitById()

Code Snippet

https://github.com/sherlock-audit/2024-05-gamma-staking/blob/main/StakingV2/src/Lock.sol#L313
https://github.com/sherlock-audit/2024-05-gamma-staking/blob/main/StakingV2/src/Lock.sol#L349
https://github.com/sherlock-audit/2024-05-gamma-staking/blob/main/StakingV2/src/Lock.sol#L596
https://github.com/sherlock-audit/2024-05-gamma-staking/blob/main/StakingV2/src/Lock.sol#L569

Tool used

Manual Review

Recommendation

No recommendation for exitLateById() since the optimal way to use this function is near the end of a lockPeriod.

Consider adding slippage protection to earlyExitById().

-   function earlyExitById(uint256 lockId) external whenNotPaused {
+   function earlyExitById(uint256 lockId, uint256 expectedAmount) external whenNotPaused {
        if (isEarlyExitDisabled) {
            revert EarlyExitDisabled();
        }
        _updateReward(msg.sender);
        LockedBalance memory lock = locklist.getLockById(msg.sender, lockId);

        if (lock.unlockTime != 0)
            revert InvalidLockId();

        uint256 coolDownSecs = calcRemainUnlockPeriod(lock);
        lock.unlockTime = block.timestamp + coolDownSecs;
        uint256 penaltyAmount = calcPenaltyAmount(lock);
        locklist.removeFromList(msg.sender, lockId);
        Balances storage bal = balances[msg.sender];
        lockedSupplyWithMultiplier -= lock.amount * lock.multiplier;
        lockedSupply -= lock.amount;
        bal.locked -= lock.amount;
        bal.lockedWithMultiplier -= lock.amount * lock.multiplier;
        _updateRewardDebt(msg.sender);

+       require(expectedAmount >= lock.amount - penaltyAmount);
        if (lock.amount > penaltyAmount) {
            IERC20(stakingToken).safeTransfer(msg.sender, lock.amount - penaltyAmount);
            IERC20(stakingToken).safeTransfer(treasury, penaltyAmount);
            emit EarlyExitById(lockId, msg.sender, lock.amount - penaltyAmount, penaltyAmount);
        } else {
            IERC20(stakingToken).safeTransfer(treasury, lock.amount);
        emit EarlyExitById(lockId, msg.sender, 0, penaltyAmount);
        }
    }

Hi @santipu03 , i hope you are good.
I've following concerns on the cited dups .

#45 is not a valid duplicate as it takes about user's action of calling exitlatebyId multiple times

#307 is does not show any loss of funds and vaguely states and do not clarify how does the issue actually cause loss of funds

#59 demonstrates the use of block stuffing to force staker into another cycle and does not lead to user losing funds - also as you mentioned in my issue This isn't a feasible attack because the attacker would be losing tens of thousands of dollars each day (gas prices would go to the moon when an address is filling all blocks) just to gain nothing but to grieve others.
although not for days but tons of blocks would be needed to be filled to force the victim into new cycle which is also infeasible
additionally since there is no loss of funds due to getting higher penalty i don't believe this is a valid duplicate and even valid in the first place

The above mentioned according my reasons above are invalid and should not be duplicates`

however i would be looking forward to what others think.

Escalate

I would like to escalate regarding the grouping of these issues

There are two impacts caused in two different functions by not having a protection parameter.

  1. Causes loss of funds for the user through greater penalty amount.
  2. Causes locking of funds for the user.

Submissions that identify both impacts

#65
#42
#228
#294
#62 (0xRajKumar)
#115 (0xRajKumar)
#209 (pkqs90)
#212 (pkqs90)

Submissions that only identify one impact

#15
#16
#41
#95
#97
#111
#150
#202

Furthermore I am not sure if #59 and #307 should be placed in any of these groups because I believe they are invalid

I believe it is only fair for the Watsons who have identified both impacts to be treated in a separate group, I do agree that the fix is the same for both functions, but the underlying functions and impacts are different.

Escalate

I would like to escalate regarding the grouping of these issues

There are two impacts caused in two different functions by not having a protection parameter.

  1. Causes loss of funds for the user through greater penalty amount.
  2. Causes locking of funds for the user.

Submissions that identify both impacts

#65
#42
#228
#294
#62 (0xRajKumar)
#115 (0xRajKumar)
#209 (pkqs90)
#212 (pkqs90)

Submissions that only identify one impact

#15
#16
#41
#95
#97
#111
#150
#202

Furthermore I am not sure if #59 and #307 should be placed in any of these groups because I believe they are invalid

I believe it is only fair for the Watsons who have identified both impacts to be treated in a separate group, I do agree that the fix is the same for both functions, but the underlying functions and impacts are different.

You've created a valid escalation!

To remove the escalation from consideration: Delete your comment.

You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final.

Hi there,

The issue MUST be HIGH @santipu03

because the issue causes Definite loss of funds

Additionally due to high likelihood and impact , the issue clearly deems to be a valid high..

looking forward to know what others think.

Regarding the escalation of @omar-ahsan:

I considered the root cause of this issue to be the following: The lack of slippage parameters on exit functions will cause an unexpected loss or a lock of funds.

I agree that there are two impacts that will be caused by two functions, however, the root cause is still the same. According to the Sherlock guidelines, all issues that identify the same root cause with a valid attack path and impact should be considered duplicates, and that's why I grouped all those issues under this report.

Issue #59 I considered valid because it identifies the root cause and describes a valid attack path and impact. Issue #307 also describes a correct scenario where a user may have his transaction delayed and pay extra penalties.


Regarding @0xreadyplayer1 comment, please don't try to chop definitions so they fit in your argument, the definition of a high-severity issue includes that it must not require extensive requirements or external states. For this issue to be triggered, a user must exit a lock within the last minutes of their period, which is already an unlikely scenario overall.

Moreover, most of Ethereum nodes discard a transaction when it has been in the mempool for some time and is still not confirmed, if I remember correctly it was about 2 hours for Geth. This means that if a transaction hasn't been confirmed for 2 hours, it won't get executed at all. Taking this into account, this issue will only be triggered when a user tries to exit a lock with a low-gas transaction less than 2 hours before the lock period finishes, which I consider an extensive external condition.

For this reason, this issue warrants medium severity.

Agree @santipu03 will take care next time sir 💯

@santipu03 While I wouldn't necessarily argue this issue is definitely a high, I disagree with your argument. You said that a user exiting a lock within the last minutes of the period is unlikely but that is not the case. As a matter of fact, especially for exiting late, every user would try to exit as late as possible, even in the last second if they could in order to not miss out on new potential rewards.

@santipu03 I agree the root cause is the same that there exists a lack of slippage parameters on both functions

There is also a statement in the rules guideline which states

In case the same vulnerability appears across multiple places in different contracts, they can be considered duplicates.
The exception to this would be if underlying code implementations, impact, and the fixes are different, then they can be treated separately.

My escalation stems from the recent judgement made in Zivoe Content on this particular group of issues where the above statement was used to group certain issues

The head agreed that the core issue is the same, the function containing the vulnerability is the same but the the impact and fixes are different hence the issues were regrouped accordingly. I believe that not all 3 are required i.e. code implementations, impact, and the fixes to be different. In this case the functions and impacts are different while the fix is the same for both functions.

I would like to hear the opinion of the head on this and accept the decision which will be made by them.

@omar-ahsan
Have you looked at my issue carefully?
Two impacts of early and late exit were mentioned.
“User may not withdraw at expectation time, or pay more penalty than expected.”
It involves executing a transaction almost at the nearly unlock time.
Paying more penalty means losing user's funds.
I don't understand why my issue is not valid.

@petro1912 Your submission targets the calcRemainUnlockPeriod() function but does not explain through which functions the user can face these impacts, so the path is not defined, only the function through which the time is calculated.

In case the same vulnerability appears across multiple places in different contracts, they can be considered duplicates.
The exception to this would be if underlying code implementations, impact, and the fixes are different, then they can be treated separately.

@omar-ahsan In this case, the impact is different but the fixes are the same, so following the above rules, all reports should be considered duplicates. The rules declare an exception, but this issue here doesn't meet the requirements for that exception.

@santipu03 like I mentioned, the head made a ruling in Zivoe when the function was same, impact was different and the fixes were different, this leads me to believe that not all 3 are required to be different. Here in this case the functions and impacts are different but fixes are same.

I believe medium severity is appropriate here, since early exits will be an uncommon occurence and users executing an early exit for a lock within the last minutes.seconds of their period, further reduces likelihood.

@omar-ahsan I also agree with lead judge comments here regarding duplication, since it is clear within sherlock rules for duplication of issues with same root cause and fixes.

commented

@nevillehuang if #45 is a user mistake, why is this one not a user mistake? Submitting a transaction minutes or seconds before expiry is done knowingly, so the user is accepting the risk - all users know that blockhain transactions aren't immediate, and in this case they know their lock will be re-locked if not included in time.

There is no vulnerability here, the contract works as intended, and all that happened is that the user took a reckless risk for likely 0 reward.

They gain 0 reward form this because a reward distribution must happen in the few seconds / minutes before the lock expiry (because rewards are immediately distributed) - making this behavior not only a user mistake, but also a highly unlikely one due to lack of incentive.

@guhu95 This issue describes a scenario where the user is calling the functions correctly before lock expires but is forced to suffer a higher penalty from early exits (a difference as large as 45% penalty). There is a loss of fund here and there is a fix that could prevent this from occuring.

In issue #45, as long as user waits for finality and not call late exits twice, their funds will never be locked, and relocks will be performed as intended per code logic (since their late exit would apply for an expired lock), so they will not lose their funds, and in fact would have the possibility to gain more rewards from the reward while retaining multiplier. After discussions with sherlocks internal judge, we conclude it is invalid/low severity based on user error.

commented

@nevillehuang

.. but is forced to suffer a higher penalty from early exits

They are not forced, they take the risk knowingly by submitting the tx seconds before the re-lock. They know they will be relocked if the transaction executes late.

Furthermore, there's no incentive to do so as explained above. The only chance of any reward is if a distribution by the admin happens in these few seconds, and if e.g., they will happen every two weeks it means there - so if they submit 10 seconds before - there's a 0.0008% chance a distribution will happen in these 10 seconds.

There is a loss of fund here and there is a fix that could prevent this from occuring.

There is no loss of funds since a user exiting seconds before will not call "early" exit, they will call "late" exit. So this is exactly the same situation - they will just get relocked.

commented

The reason a user exiting seconds before will not call "early" exit, is exactly because they lose some of their stake (a tiny amount) for no gain. That's because calling "late" will lose nothing, and will result in all of the stake returned immediately (because it's seconds before expiry).

If the user is so sensitive to a minuscule chance of a reward distribution happening in the last few seconds, they will also avoid losing part of their stake to the penalty, so will call "late".

This issue is entirely a user mistake, that the user commits with full knowledge, for no gain, and then just gets relocked for 30 days. Everything works as intended.

At the point when user calls an early exit, the state at which he expects it to be included is correct, that is he should not be penalized for an early exit/should not be penalized so heavily for an early exit, so he is using the function correctly. This is not user mistake to me, so I believe medium severity is appropriate.

However, for issue #45 to be true, the user must have called late exit twice, which goes against the intended use case of the functionality. So my final decision would still be to reject escalation and keep this issue a valid medium severity.

commented

At the point when user calls an early exit, the state at which he expects it to be included is correct, that is he should not be penalized for an early exit/should not be penalized so heavily for an early exit, so he is using the function correctly. This is not user mistake to me, so I believe medium severity is appropriate.

A user will not call early exit in this issue, please have another look at #65 (comment) . The "early exit", with penalty, is an incorrect scenario in this case. The user will only call "late exit".

the state at which he expects it to be included is correct

Why is that correct? The users knows that they will be auto-relocked, and they know how penalties work, and they know blockchains execution happens with a delay. The user knows they have a very high chance of being relocked if calling seconds before, so they are aware.

The project itself has a complete exit mechanism (of course, we can think that the exit conditions are unfriendly, but you have accepted this setting when you choose to enter.). Users have the right to choose to maximize their benefits to execute the exit, and they should also bear the risk of failure, which is normal. We can assume that adding exit protection will still result in the following two situations:

  1. The same operation of maximizing benefits will eventually be rolled back and continued to deposit, the difference is that there is a return, so the question is, since you can accept continuous deposits, why do you want to exit?
  2. If you want to exit, it is impossible to maximize your profits. Execute early and exit successfully.
    I don't think anyone would pay off their credit card at the last minute, as they will also be fined.

I still stand by my comments here, at the point where user calls an early exit, they should expect to not be penalized, so this is not considered them not utilizing the protocol inappropriately. Still planning to reject escalation and leave issue as it is.

Result:
Medium
Has duplicates

Escalations have been resolved successfully!

Escalation status:

The Lead Senior Watson signed off on the fix.