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: 36/126
Findings: 2
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.0056 USDC - $0.01
A malicious user can prevent another user's stake from being unlocked/withdrawn for an indefinite amount of time at very little cost (1 wei).
A user's stake can be locked basically forever due to a malicious user depositing on their behalf and extending the stake period / locked time.
A malicious user can lock another user's stake forever by depositing arbitrary amounts through the lockOnBehalf()
function.
Any user can deposit an arbitrary amount of funds on behalf of any other user, the only requirement is for both accounts to be registered and for the user which is calling the lockOnBehalf()
function to have enough funds to cover the deposited amount, which can be 1 wei.
The LockManager contract / _lock()
function will extend the user's stake for the lockDuration
or for the minimum lock duration if such isn't set to the whole amount which is deposited to that account, no matter what the secondary/following deposit amount is.
A malicious user can take advantage of this and basically lock another user(s) stake/deposit forever.
By calling lockOnBehalf()
which subsequently calls _lock()
with the malicious user address being passed as the _tokenOwner
argument and the victim as the _lockRecipient
.
function _lock( address _tokenContract, uint256 _quantity, address _tokenOwner, address _lockRecipient ) private { ( address _mainAccount, MunchablesCommonLib.Player memory _player ) = accountManager.getPlayer(_lockRecipient); if (_mainAccount != _lockRecipient) revert SubAccountCannotLockError(); if (_player.registrationDate == 0) revert AccountNotRegisteredError(); // check approvals and value of tx matches if (_tokenContract == address(0)) { if (msg.value != _quantity) revert ETHValueIncorrectError(); } else { if (msg.value != 0) revert InvalidMessageValueError(); IERC20 token = IERC20(_tokenContract); 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); // set their lock duration in playerSettings playerSettings[_lockRecipient].lockDuration = _lockDuration; emit Locked( _lockRecipient, _tokenOwner, _tokenContract, _quantity, remainder, numberNFTs, _lockDuration ); }
_amount
in the msg.value or if they've given approval to the contract for that amount depending on whether we're using native tokens or ERC20.After that, it will continue with the standard flow and increase the user's lock period for what they've set as the lock period or for the minimum one.
unlock
transactions or just calling lock
at arbitrary times in order to extend the lock.setLockDuration()
function if it's used to shorten the already existing lock period.Here's a coded PoC which can be used in the SpeedRun.t.sol test suite in order to test this:
function testDoS() public { uint256 lockAmount1Wei = 1 wei; uint256 victimLockAmount = 5 ether; console.log("Beginning run()"); deployContracts(); // register me amp.register(MunchablesCommonLib.Realm(3), address(0)); logSnuggery("Initial snuggery"); //register victim vm.prank(victim); amp.register(MunchablesCommonLib.Realm(3), address(0)); vm.prank(victim); lm.setLockDuration(15 days); vm.prank(victim); lm.lock{value: victimLockAmount}(address(0), victimLockAmount); vm.warp(16 days); lm.lockOnBehalf{value: lockAmount1Wei}(address(0), lockAmount1Wei, victim); vm.startPrank(victim); vm.expectRevert(); lm.unlock(address(0), victimLockAmount); vm.stopPrank(); }
Manual Review
lockOnBehalf()
to be called only on behalf of users which have previously approved addresses to be able to do this, something similar like allowances in terms of ERC20 tokens.DoS
#0 - c4-judge
2024-06-05T12:58:44Z
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
Due to a missing check on approveUSDPrice()
a single price feed role can first disapprove a price and then approve it, basically casting two votes instead of one.
When a price feed role wants to disapprove a price there are checks which make sure that they are not the proposer, or they haven't already approved the price:
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();
Although some of the above-mentioned validations are not present in the approveUSDPrice()
function.
A price feed role is able to both disapprove and approve a proposed price, as long as they first disapprove it, and then approve it.
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(); usdUpdateProposal.approvals[msg.sender] = _usdProposalId; usdUpdateProposal.approvalsCount++;
disapprovals[msg.sender]
to the current id.approveUSDPrice()
if they've casted a vote for the price disapproval, will be able to also approve the price, effectively casting two votes.Manual Review
Include the following check in approveUSDPrice()
:
if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) revert ProposalAlreadyDisapprovedError();
Invalid Validation
#0 - c4-judge
2024-06-05T12:42:32Z
alex-ppg marked the issue as satisfactory