Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Block stuffing
Description
Bug Description
Any user can delegate the balance of the locked veALCX NFT token amount to anyone by calling delegate(). https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L1103-L1130
function_moveAllDelegates(address owner,address src,address dst) internal {// You can only redelegate what you ownif (src != dst) {if (src !=address(0)) {// ...SNIP... }if (dst !=address(0)) {uint32 dstCheckpoints = numCheckpoints[dst];uint256[] memory dstTokensOld = dstCheckpoints >0? checkpoints[dst][dstCheckpoints -1].tokenIds: checkpoints[dst][0].tokenIds;uint256 ownerTokenCount = ownerToTokenCount[owner]; require(dstTokensOld.length + ownerTokenCount <= MAX_DELEGATES, "dst would have too many tokenIds"); //@audit DoS
// Create a new array of tokenIds, with the owner's tokens addeduint256[] memory dstTokensNew =newuint256[](dstTokensOld.length + ownerTokenCount);// Copy arrayfor (uint256 i =0; i < dstTokensOld.length; i++) { dstTokensNew[i] = dstTokensOld[i]; }// Plus all that's ownedfor (uint256 i =0; i < ownerTokenCount; i++) {uint256 tId = ownerToTokenIdList[owner][i]; dstTokensNew[dstTokensOld.length + i] = tId; }// Find the index of the checkpoint to create or updateuint32 dstIndex =_findWhatCheckpointToWrite(dst);// dst has a new or updated checkpoint with the _tokenId added checkpoints[dst][dstIndex] =Checkpoint({ timestamp: block.timestamp, tokenIds: dstTokensNew }); // <==// Add to numCheckpoints if the last checkpoint is different from the current block timestampif (dstCheckpoints ==0|| checkpoints[dst][dstCheckpoints -1].timestamp != block.timestamp) { numCheckpoints[dst] = dstCheckpoints +1; } } }}
function_moveTokenDelegates(address src,address dst,uint256_tokenId) internal {if (src != dst && _tokenId >0) {// If the source is not the zero address, we decrement the number of tokenIdsif (src !=address(0)) {// ...SNIP... }// If the destination is not the zero address, we increment the number of tokenIdsif (dst !=address(0)) {uint32 dstCheckpoints = numCheckpoints[dst];uint256[] memory dstTokensOld = dstCheckpoints >0? checkpoints[dst][dstCheckpoints -1].tokenIds: checkpoints[dst][0].tokenIds;uint256[] memory dstTokensNew =newuint256[](dstTokensOld.length + 1);require(dstTokensOld.length +1<= MAX_DELEGATES,"dst would have too many tokenIds"); //@audit DoS// Copy array plus _tokenIdfor (uint256 i =0; i < dstTokensOld.length; i++) { dstTokensNew[i] = dstTokensOld[i]; } dstTokensNew[dstTokensNew.length -1] = _tokenId;// Find the index of the checkpoint to create or updateuint32 dstIndex =_findWhatCheckpointToWrite(dst);// dst has a new or updated checkpoint with the _tokenId added checkpoints[dst][dstIndex] =Checkpoint({ timestamp: block.timestamp, tokenIds: dstTokensNew }); // <==// Add to numCheckpoints if the last checkpoint is different from the current block timestampif (dstCheckpoints ==0|| checkpoints[dst][dstCheckpoints -1].timestamp != block.timestamp) { numCheckpoints[dst] = dstCheckpoints +1; } } }}
As the delegated tokens are maintained in an array that's vulnerable to DOS attack, the VotingEscrow contract has a safety check of MAX_DELEGATES = 1024 preventing an address from having a huge array. https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L33-L34
/// @notice Maximum number of delegates a token can haveuint256publicconstant MAX_DELEGATES =1024; // avoid too much gas
Given the current implementation, any user with 1024 delegated tokens takes more than 25M gas to transfer/delegate/burn/mint a token as shown in PoC. It will consume a lot of gas as its using function like _moveTokenDelegates() and _moveAllDelegates(), which are vulnerable to DoS attack when MAX_DELEGATES limit is reached for delegated tokens. However, the current gas limit of the ETH chain is 30M. (ref: Etherscan).
This functions(_moveTokenDelegates() and _moveAllDelegates()) from VotingEscrow are called in following functions, which makes them vulnerable to DoS.
delegate()
safeTransferFrom()
transferFrom()
merge()
withdraw()
As we can see in a PoC for a single withdrawal tx, its very expensive for a user as it cost more than 25M gas for a victim.
Current cost of the tx for around 25M gas is around $1000, calculated as follow:
This cost will be even higher when block space in high demand as base fee will increase and also users are willing to offer extra to get a place for their tx in block space.
Tool to find real time gas cost for 25M gas cost: https://www.cryptoneur.xyz/en/gas-fees-calculator?usedGas=25000000&txnType=Custom&gasPrice=instant
Also it will be difficult for a user to get a space in a block space when their tx is consuming almost a gas limit of a block (30M). User's tx will get rejected by builder as its not providing any incentive (apart from tx cost) for a builder to include their tx in a block during high demand of a block space.
If someone trying to attack a victim's address by creating a new address, a new lock, and delegating to the victim. By the time the attacker hit the gas limit, the victim can not withdraw/transfer/delegate transaction in a high demand of a block space.
Impact
Due to the mentioned attack, multiple users wont be able to withdraw their tokens in the same block space as there is a gas limit of 30M on ethereum for a transaction. Users tx could land when there is less traffic on ethereum, but attacker could do block stuffing for just a 5-6 million gas for the same block to grief user's withdraw transaction.
Recommendation
Adjust the MAX_DELEGATES from 1024 to 128 Or Give an option for users to opt-out/opt-in for a certain delegated tokens. Users will only accept the delegated tokens if they opt-in, or users can opt-out to refuse any uncommissioned delegated tokens.
Paste following foundry code in src/test/VotingEscrow.t.sol
Run using FOUNDRY_PROFILE=default forge test --fork-url $FORK_URL --fork-block-number 17133822 --match-contract VotingEscrowTest --match-test testDelegateLimitAttack -vv
functiontestDelegateLimitAttack() public {// Give some tokens to this contract hevm.startPrank(address(admin));IERC20(bpt).transfer(address(this), TOKEN_1M); hevm.stopPrank();// Give full approval to voting escrowIERC20(bpt).approve(address(veALCX), type(uint256).max);uint EPOCH = veALCX.EPOCH();uint tokenId = veALCX.createLock(TOKEN_1, EPOCH,false);for(uint256 i =0; i < veALCX.MAX_DELEGATES() -1; i++) { hevm.roll(block.number +1); hevm.warp(block.timestamp +12);address fakeAccount =address(uint160(block.timestamp + i));IERC20(bpt).transfer(fakeAccount,1); hevm.startPrank(fakeAccount);IERC20(bpt).approve(address(veALCX), type(uint256).max); veALCX.createLock(1, MAXTIME,true); veALCX.delegate(address(this)); hevm.stopPrank(); } hevm.roll(block.number +1); hevm.warp(block.timestamp + EPOCH); veALCX.startCooldown(tokenId); hevm.roll(block.number +1); hevm.warp(block.timestamp + EPOCH);uint initialGas = gasleft(); veALCX.withdraw(tokenId);uint gasUsed = initialGas - gasleft(); console.log("Gas Used in total: ", gasUsed);assertGt(gasUsed,25_000_000);}