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: 20/126
Findings: 2
Award: $28.81
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: SpicyMeatball
Also found by: 0rpse, 0xMosh, 0xblack_bird, 0xdice91, 0xhacksmithh, 0xleadwizard, 0xmystery, Audinarey, AvantGard, Bigsam, Dots, EPSec, Eeyore, Janio, Limbooo, LinKenji, Mahmud, MrPotatoMagic, Myd, Oxsadeeq, Sabit, SovaSlava, Stefanov, Tychai0s, Utsav, Varun_05, Walter, adam-idarrha, ahmedaghadi, araj, aslanbek, ayden, bigtone, c0pp3rscr3w3r, carrotsmuggler, crypticdefense, dhank, fyamf, gajiknownnothing, gavfu, itsabinashb, jasonxiale, joaovwfreire, ke1caM, leegh, merlinboii, mitko1111, n4nika, pfapostol, prapandey031, rouhsamad, sandy, snakeeaterr, stakog, steadyman, swizz, tedox, th3l1ghtd3m0n, trachev, turvy_fuzz, xyz, yashgoel72, zhaojohnson
0.0105 USDC - $0.01
Malicious users can unlock token before minLockDuration
by calling setLockDuration()
function due to invalid unlock time calculation in the setLockDuration()
function.
During the lockdrop period(period between lockdrop.start
and lockdrop.end
),
unrevealed nfts are added for the player using the addReveal()
function. During this period, the _lockDuration
must be >= lockdrop.minLockDuration
.
_lock(): 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)); } }
But, the user can call setLockDuration()
function to bypass this minLockDuration check.
Let's assume, minLockDuration
is set to 30 days. Now, if the _lockDuration
value is set anything below 30 days, this check will revert in the _lock
function:
if ( _lockDuration < lockdrop.minLockDuration || _lockDuration > uint32(configStorage.getUint(StorageKey.MaxLockDuration))) revert InvalidLockDurationError();
and if the _lockDuration
hasn't been set previously by the user, it will be defaulted to minLockDuration
.
_lock(): if (_lockDuration == 0) { _lockDuration = lockdrop.minLockDuration; }
Now, let's assume user hasn't set any lock duration using setLockDuration()
function. Thus, the _lockDuration
will be set to 30 days(suppose).
Theoretical PoC:
_lockDuration
30 days.block.timestamp
is 0, thus the unlock time will be set at timestamp 0 + 30 * 86400 = 2592000
(30 days timestamp) and last lock time will be set to 0._lock(): lockedToken.lastLockTime = uint32(block.timestamp); lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration);
setLockDuration()
with _duration
value 15 days
. Now, inside the setLockDuration()
function, there is a check to make sure _duration
is not set before unlock time.setLockDuration(): if (uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime) { revert LockDurationReducedError(); }
Here, block.timestamp is 15 days, _duration
supplied is also 15 days and unlock time is 30 days
. Thus, this check passes as 15 + 15 !< 30
.
lastLockTime
+ _duration
supplied.setLockDuration(): uint32 lastLockTime = lockedTokens[msg.sender][tokenContract].lastLockTime; lockedTokens[msg.sender][tokenContract].unlockTime = lastLockTime + uint32(_duration);
i.e in our case 0 + 15. Thus, unlock time
is updated by attacker to 15 days, and now the attacker can withdraw at any time and 30 days minLockDuration is bypassed.
The root cause is
setLockDuration()
function can be called anytime and the_duration
value supplied tosetLockDuration()
function is added withlastLockTime
value instead ofblock.timestamp
while setting new update time.
Manual Analysis
In the setLockDuration()
function, update the unlockTime as such:
function setLockDuration(uint256 _duration) external notPaused { if (_duration > configStorage.getUint(StorageKey.MaxLockDuration)) { revert MaximumLockDurationError(); } playerSettings[msg.sender].lockDuration = uint32(_duration); uint256 configuredTokensLength = configuredTokenContracts.length; for (uint256 i; i < configuredTokensLength; i++) { address tokenContract = configuredTokenContracts[i]; if (lockedTokens[msg.sender][tokenContract].quantity > 0) { // check they are not setting lock time before current unlocktime if (uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime) { revert LockDurationReducedError(); } - uint32 lastLockTime = lockedTokens[msg.sender][tokenContract].lastLockTime; - lockedTokens[msg.sender][tokenContract].unlockTime = lastLockTime + uint32(_duration); + lockedTokens[msg.sender][tokenContract].unlockTime = uint32(block.timestamp) + uint32(_duration); } } emit LockDuration(msg.sender, _duration); }
Other
#0 - c4-judge
2024-06-04T12:41:33Z
alex-ppg marked the issue as duplicate of #89
#1 - c4-judge
2024-06-05T12:53:04Z
alex-ppg marked the issue as partial-75
28.7985 USDC - $28.80
User can unlock all amount including remainder amount but remainder amount is not deleted from the lockedTokens
mapping.
When locking, in the _lock()
function, remainder
amount is added to supplied _quantity
. But, if this the user first time locking, lockedToken.remainder
amount will be 0
.
lock(): uint256 quantity = _quantity + lockedToken.remainder;
Then, remainder
amount is calculated as:
lock(): remainder = quantity % configuredToken.nftCost;
Now, remainder
and _quantity
are stored as:
lock(): lockedToken.remainder = remainder; lockedToken.quantity += _quantity;
Up until now, for a user first time locking,
lockedToken.remainder
= 0remainder
= 100 wei_quantity
= 1e18 + 100 weiNow, if the user unlocks all amount(1e18 + 100 wei) using unlock()
function,
unlock(): lockedToken.quantity -= _quantity;
All the amount, i.e 1e18 + 100 wei
including remainder is withdrawn but remainder
is never deleted in the unlock()
function.
function unlock(address _tokenContract, uint256 _quantity) external notPaused nonReentrant { LockedToken storage lockedToken = lockedTokens[msg.sender][_tokenContract]; if (lockedToken.quantity < _quantity) { revert InsufficientLockAmountError(); } if (lockedToken.unlockTime > uint32(block.timestamp)) { revert TokenStillLockedError(); } // force harvest to make sure that they get the schnibbles that they are entitled to accountManager.forceHarvest(msg.sender); lockedToken.quantity -= _quantity; // send token if (_tokenContract == address(0)) { payable(msg.sender).transfer(_quantity); } else { IERC20 token = IERC20(_tokenContract); token.transfer(msg.sender, _quantity); } emit Unlocked(msg.sender, _tokenContract, _quantity); }
Thus when the user locks again, the 100 wei
remainder amount is again added to the supplied quantity:
_lock(): uint256 quantity = _quantity + lockedToken.remainder;
And the cycle continues as long as the user locks and unlocks again and again and there exists remainder amount.
The root cause is calculating
remainder
from supplied amount, adding previous remainder and letting withdraw all amount including remainder amount.
Manual Analysis
Modify the unlock()
function as such:
function unlock(address _tokenContract, uint256 _quantity) external notPaused nonReentrant { LockedToken storage lockedToken = lockedTokens[msg.sender][_tokenContract]; if (lockedToken.quantity < _quantity) { revert InsufficientLockAmountError(); } if (lockedToken.unlockTime > uint32(block.timestamp)) { revert TokenStillLockedError(); } // force harvest to make sure that they get the schnibbles that they are entitled to accountManager.forceHarvest(msg.sender); lockedToken.quantity -= _quantity; + if (lockedToken.quantity == 0) { + delete lockedToken.remainder; + } else if (lockedToken.quantity <= lockedToken.remainder) { + lockedToken.remainder = lockedToken.remainder - lockedToken.quantity; + } // send token if (_tokenContract == address(0)) { payable(msg.sender).transfer(_quantity); } else { IERC20 token = IERC20(_tokenContract); token.transfer(msg.sender, _quantity); } emit Unlocked(msg.sender, _tokenContract, _quantity); }
The above modification will delete remainder
amount if all the amount is withdrawn or set the remainder amount to remaining amount left. The else if
condition is used in case if some malicious user leaves 1 wei in his account to re-claim the already withdrawn remainder amount.
Other
#0 - c4-judge
2024-06-05T13:04:16Z
alex-ppg marked the issue as satisfactory
#1 - c4-judge
2024-06-05T13:04:46Z
alex-ppg changed the severity to 2 (Med Risk)