Badger eBTC Audit + Certora Formal Verification Competition - ether_sky's results

Use stETH to borrow Bitcoin with 0% fees | The only smart contract based #BTC.

General Information

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

eBTC Protocol

Findings Distribution

Researcher Performance

Rank: 4/52

Findings: 3

Award: $8,768.05

QA:
grade-a

🌟 Selected for report: 0

🚀 Solo Findings: 0

Findings Information

🌟 Selected for report: shaka

Also found by: ether_sky

Labels

bug
2 (Med Risk)
satisfactory
edited-by-warden
insufficient quality report
duplicate-310

Awards

5406.5906 USDC - $5,406.59

External Links

Lines of code

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

Vulnerability details

Impact

In certain situations, the priceFeed may retrieve values from untrusted ChainLink oracles. The utilization of such incorrect price values can adversely impact the protocol.

Proof of Concept

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.
  • The current price value has not deviated by more than 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); } } }

https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/PriceFeed.sol#L293

if (status == Status.usingFallbackChainlinkFrozen) { if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) { ... } if (_chainlinkIsFrozen(chainlinkResponse)) { ... } if (_fallbackIsBroken(fallbackResponse)) { _changeStatus(Status.usingChainlinkFallbackUntrusted); return _storeChainlinkPrice(chainlinkResponse.answer); } }

Tools Used

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.

Assessed type

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:

  • ChainLink is not broken.
  • ChainLink is not frozen.
  • The current price value has not deviated by more than 50% from the previous price. If the third condition is not satisfied, we use the 'lastGoodPrice' instead of the Chainlink value.

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.

Codelines: https://github.com/code-423n4/2023-10-badger/blob/2bacb215bf3257320e4f6dd7dcb039f1f06f3214/packages/contracts/contracts/PriceFeed.sol#L291-L294

scenario:

  1. The fallback oracle is set.
  2. The current init status is usingFallbackChainlinkFrozen, case 4.
  3. The current chainlink price feed is alive and not broken. But it returns a 50% diff price cmp with the pre round.
  4. The fallback oracle is broken now.

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:

  • When the state machine is in status bothOraclesUntrusted and the fallback is address(0) the if statement will be triggered, the system will further check if chainlink is neither broken or frozen and change the status to usingChainlinkFallbackUntrusted, return the chainlink answer and store it as the lastGoodPrice.
            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:

  • The same situation happens when the state machine is in status usingFallbackChainlinkFrozen, the system checks if chainlink is neither broken or frozen after that the next if statement is triggered which indicates the fallback is broken, so the system will change the state to usingChainlinkFallbackUntrusted return the chainlink answer and store is as the lastGoodPrice.
            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

Findings Information

🌟 Selected for report: 0xRobocop

Also found by: Weed0607, ether_sky

Labels

bug
2 (Med Risk)
satisfactory
sponsor disputed
insufficient quality report
duplicate-199

Awards

3243.9544 USDC - $3,243.95

External Links

Lines of code

https://github.com/code-423n4/2023-10-badger/blob/f2f2e2cf9965a1020661d179af46cb49e993cb7e/packages/contracts/contracts/CdpManager.sol#L320-L328

Vulnerability details

Impact

I came across the following in the main invariants: R-07: TCR should not decrease after redemptions.

Proof of Concept

The first CDP with the lowest ICR higher than the MCR can be redeemed.

Let's imagine the following scenario: There are 3 CDPs 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)

Tools Used

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.

Assessed type

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.

Awards

117.508 USDC - $117.51

Labels

bug
grade-a
QA (Quality Assurance)
insufficient quality report
Q-08

External Links

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

AuditHub

A portfolio for auditors, a security profile for protocols, a hub for web3 security.

Built bymalatrax © 2024

Auditors

Browse

Contests

Browse

Get in touch

ContactTwitter