31413 - [SC - Medium] DOS attack by delegating tokens at MAX_DELEGATES

Submitted on May 18th 2024 at 20:04:09 UTC by @savi0ur for Boost | Alchemix

Report ID: #31413

Report type: Smart Contract

Report severity: Medium

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

Impacts:

  • 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 own
    if (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 added
            uint256[] memory dstTokensNew = new uint256[](dstTokensOld.length + ownerTokenCount);

            // Copy array
            for (uint256 i = 0; i < dstTokensOld.length; i++) {
                dstTokensNew[i] = dstTokensOld[i];
            }

            // Plus all that's owned
            for (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 update
            uint32 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 timestamp
            if (dstCheckpoints == 0 || checkpoints[dst][dstCheckpoints - 1].timestamp != block.timestamp) {
                numCheckpoints[dst] = dstCheckpoints + 1;
            }
        }
    }
}

https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L1033-L1052

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

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.

References

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L1033-L1052

  • https://github.com/alchemix-finance/alchemix-v2-dao/blob/f1007439ad3a32e412468c4c42f62f676822dc1f/src/VotingEscrow.sol#L1103-L1130

Proof Of Concept

Steps to Run using Foundry:

  • 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

Console Output:

Last updated

Was this helpful?