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: 19/126
Findings: 4
Award: $28.81
🌟 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#L401-L410 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L275-L294
A Normal User funds get locked in contract, beacuse each time an Malicious Attacker can frontrun User's unlock()
Tx and lock some DUST amount on behalf him(User's behalf). In result unlockTime
for Normal User increased and his unlock()
Tx failed.
So a user can unlock his locked fund via calling unlock()
when unlockTime
passed, below is the following code segment
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)) //@audit revert TokenStillLockedError();
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L410-L412
But this protocol allow Any user to lock for another User via lockOnBehalf()
One major issue here is that there are no minimum amountchecks for _quantity
locked
basically a User can call lockOnBehalf() with DUST amount like 1wei
for another user's behalf & function flow will like below
lockOnBehalf() --> _lock()
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L275-L294
Whenever a lock happens following state change changes happen
lockedToken.remainder = remainder; lockedToken.quantity += _quantity; lockedToken.lastLockTime = uint32(block.timestamp); lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration);
Here we see that with each lock, unlockTime
set to uint32(block.timestamp) + uint32(_lockDuration)
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L382-L384
here _lockDuration
could be playerSettings[_lockRecipient].lockDuration
Or if this value not present then lockdrop.minLockDuration
uint32 _lockDuration = playerSettings[_lockRecipient].lockDuration; if (_lockDuration == 0) { _lockDuration = lockdrop.minLockDuration; }
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L347-L351
So With each DUST lock, unlockTime
increased via _lockDuration
period
By Following step this attack preformed
lock()
some fundunlockTime
passed he wanted to withdraw his fund and call unlock()
lockOnBehalf()
with parameter _quantity = 1wei(Dust)
& _onBehalfOf = User's address
_lock()
get executed and stateVariables updated, so user's unlockTime now increased block.timestamp + _lockDuration
unlock()
) failed due to below checkif (lockedToken.unlockTime > uint32(block.timestamp)) revert TokenStillLockedError();
This attack against a Regualr User can lunched multiple times, so basically that user will targeted by an attacker. By doing this an Attacker can keep locking User's fund for infinity time.
Manual review
To prevent this lockOnBehalf()
should have some sort of minimum _quantity
check, so that it will become very expensive for an Attacker to keep locking User's fund.
DoS
#0 - c4-judge
2024-06-05T12:57:55Z
alex-ppg marked the issue as partial-75
#1 - 0xhacksmithh
2024-06-06T05:51:09Z
I don't understand why there is a partial
payment tag. Report correctly explain intention and attack path-way. Its a quite simple & common bug, so i think there is no necessary requirement for any coded POC for this, as Bug itself is self-explanatory.
#2 - alex-ppg
2024-06-10T11:14:44Z
Hey @0xhacksmithh, my response on the primary exhibit details why a penalty has been applied. Specifically, your recommended mitigation would not solve the vulnerability as there is no minimum quantity that can be defined to be sensible f.e. for multi-million locks.
🌟 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#L254-L268
A User can decrease his lockingPeriod
significantly and by pass it via calling setLockDuration()
with well crafted _duration
parameter. Refer POC for more detail explanation
When i talked with sponcer
According to him setLockDuration()
is used for to increase lock duration
But thats not case here. Malicious User can decrese his unlockTime
significantly by multiple time calling to setLockDuration()
with proper calculated _duration
parameter.
Infected code segment here is below
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); }
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L254-L268
Lets go with an example to understand this
Some assumption here to keep exmaple crunchy and crispy
lockdrop.minLockDuration
= 50 sec (which will be our lockDuration, cause
playerSettings[_lockRecipient].lockDuration = 0 for now
)lockedTokens[User-S address][ETH]
So now juicy part
lock()
lockedToken.unlockTime
= block.timestamp + lockDuration = 50 sec + 50 sec = 100 sec
lockedToken.lastLockTime
= block.timestamp = 50 sec
setLockDuration(40sec)
And above code segment clause checks happen as belowif ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime ) { revert LockDurationReducedError(); }
if (60 sec + 40 sec < 100 sec) { revert ...}
uint32 lastLockTime = lockedTokens[msg.sender][tokenContract] .lastLockTime; lockedTokens[msg.sender][tokenContract].unlockTime = lastLockTime + uint32(_duration);
uint32 lastLockTime = lockedToken.lastLockTime = 50 sec
lockedTokens.unlockTime
= 50 sec + 40 sec = 90 sec
1 sec passed block.timestamp at 61sec
User-S call setLockDuration(29 sec)
if (61 sec + 29 sec < 90 sec) { revert ...} => FALSE
so STATE update
uint32 lastLockTime = lockedToken.lastLockTime = 50 sec
lockedTokens.unlockTime
= 50 sec + 29 sec = 79 sec
1 sec passed block.timestamp at 62sec
User-S call setLockDuration(17 sec)
if (62 sec + 17 sec < 79 sec) { revert ...} => FALSE
so STATE update
uint32 lastLockTime = lockedToken.lastLockTime = 50 sec
lockedTokens.unlockTime
= 50 sec + 17 sec = 67 sec
So on .....
Here we can see that
initial unlockTime
for User-S = 100 sec
current unlockTime
= 67 sec although only 20 sec passed (62 - 50 sec), at 50th second initial lock
was created.
This attack could more optimized i just give above simple example to shows root cause and how this works.
Manual Review
I believe root cause is while upadting unlockTime
with new value it is using initial lastLockTime
which behave a constant in this situation as it never updated after.
Context
#0 - c4-judge
2024-06-04T12:41:44Z
alex-ppg marked the issue as duplicate of #89
#1 - c4-judge
2024-06-05T12:52:37Z
alex-ppg marked the issue as partial-75
#2 - 0xhacksmithh
2024-06-06T06:33:10Z
Don't know why Partial
payment here as well.
Bug was quite state forward. i believe explained correctly with attack pathway and Impact. Sponcer confirmed as well in PT
#3 - alex-ppg
2024-06-10T11:16:11Z
Hey @0xhacksmithh, once again the primary submission does provide some insight as to why this finding was penalized. The mitigation is incorrect, and thus a 25% reduction (i.e. 75% reward) has been applied.
🌟 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.0037 USDC - $0.00
Judge has assessed an item in Issue #546 as 2 risk. The relevant finding follows:
approve
& disapprove
same time as there is no check for disapproval
in approveUSDPrice()
A PriceFeeder
at same time can disapprove
and approve
same purposed price cause absence
of disapproval
check in approveUSDPrice()
approveUSDPrice()
has following checks
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.proposedPrice != _price) revert ProposalPriceNotMatchedError();
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L191-L197
It never checks msg.sender already disapproved current purposal or not,
So as a result priceFeeder
can first call disapproveUSDPrice()
and then call approveUSDPrice()
which is not a intended behaviour.
Implement similar check present in disapproveUSDPrice()
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.proposedPrice != _price) revert ProposalPriceNotMatchedError(); + if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) + revert ProposalAlreadyDisapprovedError();
#0 - c4-judge
2024-06-05T12:32:43Z
alex-ppg marked the issue as duplicate of #495
#1 - c4-judge
2024-06-05T12:33:21Z
alex-ppg marked the issue as partial-25
#2 - 0xhacksmithh
2024-06-06T07:26:25Z
I noticed those report which are Upgraded from QA, marked as Partial-25
although they explaining Bug correctly with mitigation similar like other satisfactory
mid
Bug report.
Why that so,
I give you my thought process why i submit this in QA rather than MID
When i asked to sponcor
Q. Role.PriceFeed can approve even he disapprove with price same time, is this intended
Replay
not intended but not too much of an issue since its trusted
So As they said feeders
are trusted and those are set by protocol itself. So i think its a just coding problem and submit it as a QA.
Even if it submited in QA, it explains Buggy Code part, its impact, and its mitigation similar like other MID
report
#3 - alex-ppg
2024-06-10T11:13:07Z
Hey @0xhacksmithh, thanks for your feedback. We have historically recommended against using Sponsor discussions when debating issues as a judge's assessment of an issue is distinct from the sponsor's. In a traditional security audit, the security auditor has the final say on the severity of items listed in their report rather than the client as it is in the client's best interest to have fewer issues / of different severity.
The submission is a valid vulnerability, and all duplicates submitted as QA reports have been awarded 25% and this will not change. I advise you to seek clarifications from sponsors rather than disclosing vulnerabilities you intend to submit as that may also result in a compromise of the contest's process.
28.7985 USDC - $28.80
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L344-L387 https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L401-L424
Nft could minted with DUST amount.
While User call unlock()
it on going to change quantity
STATE variable but not consider remainder
function unlock( address _tokenContract, uint256 _quantity ) external notPaused nonReentrant { LockedToken storage lockedToken = lockedTokens[msg.sender][ _tokenContract ]; if (lockedToken.quantity < _quantity) // @audit Remainder not considered 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; // @audit-issue // 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); }
https://github.com/code-423n4/2024-05-munchables/blob/main/src/managers/LockManager.sol#L401-L424
unlock()
work as follows
_quantity
parameter against quantity
locked by this Userquantity
corresponding to input parameter _quantity
But Problem here is its not considering to reset or adjust remainder
stored for that User
So a User can preform an attack and mint NFT with very less amount (Dust amount) due to above missing check.
Assume a NFT cost = 1 eth
Alice
call lock()
with 0.9 ETHQuantity = 0.9 ETH
uint256 quantity = _quantity + lockedToken.remainder;
remainder = 0.9 ETH
remainder = quantity % configuredToken.nftCost;
No. Nft minted = 0
numberNFTs = (quantity - remainder) / configuredToken.nftCost;
So its STATES will be
remainder
& quantity
)In my Previous Bug Report i explained how this unlockTime could by passed, This attack can preform independently or also with addition of previos one.
Alice
call unlock()
once unlockTime
reached and withdraw all(0.9 ETH)So its STATES will be
Alice
again call lock()
with 0.1 Ethquantity = _quantity + lockedToken.remainder = 0.1 ETH + 0.9 ETH remainder = 0 ETH No. Nft minted = 1
So its STATES will be
Here we can see Total locked fund for Alice is 0.1ETH
and 1 Nft minted for him.
This attack can also go beyond. An attacker can mint Nft with DUST amount by above approch.
Manual review
While unlocking
for an user function should also accounted for remainder
stored in storage
If fully unlocking happens then remainder
should reset to 0
If partial then remainder
adjusted with corresponding unlocking amount
Context
#0 - c4-judge
2024-06-05T13:04:13Z
alex-ppg marked the issue as satisfactory
#1 - c4-judge
2024-06-05T13:04:47Z
alex-ppg changed the severity to 2 (Med Risk)