Open c4-bot-10 opened 6 months ago
GalloDaSballo marked the issue as primary issue
GalloDaSballo marked the issue as sufficient quality report
GalloDaSballo marked the issue as high quality report
maybe @Foon256 can comment on this.
trust1995 marked the issue as satisfactory
trust1995 marked the issue as selected for report
trust1995 changed the severity to 3 (High Risk)
When a market accrues bad debt, which can be inflated due to an accounting error, fees and incentives will no longer be distributed Note The discussion had quite a bit of back and forth, for this reason the whole conversation is pasted below
Alex: This seems to be tied to a specific interpretation of this discussion we've had around loss of yield as high
Hickup: fees would be considered as matured yield? given that it extends beyond the protocol to beneficials and incentive owners, i'm leaning towards a high more than a med
Alex: Yes it would be considered matured
I don't have an opinion on this report yet and will follow up later today with my notes
Not fully made up my mind but here's a couple of points:
For Med: Loss of Yield -> There is no loss of principal so Med seems fine
For High: The contract is not losing yield in some case, the contract is losing 100% of all yield The contract is no longer serving it's purpose
External Conditions: Bad debt must be formed
Bad debt handling is part of the system design, so assuming this can happen is fair, and starting from a scenario in which this can happen is also fair
That said, in reality, this may never happen
My main point for downgrade is that while the contract is losing all of the yield, nothing beside that is impacted, not fully sure on this one
LSDan: I'm aligned with high on this one. Even though the conditions that lead to it are rare and there are arguably external conditions in some scenarios, there is a direct loss of funds and the functional loss of a contract's purpose. Once this situation occurs, there is no clean way back from it.
Alex: I think this is the issue where we will have some contention, I think the Sponsor interpretation is important to keep in mind as it's pretty rational
I would like to think about it a bit more
I'm leaning towards Med on this report, I think the Sponsors POV is valid
There is an accounting error, it would not cause permanent loss of funds It would be mitigated by deprecating the market and creating a new one
My main argument is that if this was live, this would trigger a re-deploy but it would not trigger any white hat rescue operation, as funds would be safe
Hickup:
The core argument for M severity is that fees are a secondary concern.
This goes against the supreme court decision where fees shouldn't be treated as 2nd class citizens: https://docs.code4rena.com/awarding/judging-criteria/severity-categorization#loss-of-fees-as-low Loss of fees should be regarded as an impact similar to any other loss of capital: Loss of real amounts depends on specific conditions and likelihood considerations.
Likelihood: Requirement of bad debt formation. Once there is, funds (fees) are permanently bricked.
There is an accounting error, it would not cause permanent loss of funds It would be mitigated by deprecating the market and creating a new one
The funds you are referring to are user funds? Separately, I don't see how it would mitigate the bricking once it happens.
Alex: I don't think that the ruling means that loss of fees should be treated as high at all times
The main argument is that the broken accounting doesn't create a state that is not recoverable: Some fees are lost User deprecates market (raises interests or pauses) Deployes new Market System resumes functioning as intended
My main argument is that this would not cause a War Room, it would cause a deprecation that the system can handle
Hickup: In what cases / scenarios would loss of fees be high then? Most, if not all, won't have a war room for protocol fees
The reason I would consider to justify downgrading is the low likelihood of the external requirement of bad debt formation + >= 2 partial liquidations
I would dissent and argue for high severity. permanent loss of unclaimed fees blast radius: affects not just the protocol, but incentive owners and beneficiaries. Had the fees gone only to the protocol, I'd lean a bit more towards M.
Is WiseLending immutable in a poolToken instance?
What contracts would have to be re-deployed?
Alex: Liquidation premium being denied could be a valid High loss of yield, Loss of gas for refunds when the system entire goal is that (e.g. keepers, voting on Nouns)
The finding shows how in the specific case of liquidations with bad debt, a market will stop accounting for fees
2 aggravating circumnstances seem to be: Inability to pause and replace each market The Math for bad debt is also wrong, leading to the inability to fix the bug
This would still cause a loss of fees for a certain period of time, as the admin would eventually be able to set the market fees to either a state that would cause users to stop using it or 0 as a means to stop the loss
I think that the accounting mistake is notable, and I understand the reasoning for raising severity
That said, because we have to judge by impact of the finding, I believe Medium Severity to be most appropriate
I maintain my stance for H severity for the reasons I stated above: permanent loss of unclaimed fees impact on protocol ecosystem: beneficiaries and incentive owners
I'm still of the opinion that High is most appropriate here. The impact is significant enough that raising the severity beyond medium makes sense.
The severity is kept at High Severity, with a non unanimous verdict
I recommend monitoring how this decision influences future decisions on severities, especially when it comes to a percentage loss of yield, an attacker having the button to cause a loss of yield, against this instance which is the permanent inability for the contract to record a gain of yield.
For transparency and per conversation with the sponsors, see here for the Wise Lending team's mitigation.
Lines of code
https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/WiseSecurity/WiseSecurity.sol#L419-L434 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/FeeManager/FeeManager.sol#L689-L699 https://github.com/code-423n4/2024-02-wise-lending/blob/main/contracts/FeeManager/FeeManager.sol#L663-L670
Vulnerability details
Bug Description
Protocol fees can be collected from the
WiseLending
contract and sent to theFeeManager
contract via the permissionlessFeeManager::claimWiseFees
function. During this call, incentives will only be distributed forincentive owners
iftotalBadDebtETH
(global bad debt) is equal to 0:FeeManager::claimWiseFees
The fees sent to the
FeeManager
are then able to be claimed bybeneficials
via theFeeManager::claimFeesBeneficial
function or byincentive owners
via theFeeManager::claimIncentives
function (if incentives have been distributed to the owners):FeeManager::claimIncentives
However,
beneficials
are only able to claim fees if there is currently no global bad debt in the system (totalBadDebtETH == 0
).FeeManager::claimFeesBeneficial
Below I will explain how the bad debt accounting logic used during partial liquidations can result in a state where
totalBadDebtETH
is permanently greater than0
. When this occurs,beneficials
will no longer be able to claim fees via theFeeManager::claimFeesBeneficial
function and new incentives will no longer be distributed when fees are permissionlessly collected via theFeeManager::claimWiseFees
function.When a position is partially liquidated, the
WiseSecurity::checkBadDebtLiquidation
function is executed to check if the position has created bad debt, i.e. if the position's overall borrow value is greater than the overall (unweighted) collateral value. If the post liquidation state of the position created bad debt, then the bad debt is recorded in a global and position-specific state:WiseSecurity::checkBadDebtLiquidation
FeeManagerHeper.sol
As we can see above, the method by which the global and position's state is updated is not consistent (total debt increases, but position's debt is set to recent debt). Since liquidations can be partial, a position with bad debt can undergo multiple partial liquidations and each time the
totalBadDebtETH
will be incremented. However, thebadDebtPosition
for the position will only be updated with the most recent bad debt that was recorded during the last partial liquidation. Note that due to the condition on line 419 ofWiseSecurity::checkBadDebtLiquidation
, thebadDebtPosition
will be reset to 0 whentotalBorrow == bareCollateral
(LTV == 100%). However, in this case, any previously recorded bad debt for the position will not be deducted from thetotalBadDebtETH
. Lets consider two examples:Scenario 1: Due to a market crash, a position's LTV goes above 100%. The position gets partially liquidated, incrementing
totalBadDebtETH
byx
(bad debt from 1st liquidation) and settingbadDebtPosition[_nftId]
tox
. The position gets partially liquidated again, this time incrementingtotalBadDebtETH
byy
(bad debt from 2nd liquidation) and settingbadDebtPosition[_nftId]
toy
. The resulting state:Scenario 2: Due to a market crash, a position's LTV goes above 100%. The position gets partially liquidated, incrementing
totalBadDebtETH
byx
and settingbadDebtPosition[_nftId]
tox
. The position gets partially liquidated again, but this time thetotalBorrow
is equal tobareCollateral
(LTV == 100%) and thus no bad debt is created. Due to the condition on line 419,totalBadDebtETH
will be incremented by 0, butbadDebtPosition[_nftId]
will be reset to 0. The resulting state:Note that scenario 1 is more likely to occur since scenario 2 requires the additional partial liquidation to result in an LTV of exactly 100% for the position.
As we can see, partial liquidations can lead to
totalBadDebtETH
being artificially inflated with respect to the actual bad debt created by a position.When bad debt is created, it is able to be paid back via the
FeeManager::paybackBadDebtForToken
orFeeManager::paybackBadDebtNoReward
functions. However, the maximum amount of bad debt that can be deducted during these calls is capped at the bad debt recorded for the position specified (badDebtPosition[_nftId]
). Therefore, the excess "fake" bad debt can not be deducted fromtotalBadDebtETH
, resulting intotalBadDebtETH
being permanently greater than0
.Below is the logic that deducts the bad debt created by a position when it is paid off via one of the payback functions mentioned above:
FeeManagerHelper::_updateUserBadDebt
The above code is invoked in the
FeeManagerHelper::updatePositionCurrentBadDebt
function, which is in turn invoked during both of the payback functions previously mentioned. You will notice that the above code properly takes into account the change in the bad debt of the position in question. I.e. if thebadDebtPosition[_nftId]
decreased (after being paid back), then thetotalBadDebtETH
will decrease as well. Therefore, thetotalBadDebtETH
can only be deducted by at most the current bad debt of a position. Returning to the previous example in scenario 1, this means thattotalBadDebtETH
would remain equal tox
, since onlyy
amount of bad debt can be paid back.Impact
In the event a position creates bad debt, partial liquidations of that position can lead to the global
totalBadDebtETH
state variable being artificially inflated. This additional "fake debt" can not be deducted from the global state when the actual bad debt of the position is paid back. Thus, theFeeManager::claimFeesBeneficial
function will be permanently DOS-ed, preventing anybeneficials
from claiming fees in theFeeManager
contract. Additionally, no new incentives are able to be distributed toincentive owners
in this state. However, protocol fees can still be collected in this state via the permissionlessFeeManager::claimWiseFees
function, and sinceincentive owners
andbeneficials
are the only entities able to claim these fees, this can lead to fees being permanently locked in theFeeManager
contract.Justification for Medium Severity:
Although not directly affecting end users, the function of claiming beneficial fees and distributing new incentives will be permanently bricked. To make matters worse, anyone can continue to collect fees via the permissionless
FeeManager::claimWiseFees
function, which will essentially "burn" any pending or future fees by locking them in theFeeManager
(assuming all previously gathered incentives have been claimed). This value is therefore leaked from the protocol every time additional fees are collected in this state.Once this state is reached, any pending or future fees should ideally be left in the
WiseLending
contract, providing value back to the users instead of allowing that value to be unnecessarily "burned". However, the permissionless nature of theFeeManager::claimWiseFees
function allows bad actors to further grief the protocol during this state by continuing to collect fees.Note that once this state is reached, and Wise Lending is made aware of the implications, all fees (for all pools) can be set to 0 by the
master
address. This would ensure that no future fees are sent to theFeeManager
. However, this does not stop pending fees from being collected. Additionally, a true decentralized system (such as a DAO) would likely have some latency between proposing such a change (decreasing fee value) and executing that change. Therefore, any fees distributed during that period can be collected.Proof of Concept
Place the following test in the
contracts/
directory and run withforge test --match-path contracts/BadDebtTest.t.sol
:Tools Used
Manual
Recommended Mitigation Steps
I would recommend updating
totalBadDebtETH
with thedifference
of the previous and new bad debt of a position in theWiseSecurity::checkBadDebtLiquidation
function, similar to how it is done in theFeeManagerHelper::_updateUserBadDebt
internal function.Example implementation:
Assessed type
Other