Platform: Code4rena
Start Date: 24/10/2023
Pot Size: $149,725 USDC
Total HM: 7
Participants: 52
Period: 21 days
Judge: ronnyx2017
Total Solo HM: 2
Id: 300
League: ETH
Rank: 4/52
Findings: 3
Award: $8,768.05
🌟 Selected for report: 0
🚀 Solo Findings: 0
5406.5906 USDC - $5,406.59
https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/PriceFeed.sol#L231 https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/PriceFeed.sol#L293
In certain situations, the priceFeed
may retrieve values from untrusted ChainLink oracles
.
The utilization of such incorrect price
values can adversely impact the protocol
.
There are two oracles
: ChainLink
and Fallback
.
We consider ChainLink
to be live
and reliable
when the following conditions are met:
ChainLink
is not broken.ChainLink
is not frozen.50%
from the previous price.These conditions
are utilized in the current code base
as follows:
if (status == Status.chainlinkWorking) { if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) { ... return; } if (_chainlinkIsFrozen(chainlinkResponse)) { ... return; } if (_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)) { ... return; } // We believe this ChainLink oracle and use it's value return _storeChainlinkPrice(chainlinkResponse.answer); } if (status == Status.usingChainlinkFallbackUntrusted) { if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) { ... return; } if (_chainlinkIsFrozen(chainlinkResponse)) { ... return; } if (_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)) { ... return; } // We believe this ChainLink oracle and use it's value return _storeChainlinkPrice(chainlinkResponse.answer); }
If the current price value
has deviated by more than 50%
from the previous price
, we consider ChainLink
unreliable and perform an additional check
with the Fallback Oracle
as below.
if (_bothOraclesSimilarPrice(chainlinkResponse, fallbackResponse)) { return _storeChainlinkPrice(chainlinkResponse.answer); }
However, in some cases, we use the ChainLink
value without applying the third condition check
, potentially leading to the use of an untrusted ChainLink value
.
https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/PriceFeed.sol#L231
if (status == Status.bothOraclesUntrusted) { if (address(fallbackCaller) == address(0)) { // If CL has resumed working if ( !_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse) && !_chainlinkIsFrozen(chainlinkResponse) ) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer); } } }
if (status == Status.usingFallbackChainlinkFrozen) { if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) { ... } if (_chainlinkIsFrozen(chainlinkResponse)) { ... } if (_fallbackIsBroken(fallbackResponse)) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer); } }
Please use lastGoodPrice
instead of the ChainLink
value.
if (status == Status.bothOraclesUntrusted) { if (address(fallbackCaller) == address(0)) { if ( !_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse) && !_chainlinkIsFrozen(chainlinkResponse) ) { _changeStatus(Status.usingChainlinkFallbackUntrusted); - return _storeChainlinkPrice(chainlinkResponse.answer); + return lastGoodPrice; } } } if (status == Status.usingFallbackChainlinkFrozen) { if (_fallbackIsBroken(fallbackResponse)) { _changeStatus(Status.usingChainlinkFallbackUntrusted); - return _storeChainlinkPrice(chainlinkResponse.answer); + return lastGoodPrice; } }
By doing so, we can avoid using an incorrect value
from the untrusted ChainLink
and transition to usingChainlinkFallbackUntrusted
state, which is implemented correctly.
Error
#0 - c4-pre-sort
2023-11-15T10:41:22Z
bytes032 marked the issue as insufficient quality report
#1 - bytes032
2023-11-15T10:41:25Z
OOS
#2 - c4-pre-sort
2023-11-16T07:30:03Z
bytes032 marked the issue as duplicate of #141
#3 - c4-judge
2023-11-25T05:48:23Z
jhsagd76 marked the issue as not a duplicate
#4 - c4-judge
2023-11-25T05:51:18Z
jhsagd76 marked the issue as duplicate of #310
#5 - c4-judge
2023-11-27T09:50:52Z
jhsagd76 marked the issue as satisfactory
#6 - jhsagd76
2023-11-27T09:57:18Z
The report does not identify the key issues and provide the incorrect mitigation solutions. I downgrade it to 25%.
#7 - c4-judge
2023-11-27T09:57:24Z
jhsagd76 removed the grade
#8 - c4-judge
2023-11-27T09:57:32Z
jhsagd76 marked the issue as partial-25
#9 - etherSky111
2023-12-05T06:33:24Z
Hi @jhsagd76 ,
Thank you for reviewing the reports. Could you kindly review my comments?
The main issue here is the inconsistency in our use of validation checks across different cases. In the 'chainlinkWorking' and 'usingChainlinkFallbackUntrusted' cases, we ensure ChainLink validity by verifying three conditions:
However, in 'bothOraclesUntrusted', we only check the first two conditions. This discrepancy is the reason why we can obtain different prices within the same transaction.
Let me clarify my concern. Suppose the fallback is not set. In Status.usingChainlinkFallbackUntrusted, we check the third condition even when the fallback is 0, and we use lastGoodPrice and convert it to Status.bothOraclesUntrusted status.
if (status == Status.usingChainlinkFallbackUntrusted) { if (_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)) { _changeStatus(Status.bothOraclesUntrusted); return lastGoodPrice; } }
However, in Status.bothOraclesUntrusted status, we don't check the third condition and use the Chainlink response.
if (status == Status.bothOraclesUntrusted) { if (address(fallbackCaller) == address(0)) { // If CL has resumed working if ( !_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse) && !_chainlinkIsFrozen(chainlinkResponse) ) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer); } } }
I believe there are two possible solutions based on how we consider the third condition when there is no fallback. The first approach is to consider Chainlink as reliable only when all three conditions are satisfied, regardless of whether the fallback is set or not. In this case, my suggested approach is correct. Because we have already used the lastGoodPrice before transitioning to the bothOraclesUntrusted status, and if the fallback is not set and the Chainlink is neither broken nor frozen, we can use lastGoodPrice once more and transition to the usingChainlinkFallbackUntrusted status. In this status, we evaluate the third condition irrespective of whether the fallback is set or not. If the third condition is met, we can confidently use the Chainlink value obtained at this step without any inconsistency. If the third condition is not met, we can still use lastGoodPrice and transition back to the bothOraclesUntrusted status. So if we consider Chainlink as reliable only when all three conditions are met, this approach ensures that there is no risk to use untrusted price values.
The second approach is to consider Chainlink as reliable when the first two conditions are satisfied and the fallback is not set or all three conditions are satisfied with the fallback set. In this case, the suggested approach in 310 is correct.
In short, both solutions can prevent incorrect price values, and the choice depends on how we want to handle the reliability of Chainlink in the absence of a fallback.
Thanks.
#10 - jhsagd76
2023-12-06T08:11:54Z
I don't think the mitigation in the original report is valid, and it also didn't point out the main impact.
But I found that it seems like a diff edge case wasn't mentioned in the primary report. It can't result in return different prices in the same transaction. This impact is not that severe, but also worth considering.
scenario:
usingFallbackChainlinkFrozen
, case 4.When calls the fetchPrice
function:
Call I.:
_changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer);
State is changed to usingChainlinkFallbackUntrusted, and return the current chainlink print directly.
Call II.: Jump to state usingChainlinkFallbackUntrusted, case 5. Chainlink print is not broken, not Frozen, not Similar, but ChangeAboveMax. So the lastGoodPrice is returned, which is equal to the chainlinkResponse.answer in the Call I. And the state -> bothOraclesUntrusted.
if (_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)) { _changeStatus(Status.bothOraclesUntrusted); return lastGoodPrice; }
So finally, after the Call I, an unreliable value has been stored as the lastGoodPrice
. kindly ping the sponsor @CodingNameKiki to ensure they won't miss anything.
Given this new edge case mentioned in the original report, I am increasing the reward to 50%.
#11 - c4-judge
2023-12-06T08:12:24Z
jhsagd76 marked the issue as partial-50
#12 - CodingNameKiki
2023-12-06T10:10:07Z
Hey @jhsagd76, l have previously discussed this concern when l was doing a research on the mitigations. here.
It seems that Liquity decided to keep the functionality that way given the fact it's very unlikely there will be 50% Deviation between two rounds. But l agree that this issue shows edge cases in the original report and should be awarded as duplicate because the core issue achieved is the same - 50% Deviation between two rounds in both reports. here.
<img width="519" alt="Screenshot 2023-12-06 at 11 19 51" src="https://github.com/code-423n4/2023-10-badger-findings/assets/112419701/ccd57e52-5f62-47b9-a5c1-4ef078640001">Based on the below statements the core outcome is the same.
Original issue:
if (address(fallbackCaller) == address(0)) { // If CL has resumed working if ( !_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse) && !_chainlinkIsFrozen(chainlinkResponse) ) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer); } }
Duplicate issue:
if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) { // If both Oracles are broken, return last good price if (_fallbackIsBroken(fallbackResponse)) { _changeStatus(Status.bothOraclesUntrusted); return lastGoodPrice; } // If Chainlink is broken, remember it and switch to using Fallback _changeStatus(Status.usingFallbackChainlinkUntrusted); if (_fallbackIsFrozen(fallbackResponse)) { return lastGoodPrice; } // If Fallback is working, return Fallback current price return _storeFallbackPrice(fallbackResponse); } if (_chainlinkIsFrozen(chainlinkResponse)) { // if Chainlink is frozen and Fallback is broken, remember Fallback broke, and return last good price if (_fallbackIsBroken(fallbackResponse)) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return lastGoodPrice; } // If both are frozen, just use lastGoodPrice if (_fallbackIsFrozen(fallbackResponse)) { return lastGoodPrice; } // if Chainlink is frozen and Fallback is working, keep using Fallback (no status change) return _storeFallbackPrice(fallbackResponse); } // if Chainlink is live and Fallback is broken, remember Fallback broke, and return Chainlink price if (_fallbackIsBroken(fallbackResponse)) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer); }
Originally ebtc seems to be designed based on Liquity, so based on Liquity if the duplicated issue is okay scenario for use. Perhaps l think the issue in the original report should be okay as well or perhaps they should be fixed on both places? Thanks for the ping will forward to the team.
#13 - CodingNameKiki
2023-12-06T13:09:30Z
^ We decided to fix the concern on both places.
Again, thanks for the mention.
#14 - etherSky111
2023-12-06T18:09:49Z
Hi @jhsagd76 , could you please check CodingNameKiki's comment?
#15 - etherSky111
2023-12-07T04:16:25Z
Thanks, @jhsagd76 , @CodingNameKiki
#16 - jhsagd76
2023-12-08T04:29:03Z
Based on the above discussion, and 75% tag are currently unavailable and according to the rules related to "Penalty / Award Standardization - Duplicate Report PoC Thoroughness", remove partial 50%
#17 - c4-judge
2023-12-08T04:29:33Z
jhsagd76 marked the issue as full credit
#18 - c4-judge
2023-12-08T14:11:15Z
jhsagd76 marked the issue as satisfactory
3243.9544 USDC - $3,243.95
I came across the following in the main invariants:
R-07: TCR should not decrease after redemptions.
The first CDP
with the lowest ICR
higher than the MCR
can be redeemed.
Let's imagine the following scenario:
There are 3 CDP
s with the same debts and different collateral shares.
ICR[0] = 130%, ICR[1] = 128%, ICR[2] = 105%
Then
originalTCR = 121% ( = (130 + 128 + 105) / 3 ) // 3 CDPs has the same debts (denominators)
This implies that the second CDP
can be redeemable
.
After redeeming the second CDP
, the TCR
obviously falls below the original TCR
.
TCR = (ICR[0] + ICR[2]) / 2 = 120% < 121% (originalTCR)
We can add below check to the redeemCollateral
function:
function redeemCollateral() { ... _syncGracePeriodForGivenValues( totals.systemCollSharesAtStart - totals.collSharesDrawn - totals.totalCollSharesSurplus, totals.systemDebtAtStart - totals.debtToRedeem, totals.price ); + uint256 newTcr = _computeTCRWithGivenSystemValues( + totals.systemCollSharesAtStart - totals.collSharesDrawn - totals.totalCollSharesSurplus, + totals.systemDebtAtStart - totals.debtToRedeem, + totals.price + ); + require(totals.tcrAtStart <= newTcr, ""); }
After adding this, we observe that some tests will fail.
This indicates that we are allowing the TCR
to fall below the original TCR
after redemption at this point, violating the main invariants.
Error
#0 - c4-pre-sort
2023-11-16T07:58:23Z
bytes032 marked the issue as insufficient quality report
#1 - bytes032
2023-11-16T07:58:26Z
No proof of impact
#2 - c4-sponsor
2023-11-20T09:09:45Z
GalloDaSballo (sponsor) disputed
#3 - GalloDaSballo
2023-11-20T09:10:11Z
We have shared broken invariants and this one also doesn't demonstrate a valid impact, this is not weaponized
#4 - jhsagd76
2023-11-26T01:10:52Z
Just mark it as a dup of #199 , although they are a little different. Pls check the comment I left in the #199 and prove it should be a MED.
#5 - c4-judge
2023-11-26T01:11:03Z
jhsagd76 marked the issue as duplicate of #199
#6 - c4-judge
2023-11-26T01:11:18Z
jhsagd76 changed the severity to QA (Quality Assurance)
#7 - c4-judge
2023-11-28T06:29:58Z
jhsagd76 marked the issue as grade-c
#8 - c4-judge
2023-12-07T02:34:16Z
This previously downgraded issue has been upgraded by jhsagd76
#9 - c4-judge
2023-12-07T02:35:03Z
jhsagd76 marked the issue as selected for report
#10 - c4-judge
2023-12-07T02:36:10Z
jhsagd76 marked the issue as satisfactory
#11 - c4-judge
2023-12-07T02:42:51Z
jhsagd76 marked the issue as not selected for report
#12 - c4-judge
2023-12-07T02:43:08Z
jhsagd76 marked issue #199 as primary and marked this issue as a duplicate of 199
#13 - jhsagd76
2023-12-07T02:45:01Z
make sense, still maintain a 100% reward for this issue.
🌟 Selected for report: SpicyMeatball
Also found by: 0xBeirao, 7ashraf, LokiThe5th, OMEN, TrungOre, alexzoid, alpha, bdmcbri, ether_sky, fatherOfBlocks, ge6a, hihen, hunter_w3b, jasonxiale, ladboy233, lsaudit, niroh, nobody2018, nonseodion, peanuts, prapandey031, shaka, twcctop, twicek, wangxx2026
117.508 USDC - $117.51
I believe this is an incorrect check for partial debt size
.
Partial liquidation
or redemption
should reserve collateral
of at least 2 stEth
.
We have below check for this.
function _requirePartialLiqCollSize(uint256 _entireColl) internal pure { require( _entireColl >= MIN_NET_STETH_BALANCE, "LiquidationLibrary: Coll remaining in partially liquidated CDP must be >= minimum" ); }
However, this check adds another limitation
to the debt size
.
https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/LiquidationLibrary.sol#L896-L906
require( (_partialDebt + _convertDebtDenominationToBtc(MIN_NET_STETH_BALANCE, _price)) <= _entireDebt, "LiquidationLibrary: Partial debt liquidated must be less than total debt" );
#0 - c4-pre-sort
2023-11-17T14:46:26Z
bytes032 marked the issue as insufficient quality report
#1 - jhsagd76
2023-11-28T09:59:37Z
1L+2S = 7