Platform: Code4rena
Start Date: 21/12/2023
Pot Size: $90,500 USDC
Total HM: 10
Participants: 39
Period: 18 days
Judge: LSDan
Total Solo HM: 5
Id: 315
League: ETH
Rank: 15/39
Findings: 2
Award: $380.63
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: erebus
Also found by: adeolu, oakcobalt, thank_you, windowhan001
358.7303 USDC - $358.73
In liquidity_lockbox.sol, withdraw liquidity is vulnerable to front run due to disabled slippage protection, user might experience loss in asset tokens due to front-run.
liquidity_lockbox.sol allows depositing NFT liquidity positions in whirlpools in exchange for ERC20 bridged tokens. When withdrawing liquidity positions, withdraw()
can be called which will transfer and burn the caller's ERC20 bridged tokens, and then call decreaseLiquidity()
on whirlpool, which will decrease the original NFT liquidity positions, and convert amount
of liquidity into asset tokens in the pool.
However, the vulnerability is that the slippage protection option provided by whirlpool's decreaseLiquidity()
is disabled in withdraw()
by hardcoding 0
.
//solidity/liquidity_lockbox.sol function withdraw(uint64 amount) external { ... //@audit decreaseLiquidity(amount,0,0) hardcodes minimal output amounts for assetA and assetB in the whirlpool into 0,0, exposing withdraw() to front-run risks |> whirlpool.decreaseLiquidity{accounts: metasDecreaseLiquidity, seeds: [[pdaProgramSeed, pdaBump]]}(amount, 0, 0); ...
In orca's whirlpool implementation, we can see (amount, 0, 0)
will set token_min_a
and token_min_b
as 0.
//https://github.com/orca-so/whirlpools/blob/main/programs/whirlpool/src/instructions/decrease_liquidity.rs /* Removes liquidity from an existing Whirlpool Position. */ pub fn handler( ctx: Context<ModifyLiquidity>, liquidity_amount: u128, |> token_min_a: u64, |> token_min_b: u64, ) -> Result<()> { ... if delta_a < token_min_a { return Err(ErrorCode::TokenMinSubceeded.into()); } else if delta_b < token_min_b { return Err(ErrorCode::TokenMinSubceeded.into()); } ...
As seen above, disabling slippage protection allows any amount of output asset tokens to settle which put the caller at risk of losing asset tokens.
Manual Review
In withdraw()
, consider add tokenMinA
and tokenMinB
arguments to be passed manually by caller to be used for slippage protection.
MEV
#0 - c4-pre-sort
2024-01-10T15:19:28Z
alex-ppg marked the issue as duplicate of #339
#1 - c4-pre-sort
2024-01-10T15:19:31Z
alex-ppg marked the issue as sufficient quality report
#2 - c4-judge
2024-01-19T20:47:10Z
dmvt marked the issue as satisfactory
🌟 Selected for report: IllIllI
Also found by: 0x11singh99, 0xA5DF, 0xMilenov, 0xTheC0der, 7ashraf, Bauchibred, EV_om, Kaysoft, Sathish9098, SpicyMeatball, cheatc0d3, erebus, hash, imare, immeas, joaovwfreire, lil_eth, lsaudit, oakcobalt, para8956, peanuts, rvierdiiev, slvDev, trachev
21.8971 USDC - $21.90
decreaseAllowance()
returns misleading values when type(uint256).max
is current spender allowance.In OLAS.sol, decreaseAllowance()
allows a caller to reduce the current allowance for a spender. When allowance is successfully reduced, the function return true to indicate success.
However, when the existing allowance for the spender is type(uint256).max
, the function will do nothing but also return true. This is misleading behavior that might cause the caller or the parent flow to mistakenly assume the allowance has been reduced when it is not. The caller might not be aware that max allowance has been set for the spender.
//contracts/OLAS.sol function decreaseAllowance(address spender, uint256 amount) external returns (bool) { uint256 spenderAllowance = allowance[msg.sender][spender]; //@audit when spenderAllowance is type(uint256).max, if statement will be skipped, but function will still return true, which is misleading because the allowance is not reduced and the caller might also not be aware that max allowance has been set for the spender. |> if (spenderAllowance != type(uint256).max) { spenderAllowance -= amount; allowance[msg.sender][spender] = spenderAllowance; emit Approval(msg.sender, spender, spenderAllowance); } return true; }
Recommendation: Consider revert with an error message to alert user when spenderAllowance is type(uint256).max.
In veOLAS.sol, there are comments explaining time-weighted vote count logic. However, there is incorrect comment when it comes to block number and block time.
It says We cannot really do block numbers per se because slope is per time, not per block, and per block could be fairly bad because Ethereum changes its block times. What we can do is to extrapolate ***At functions.
The above comment is incorrect, because Ethereum already has fixed block time as of now. Although this doesn't affect the overall code logic, it's good practice and reduces efforts in audits to have correct code comments.
Recommendation: Remove the comment regarding Ethereum block times.
In veOLAS.sol, there are multiple functions that takes uint256 unlockTime
as one of the arguments. However, the code comment for @param unlockTime
is incorrect and misleading.
When passed as a parameter uint256 unlockTime
will be the duration of the lock in seconds. And inside function body, unlockTime
will be re-written to the end timestamp of the lock based on the lock duration passed. unlockTime = ((block.timestamp + unlockTime) / WEEK) * WEEK;
The comment for @param unlockTime
is unlockTime Time when tokens unlock, rounded down to a whole week.
, which indicates the parameter should be the lock end timestamp when the parameter is used as lock duration in function body.
Instances(2): (1)
//contracts/veOLAS.sol //@audit incorrect and misleading comments, based on implementation below, unlockTime is supposed to be the duration of the lock, however, comment suggest it's the end lock timestamp |> /// @param unlockTime Time when tokens unlock, rounded down to a whole week. function _createLockFor(address account, uint256 amount, uint256 unlockTime) private { ... unchecked { unlockTime = ((block.timestamp + unlockTime) / WEEK) * WEEK; }
//contracts/veOLAS.sol /// @dev Extends the unlock time. //@audit incorrect and misleading comments, based on implementation below, unlockTime is supposed to be the duration of the lock, however, comment suggest it's the end lock timestamp |> /// @param unlockTime New tokens unlock time. function increaseUnlockTime(uint256 unlockTime) external { ... unchecked { unlockTime = ((block.timestamp + unlockTime) / WEEK) * WEEK; }
Recommendation:
Change @param unlockTime
comment to say the lock duration.
balanceOfAt()
in veOLAS.sol will return inaccurate value when the balance of future blockNumber
is queriedIn veOLAS.sol, balanceOfAt()
the lock balance for at a given blockNumber will be returned. However, the return value will be inaccurate when the blockNumber
is a future block because the lock balance might change.
In balanceOfAt()
, first the closest block number to provided blockNumber
will be search in _findPointByBlock()
. When the input blockNumber
is in-between the earliest checkpoint blockNumber of the account and the last checkpoint of the account, _findPointByBlock()
will return the block number in record that is right before blockNumber
parameter.
But when the input blockNumber
is after the last checkpoint of the account, _findPointByBlock()
will return the last checkpoint without checking whether the input blockNumber
is earlier or later than current block.timestamp
. This means, if blockNumber
is a future block (> block.timestamp
), the function will still pass and return the balance at the last checkpoint, this is inaccurate and misleading since we don't know what will happen in the future. Instead, the function should check whether blockNumber
is greater than current block.timestamp
, if so revert with an alert message to avoid giving misleading answers.
//contracts/veOLAS.sol function balanceOfAt(address account, uint256 blockNumber) external view returns (uint256 balance) { //@audit future blockNumber input will likely result in inaccurate return value since account balance might change. Consider disable when query account balance at a future block. // Find point with the closest block number to the provided one |> (PointVoting memory uPoint, ) = _findPointByBlock(blockNumber, account); // If the block number at the point index is bigger than the specified block number, the balance was zero //@audit this will return balance even if blockNumber is a future block if (uPoint.blockNumber < (blockNumber + 1)) { |> balance = uint256(uPoint.balance); } } function _findPointByBlock(uint256 blockNumber, address account) internal view returns (PointVoting memory point, uint256 minPointNumber) { ... // Binary search that will be always enough for 128-bit numbers for (uint256 i = 0; i < 128; ++i) { //@audit-info note: this will ensure that minPointNumber found will be right before maxPointNumber if ((minPointNumber + 1) > maxPointNumber) { break; } ... } // Get the found point if (account == address(0)) { point = mapSupplyPoints[minPointNumber]; } else { //@audit-info note: this returns minPointNumber that is before blockNumber point = mapUserPoints[account][minPointNumber]; } ...
Recommendation:
Consider check blockNumber
with block.timestamp
and revert when blockNumber
is greater than block.timestamp
In contracts/wveOLAS.sol, getUserPoint()
wraps veOLAS.sol's getUserPoint()
function to handle out-of-bonds condition where an input idx
(index) is out of bound of user's PointVoting[]
stored in mapUserPoints
.
However, the bypass for out-of-bounds condition is invalid. In weOLAS.sol's getUserPoint()
, first the array length of PointVoting[]
is queried by calling uint256 userNumPoints = IVEOLAS(ve).getNumUserPoints(account)
. Then if (userNumPoints > 0) {
is used to make sure veOLAS.sol's getUserPoint()
will only be called when userNumPoints>0
this is an invalid condition. The correct condition to handle out-of-bounds will be idx<userNumPints
which ensure that if statement body will only be called when idx
is within bounds.
//contracts/wveOLAS.sol function getUserPoint(address account, uint256 idx) public view returns (PointVoting memory uPoint) { // Get the number of user points uint256 userNumPoints = IVEOLAS(ve).getNumUserPoints(account); //@audit this if condition is invalid, userNumPints>0 doesn't ensure that idx is within bounds. As a result, calling getUserPoint() will still risk out-of-bounds revert errors. |> if (userNumPoints > 0) { uPoint = IVEOLAS(ve).getUserPoint(account, idx); } }
Recommendation:
Change the if condition into if (idx< userNumPoints)
In GuardCM.sol, _verifyData()
contains some mechanism code which is already defined in helper functions getTargetSelectorChainId()
. Writing the same mechanism code multiple times in the same contract is error-prone and increase audit time, especially when there are existing helper functions defined that can be used.
//contracts/multisigs/GuardCM.sol function _verifyData(address target, bytes memory data, uint256 chainId) internal { //@audit the below implementation can be replaced by helper getTargetSelectorChainId() // Push a pair of key defining variables into one key // target occupies first 160 bits |> uint256 targetSelectorChainId = uint256(uint160(target)); // selector occupies next 32 bits |> targetSelectorChainId |= uint256(uint32(bytes4(data))) << 160; // chainId occupies next 64 bits |> targetSelectorChainId |= chainId << 192; // Check the authorized combination of target and selector |> if (!mapAllowedTargetSelectorChainIds[targetSelectorChainId]) { revert NotAuthorized(target, bytes4(data), chainId); } }
//contracts/multisigs/GuardCM.sol function getTargetSelectorChainId(address target, bytes4 selector, uint256 chainId) external view returns (bool status) { // Push a pair of key defining variables into one key // target occupies first 160 bits uint256 targetSelectorChainId = uint256(uint160(target)); // selector occupies next 32 bits targetSelectorChainId |= uint256(uint32(selector)) << 160; // chainId occupies next 64 bits targetSelectorChainId |= chainId << 192; status = mapAllowedTargetSelectorChainIds[targetSelectorChainId]; }
As seen above, _verifyData()
can directly use getTargetSelectorChainId()
to get the status.
Recommendation:
Simplify _verifyData()
to directly use if(!getTargetSelectorChainId(target, bytes4(data),chainId){
_verifySchedule()
can be simplified to user existing helper function getBridgeMediatorChainId()
and avoid duplicated mechanism code.(Note: Not included in the bot report)In GuardCM.sol, _verifySchedule()
contains same mechanism code as existing helper getBridgeMediatorChainId()
which fetches chainId
and bridgeMediatorL2
from L1 mediator address targets[i]
(address bridgeMediatorL1).
//contracts/multisigs/GuardCM.sol function _verifySchedule(bytes memory data, bytes4 selector) internal { ... // Traverse all the schedule targets and selectors extracted from calldatas for (uint i = 0; i < targets.length; ++i) { //@audit bridgeMediatorL2ChainId and bridgeMediatorL2 can be directly fetched using helper function getBridgeMediatorChainId(), which contains the same mechanism code. // Get the bridgeMediatorL2 and L2 chain Id, if any |> uint256 bridgeMediatorL2ChainId = mapBridgeMediatorL1L2ChainIds[targets[i]]; // bridgeMediatorL2 occupies first 160 bits |> address bridgeMediatorL2 = address(uint160(bridgeMediatorL2ChainId)); // Check if the data goes across the bridge if (bridgeMediatorL2 != address(0)) { // Get the chain Id // L2 chain Id occupies next 64 bits uint256 chainId = bridgeMediatorL2ChainId >> 160; // Process the bridge logic _processBridgeData(callDatas[i], bridgeMediatorL2, chainId); } else { // Verify the data right away as it is not the bridged one _verifyData(targets[i], callDatas[i], block.chainid); }
//contracts/multi sigs/GuardCM.sol function getBridgeMediatorChainId(address bridgeMediatorL1) external view returns (address bridgeMediatorL2, uint256 chainId) { // Get the bridgeMediatorL2 and L2 chain Id uint256 bridgeMediatorL2ChainId = mapBridgeMediatorL1L2ChainIds[bridgeMediatorL1]; // bridgeMediatorL2 occupies first 160 bits bridgeMediatorL2 = address(uint160(bridgeMediatorL2ChainId)); // L2 chain Id occupies next 64 bits chainId = bridgeMediatorL2ChainId >> 160; }
As seen above, getBridgeMediatorChainId()
will directly fetch chainId and bridgeMediatorL2 and saves additional line of code in _verifySchedule()
.
Recommendation:
Consider simplify code in _verifySchedule()
into (address bridgeMediatorL2, uint256 bridgeMediatorL2ChainId)=getBridgeMediatorChainId(targets[i])
.
In FxERC20RootTunnel.sol _withdraw()
, there is an incorrect comment for @dev
. The comment says Withdraws bridged tokens from L1 to get their original tokens on L1 by a specified address
. This is incorrect and misleading because _withdraw()
is intended to burn tokens on L1 and get original tokens on L2.
//contracts/bridges/FxERC20RootTunnel.sol //@audit incorrect comment - Withdraws bridged tokens from L1 to get their original tokens on L2. |> /// @dev Withdraws bridged tokens from L1 to get their original tokens on L1 by a specified address. /// @notice Reentrancy is not possible as tokens are verified before the contract deployment. /// @param to Destination address on L2. /// @param amount Token amount to be withdrawn. function _withdraw(address to, uint256 amount) internal { ...
Recommendation:
Change the comments into Withdraws bridged tokens from L1 to get their original tokens on L2 by a specified address
.
tokenByIndex()
in GenericRegistry.sol returns incorrect unitId
In GenericRegistry.sol, tokenByIndex()
will add 1 to input token id
. This is incorrect, because token id
would have already be incremented by 1 before safeMint()
in UnitRegistry.sol. This means token id
should equal unitId
.
In UnitRegistry.sol create()
we can see that when creating a unit token id and uintId will be the same. uintId
will be incremented by 1 before calling _safeMint(uintOwner,uintId);
. So both uintId
and token id will start from 1.
//contracts/GenericRegistry.sol function tokenByIndex( uint256 id ) external view virtual returns (uint256 unitId) { //@audit incorrect, unitId will equal id, which is ensured in UnitRegistry.sol create() |> unitId = id + 1; if (unitId > totalSupply) { revert Overflow(unitId, totalSupply); } }
//contracts/UnitRegistry.sol function create(address unitOwner, bytes32 unitHash, uint32[] memory dependencies) external virtual returns (uint256 unitId) { ... unitId++; // Initialize the unit and mint its token Unit storage unit = mapUnits[unitId]; unit.unitHash = unitHash; unit.dependencies = dependencies; ... |> _safeMint(unitOwner, unitId); ...
Recommendation:
Revise tokenByIndex()
implementation to directly assign id
to unitId
.
In Depository.sol there are (2) functions that currently run unnecessary for-loops and can be simplified to only use one for-loop.
Instances(2):
(1)
In getProducts()
, there are two for-loops(numProducts). And the second for-loop can be eliminated by directly pushing the indices into the array productsIds[]
when the condition is satisfied.
//contracts/Depository.sol function getProducts( bool active ) external view returns (uint256[] memory productIds) { // Calculate the number of existing products uint256 numProducts = productCounter; bool[] memory positions = new bool[](numProducts); uint256 numSelectedProducts; // Traverse to find requested products //@audit Can be simplified to use only one for-loop, if(){productIds.push(i)} for (uint256 i = 0; i < numProducts; ++i) { // Product is always active if its supply is not zero, and inactive otherwise if ( (active && mapBondProducts[i].supply > 0) || (!active && mapBondProducts[i].supply == 0) ) { |> positions[i] = true; ++numSelectedProducts; } } // Form active or inactive products index array productIds = new uint256[](numSelectedProducts); uint256 numPos; //@audit this for-loop can be eliminated |> for (uint256 i = 0; i < numProducts; ++i) { //@audit-info note: when positions[i] = true, it satisfies the search criteria if (positions[i]) { productIds[numPos] = i; ++numPos; } } }
(2)
In getBonds()
, there are two for-loops(numProducts). And the second for-loop can be eliminated by directly pushing the indices into the array productsIds[]
when the condition is satisfied.
//contracts/Depository.sol function getBonds( address account, bool matured ) external view returns (uint256[] memory bondIds, uint256 payout) { // Check the address if (account == address(0)) { revert ZeroAddress(); } uint256 numAccountBonds; // Calculate the number of pending bonds uint256 numBonds = bondCounter; bool[] memory positions = new bool[](numBonds); // Record the bond number if it belongs to the account address and was not yet redeemed for (uint256 i = 0; i < numBonds; ++i) { // Check if the bond belongs to the account // If not and the address is zero, the bond was redeemed or never existed if (mapUserBonds[i].account == account) { // Check if requested bond is not matured but owned by the account address if ( !matured || // Or if the requested bond is matured, i.e., the bond maturity timestamp passed block.timestamp >= mapUserBonds[i].maturity ) { //@audit Can be simplified to use only one for-loop, if(){if(){bondIds.push()}} |> positions[i] = true; ++numAccountBonds; // The payout is always bigger than zero if the bond exists payout += mapUserBonds[i].payout; } } } // Form pending bonds index array bondIds = new uint256[](numAccountBonds); uint256 numPos; //@audit this for-loop can be eliminated |> for (uint256 i = 0; i < numBonds; ++i) { if (positions[i]) { bondIds[numPos] = i; ++numPos; } } }
Recommendation:
In the first for-loop, when the condition is met directly use productIds.push(i)
.
For exmaple:
function getProducts( bool active ) external view returns (uint256[] memory productIds) { uint256 numProducts = productCounter; // Use a single loop to filter and push indices directly into productIds for (uint256 i = 0; i < numProducts; ++i) { if ( (active && mapBondProducts[i].supply > 0) || (!active && mapBondProducts[i].supply == 0) ) { productIds.push(i); } } }
TokenomicsProxy.sol follows EIP-1822 standards as exemplified in code doc Proxy implementation is created based on the Universal Upgradeable Proxy Standard (UUPS) EIP-1822
.
EIP-1822 requires upgrade logic be implemented in the implementation contract (Tokenomics.sol). Specifically, it's required that during the upgrade call, compatibility of the new implementation contract needs to be checked in the upgrade function. And also, it's required that the implementation contract has a proxiable()
function to indicate support of EIP-1822 standards.
The vulnerability is (1) Tokenomics.sol doesn't check for compatibility for the new implementation in the upgrade function (changeTokenomicsImplementation()
), (2) neither does current Tokenomics.sol implement a proxiable()
function to indicate compatibility.
According EIP-1822(see here), a sample implementation that satisfies the two requirements above looks like this.
//https://eips.ethereum.org/EIPS/eip-1822 contract Proxiable { // Code position in storage is keccak256("PROXIABLE") = "0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7" function updateCodeAddress(address newAddress) internal { require( bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(), "Not compatible" ); assembly { // solium-disable-line sstore(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7, newAddress) } } function proxiableUUID() public pure returns (bytes32) { return 0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7; } }
However, current Tokenomcis.sol's upgrade implementation will only check for address(0), then directly assign the new address to storage without requiring bytes32(0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7) == Proxiable(newAddress).proxiableUUID(), "Not compatible"
, as required by standard.
This exposes the risk of the new implementation might not be upgradable or compatible, causing the Tokenmocis upgrade mechanism be permanently disabled.
//contracts/Tokenomics.sol function changeTokenomicsImplementation(address implementation) external { // Check for the contract ownership if (msg.sender != owner) { revert OwnerOnly(msg.sender, owner); } // Check for the zero address if (implementation == address(0)) { revert ZeroAddress(); } // Store the implementation address under the designated storage slot //@audit an incompatible implementation might be directly written in storage, disabling upgrade mechanism for the future assembly { |> sstore(PROXY_TOKENOMICS, implementation) } emit TokenomicsImplementationUpdated(implementation); }
Recommendation: Check for compatibility before overwriting implementation address and follow EIP-1822.
#0 - c4-pre-sort
2024-01-10T14:42:24Z
alex-ppg marked the issue as sufficient quality report
#1 - c4-judge
2024-01-20T13:26:37Z
dmvt marked the issue as grade-b