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: 34/126
Findings: 3
Award: $0.02
🌟 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
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L382-L384 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L401-L427 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L275-L294 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L311-L398 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L256-L261
LockManager
allows users to lock tokens in return for rewards
. They can unlock their tokens once their respective unlockTime
has passed.
An attacker can permanently lock another user's tokens by calling lockOnBehalf()
with 0 amount, updating the innocent user's unlockTime
to the next lockDuration
.
The attacker can repeat this before the unlockTime
has passed, permanently locking the tokens in LockManager
.
Alice decides to lock 1000e18
USDB with a lockDuration
of 1 days
:
lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration);
After the unlockTime
has passed, Alice decides to unlock her tokens by calling LockManager::unlock
.
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); }
An attacker can front-run this transaction by calling lockOnBehalf()
with _quantity = 0
, and _onBehalfOf = Alice's address
.
function lockOnBehalf( address _tokenContract, uint256 _quantity, //@audit 0 amount address _onBehalfOf //@audit Alice's address ) 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); }
function _lock( address _tokenContract, uint256 _quantity, //@audit 0 amount address _tokenOwner, //@audit Address of attacker address _lockRecipient //@audit Alice's address ) private { // check approvals and value of tx matches if (_tokenContract == address(0)) { if (msg.value != _quantity) revert ETHValueIncorrectError(); //@audit this will not revert if msg.value == _quantity where msg.value and _quantity are both 0 } else { if (msg.value != 0) revert InvalidMessageValueError(); //@audit if msg.value != 0, we revert because that means the user accidentally sent ETH when they intend to send ERC20 token. IERC20 token = IERC20(_tokenContract); //@audit these checks will pass if _quantity is 0, because allowance is 0 as well. uint256 allowance = token.allowance(_tokenOwner, address(this)); if (allowance < _quantity) revert InsufficientAllowanceError(); } LockedToken storage lockedToken = lockedTokens[_lockRecipient][ _tokenContract ]; ConfiguredToken storage configuredToken = configuredTokens[ _tokenContract ]; // they will receive schnibbles at the new rate since last harvest if not for force harvest accountManager.forceHarvest(_lockRecipient); // add remainder from any previous lock uint256 quantity = _quantity + lockedToken.remainder; uint256 remainder; uint256 numberNFTs; 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); //@audit unlockTime is updated with the original lock duration plus the current timestamp // set their lock duration in playerSettings playerSettings[_lockRecipient].lockDuration = _lockDuration; }
As we can see, the allowance checks will pass if 0 amount is specified as the quantity
parameter. The unlockTime
will be updated to lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration)
.
When Alice's unlock()
transaction is executed:
if (lockedToken.unlockTime > uint32(block.timestamp)) //@audit unlockTime was just updated, so this will be true revert TokenStillLockedError();
It will revert. This process can be repeated to permanently lock her tokens.
It's important to note that there is a function setLockDuration()
that allows users to change their unlockTime
. However, this function cannot reduce their unlockTime
, it can only extend it, as per the following check:
if ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime ) { revert LockDurationReducedError(); }
So if Alice attempts to reduce her unlockTime
, it will revert.
Manual Review.
Revert if 0 amount is specified as quantity
:
function _lock( address _tokenContract, uint256 _quantity, address _tokenOwner, address _lockRecipient ) private { + if(_quantity == 0) revert zeroAmount();
DoS
#0 - c4-judge
2024-06-05T12:58:41Z
alex-ppg marked the issue as partial-75
🌟 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
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L245-L269 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L382-L384 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L256-L261
LockManager
allows users to lock tokens in return for rewards
. They can unlock their tokens once their respective unlockTime
has passed.
LockManager::setLockDuration()
allows any user to update their unlockTime
if they decide to extend the time to keep their tokens locked.
An attacker can cause DoS
of setLockDuration()
by updating the user's unlockTime
by locking on behalf of them with 0 amount.
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) { if ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime //@audit they cannot reduce unlockTime ) { revert LockDurationReducedError(); } uint32 lastLockTime = lockedTokens[msg.sender][tokenContract] .lastLockTime; lockedTokens[msg.sender][tokenContract].unlockTime = lastLockTime + uint32(_duration); } } emit LockDuration(msg.sender, _duration); }
If lockedTokens[msg.sender][tokenContract].quantity > 0
, that means the user already has locked some tokens, and if they attempt to reduce their unlockTime
, the function will revert.
An attacker can front-run this transaction and call lockOnBehalf
with quantity = 0
and specify _onBehalfOf
as the address of the user.
Their unlockTime
will be updated:
lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration);
If the user's _duration
specified is less than block.timestamp + _lockDuration
, their setLockDuration()
call will revert.
if ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime //@audit they cannot reduce unlockTime ) { revert LockDurationReducedError(); }
Manual Review
Revert if 0 amount is specified as quantity
:
function _lock( address _tokenContract, uint256 _quantity, address _tokenOwner, address _lockRecipient ) private { + if(_quantity == 0) revert zeroAmount();
DoS
#0 - c4-judge
2024-06-05T12:58:31Z
alex-ppg marked the issue as partial-75
#1 - c4-judge
2024-06-05T13:00:01Z
alex-ppg changed the severity to 3 (High Risk)
🌟 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#L381-L384 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L256-L269
LockManager
allows users to lock tokens in return for rewards
. They can unlock their tokens once their respective unlockTime
has passed.
setLockDuration()
allows users to update their lockDuration
, which will also update their unlockTime
. Users are only allowed to specify a lockDuration
that extends their unlockTime
, not one that reduces it.
However, the unlockTime
is incorrectly updated from the lastLockTime
, rather than from the current unlockTime
. This allows a user to reduce their unlockTime
, breaking a protocol invariant.
When users lock their tokens, the following values are set:
lockedToken.lastLockTime = uint32(block.timestamp); lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration);
After the unlockTime
has passed, users can unlock their tokens and receive rewards.
During their lock period
, users can update their unlockTime
by calling setLockDuration()
with a new lockDuration
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); }
We revert if uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime
(where _duration
is the new duration), because a protocol invariant is that users cannot reduce their unlockTime
.
The unlockTime
is then updated to lastLockTime + uint32(_duration)
.
Consider the following example:
Alice locks her tokens by calling lock()
with the initial _lockDuration
of 10 days. lastLockTime
is set to the current block.timestamp
and unlockTime
is set to block.timestamp + 10 days
.
Now, let's assume 5 days
have passed since she has locked.
Alice calls setLockDuration()
with the new _duration
as 6 days
. The uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime
check will not execute because 6 days from the current block.timestamp
will be 11 days
from the original lock time
(since 5 days
have already passed). This is greater than the unlockTime
, which is 10 days
from the original lock time
.
To put it more simply:
uint32(block.timestamp) + uint32(_duration)
= 6 days from now, which is 11 days after the initial lock (since 5 days have passed).
lockedTokens[msg.sender][tokenContract].unlockTime
= 5 days from now, since it was set to 10 days after the initial lock.
So uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime
is false, and the unlockTime
will be updated.
Recall that lastLockTime
is the time when Alice originally locked her tokens.
So the new unlockTime
is set to lastLockTime + uint32(_duration)
, which is 6 days
from the original lock date. Alice will now be able to unlock her tokens 6 days from the original lock time (which is 1 day from now, since 5 days have already passed), when she should have only been able to unlock her tokens after 10 days
.
In this case, she bypassed the protocol invariant where users cannot reduce lock duration.
Manual Review.
Update the new _duration
from the unlockTime
, rather than from the original lock time lastLockTime
. Considering the above example, if Alice now specifies 6 days
as the new lock duration
, it will not change the unlock time
from 10 days
to 6 days
from the original time, but rather 6 more days
from the current unlock time
, which is total of 16 days
from when she originally locked her tokens.
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 += _duration; } } emit LockDuration(msg.sender, _duration); }
Invalid Validation
#0 - c4-judge
2024-06-04T12:41:45Z
alex-ppg marked the issue as duplicate of #89
#1 - c4-judge
2024-06-05T12:52:36Z
alex-ppg marked the issue as partial-75
🌟 Selected for report: robertodf99
Also found by: 0xAadi, 0xAkira, 0xdice91, 0xhacksmithh, 0xleadwizard, AgileJune, Bauchibred, Bbash, Beosin, Bigsam, Dots, EPSec, EaglesSecurity, Eeyore, Evo, John_Femi, Mahmud, MrPotatoMagic, RotiTelur, Rushkov_Boyan, Sabit, Sentryx, Stormreckson, Topmark, Tychai0s, Utsav, Walter, ZanyBonzy, ZdravkoHr, adam-idarrha, araj, aslanbek, avoloder, bigtone, brevis, brgltd, carrotsmuggler, crypticdefense, dd0x7e8, dhank, djanerch, falconhoof, iamandreiski, joaovwfreire, leegh, merlinboii, mitko1111, pamprikrumplikas, pfapostol, prapandey031, swizz, trachev, twcctop, typicalHuman, unique, xyz
0.0148 USDC - $0.01
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L225-L226 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L191-L197
LockManager
allows users to lock tokens in return for rewards
. They can unlock their tokens once their respective unlockTime
has passed.
When users unlock their tokens, AccountManager::forceHarvest()
is called, which gives users reward based off the USD
value of their locked tokens.
This USD
value can be changed at anytime by a proposer
with Pricefeed
role via a call to proposeUSDPrice()
.
If enough Pricefeed
roles approve the new USD
value, that value will then be updated.
Pricefeed
roles can also disapprove the proposed USD
value. If enough disapprove, then the proposal will be rejected.
There is a check in place to ensure that if a Pricefeed
role has already approved a proposal, then they cannot disapprove it.
However, this invariant can be broken by disapproving first and then proceeding to approve the same proposal.
When an address with Pricefeed
role disapproves a proposed USD
value, there is a check to see if they already approved
if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) revert ProposalAlreadyApprovedError();
This is to ensure that when a proposal is approved by a Pricefeed
role, the same address cannot disapprove the same proposal. This is an invariant where the same msg.sender cannot approve and disapprove the same proposal.
However, when approving, there is no check to see if the Pricefeed
role has already disapproved the proposal.
if (usdUpdateProposal.proposer == address(0)) revert NoProposalError(); if (usdUpdateProposal.proposer == msg.sender) revert ProposerCannotApproveError(); if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) revert ProposalAlreadyApprovedError(); if (usdUpdateProposal.proposedPrice != _price) revert ProposalPriceNotMatchedError();
The same Pricefeed
role can approve a proposal they already disapproved, breaking a protocol invariant.
Manual Review.
Upon approval, revert if the caller had already disapproved the proposal:
function approveUSDPrice( uint256 _price ) external onlyOneOfRoles( [ Role.PriceFeed_1, Role.PriceFeed_2, Role.PriceFeed_3, Role.PriceFeed_4, Role.PriceFeed_5 ] ) { if (usdUpdateProposal.proposer == address(0)) revert NoProposalError(); if (usdUpdateProposal.proposer == msg.sender) revert ProposerCannotApproveError(); if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) revert ProposalAlreadyApprovedError(); + if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) + revert ProposalAlreadyDisapprovedError(); if (usdUpdateProposal.proposedPrice != _price) revert ProposalPriceNotMatchedError(); usdUpdateProposal.approvals[msg.sender] = _usdProposalId; usdUpdateProposal.approvalsCount++; if (usdUpdateProposal.approvalsCount >= APPROVE_THRESHOLD) { _execUSDPriceUpdate(); } emit ApprovedUSDPrice(msg.sender); }
Invalid Validation
#0 - c4-judge
2024-06-05T12:42:27Z
alex-ppg marked the issue as satisfactory