Platform: Code4rena
Start Date: 09/01/2024
Pot Size: $100,000 USDC
Total HM: 13
Participants: 28
Period: 28 days
Judge: 0xsomeone
Total Solo HM: 8
Id: 319
League: ETH
Rank: 2/28
Findings: 4
Award: $12,319.79
π Selected for report: 2
π Solo Findings: 2
π Selected for report: Aymen0909
Also found by: bin2chen, etherhood, jasonxiale, kodyvim, minhquanym
1143.106 USDC - $1,143.11
The withdraw_helper()
function is an internal function called by the external withdraw()
function to handle trove accounting. This function reads the current trove_balance
from storage and calculates the new_trove_balance
and new_total
values. However, these calculations occur before the charge()
function is called.
The charge()
function is used to accrue any interest from the last interacted timestamp to the current timestamp. It also pulls any redistribution debts and yangs. During this process, the yang balance of troves might be updated due to exceptional redistribution.
After calling charge()
, new_total
and new_trove_balance
are written to storage. But, these values may be outdated if the trove balance was updated in the charge()
function. As a result, the stored values could be incorrect, potentially causing user losses.
fn withdraw_helper(ref self: ContractState, yang: ContractAddress, trove_id: u64, amount: Wad) { ... let trove_balance: Wad = self.deposits.read((yang_id, trove_id)); ... let new_trove_balance: Wad = trove_balance - amount; let new_total: Wad = self.yang_total.read(yang_id) - amount; // @audit Need to charge first, `trove_balance` might change after `charge()` self.charge(trove_id); // @audit `new_trove_balance` and `new_total` is calculated before `charge()`, it is outdated but still written to storage. self.yang_total.write(yang_id, new_total); self.deposits.write((yang_id, trove_id), new_trove_balance); ... }
Consider the following scenario:
MIN_RECIPIENT_POOL_YANG - 1
yang X.redistributed_yang_recipient_pool
will be MIN_RECIPIENT_POOL_YANG - 1
, resulting in an exceptional redistribution.amount = MIN_RECIPIENT_POOL_YANG - 1
X that she deposited into her pool.withdraw_helper
function calculates new_trove_balance = 0
. The charge()
function then pulls the exceptional distribution and increases Alice's yang X trove balance. However, after that, new_trove_balance = 0
is still written to storage, meaning the amount of X redistributed to Alice's trove is lost forever.Manual Review
Call the charge()
function before any calculation in the withdraw_helper()
function.
Other
#0 - tserg
2024-02-07T08:27:08Z
This is valid - duplicate of #211.
#1 - c4-pre-sort
2024-02-09T18:06:24Z
bytes032 marked the issue as duplicate of #211
#2 - c4-pre-sort
2024-02-09T18:06:39Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2024-02-26T16:36:49Z
alex-ppg marked the issue as satisfactory
π Selected for report: kfx
Also found by: TrungOre, bin2chen, minhquanym
2116.8629 USDC - $2,116.86
The function redistribute_helper()
in the Opus codebase may skip over an empty yang during the redistribution process. This can cause an incorrect update to the yang balances within a trove.
The redistribute_helper()
function iterates over each yang in a trove for redistribution. During this process, it maintains two lists: new_yang_totals
and updated_trove_yang_balances
. These lists record the new total yang and new trove yang balance after each distribution, respectively. Both lists should have the same index, meaning index i
in each list should refer to the same yang.
At the end of the function, the values in these two lists are used to write to storage:
let mut new_yang_totals: Span<YangBalance> = new_yang_totals.span(); let mut updated_trove_yang_balances: Span<YangBalance> = updated_trove_yang_balances.span(); loop { match new_yang_totals.pop_front() { Option::Some(total_yang_balance) => { let updated_trove_yang_balance: YangBalance = *updated_trove_yang_balances.pop_front().unwrap(); self .deposits .write((updated_trove_yang_balance.yang_id, trove_id), updated_trove_yang_balance.amount); self.yang_total.write(*total_yang_balance.yang_id, *total_yang_balance.amount); }, Option::None => { break; }, }; };
As you can see, each time new_yang_totals
pops an element, updated_trove_yang_balances
also pops one element to write to storage. If new_yang_totals.len() < updated_trove_yang_balances.len()
, some of the last updated_trove_yang_balances
won't be updated. This can cause incorrect accounting for the trove following liquidation.
Specifically, if there are three yangs A, B, and C, and the balance of B is zero, new_yang_totals
will only contain values for [A, C], while updated_trove_yang_balances
contains all values for all [A, B, C]. During the writing to storage, updated_trove_yang_balances
of C won't be processed.
The scenario described in the Impact section can occur when skipping an empty yang:
// Skip over this yang if it has not been deposited in the trove // @audit `new_yang_totals` is not appended if trove_yang_amt.is_zero() { updated_trove_yang_balances.append(*yang_balance); continue; }
During the loop through the yangs of the trove, if trove_yang_amt
is zero, the function will skip over it and append the same trove balance to updated_trove_yang_balances
. However, nothing is added to new_yang_totals
. This can cause a mismatch between these two lists. In the end, the length of new_yang_totals
would be smaller than updated_trove_yang_balances
, causing some of the last values in updated_trove_yang_balances
not to be written to storage.
Manual Review
To mitigate this issue, append to new_yang_totals
when skipping over a yang.
Other
#0 - tserg
2024-02-07T08:25:33Z
This is valid - duplicate of #199.
#1 - c4-pre-sort
2024-02-09T18:09:07Z
bytes032 marked the issue as duplicate of #199
#2 - c4-pre-sort
2024-02-09T18:09:18Z
bytes032 marked the issue as sufficient quality report
#3 - c4-judge
2024-02-26T16:49:26Z
alex-ppg marked the issue as satisfactory
π Selected for report: minhquanym
4529.9124 USDC - $4,529.91
In the Opus protocol, the absorber is a stability pool that permits yin holders to contribute their yin and participate in liquidations (also known as absorptions) as a consolidated pool. Provided yin from users could be absorbed during an absorption. In return, users receive yang, which usually has a higher value than the absorbed yin. However, in the event of bad debt, it could be less. This situation could lead to risk-free yield frontrunning tactics, where an attacker only provides yin when the absorption brings profit then removing all yin right after that.
The Opus team is aware of this attack vector and has therefore implemented a request-withdraw procedure. Users must first request a withdrawal and wait for a certain period before executing the withdrawal.
However, the provide()
method does not reset these request timestamps, which allows an attacker to bypass this safeguard.
The attacker's tactics will be:
100
is just arbitrary value to describe the idea. It could be higher or lower depending on the REQUEST_BASE_TIMELOCK
and REQUEST_VALIDITY_PERIOD
.X + REQUEST_VALIDITY_PERIOD
, the 3rd at X + 2 * REQUEST_VALIDITY_PERIOD
, and so on. The 1st account request will expire at X + REQUEST_BASE_TIMELOCK + REQUEST_VALIDITY_PERIOD
, but now 2nd request account become valid because it has requested at X + REQUEST_VALIDITY_PERIOD
.provide()
function does not reset the request, the attacker can simply select one account with a valid removing request and perform the "risk-free yield tactics". He does this by providing a large amount of yin to this account to earn yield and then immediately calling remove()
.Manual Review
Reset the withdrawal request in the provide()
function.
MEV
#0 - c4-pre-sort
2024-02-10T06:12:03Z
bytes032 marked the issue as sufficient quality report
#1 - c4-pre-sort
2024-02-10T09:31:16Z
bytes032 marked the issue as primary issue
#2 - c4-sponsor
2024-02-15T15:50:44Z
tserg (sponsor) confirmed
#3 - c4-sponsor
2024-02-15T15:50:47Z
tserg marked the issue as disagree with severity
#4 - tserg
2024-02-15T15:51:42Z
The economic feasibility of the exploit is questionable, and it requires extensive infra to be set up and maintained. Ultimately, no user funds are at risk.
#5 - alex-ppg
2024-02-26T17:37:54Z
The Warden has demonstrated how a malicious user can game the queued withdrawal system of Opus and cycle withdrawal authorizations between multiple accounts to always retain one that can immediately withdraw at any given point in time. As a provision will not reset those requests, the account whose withdrawal authorization is valid at a point a lucrative absorption occurs can be exploited to acquire a bigger share of the collateral.
I believe a medium-risk grade is better suited for this finding as only the "reward" distribution is manipulated and it requires extensive money-translated effort (i.e. gas for maintaining positions active) that will further reduce the economic viability of this exploit.
#6 - c4-judge
2024-02-26T17:38:05Z
alex-ppg changed the severity to 2 (Med Risk)
#7 - c4-judge
2024-02-26T17:38:09Z
alex-ppg marked the issue as satisfactory
#8 - c4-judge
2024-02-26T17:38:12Z
alex-ppg marked the issue as selected for report
#9 - minhquanym
2024-02-28T02:49:24Z
Hi @alex-ppg, I would like to highlight that this finding shows a unique way to exploit the withdrawal queue using multiple accounts. The other mentioned reports are only able to bypass the time lock for a short period of time, while this report shows that it could be bypassed indefinitely. Thank you for your consideration.
#10 - rodiontr
2024-02-28T06:14:46Z
Hi @alex-ppg, I would like to highlight that this finding shows a unique way to exploit the withdrawal queue using multiple accounts. The other mentioned reports are only able to bypass the time lock for a short period of time, while this report shows that it could be bypassed indefinitely. Thank you for your consideration.
hi @minhquanym, but the impact is still the same - implementing risk-free yield tactics
π Selected for report: minhquanym
4529.9124 USDC - $4,529.91
In a redistribution, the unhealthy trove's collateral and debt is distributed among troves proportionally to its collateral composition. If no other troves has deposited a yang that is to be redistributed, then the debt and value attributed to that yang will be redistributed among all other yangs in the system according to their value proportionally to the total value of all remaining yangs in the system.
However, the Opus protocol allows anyone to deposit into any trove, even if they are not the trove's owner, as seen in the function abbot.deposit()
. This feature could potentially be exploited by an attacker. For instance, if a yang is only deposited in one trove that's being redistributed, the attacker could deposit a small amount into a victim's trove. This could result in all bad debt being redistributed to the victim's trove instead of exceptional redistribution, even if the victim didn't want this (i.e., they didn't deposit this yang into their trove).
fn deposit(ref self: ContractState, trove_id: u64, yang_asset: AssetBalance) { // There is no need to check the yang address is non-zero because the // Sentinel does not allow a zero address yang to be added. assert(trove_id != 0, 'ABB: Trove ID cannot be 0'); assert(trove_id <= self.troves_count.read(), 'ABB: Non-existent trove'); // note that caller does not need to be the trove's owner to deposit // @audit Attacker could deposit for any trove to make it the only trove deposited this yang and being redistributed when bad debt happen self.deposit_helper(trove_id, get_caller_address(), yang_asset); }
Consider the following scenario:
Manual Review
Limit deposits to only the trove's owner.
Other
#0 - c4-pre-sort
2024-02-10T08:22:07Z
bytes032 marked the issue as primary issue
#1 - c4-pre-sort
2024-02-10T08:22:09Z
bytes032 marked the issue as sufficient quality report
#2 - bytes032
2024-02-10T18:15:06Z
Note for judge re dup #109
Even though both of them are about anyone could deposit to any trove, #109 talked about different case where the threshold of a trove depending on which yangs it deposited,
#3 - tserg
2024-02-15T16:03:13Z
For #115, it is Acknowledge + Disagree with severity because the likelihood of this happening is low.
For #109, it is Confirm.
#4 - c4-sponsor
2024-02-21T13:44:40Z
tserg (sponsor) acknowledged
#5 - c4-sponsor
2024-02-21T13:44:43Z
tserg marked the issue as disagree with severity
#6 - alex-ppg
2024-02-26T17:39:02Z
The Warden has demonstrated how bad debt re-allocation can be "gamed" due to the permissionless deposit system of troves.
This particular attack vector is quite interesting and I commend the Warden for their out-of-the-box thinking. I believe a medium-risk grade, however, is better suited for it given that the likelihood of a liquidation of a trove with an asset that is not held by any other trove in the system is low.
#7 - c4-judge
2024-02-26T17:39:08Z
alex-ppg changed the severity to 2 (Med Risk)
#8 - c4-judge
2024-02-26T17:39:12Z
alex-ppg marked the issue as satisfactory
#9 - c4-judge
2024-02-26T17:39:14Z
alex-ppg marked the issue as selected for report