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: 28/126
Findings: 3
Award: $0.03
🌟 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.0056 USDC - $0.01
The protocol allows users to lock tokens to earn rewards and mint NFTs. However, a critical flaw in the lockOnBehalf
function permits any user to indefinitely extend the lock duration of another user's funds, potentially leading to denial of service (DOS) as users cannot access or unlock their locked tokens. and also allowing to DOS the ability of user to change their lockDuration using setLockDuration
The lockOnBehalf
function enables a user to lock tokens on behalf of another user, calling the internal _lock
function to handle the locking mechanism:
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); }
Within _lock
, the unlock time for the funds is reset based on the current block timestamp plus the designated lock duration, effectively allowing the resetting of the lock period:
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;
This mechanism can be exploited by malicious actors to continually extend the lock duration by initiating a new lock just before the unlock time is reached:
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); }
The attacker can effectively prevent the intended unlock of funds by repeatedly using lockOnBehalf to extend the lock duration, causing a denial of service.
if (_lockDuration == 0) { _lockDuration = lockdrop.minLockDuration; }
the attacker can extend the lock duration while locking only 1 wei.
he can also DOS the setLockDuration
by locking before the user calls it , leading to a revert in this condition:
if ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime )
assumptions
Lock Funds
lockOnBehalf(address(0), 1, Alice)
DOS of changing lockDuration
setLockDuration(15 days)
lockOnBehalf(address(0), 1, Alice)
if ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime )
DOS for users trying to unlock their funds , and permanent locking of users funds, and ability to change the lockDuration which impacts his schnibble reward amount.
Manual Review
do not allow other users to stake on behalf of another user, remove the function lockOnBehalf
DoS
#0 - c4-judge
2024-06-05T12:58:29Z
alex-ppg marked the issue as satisfactory
🌟 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.014 USDC - $0.01
The protocol allows users to lock funds to mint NFTs, requiring a minimum lock duration. However, the setLockDuration
function permits users to reduce their lock duration by half, circumventing the intended minimum lock period required for NFT minting.
The _lock
function is responsible for users locking their tokens to mint NFTs, with validation against the minimum and maximum lock durations:
if ( lockdrop.start <= uint32(block.timestamp) && lockdrop.end >= uint32(block.timestamp) ) { => if ( _lockDuration < lockdrop.minLockDuration || _lockDuration > uint32(configStorage.getUint(StorageKey.MaxLockDuration)) ) revert InvalidLockDurationError();
To mint an NFT, users must meet the minLockDuration
requirement. The unlock time is initially set as follows in the lock function :
lockedToken.unlockTime = uint32(block.timestamp) + uint32(_lockDuration);
The setLockDuration
function allows users to modify the duration of their locks, crucially without altering the initial unlock time for existing locks:
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); } }
The vulnerability arises because the check (uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime)
allows users to effectively halve their lock duration post-lock, bypassing the minimum lock period for benefits such as NFT minting,because the check checks block.timestamp while applying it on lastLockTime.
assumptions:
attack Scenario
if ( uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime )
this check will pass because block.timestamp + 15 days = unlockTime as only 15 days are left on the lock.lockedTokens[msg.sender] [tokenContract].unlockTime = lastLockTime + uint32(_duration) = lastLockTime + 15days
so he is able to unlock only after 15 days from when he locked .users are able to unlock their funds in half the time. allowing users to mint NFT's while only locking for minLockDuration/2. this is going to let users be able to lock and unlock more times and mint more nft's. also impacting schnibble bonus.
Manual Review
- if (uint32(block.timestamp) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime) + if (uint32(lastLockTime) + uint32(_duration) < lockedTokens[msg.sender][tokenContract].unlockTime)
Math
#0 - c4-judge
2024-06-04T12:41:40Z
alex-ppg marked the issue as duplicate of #89
#1 - c4-judge
2024-06-05T12:52:47Z
alex-ppg marked the issue as satisfactory
🌟 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
The protocol utilizes five price feeds to establish consensus on the USD price of an asset. To determine the current reported price, these feeds can either disapprove or approve a proposed price. Upon reaching a predefined threshold, a disapproved price is removed, whereas an approved price is accepted. However, due to a missing validation check in the approveUSDPrice
function, a single price feed can concurrently disapprove and approve the same price proposal.
there are 5 price Feed roles in the protocol that push the price of tokens supported by the protocol in USD, they approve and disapprove using functions approveUSDPrice
and 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(); ...
function disapproveUSDPrice( 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.approvals[msg.sender] == _usdProposalId) revert ProposalAlreadyApprovedError(); if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) revert ProposalAlreadyDisapprovedError(); if (usdUpdateProposal.proposedPrice != _price) revert ProposalPriceNotMatchedError();
as we can see in the disapproveUSDPrice
it is checked that the msgSender did not previously approve or disapprove the price. but this check is lacking in the approveUSDPrice
. allowing a priceFeed to both approve and disapprove a usd price.
a price Feed role is allowed to approve and disapprove a price , leading to wrong threshold calculation.
Manual Review
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();
Context
#0 - c4-judge
2024-06-05T12:42:25Z
alex-ppg marked the issue as satisfactory