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: 2/39
Findings: 6
Award: $20,540.04
π Selected for report: 3
π Solo Findings: 2
π Selected for report: hash
11846.5006 USDC - $11,846.50
Deposits will be unwithdrawable from the lockbox
If the entire liquidity of a position has been removed, the withdraw function calls the updateFeesAndRewards
function on the Orca pool before attempting to close the position.
function withdraw(uint64 amount) external { address positionAddress = positionAccounts[firstAvailablePositionAccountIndex]; ...... uint64 positionLiquidity = mapPositionAccountLiquidity[positionAddress]; ...... uint64 remainder = positionLiquidity - amount; ...... if (remainder == 0) { // Update fees for the position AccountMeta[4] metasUpdateFees = [ AccountMeta({pubkey: pool, is_writable: true, is_signer: false}), AccountMeta({pubkey: positionAddress, is_writable: true, is_signer: false}), AccountMeta({pubkey: tx.accounts.tickArrayLower.key, is_writable: false, is_signer: false}), AccountMeta({pubkey: tx.accounts.tickArrayUpper.key, is_writable: false, is_signer: false}) ]; whirlpool.updateFeesAndRewards{accounts: metasUpdateFees, seeds: [[pdaProgramSeed, pdaBump]]}();
This is faulty as the updateFeesAndRewards
function will always revert if the position's liquidity is 0.
update_fees_and_rewards -> calculate_fee_and_reward_growths -> _calculate_modify_liquidity
fn _calculate_modify_liquidity( whirlpool: &Whirlpool, position: &Position, tick_lower: &Tick, tick_upper: &Tick, tick_lower_index: i32, tick_upper_index: i32, liquidity_delta: i128, timestamp: u64, ) -> Result<ModifyLiquidityUpdate> { // Disallow only updating position fee and reward growth when position has zero liquidity if liquidity_delta == 0 && position.liquidity == 0 { return Err(ErrorCode::LiquidityZero.into()); }
Since the withdrawal positions are chosen sequentially, only a maximum of (first position's liquidity - 1) amount of liquidity can be withdrawn.
https://gist.github.com/10xhash/a687ef66de8210444a41360b86ed4bca
Manual review
Avoid the update_fees_and_rewards
call completely since fees and rewards would be updated in the decreaseLiquidity
call.
call/delegatecall
#0 - c4-pre-sort
2024-01-10T15:38:18Z
alex-ppg marked the issue as sufficient quality report
#1 - c4-sponsor
2024-01-15T17:42:40Z
mariapiamo (sponsor) disputed
#2 - c4-judge
2024-01-20T14:35:44Z
dmvt marked the issue as unsatisfactory: Invalid
#3 - 10xhash
2024-01-23T07:19:26Z
Why is this issue not valid?
The call to updateFeesAndRewards
of whirlpool will revert when the liquidity of that position is 0. This implementation calls updateFeesAndRewards
after withdrawing (ie. calling decreaseLiquidity) on whirlpool and hence will revert.
In the newly released version of lockbox written in Rust, they have updated the call order to only call decreaseLiquidity after the call to updateFeesAndRewards.
If it helps to view the issue's validity on the newer codebase, please apply the following diff and run the tests. In the test, the second liquidity withdrawal will fail.
diff --git a/lockbox2/programs/liquidity_lockbox/src/lib.rs b/lockbox2/programs/liquidity_lockbox/src/lib.rs index c94a678..13c86a4 100644 --- a/lockbox2/programs/liquidity_lockbox/src/lib.rs +++ b/lockbox2/programs/liquidity_lockbox/src/lib.rs @@ -376,6 +376,29 @@ pub mod liquidity_lockbox { // Get program signer seeds let signer_seeds = &[&ctx.accounts.lockbox.seeds()[..]]; + // CPI to decrease liquidity + let cpi_program_modify_liquidity = ctx.accounts.whirlpool_program.to_account_info(); + let cpi_accounts_modify_liquidity = ModifyLiquidity { + whirlpool: ctx.accounts.whirlpool.to_account_info(), + position: ctx.accounts.position.to_account_info(), + position_authority: ctx.accounts.lockbox.to_account_info(), + position_token_account: ctx.accounts.pda_position_account.to_account_info(), + tick_array_lower: ctx.accounts.tick_array_lower.to_account_info(), + tick_array_upper: ctx.accounts.tick_array_upper.to_account_info(), + token_owner_account_a: ctx.accounts.token_owner_account_a.to_account_info(), + token_owner_account_b: ctx.accounts.token_owner_account_b.to_account_info(), + token_vault_a: ctx.accounts.token_vault_a.to_account_info(), + token_vault_b: ctx.accounts.token_vault_b.to_account_info(), + token_program: ctx.accounts.token_program.to_account_info() + }; + + let cpi_ctx_modify_liquidity = CpiContext::new_with_signer( + cpi_program_modify_liquidity, + cpi_accounts_modify_liquidity, + signer_seeds + ); + whirlpool::cpi::decrease_liquidity(cpi_ctx_modify_liquidity, amount as u128, token_min_a, token_min_b)?; + // Update fees for the position let cpi_program_update_fees = ctx.accounts.whirlpool_program.to_account_info(); let cpi_accounts_update_fees = UpdateFeesAndRewards { @@ -413,29 +436,6 @@ pub mod liquidity_lockbox { ); whirlpool::cpi::collect_fees(cpi_ctx_collect_fees)?; - // CPI to decrease liquidity - let cpi_program_modify_liquidity = ctx.accounts.whirlpool_program.to_account_info(); - let cpi_accounts_modify_liquidity = ModifyLiquidity { - whirlpool: ctx.accounts.whirlpool.to_account_info(), - position: ctx.accounts.position.to_account_info(), - position_authority: ctx.accounts.lockbox.to_account_info(), - position_token_account: ctx.accounts.pda_position_account.to_account_info(), - tick_array_lower: ctx.accounts.tick_array_lower.to_account_info(), - tick_array_upper: ctx.accounts.tick_array_upper.to_account_info(), - token_owner_account_a: ctx.accounts.token_owner_account_a.to_account_info(), - token_owner_account_b: ctx.accounts.token_owner_account_b.to_account_info(), - token_vault_a: ctx.accounts.token_vault_a.to_account_info(), - token_vault_b: ctx.accounts.token_vault_b.to_account_info(), - token_program: ctx.accounts.token_program.to_account_info() - }; - - let cpi_ctx_modify_liquidity = CpiContext::new_with_signer( - cpi_program_modify_liquidity, - cpi_accounts_modify_liquidity, - signer_seeds - ); - whirlpool::cpi::decrease_liquidity(cpi_ctx_modify_liquidity, amount as u128, token_min_a, token_min_b)?; - // Update the position liquidity ctx.accounts.lockbox.total_liquidity = ctx.accounts.lockbox .total_liquidity
#4 - dmvt
2024-01-23T19:59:21Z
This:
This is faulty as the updateFeesAndRewards function will always revert if the position's liquidity is 0.
is incorrect. liquidity_delta
must also be 0.
#5 - 10xhash
2024-01-23T20:30:08Z
But a call to updateFeesAndRewards
would be having liquidity_delta
0 since no liquidity is being removed/added
#6 - dmvt
2024-01-23T20:54:45Z
I've ask the sponsor to comment with their reasoning as well, but this issue as reported is insufficient quality for a high risk. The language is incorrect and the impact is not clearly stated.
#7 - kupermind
2024-01-23T21:12:14Z
We have changed the order of operations in our rust program indeed, and it works there. Verified the order of instructions provided here and it fails if the remainder is zero.
#8 - dmvt
2024-01-23T21:16:14Z
Ok. I really hate when this happens, but seems like you have a valid unique high with less than ideal quality. I'm going to reinstate the credit because regardless of quality issues, you've saved the sponsor from a major issue in production. In the future, please spend a bit more time describing your issues and their impact, particularly when high risk. The more detail you can give and the more specific / accurate your statements, the more value you provide.
#9 - c4-judge
2024-01-23T21:16:26Z
dmvt removed the grade
#10 - c4-judge
2024-01-23T21:16:30Z
dmvt marked the issue as selected for report
#11 - c4-judge
2024-01-23T21:16:35Z
dmvt marked the issue as primary issue
π Selected for report: hash
Also found by: 0xTheC0der, HChang26, c0pp3rscr3w3r
2159.0247 USDC - $2,159.02
https://github.com/code-423n4/2023-12-autonolas/blob/2a095eb1f8359be349d23af67089795fb0be4ed1/tokenomics/contracts/Tokenomics.sol#L1037-L1038 https://github.com/code-423n4/2023-12-autonolas/blob/2a095eb1f8359be349d23af67089795fb0be4ed1/governance/contracts/OLAS.sol#L75-L84
Bond depositors and agent/component owner's may never receive the payout Olas Incorrect inflation control
effectiveBond
is used to account how much of Olas is available for bonding. This includes Olas that are to be minted in the current epoch ie. effectiveBond
will include the Olas partitioned for bonding in epoch 5 at the beginning of epoch 5 itself. In case of epoch's crossing YEAR
intervals, a portion of the Olas would actually only be mintable in the next year due to the yearwise inflation control enforced at the mint (after 9 years due to fixed supply till 10 years). Due to silent reverts, this can lead to lost Olas payouts
The inflation for bonds are accounted using the effectiveBond
variable.
https://github.com/code-423n4/2023-12-autonolas/blob/2a095eb1f8359be349d23af67089795fb0be4ed1/tokenomics/contracts/Tokenomics.sol#L609-L617
function reserveAmountForBondProgram(uint256 amount) external returns (bool success) { ..... // Effective bond must be bigger than the requested amount uint256 eBond = effectiveBond; if (eBond >= amount) { eBond -= amount; effectiveBond = uint96(eBond); success = true; emit EffectiveBondUpdated(eBond); } }
This variable is updated with the estimated bond Olas at the beginning of an epoch itself.
function checkpoint() external returns (bool) { ..... // Update effectiveBond with the current or updated maxBond value curMaxBond += effectiveBond; effectiveBond = uint96(curMaxBond);
In case of epochs crossing YEAR
intervals after 9 years, the new Olas amount will not be fully mintable in the same year due to the inflation control check enforced in the Olas contract.
function mint(address account, uint256 amount) external { .... // Check the inflation schedule and mint if (inflationControl(amount)) { _mint(account, amount); }
Whenever a deposit is made on a bond, the required Olas is minted by the treasury and transferred to the Depository contract, from where the depositor claims the payout after the vesting time. Olas.sol
doesn't revert for inflation check failure but fails silently. This can cause a deposit to succeed but corresponding redeem to fail since payout Olas has not been actually minted.
It can also happen that agent/component owner's who have not claimed the topup Olas amount will loose their reward due to silent return when minting their reward.
https://gist.github.com/10xhash/2157c1f2cdc9513b3f0a7f359a65015e
Manual review
In case of multi-year epochs, separate bond amounts of next year
Timing
#0 - c4-pre-sort
2024-01-10T15:38:49Z
alex-ppg marked the issue as sufficient quality report
#1 - c4-sponsor
2024-01-16T15:19:16Z
kupermind (sponsor) confirmed
#2 - c4-judge
2024-01-20T14:14:12Z
dmvt marked the issue as selected for report
#3 - c4-judge
2024-01-23T12:43:48Z
dmvt marked the issue as primary issue
#4 - 10xhash
2024-01-24T08:13:12Z
Could you please consider the following points regarding duped #322,#461 and #390
The root cause in this issue is the breaking of protocol's assumption that tokenomics is supposed to enforce year wise inflation. This broken assumption is made highly impactful by the silent revert vulnerability. Issues #322,#461 and #390 do not address/mention this root cause and just highlights the impact part if the assumption is broken.
In this issue, there are two vulnerabilities:
Issue 2 in isolation, which is what is highlighted by reports #322,#461 and #390, would not have any impact since the tokenomics is supposed to take care of enforcing inflation control.
Fixing just issue 2, with a faulty tokenomics ie. issue 1 still persisting, would still leave some issues in the protocol:
π Selected for report: erebus
Also found by: BugzyVonBuggernaut, hash
2460.4271 USDC - $2,460.43
An attacker can cause deposits to be locked in the lockbox
In withdraw
, if the position has 0 liquidity the execution is reverted
function withdraw(uint64 amount) external { address positionAddress = positionAccounts[firstAvailablePositionAccountIndex]; ..... uint64 positionLiquidity = mapPositionAccountLiquidity[positionAddress]; // Check that the token account exists if (positionLiquidity == 0) { revert("No liquidity on a provided token account"); }
Since the withdrawal positions are chosen sequentially, a position with liquidity 0 present in the ith index of positionAccounts
array, will make all positions from i -> end
locked and unwithdrawable.
Due to lack of checks in the deposit function, an attacker can make a depsoit with 0 liquidity resulting in the above scenario. This will lead to all funds associated with further deposits to be locked in the lockbox.
https://gist.github.com/10xhash/168166ba89447fa6fbc61981866c1b28
Manual review
Require liquidity of depositing positions to be non-zero
Invalid Validation
#0 - c4-pre-sort
2024-01-10T15:32:02Z
alex-ppg marked the issue as duplicate of #341
#1 - c4-pre-sort
2024-01-10T15:32:10Z
alex-ppg marked the issue as sufficient quality report
#2 - c4-judge
2024-01-19T20:04:59Z
dmvt marked the issue as satisfactory
498.2365 USDC - $498.24
User's / protocol unable to withdraw intended amounts from the lockbox
When withdrawing it is required to pass all the associated accounts correctly. Hence the getLiquidityAmountsAndPositions
function is implemented to obtain the required data for a given withdrawal amount.
But the function is wrongly implemented and gives incorrect withdrawal amount for the last position
function getLiquidityAmountsAndPositions(uint64 amount) external view returns (uint64[] positionAmounts, address[] positionAddresses, address[] positionPdaAtas) { ...... uint64 liquiditySum = 0; for (uint32 i = firstAvailablePositionAccountIndex; i < numPositionAccounts; ++i) { ..... liquiditySum += positionLiquidity; ..... if (liquiditySum >= amount) { amountLeft = liquiditySum - amount; break; } } for (uint32 i = 0; i < numPositions; ++i) { .... positionAmounts[i] = mapPositionAccountLiquidity[positionAddresses[i]]; .... } // @audit incorrect if (numPositions > 0 && amountLeft > 0) { positionAmounts[numPositions - 1] = amountLeft; } }
Instead of subtracting amountLeft
from the final position, the positionAmount is set as amountLeft. This can cause the overall sum to be greater or less than the actual amount
1 Position with liquidity 150 User wants to withdraw 100
Function walkthrough: After first loop block, liquiditySum == 150 hence amountLeft = 50 In the second loop block, positionAmounts[0] would be set to 50 This would make the totalWithdrawal amount to 50 while the actual intended withdrawal was 100
Manual review
Subtract from the final position instead of setting
if (numPositions > 0 && amountLeft > 0) { positionAmounts[numPositions - 1] -= amountLeft; }
Math
#0 - c4-pre-sort
2024-01-10T14:54:30Z
alex-ppg marked the issue as duplicate of #452
#1 - c4-pre-sort
2024-01-10T14:54:34Z
alex-ppg marked the issue as sufficient quality report
#2 - c4-judge
2024-01-19T22:03:28Z
dmvt marked the issue as satisfactory
π Selected for report: hash
3553.9502 USDC - $3,553.95
Possible DOS when withdrawing liquidity from Lockbox
When withdrawing it is required to pass all the associated accounts in the transaction. But among these (position,pdaPositionAccount and positionMint) are dependent on the current modifiable-state of the account ie. if another withdrawal occurs, the required accounts to be passed to the function call might change resulting in a revert.
@mutableAccount(pool) @account(tokenProgramId) @mutableAccount(position) @mutableAccount(userBridgedTokenAccount) @mutableAccount(pdaBridgedTokenAccount) @mutableAccount(userWallet) @mutableAccount(bridgedTokenMint) @mutableAccount(pdaPositionAccount) @mutableAccount(userTokenAccountA) @mutableAccount(userTokenAccountB) @mutableAccount(tokenVaultA) @mutableAccount(tokenVaultB) @mutableAccount(tickArrayLower) @mutableAccount(tickArrayUpper) @mutableAccount(positionMint) @signer(sig) function withdraw(uint64 amount) external { address positionAddress = positionAccounts[firstAvailablePositionAccountIndex]; if (positionAddress != tx.accounts.position.key) { revert("Wrong liquidity token account"); }
The DOS for a withdrawal can be caused by another user withdrawing before the user's transaction. Due to the possibility to steal fees, attackers would be motivated to frequently call the withdraw method making such a scenario likely.
Manual review
To mitigate this it would require a redesign on how the lockbox accepts liquidity. Instead of adding new positions, the lockbox can keep its liquidity in a single position continuously increasing its liquidity for deposits.
Context
#0 - c4-pre-sort
2024-01-10T15:38:34Z
alex-ppg marked the issue as sufficient quality report
#1 - c4-sponsor
2024-01-15T17:24:36Z
mariapiamo (sponsor) confirmed
#2 - c4-judge
2024-01-20T14:31:07Z
dmvt marked the issue as selected for report
π 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
Epoch cannot be incremented causing partial bricking of protocol. This will cause donations of current epoch to be locked
1 year is considered as a valid new epochLen
function changeTokenomicsParameters( uint256 _devsPerCapital, uint256 _codePerDev, uint256 _epsilonRate, uint256 _epochLen, uint256 _veOLASThreshold ) external { ...... if (uint32(_epochLen) >= MIN_EPOCH_LENGTH && uint32(_epochLen) <= ONE_YEAR) { nextEpochLen = uint32(_epochLen); } else { _epochLen = epochLen; }
But the checkpoint function reverts incase the time difference b/w the two epochs is greater than 1 year.
function checkpoint() external returns (bool) { ...... uint256 diffNumSeconds = block.timestamp - prevEpochTime; uint256 curEpochLen = epochLen; if (diffNumSeconds < curEpochLen || diffNumSeconds > ONE_YEAR) { return false; }
Hence in case the epochLen is set close to 1 year, it will likely cause checkpoint()
to revert since block timestamp can differ from the narrow possible range.
Manual review
Validate nextEpochLen
to be a sizable amount of time lower than 1 year.
Invalid Validation
#0 - c4-pre-sort
2024-01-10T15:09:30Z
alex-ppg marked the issue as primary issue
#1 - c4-pre-sort
2024-01-10T15:09:35Z
alex-ppg marked the issue as sufficient quality report
#2 - c4-sponsor
2024-01-16T15:17:36Z
kupermind marked the issue as disagree with severity
#3 - c4-sponsor
2024-01-16T15:18:23Z
kupermind (sponsor) confirmed
#4 - c4-judge
2024-01-18T19:55:29Z
dmvt marked the issue as unsatisfactory: Out of scope
#5 - c4-judge
2024-01-20T14:57:32Z
dmvt marked the issue as satisfactory
#6 - c4-judge
2024-01-20T14:57:42Z
dmvt changed the severity to QA (Quality Assurance)
#7 - c4-judge
2024-01-20T14:57:46Z
dmvt marked the issue as grade-b
#8 - BenRai1
2024-01-23T05:46:38Z
Hi @dmvt, I was wondering why this issue was downgraded to QA. It shows how it is possible to DOS the whole protocol if the epoch lenght is set to 1 year and therefore, in my opinion should at least be a medium, maybe even a high. Would be great if you could help me understand the reasening behind this.
#9 - dmvt
2024-01-23T19:51:36Z
From the sponsor:
<img width="517" alt="Screenshot 2024-01-23 at 7 50 49β―PM" src="https://github.com/code-423n4/2023-12-autonolas-findings/assets/1116695/66c4cff5-7375-4b81-af4d-c169649cf222">I agree with their assessment