Skip to content
This repository has been archived by the owner on Jan 12, 2025. It is now read-only.

cocacola - Voting does not take into account end of staking lock period #166

Open
sherlock-admin3 opened this issue Jul 15, 2024 · 18 comments
Labels
Escalation Resolved This issue's escalations have been approved/rejected Has Duplicates A valid issue with 1+ other issues describing the same vulnerability High A High severity issue. Reward A payout will be made for this issue Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed

Comments

@sherlock-admin3
Copy link
Contributor

sherlock-admin3 commented Jul 15, 2024

cocacola

High

Voting does not take into account end of staking lock period

Summary

The protocol allows to vote in Voter contract by means of staked position in MlumStaking. To vote, user must have staking position with certain properties. However, the voting does not implement check against invariant that the remaining lock period needs to be longer then the epoch time to be eligible for voting. Thus, it is possible to vote with stale voting position. Additionally, if position's lock period finishes inside of the voting epoch it is possible to vote, withdraw staked position, stake and vote again in the same epoch. Thus, voting twice with the same stake amount is possible from time to time. Ultimately, the invariant that voting once with same balance is only allowed is broken as well.
The voting will decide which pools receive LUM emissions and how much.

Vulnerability Detail

The documentation states that:

Who is allowed to vote
Only valid Magic LUM Staking Position are allowed to vote. The overall lock needs to be longer then 90 days and the remaining lock period needs to be longer then the epoch time.

User who staked position in the MlumStaking contract gets NFT minted as a proof of stake with properties describing this stake. Then, user can use that staking position to vote for pools by means of vote() in Voter contract. The vote() functions checks if initialLockDuration is higher than _minimumLockTime and lockDuration is higher than _periodDuration to process further. However, it does not check whether the remaining lock period is longer than the epoch time.
Thus, it is possible to vote with stale staking position.
Also, current implementation makes renewLockPosition and extendLockPosition functions useless.

    function vote(uint256 tokenId, address[] calldata pools, uint256[] calldata deltaAmounts) external {
        if (pools.length != deltaAmounts.length) revert IVoter__InvalidLength();

        // check voting started
        if (!_votingStarted()) revert IVoter_VotingPeriodNotStarted();
        if (_votingEnded()) revert IVoter_VotingPeriodEnded();

        // check ownership of tokenId
        if (_mlumStaking.ownerOf(tokenId) != msg.sender) {
            revert IVoter__NotOwner();
        }

        uint256 currentPeriodId = _currentVotingPeriodId;
        // check if alreay voted
        if (_hasVotedInPeriod[currentPeriodId][tokenId]) {
            revert IVoter__AlreadyVoted();
        }

        // check if _minimumLockTime >= initialLockDuration and it is locked
        if (_mlumStaking.getStakingPosition(tokenId).initialLockDuration < _minimumLockTime) {
            revert IVoter__InsufficientLockTime();
        }
        if (_mlumStaking.getStakingPosition(tokenId).lockDuration < _periodDuration) {
            revert IVoter__InsufficientLockTime();
        }

        uint256 votingPower = _mlumStaking.getStakingPosition(tokenId).amountWithMultiplier;

        // check if deltaAmounts > votingPower
        uint256 totalUserVotes;
        for (uint256 i = 0; i < pools.length; ++i) {
            totalUserVotes += deltaAmounts[i];
        }

        if (totalUserVotes > votingPower) {
            revert IVoter__InsufficientVotingPower();
        }

        IVoterPoolValidator validator = _poolValidator;

        for (uint256 i = 0; i < pools.length; ++i) {
            address pool = pools[i];

            if (address(validator) != address(0) && !validator.isValid(pool)) {
                revert Voter__PoolNotVotable();
            }

            uint256 deltaAmount = deltaAmounts[i];

            _userVotes[tokenId][pool] += deltaAmount;
            _poolVotesPerPeriod[currentPeriodId][pool] += deltaAmount;

            if (_votes.contains(pool)) {
                _votes.set(pool, _votes.get(pool) + deltaAmount);
            } else {
                _votes.set(pool, deltaAmount);
            }

            _notifyBribes(_currentVotingPeriodId, pool, tokenId, deltaAmount); // msg.sender, deltaAmount);
        }

        _totalVotes += totalUserVotes;

        _hasVotedInPeriod[currentPeriodId][tokenId] = true;

        emit Voted(tokenId, currentPeriodId, pools, deltaAmounts);
    }

The documentation states that minimum lock period for staking to be eligible for voting is 90 days.
The documentation states that voting for pools occurs biweekly.

Thus, assuming the implementation with configuration presented in the documentation, every 90 days it is possible to vote twice within the same voting epoch by:

  • voting,
  • withdrawing staked amount,
  • creating new position with staking token,
  • voting again.
    function createPosition(uint256 amount, uint256 lockDuration) external override nonReentrant {
        // no new lock can be set if the pool has been unlocked
        if (isUnlocked()) {
            require(lockDuration == 0, "locks disabled");
        }

        _updatePool();

        // handle tokens with transfer tax
        amount = _transferSupportingFeeOnTransfer(stakedToken, msg.sender, amount);
        require(amount != 0, "zero amount"); // createPosition: amount cannot be null

        // mint NFT position token
        uint256 currentTokenId = _mintNextTokenId(msg.sender);

        // calculate bonuses
        uint256 lockMultiplier = getMultiplierByLockDuration(lockDuration);
        uint256 amountWithMultiplier = amount * (lockMultiplier + 1e4) / 1e4;

        // create position
        _stakingPositions[currentTokenId] = StakingPosition({
            initialLockDuration: lockDuration,
            amount: amount,
            rewardDebt: amountWithMultiplier * (_accRewardsPerShare) / (PRECISION_FACTOR),
            lockDuration: lockDuration,
            startLockTime: _currentBlockTimestamp(),
            lockMultiplier: lockMultiplier,
            amountWithMultiplier: amountWithMultiplier,
            totalMultiplier: lockMultiplier
        });

        // update total lp supply
        _stakedSupply = _stakedSupply + amount;
        _stakedSupplyWithMultiplier = _stakedSupplyWithMultiplier + amountWithMultiplier;

        emit CreatePosition(currentTokenId, amount, lockDuration);
    }

Proof of Concept

Scenario 1:

function testGT_vote_twice_with_the_same_stake() public {
        vm.prank(DEV);
        _voter.updateMinimumLockTime(2 weeks);

        _stakingToken.mint(ALICE, 1 ether);

        vm.startPrank(ALICE);
        _stakingToken.approve(address(_pool), 1 ether);
        _pool.createPosition(1 ether, 2 weeks);
        vm.stopPrank();

        skip(1 weeks);

        vm.prank(DEV);
        _voter.startNewVotingPeriod();

        vm.startPrank(ALICE);
        _voter.vote(1, _getDummyPools(), _getDeltaAmounts());
        vm.expectRevert(IVoter.IVoter__AlreadyVoted.selector);
        _voter.vote(1, _getDummyPools(), _getDeltaAmounts());
        vm.stopPrank();

        assertEq(_voter.getTotalVotes(), 1 ether);

        skip(1 weeks + 1);

        vm.startPrank(ALICE);
        _pool.withdrawFromPosition(1, 1 ether);
        vm.stopPrank();

        vm.startPrank(ALICE);
        vm.expectRevert();
        _voter.vote(1, _getDummyPools(), _getDeltaAmounts());
        _stakingToken.approve(address(_pool), 1 ether);
        _pool.createPosition(1 ether, 2 weeks);
        _voter.vote(2, _getDummyPools(), _getDeltaAmounts());
        vm.stopPrank();

        assertEq(_voter.getTotalVotes(), 2 ether);
    }

Scenario 2:

function testGT_vote_twice_with_the_same_stake() public {
        vm.prank(DEV);
        _voter.updateMinimumLockTime(2 weeks);

        _stakingToken.mint(ALICE, 1 ether);

        vm.startPrank(ALICE);
        _stakingToken.approve(address(_pool), 1 ether);
        _pool.createPosition(1 ether, 2 weeks);
        vm.stopPrank();

        skip(1 weeks);

        vm.prank(DEV);
        _voter.startNewVotingPeriod();

        vm.startPrank(ALICE);
        _voter.vote(1, _getDummyPools(), _getDeltaAmounts());
        vm.expectRevert(IVoter.IVoter__AlreadyVoted.selector);
        _voter.vote(1, _getDummyPools(), _getDeltaAmounts());
        vm.stopPrank();

        assertEq(_voter.getTotalVotes(), 1 ether);

        skip(1 weeks + 1);

        vm.startPrank(ALICE);
        _pool.withdrawFromPosition(1, 1 ether);
        vm.stopPrank();

        vm.startPrank(ALICE);
        vm.expectRevert();
        _voter.vote(1, _getDummyPools(), _getDeltaAmounts());
        _stakingToken.approve(address(_pool), 1 ether);
        _pool.createPosition(1 ether, 2 weeks);
        _voter.vote(2, _getDummyPools(), _getDeltaAmounts());
        vm.stopPrank();

        assertEq(_voter.getTotalVotes(), 2 ether);
    }

Impact

A user can vote with stale staking position, then withdraw the staking position with any consequences.
Additionally, a user can periodically vote twice with the same balance of staking tokens for the same pool to increase unfairly the chance of the pool being selected for further processing.

Code Snippet

https://github.com/sherlock-audit/2024-06-magicsea/blob/main/magicsea-staking/src/Voter.sol#L172
https://github.com/sherlock-audit/2024-06-magicsea/blob/main/magicsea-staking/src/Voter.sol#L175

Tool used

Manual Review

Recommendation

It is recommended to enforce the invariant that the remaining lock period must be longer than the epoch time to be eligible for voting.
Additionally, It is recommended to prevent double voting at any time. One of the solution can be to prevent voting within the epoch if staking position was not created before epoch started.

@github-actions github-actions bot added High A High severity issue. Has Duplicates A valid issue with 1+ other issues describing the same vulnerability labels Jul 21, 2024
This was referenced Jul 21, 2024
@0xSmartContract 0xSmartContract added Has Duplicates A valid issue with 1+ other issues describing the same vulnerability and removed Duplicate A valid issue that is a duplicate of an issue with `Has Duplicates` label labels Jul 28, 2024
@sherlock-admin2
Copy link

The protocol team fixed this issue in the following PRs/commits:
https://github.com/metropolis-exchange/magicsea-staking/pull/25

@sherlock-admin4 sherlock-admin4 changed the title Bright Clay Antelope - Voting does not take into account end of staking lock period cocacola - Voting does not take into account end of staking lock period Jul 29, 2024
@sherlock-admin4 sherlock-admin4 added the Reward A payout will be made for this issue label Jul 29, 2024
@chinmay-farkya
Copy link

Escalate

Some issues have been incorrectly duped with this issue :

#219 is not a duplicate and is actually invalid as it involves a trusted admin to use a function at a wrong time. The assertion that the admin will start a new voting period when one is already running is completely unreasonable. Also, the readme clearly mentions that a keeper bot is meant to start new epochs periodically and its off-chain and out-of-scope.
#306 is not a duplicate and is invalid as it ties to the same reasoning for #219 above
#504 is again not a duplicate as it ties to the same reasoning for #219 above
#405 is again not a duplicate as it ties to the same reasoning for #219 above
#407 is not a duplicate and is invalid due to the same reason as #219, cited above.

#252 is not a duplicate of this and is invalid/low because it just points out a difference between docs and code but it has no real impact or loss of funds. It talks about the ability to claim rewards later after more epoch periods have passed, which is completely normal and does not relate to a malicious action or a loss to the staker.
#413 is invalid because frontrunning is not possible on iota
#419 is a completely different issue and is a low because its an admin controlled action. Admin can wait for the current voting period to end, then update minimumLockTime and then start a new voting period. Since admin is trusted, they are expected to wait for current period to end and only checking time config starting from the next period.
From sherlock docs

An admin action can break certain assumptions about the functioning of the code. Example: Pausing a collateral causes some users to be unfairly liquidated or any other action causing loss of funds. This is not considered a valid issue.

There is a separate set of issues described as “expired positions can keep harvesting rewards”. This is not the same as what this issue 166 describes. Fixing 166 by not allowing voting using already expired or to-be-expired-soon during the voting period is not going to fix this set of issues, because harvesting rewards will still be possible in MLUMStaking.sol.

This set includes: #6, #530, #362 and #253

This set should be made a separate medium issue. It requires expired locks to be kicked or harvesting to be disabled for already expired locks.

@sherlock-admin3
Copy link
Contributor Author

Escalate

Some issues have been incorrectly duped with this issue :

#219 is not a duplicate and is actually invalid as it involves a trusted admin to use a function at a wrong time. The assertion that the admin will start a new voting period when one is already running is completely unreasonable. Also, the readme clearly mentions that a keeper bot is meant to start new epochs periodically and its off-chain and out-of-scope.
#306 is not a duplicate and is invalid as it ties to the same reasoning for #219 above
#504 is again not a duplicate as it ties to the same reasoning for #219 above
#405 is again not a duplicate as it ties to the same reasoning for #219 above
#407 is not a duplicate and is invalid due to the same reason as #219, cited above.

#252 is not a duplicate of this and is invalid/low because it just points out a difference between docs and code but it has no real impact or loss of funds. It talks about the ability to claim rewards later after more epoch periods have passed, which is completely normal and does not relate to a malicious action or a loss to the staker.
#413 is invalid because frontrunning is not possible on iota
#419 is a completely different issue and is a low because its an admin controlled action. Admin can wait for the current voting period to end, then update minimumLockTime and then start a new voting period. Since admin is trusted, they are expected to wait for current period to end and only checking time config starting from the next period.
From sherlock docs

An admin action can break certain assumptions about the functioning of the code. Example: Pausing a collateral causes some users to be unfairly liquidated or any other action causing loss of funds. This is not considered a valid issue.

There is a separate set of issues described as “expired positions can keep harvesting rewards”. This is not the same as what this issue 166 describes. Fixing 166 by not allowing voting using already expired or to-be-expired-soon during the voting period is not going to fix this set of issues, because harvesting rewards will still be possible in MLUMStaking.sol.

This set includes: #6, #530, #362 and #253

This set should be made a separate medium issue. It requires expired locks to be kicked or harvesting to be disabled for already expired locks.

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.

@sherlock-admin4 sherlock-admin4 added the Escalated This issue contains a pending escalation label Aug 2, 2024
@0xSmartContract
Copy link
Collaborator

#219, #306, #504, #405, #407 , #419; This group is considered invalid under "Admin Input and responsibility", I agree.

#252 low/Invalid , I agree (There is an escalation process in 252, so 252 should be evaluated as low/invalid.)

#413 I do not agree.(The verdict is that front-running on IOTA is valid)
This issue was discussed and confirmed with Sherlock officials;
The verdict is that front-running on IOTA is valid, but can be at most medium severity, since it's not reliable and uncontrollable by the attacker, though they can mitigate randomness with sending lot's of transactions.
But later this year, this will be invalid, cause when IOTA launches version 2.0, they will have no mempool at all, while the current one is public.

#6, #530, #362, #253 Set includes . I do not agree

@0xf1b0
Copy link

0xf1b0 commented Aug 5, 2024

#413 is invalid because frontrunning is not possible on iota

Front running becomes possible when the reward schedule is known in advance. There's no necessity to monitor a pool and send transactions within the same block; this can be accomplished an hour earlier.

Although it may not be the most suitable title, the issue remains definitively valid.

@J4X-98
Copy link

J4X-98 commented Aug 5, 2024

#6, #530, #362, #253 Set includes . I do not agree

@0xSmartContract what do you mean by that?

Additionally regarding #413, tx are randomly ordered on the IOTA evm which makes frontrunning impossible. The IOTA EVM documentation also states this:

"To protect against Maximal Extractable Value attacks, the IOTA Smart Contract chain natively randomizes transaction ordering, making it impossible to determine the content of a block in order to manipulate it."

@0xSmartContract
Copy link
Collaborator

#413 I do not agree.(The verdict is that front-running on IOTA is valid)
This issue was discussed and confirmed with Sherlock officials;
The verdict is that front-running on IOTA is valid, but can be at most medium severity, since it's not reliable and uncontrollable by the attacker, though they can mitigate randomness with sending lot's of transactions.
But later this year, this will be invalid, cause when IOTA launches version 2.0, they will have no mempool at all, while the current one is public.

Front-running on IOTA is valid
I also said that there was no front-run like you, but Sherlock confirmed it , @WangSecurity ;

This issue was discussed and confirmed with Sherlock officials;
The verdict is that front-running on IOTA is valid, but can be at most medium severity, since it's not reliable and uncontrollable by the attacker, though they can mitigate randomness with sending lot's of transactions.
But later this year, this will be invalid, cause when IOTA launches version 2.0, they will have no mempool at all, while the current one is public.

@J4X-98
Copy link

J4X-98 commented Aug 5, 2024

@0xSmartContract,

If the head of judging is of that opinion I guess we'll have to accept it. Although it makes almost no sense on a sandwiching attack, as the user would only be able to extract a minor part of the funds as he has to split his capital used for the sandwich into many txs.

Nevertheless could you tell me what you meant by the quoted part on top of my message?

@0xSmartContract
Copy link
Collaborator

@0xSmartContract,

If the head of judging is of that opinion I guess we'll have to accept it. Although it makes almost no sense on a sandwiching attack, as the user would only be able to extract a minor part of the funds as he has to split his capital used for the sandwich into many txs.

Nevertheless could you tell me what you meant by the quoted part on top of my message?

A set was mentioned in Escalate, I just wanted to say that this issue should not be separated into a different set.

@J4X-98
Copy link

J4X-98 commented Aug 5, 2024

@0xSmartContract,

What's your reasoning for that? As mentioned by me on the escalation of #6 these are completely different issues.

@0xSmartContract
Copy link
Collaborator

0xSmartContract commented Aug 6, 2024

@0xSmartContract,

What's your reasoning for that? As mentioned by me on the escalation of #6 these are completely different issues.

#6 and #166

  • Both issues focus on gaining unfair advantage through positions whose lock period has expired.
  • Both issues deal with the ability of positions whose lock period has expired to remain valid in the current system.
  • Both issues propose that positions should be revoked if their lock period has expired to prevent this situation.

@chinmay-farkya
Copy link

chinmay-farkya commented Aug 7, 2024

I disagree with your take that these are the same issues. Just because they happen to be in the same component of functionality, they can't be duped.

One problem exists in the voter contract, and another exists in MLUMStaking. In 166, we need to check for remaining lock duration which can also expire during the period itself.

Thus the solution for 6 which is to kick expired positions is not going to solve 166 as positions that have not expired yet but soon-to-expire will still be able to take advantage in voting. They have separate problems and solutions, and are not dups.

Both issues focus on gaining unfair advantage through positions whose lock period has expired.

Not entirely true. 166 is also about soon-to-expire positions (ie. anytime in the next 14 days) and they can't be kicked.

Both issues deal with the ability of positions whose lock period has expired to remain valid in the current system.

Again not true due to the involvement of soon-to-expire positions

Both issues propose that positions should be revoked if their lock period has expired to prevent this situation.

Now that is completely false. 166 proposes to check remaining duration and not to "revoke positions" as some positions might still be active and would expire during the period itself, they can't be simply kicked.

@J4X-98
Copy link

J4X-98 commented Aug 7, 2024

  • Both issues focus on gaining unfair advantage through positions whose lock period has expired.

This is a similar impact. But this is no reason for grouping issues. If you find 10 different ways to drain funds in a contract with different root causes you also can't merge them into one issue just because they all have the same impact.

  • Both issues deal with the ability of positions whose lock period has expired to remain valid in the current system.

This is a similar scenario, but still no reason for duping. That would be the same if you had multiple issues that occur while a contract is paused, but all originate from different root causes. You also can't duplicate those together.

  • Both issues propose that positions should be revoked if their lock period has expired to prevent this situation.

This is not true. #166 does not state anything about revoking the positions once they are expired. It just fixes the if statement.

@WangSecurity
Copy link

About the escalation comment:

  1. coffiasd - starting new voting period not check current period has already ended #219, pwning_dev - Overlapping Voting Periods #306, scammed - Voter::startNewVotingPeriod lacks check if the current voting period has ended #504, HugoDowsers - Protocol can pay rewards twice for a small period of time #405, PeterSR - Start new voting period could be called during active period #407 -- agree to invalidate based on the reasoning in the escalation comment.
  2. 252 is already considered on its escalation.
  3. fibonacci - MlumStaking rewards deposit can be front-run #413 -- As I understand, the front-running is still possible (until the launch of IOTA V2 with no mempool), but unreliable. But, the report doesn't have sufficient proof of the issue considering there are other constraints of the lock duration, for example. Hence, I agree it should be invalid.
  4. 0xBhumii - Lack of Validation for Updating Minimum Lock Time in Voting Contract #419 -- agree with the escalation comment, I believe it's precise.
  5. I agree that issues Reentrants - Expired positions remain valid without change to multipliers #6, jah - a user can still receive a reward even after the lock expires #530, typicalHuman - Users can harvest expired positions and receive the full amount of rewards with their funds being already unlocked #362 and ChinmayF - Expired Positions on MLUMStaking keep earning staking rewards #253 are a different family. Reentrants - Expired positions remain valid without change to multipliers #6 is escalated, so the discussion about them will be continued under Reentrants - Expired positions remain valid without change to multipliers #6.

Planning to accept the escalation and apply the changes above.

@WangSecurity
Copy link

Result:
High
Has duplicates

@sherlock-admin2 sherlock-admin2 removed the Escalated This issue contains a pending escalation label Aug 13, 2024
@sherlock-admin3 sherlock-admin3 added the Escalation Resolved This issue's escalations have been approved/rejected label Aug 13, 2024
@sherlock-admin4
Copy link

Escalations have been resolved successfully!

Escalation status:

@sherlock-admin2
Copy link

The Lead Senior Watson signed off on the fix.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Escalation Resolved This issue's escalations have been approved/rejected Has Duplicates A valid issue with 1+ other issues describing the same vulnerability High A High severity issue. Reward A payout will be made for this issue Sponsor Confirmed The sponsor acknowledged this issue is valid Will Fix The sponsor confirmed this issue will be fixed
Projects
None yet
Development

No branches or pull requests

8 participants