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: 6/126
Findings: 5
Award: $516.00
🌟 Selected for report: 1
🚀 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
Context: The LockManager contract has a function lockOnBehalf(), which allows anyone else to lock on behalf of a user (used by Migration manager mainly).
Issue:
The issue is that an attacker can pass 0 as the _quantity
parameter and the call will simply go through.
Impact: Since on every lock, the unlock time is extended by the lock duration (see here), the attacker can make these calls every now and then to block users from unlocking their tokens ever. Additionally, the gas price on Blast is low, which makes it easier for the attacker.
This is also an attack idea by the sponsor in the README here.
Assume user has locked 100 tokens using the lock() function previously. The unlockTime = 100 and lockDuration set by the user = anywhere between 30 days to 90 days.
Attacker calls the lockOnBehalf() with 0 as value for the _quantity
parameter and the _onBehalfOf
parameter as the user's address.
The call is not blocked anywhere in the internal _lock() function (Tip: CTRL/CMD + F the _quantity parameter in the _lock() function to see where it is used and why it is not blocked). Other than the protocol specific check for allowance here, the ERC20 standard allows zero value transfers so the call will go through.
On Line 391, we can see how the unlockTime would be extended from the current time (by atleast 30 days since that's the minLockDuration). This would prevent unlocks and thus brick the user funds in the contract.
File: LockManager.sol 390: lockedToken.lastLockTime = uint32(block.timestamp); 391: lockedToken.unlockTime = 392: uint32(block.timestamp) + 393: uint32(_lockDuration);
Manual Review
Implement an approval mechanism, which allows a user to approve a contract or EOA to lock tokens on behalf of them.
DoS
#0 - c4-judge
2024-06-05T12:58:07Z
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
Context: Function setLockDuration() allows users to set a new duration for their locks. If the user has any existing locks, the new duration + last lock time should be greater than the unlock time.
Issue: The issue is that the check here should use lastLockTime instead of block.timestamp since we use lastLockTime when updating the unlockTime - see here. (Note: The expected behaviour has been confirmed with the sponsor i.e. lock times are reset from last lock time onwards and not block.timestamp).
Impact: This allows an attacker to unlock tokens early and mint more NFTs during a lock drop event than other users by using the same tokens.
This is also an attack idea by the sponsor in the README here.
Here is the whole process:
File: LockManager.sol 247: /// @inheritdoc ILockManager 248: function setLockDuration(uint256 _duration) external notPaused { 249: if (_duration > configStorage.getUint(StorageKey.MaxLockDuration)) 250: revert MaximumLockDurationError(); 251: 252: playerSettings[msg.sender].lockDuration = uint32(_duration); 253: // update any existing lock 254: uint256 configuredTokensLength = configuredTokenContracts.length; 255: for (uint256 i; i < configuredTokensLength; i++) { 256: address tokenContract = configuredTokenContracts[i]; 257: if (lockedTokens[msg.sender][tokenContract].quantity > 0) { 258: // check they are not setting lock time before current unlocktime 259: 260: if ( 261: uint32(block.timestamp) + uint32(_duration) < 262: lockedTokens[msg.sender][tokenContract].unlockTime 263: ) { 264: revert LockDurationReducedError(); 265: } 266: 267: uint32 lastLockTime = lockedTokens[msg.sender][tokenContract] 268: .lastLockTime; 269: lockedTokens[msg.sender][tokenContract].unlockTime = 270: lastLockTime + 271: uint32(_duration); 272: } 273: } 274: 275: emit LockDuration(msg.sender, _duration); 276: }
Manual Review
Use lastLockTime instead of block.timestamp in the setLockedDuration() function.
Error
#0 - c4-judge
2024-06-04T12:41:01Z
alex-ppg marked the issue as duplicate of #89
#1 - c4-judge
2024-06-05T12:53:31Z
alex-ppg marked the issue as satisfactory
#2 - c4-judge
2024-06-05T12:54:34Z
alex-ppg changed the severity to 3 (High Risk)
🌟 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 #545 as 2 risk. The relevant finding follows:
Unlike the disapproveUSDPrice() function which validates both the disapproval and approval mapping with these checks, the approveUSDPrice() function does not check the disapproval mapping.
This allows price feed roles to first call disapproveUSDPrice(), which will pass since there has neither been an approval or disapproval from their side on the proposal yet. The function would set the disapproval mapping from price feed to the current _usdProposalId
. The price feed role can then call approveUSDPrice() and pass this check since it only checks for approval mapping and not the disapproval mapping as well.
Due to this, price feed roles can both approve and disapprove for a proposal, thereby double counting their votes.
Solution: Add the below check to the approveUSDPrice() function.
File: LockManager.sol 233: if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) 234: revert ProposalAlreadyDisapprovedError();
#0 - c4-judge
2024-06-05T12:32:12Z
alex-ppg marked the issue as duplicate of #495
#1 - c4-judge
2024-06-05T12:32:17Z
alex-ppg marked the issue as partial-25
🌟 Selected for report: PetarTolev
Also found by: Drynooo, MrPotatoMagic, bearonbike, joaovwfreire
225.2073 USDC - $225.21
Context: During a Lockdrop event, if there are extra tokens sent by the user which are not enough to cover the NFT cost, they are set as the remainder to be used next time the user locks more tokens. If the lockdrop ends without the user locking more tokens, the remainder is expected to be carried forward to the next Lockdrop event.
Issue: The issue is that after the lockdrop ends, if the user locks more tokens to earn schnibbles or an attacker or other user uses lockOnBehalfOf() to lock tokens on behalf of the user, the remainder is cleared.
Impact: This is against the expected behaviour of the protocol since the remainder amount that was expected to be carried forward to the next lockdrop event is lost. This remainder amount represented the extra tokens the user had to lock for the whole lock duration time (minimum 30 days upto 90 days max) in the previous lockdrop event. Due to this, when the user tries to use the remainder in the next event, it is not available anymore.
Note: Another way the remainder could be cleared is when the call originates from the migration manager, which ultimately routes through the lockOnBehalfOf() function. In case of the migration manager, the remainder would've been cleared both outside the lockdrop event and during the lockdrop event as well since we never reveal NFTs for migration manager. In short, this if block is skipped during lockdrops as well as during no lockdrops. For this issue, I'll only be describing the user and attacker scenario highlighted on top for simplicity, following which it should be quite intuitive to understand how this applies to the migration manager as well.
The issue is simple to understand:
Let's assume the previous campaign ended with lockedToken.remainder = 50.
Let's say the user decides to lock more tokens to earn schnibbles or a malicious attacker intentionally makes a lockOnBehalfOf() call to clear the remainder attack. Note that we are outside of the lockdrop period now.
remainder
memory variable holds a value of 0.File: LockManager.sol 347: // add remainder from any previous lock 348: uint256 quantity = _quantity + lockedToken.remainder; 349: uint256 remainder; 350: uint256 numberNFTs; 351: uint32 _lockDuration = playerSettings[_lockRecipient].lockDuration; 352: 353: if (_lockDuration == 0) { 354: _lockDuration = lockdrop.minLockDuration; 355: } 356: if ( 357: lockdrop.start <= uint32(block.timestamp) && 358: lockdrop.end >= uint32(block.timestamp) 359: ) { 360: if ( 361: _lockDuration < lockdrop.minLockDuration || 362: _lockDuration > 363: uint32(configStorage.getUint(StorageKey.MaxLockDuration)) 364: ) revert InvalidLockDurationError(); 365: if (msg.sender != address(migrationManager)) { 366: // calculate number of nfts 367: remainder = quantity % configuredToken.nftCost; 368: numberNFTs = (quantity - remainder) / configuredToken.nftCost; 369: 370: if (numberNFTs > type(uint16).max) revert TooManyNFTsError(); 371: 372: // Tell nftOverlord that the player has new unopened Munchables 373: nftOverlord.addReveal(_lockRecipient, uint16(numberNFTs)); 374: } 375: } 376: 377: // Transfer erc tokens 378: if (_tokenContract != address(0)) { 379: IERC20 token = IERC20(_tokenContract); 380: token.transferFrom(_tokenOwner, address(this), _quantity); 381: } 382: 383: 387: lockedToken.remainder = remainder; 388: lockedToken.quantity += _quantity; 389: lockedToken.lastLockTime = uint32(block.timestamp); 390: lockedToken.unlockTime = 391: uint32(block.timestamp) + 392: uint32(_lockDuration);
Through this, we observed how the value of remainder is erased and not carried forward.
Manual Review
Consider moving line 387 i.e. lockedToken.remainder = remainder;
inside the if block on Line 365 since remainder only needs to be updated during the lockdrop event. This would ensure it is never overwritten.
Error
#0 - c4-judge
2024-06-05T12:46:47Z
alex-ppg marked the issue as unsatisfactory: Invalid
#1 - mcgrathcoutinho
2024-06-05T15:46:12Z
Hi @alex-ppg, I believe this issue should be a dup of #455.
It mentions in the summary section (under the note) how this issue would occur for calls occurring through the migration manager as well.
I believe the note is quite detailed as well since it mentions that this issue would occur both outside and inside lockdrop events.
Thank you for your time!
#2 - alex-ppg
2024-06-09T12:32:07Z
Hey @mcgrathcoutinho, thanks for your PJQA contribution! The recommended mitigation step is invalid and does not resolve the issue that is briefly mentioned in the Note
you mention. In any case, the chapter does sufficiently describe that the issue would surface during the lockdrop event so a 25% reward will be assigned to it as a duplicate.
Your own text indicates that you believed more significant misbehavior arose from the migration manager, so I implore you to always report on the highest-impact execution path in your future submissions and avoid simplicity to increase the rewards you capture.
#3 - c4-judge
2024-06-10T15:00:52Z
alex-ppg marked the issue as not a duplicate
#4 - c4-judge
2024-06-10T15:00:58Z
alex-ppg marked the issue as duplicate of #455
#5 - c4-judge
2024-06-10T15:01:01Z
alex-ppg marked the issue as partial-25
🌟 Selected for report: MrPotatoMagic
Also found by: 0xAkira, Bauchibred, Deivitto, Walter, bigtone, merlinboii
290.7692 USDC - $290.77
The report consists of low-severity risk issues, governance risk issues arising from centralization vectors and a few non-critical issues in the end. The governance risks section describes scenarios in which the actors could misuse their powers.
ID | Low-severity issues |
---|---|
[L-01] | Missing setter function to change config storage contract in LockManager |
[L-02] | Missing minLockDuration validations in function configureLockdrop() |
[L-03] | Configured token decimals are not validated against token's decimals() function |
[L-04] | Missing notPaused() modifier on functions proposeUSDPrice(), approveUSDPrice(), disapproveUSDPrice() |
[L-05] | Incorrect use of only one price for all token contracts |
[L-06] | Prices can be proposed for inactive tokens |
[L-07] | Function approveUSDPrice() does not validate if price feed approves prices for the same token contract |
[L-08] | Incorrect validation in function approveUSDPrice() allows price feed roles to both approve and disapprove for a proposal Link to instance |
[L-09] | Missing minLockDuration check in function setLockDuration() |
[L-10] | Function getLockedWeightedValue() does not account for duration in schibbles calculation |
[L-11] | Negative rebase can tap into other user's tokens when a user unlocks |
[L-12] | Users can force receive schnibbles by making 0 value lock() or lockOnBehalfOf() calls |
[L-13] | Disallow users from extending their lock durations for inactive tokens |
Currently it is possible to reconfigure the BaseBlastManager state (inherited by LockManager) by using the configUpdated() function. But it is not possible to change the config storage contract using the __BaseConfigStorage_setConfigStorage() internal function existing in the BaseBlastManager.
We can see that on Line 62 of the constructor, the config storage contract is set by calling the __BaseConfigStorage_setConfigStorage() internal function. But following that if the config storage contract needs to be changed, it is not possible to do so since the internal function has not been exposed in the LockManager contract.
It's unlikely the team might want to change the config contract itself but it would be good to leave the option open in case it ever needs to be changed.
File: LockManager.sol 60: constructor(address _configStorage) { 61: 62: __BaseConfigStorage_setConfigStorage(_configStorage); 63: _reconfigure(); 64: } File: LockManager.sol 87: function configUpdated() external override onlyConfigStorage { 88: _reconfigure(); 89: }
First validation:
The function should ensure _lockdropData.end - _lockdropData.start >= _lockdropData.minLockDuration
. This ensures the lockdrop event is longer than the minimum lock times user need to lock their tokens for.
Second validation:
The function should ensure _lockdropData.minLockDuration < configStorage.getUint(StorageKey.MaxLockDuration)
. This ensure function setLockDuration() does not revert due to the check here and lock() as well due to the check here.
File: LockManager.sol 099: function configureLockdrop( 100: Lockdrop calldata _lockdropData 101: ) external onlyAdmin { 102: if (_lockdropData.end < block.timestamp) 103: revert LockdropEndedError( 104: _lockdropData.end, 105: uint32(block.timestamp) 106: ); // , "LockManager: End date is in the past"); 107: if (_lockdropData.start >= _lockdropData.end) 108: revert LockdropInvalidError(); 109: 111: lockdrop = _lockdropData; 112: 113: emit LockDropConfigured(_lockdropData); 114: }
The function does not validate _tokenData.decimals
for the _tokenContract
against the ERC20 decimals() function of the token. This validation should be put in place to not only avoid human errors but also to avoid manipulation of schnibbles calculation (see second point in Admin powers section for specific scenario on this).
File: LockManager.sol 119: function configureToken( 120: address _tokenContract, 121: ConfiguredToken memory _tokenData 122: ) external onlyAdmin { 123: if (_tokenData.nftCost == 0) revert NFTCostInvalidError(); 124: if (configuredTokens[_tokenContract].nftCost == 0) { 125: // new token 126: 127: configuredTokenContracts.push(_tokenContract); 128: } 129: 131: configuredTokens[_tokenContract] = _tokenData; 132: 133: emit TokenConfigured(_tokenContract, _tokenData); 134: }
Functions proposeUSDPrice(), approveUSDPrice(), disapproveUSDPrice() are missing the notPaused() modifier on them. This allows the price feed role to update the prices of tokens even when locks and unlocks are paused. There does not seem to be a risk associated with this but it would be better to avoid changes in token prices while the protocol is paused. This does not cause any harm as well since once the protocol wants to unpause, the team can make a pause + update prices batched call to ensure users do not profit or are affected from the previous stale price.
The function proposeUSDPrice() incorrectly uses only one price for all token contracts. This is incorrect since token contracts e.g. WETH and USDB have different USD prices. This can cause prices to be set incorrectly for token contracts and thus weighted value of the locked tokens during schnibble calculation ends up being incorrect.
The issue is being marked as low since although the functionality of the protocol is impacted, the root cause arises from the assumption that price feed roles are not aware that all token contracts share the same price and pass in incorrect parameters.
Solution: Either take in a single token contract as the input parameter or multiple prices i.e. an array as done with the _contracts parameter currently.
File: LockManager.sol 146: function proposeUSDPrice( 147: uint256 _price, 148: address[] calldata _contracts 149: )
The function proposeUSDPrice() does not check if a token contract is inactive. This allows the price feed role to update the prices for tokens that have been marked as inactive by the admin.
Solution: In _execUSDPriceUpdate(), consider checking inactive tokens and skip their price updates.
File: LockManager.sol 144: function proposeUSDPrice( 145: uint256 _price, 146: address[] calldata _contracts 147: ) 148: external 149: onlyOneOfRoles( 150: [ 151: Role.PriceFeed_1, 152: Role.PriceFeed_2, 153: Role.PriceFeed_3, 154: Role.PriceFeed_4, 155: Role.PriceFeed_5 156: ] 157: ) 158: { 159: 160: if (usdUpdateProposal.proposer != address(0)) 161: revert ProposalInProgressError(); 162: if (_contracts.length == 0) revert ProposalInvalidContractsError(); 163: 164: delete usdUpdateProposal; 165: 166: // Approvals will use this because when the struct is deleted the approvals remain 167: ++_usdProposalId; 168: 169: usdUpdateProposal.proposedDate = uint32(block.timestamp); 170: usdUpdateProposal.proposer = msg.sender; 171: usdUpdateProposal.proposedPrice = _price; 172: usdUpdateProposal.contracts = _contracts; 173: usdUpdateProposal.approvals[msg.sender] = _usdProposalId; 174: usdUpdateProposal.approvalsCount++; 175: 176: emit ProposedUSDPrice(msg.sender, _price); 177: }
The function approveUSDPrice() does not check if price feed roles are approving the price of the correct token contract (i.e. usdUpdateProposal.contracts). Due to this, if two token contracts have the same price (e.g. ETH and WETH), it does not check if the price being approved is actually for ETH or WETH i.e. the respective token contract price was proposed for. The same issue exists in disapproveUSDPrice().
File: LockManager.sol 181: function approveUSDPrice( 182: uint256 _price 183: ) 184: external 185: onlyOneOfRoles( 186: [ 187: Role.PriceFeed_1, 188: Role.PriceFeed_2, 189: Role.PriceFeed_3, 190: Role.PriceFeed_4, 191: Role.PriceFeed_5 192: ] 193: ) 194: { 195: if (usdUpdateProposal.proposer == address(0)) revert NoProposalError(); 196: 197: if (usdUpdateProposal.proposer == msg.sender) 198: revert ProposerCannotApproveError(); 199: if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) 200: revert ProposalAlreadyApprovedError(); 201: if (usdUpdateProposal.proposedPrice != _price) 202: revert ProposalPriceNotMatchedError(); 203: 204: usdUpdateProposal.approvals[msg.sender] = _usdProposalId; 205: usdUpdateProposal.approvalsCount++; 206: 207: if (usdUpdateProposal.approvalsCount >= APPROVE_THRESHOLD) { 208: _execUSDPriceUpdate(); 209: } 210: 211: emit ApprovedUSDPrice(msg.sender); 212: }
Unlike the disapproveUSDPrice() function which validates both the disapproval and approval mapping with these checks, the approveUSDPrice() function does not check the disapproval mapping.
This allows price feed roles to first call disapproveUSDPrice(), which will pass since there has neither been an approval or disapproval from their side on the proposal yet. The function would set the disapproval mapping from price feed to the current _usdProposalId
. The price feed role can then call approveUSDPrice() and pass this check since it only checks for approval mapping and not the disapproval mapping as well.
Due to this, price feed roles can both approve and disapprove for a proposal, thereby double counting their votes.
Solution: Add the below check to the approveUSDPrice() function.
File: LockManager.sol 233: if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) 234: revert ProposalAlreadyDisapprovedError();
As we can see below, function setLockDuration() only checks for MaxLockDuration and not minLockDuration. This is problematic since users can set _duration to be lower than the minimum. This has no problem outside the lockdrop event since the minimum lock duration is only specific to lockdrop events. In the lockdrop though, the calls would revert for users having lock times less than the minimum due to this check here. This is not a big issue since users can just extend their lock durations to the minLockDuration. Enforcing the minimum check would be helpful though.
File: LockManager.sol 249: function setLockDuration(uint256 _duration) external notPaused { 250: if (_duration > configStorage.getUint(StorageKey.MaxLockDuration)) 251: revert MaximumLockDurationError(); 252:
Solution: Check if the _duration parameter passed by user is greater than equal to minLockDuration, only if lockdrop event has started i.e. block.timestamp is greater than start and less than end time of the lockdrop event. This would ensure users are not forced to extend their lock times to minLockDuration when there is no lockdrop occurring and users are locking tokens just for schnibbles earning.
This function is used by the AccountManager contract to calculate the schnibbles earned by the user for the tokens they locked. The issue is that the lockedWeight
does not seem to consider the time duration the user has locked their tokens for. This means that the schnibbles the user currently receives is just based on the price and quantity they deposited.
I'm not sure if this is the intended behaviour or not since the AccountManager contract here calculates a bonus through the BonusManager which uses user's lock time - see here.
Raising this issue to bring the sponsor's attention i.e. whether weighted value should be time weighted or not since a user locking tokens for longer has no benefit for the schnibble calculation.
File: LockManager.sol 477: function getLockedWeightedValue( 478: address _player 479: ) external view returns (uint256 _lockedWeightedValue) { 480: uint256 lockedWeighted = 0; 481: uint256 configuredTokensLength = configuredTokenContracts.length; 482: for (uint256 i; i < configuredTokensLength; i++) { 483: if ( 484: lockedTokens[_player][configuredTokenContracts[i]].quantity > 485: 0 && 486: configuredTokens[configuredTokenContracts[i]].active 487: ) { 488: // We are assuming all tokens have a maximum of 18 decimals and that USD Price is denoted in 1e18 489: 490: uint256 deltaDecimal = 10 ** 491: (18 - 492: configuredTokens[configuredTokenContracts[i]].decimals); 493: 494: lockedWeighted += 495: (deltaDecimal * 496: lockedTokens[_player][configuredTokenContracts[i]] 497: .quantity * 498: configuredTokens[configuredTokenContracts[i]] 499: .usdPrice) / 500: 1e18; 501: } 502: } 503: 504: _lockedWeightedValue = lockedWeighted; 505: }
If the configured token contract being used is a rebasing token that negatively rebases, the protocol can cause loss of funds to users. This is because when the overall balance of the contract decreases, the quantity being used for withdrawals on Line 424 is based on the time the the tokens were locked. Due to this, the user unlocking can receive more tokens than needed, causing loss to other users.
The blast docs here mention that the rebasing for ETH, WETH and USDB is increasing and never decreases. But if future tokens that rebase negatively exist, this issue exists.
File: LockManager.sol 409: function unlock( 410: address _tokenContract, 411: uint256 _quantity 412: ) external notPaused nonReentrant { 413: LockedToken storage lockedToken = lockedTokens[msg.sender][ 414: _tokenContract 415: ]; 416: if (lockedToken.quantity < _quantity) 417: revert InsufficientLockAmountError(); 418: if (lockedToken.unlockTime > uint32(block.timestamp)) 419: revert TokenStillLockedError(); 420: 421: // force harvest to make sure that they get the schnibbles that they are entitled to 422: accountManager.forceHarvest(msg.sender); 423: 424: lockedToken.quantity -= _quantity; 425: 426: // send token 427: if (_tokenContract == address(0)) { 428: payable(msg.sender).transfer(_quantity); 429: } else { 430: IERC20 token = IERC20(_tokenContract); 431: token.transfer(msg.sender, _quantity); 432: } 433: 434: emit Unlocked(msg.sender, _tokenContract, _quantity); 435: }
As discussed in my only High-severity issue, it is possible for anyone to make 0 quantity lock() or lockOnBehalfOf() calls. This allows users or an attacker to force users to receive their schnibbles at any time without requiring to lock tokens or unlock them.
Users should not be allowed to extend durations for their existing locks for tokens that have been deactivated by the admin. The users should instead be forced to leave the system with the token for safer side since tokens can be deactivated for any reason and should not exist in the LockManager for long once deactivated.
ID | Non-Critical issues |
---|---|
[N-01] | Snuggery manager variable is never used in LockManager |
[N-02] | Consider removing redundant check from function approveUSDPrice() |
[N-03] | Consider removing auto approval mechanism on price proposal |
[N-04] | Consider removing redundant checks from function _execUSDPriceUpdate() |
File: LockManager.sol 75: snuggeryManager = ISnuggeryManager( 76: configStorage.getAddress(StorageKey.SnuggeryManager) 77: );
The check on Line 196 is not necessary. This is because the check on Line 198 ensures that the proposer cannot approve the price one again (Context: The price feed's approval is counted during proposal itself - see here).
File: LockManager.sol 180: function approveUSDPrice( 181: uint256 _price 182: ) 183: external 184: onlyOneOfRoles( 185: [ 186: Role.PriceFeed_1, 187: Role.PriceFeed_2, 188: Role.PriceFeed_3, 189: Role.PriceFeed_4, 190: Role.PriceFeed_5 191: ] 192: ) 193: { 194: if (usdUpdateProposal.proposer == address(0)) revert NoProposalError(); 195: 196: if (usdUpdateProposal.proposer == msg.sender) 197: revert ProposerCannotApproveError(); 198: if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) 199: revert ProposalAlreadyApprovedError();
Currently the proposer is denied from disapproving the price on their own proposal. The price feed who is the proposer should be allowed to perform disapprovals and approvals separately from their proposal. This would make the process more modular and separate instead of auto approving during proposal itself as seen here.
Solution: This solution separates the proposing and approving/disapproving on the proposal, which allows the proposer to either approve or disapprove on their price proposed. They can disapprove in case the price proposed is incorrect.
File: LockManager.sol 171: usdUpdateProposal.approvals[msg.sender] = _usdProposalId; 172: usdUpdateProposal.approvalsCount++;
File: LockManager.sol 195: if (usdUpdateProposal.proposer == msg.sender) 196: revert ProposerCannotApproveError();
The first check can be removed since _execUSDPriceUpdate() is only executed if approval threshold is met as seen here.
The second check can be removed since disapprovalsCount will always be less than the DISAPPROVE_THRESHOLD. Because if it was not, the proposal would've been deleted already - see here.
File: LockManager.sol 525: if ( 526: usdUpdateProposal.approvalsCount >= APPROVE_THRESHOLD && 527: usdUpdateProposal.disapprovalsCount < DISAPPROVE_THRESHOLD 528: ) {
#0 - 0xinsanity
2024-05-31T18:48:18Z
[L-01] This is on purpose. ConfigStorage should never be changed. [L-02] Acknowledged. [L-03] Acknowledged. [L-04] This is on purpose. Pausing is only meant for gameplay, not admin functions. [L-05] This is an incorrect interpretation of the function. Prices are meant to be set one token address at a time. The only reason we have an array there is so that we can set WETH and ETH at the same time. [L-06] Acknowledged [L-07] I don't understand this critique. [L-08] This was already fixed and requested in the error report. [L-09] Acknowledged. [L-10] Acknowledged. We only calculate based on what is left. [L-11] Acknowledged. We have no plans to support tokens like that. [L-12] lockOnBehalf has been locked down to only allow for MigrationManager calls. Calling force harvest does the same as the user calling harvest so it doesn't matter. [L-13] Acknowledged.
[N-1] Acknowledged. [N-2] Acknowledged. [N-3] Acknowledged. [N-4] Acknowledged.
#1 - michaeljyeates
2024-05-31T19:19:56Z
[L-07] is intentional, we would only propose prices for multiple tokens if they are the same price (it is designed to update WETH and ETH at the same time)
#2 - c4-judge
2024-06-05T12:37:48Z
alex-ppg marked the issue as grade-a
#3 - mcgrathcoutinho
2024-06-05T15:46:27Z
Hi @alex-ppg, why is the upgraded issue being marked as partial-25?
I believe it clearly mentions the issue with a POC and solution as well. When I submitted this issue, my judgement was that price feed roles are trusted so they would not engage in such a behaviour even if it is openly available, thus marking it as QA.
Can you please re-consider this for satisfactory since I do not think it is lacking anywhere in explaining the issue?
Thank you!
#4 - alex-ppg
2024-06-09T12:34:59Z
Hey @mcgrathcoutinho, the project's page does not mention that the Oracle members are trusted. Given that they are multiple, we cannot assume they are fully trusted as otherwise they would be only one role present. From the code alone, one can deduce that the roles are partially trusted based on their structure.
QA reported submissions that were ultimately HM cannot receive a full duplicate reward as they lack the equivalent effort of a full report. Additionally, they were misjudged as a lower risk than they actually have, indicating that a further penalty should be applied. I appreciate your contribution and will retain my original judgment.
#5 - DecentralDisco
2024-06-11T19:10:55Z
For transparency and awarding purposes, the selected for report
label has been added by staff.
#6 - DecentralDisco
2024-06-11T19:19:07Z
For awarding purposes, staff have marked as 1st place
.