ebtc-protocol / ebtc

GNU General Public License v3.0
54 stars 24 forks source link

C4 310 & 256 Mitigation #726

Closed rayeaster closed 9 months ago

rayeaster commented 10 months ago

address https://github.com/code-423n4/2023-10-badger-findings/issues/310 & https://github.com/code-423n4/2023-10-badger-findings/issues/256 by adding extra checks around PriceFeed state machine if fallback oracle is empty (not set) and CL reports 50% deviation between rounds

CodingNameKiki commented 10 months ago

Minor changes are made to PriceFeed so that the system can maintain good functionality when the fallback isn't set:

  1. If l understand correctly, the system should maintain a state between usingChainlinkFallbackUntrusted and bothOraclesUntrusted if the fallback isn't set and equals address(0), so when usingChainlinkFallbackUntrusted and chainlink is working the system keeps the same state and returns the latest answer from chainlink, otherwise it switches back to bothOraclesUntrusted, in there the system returns the lastGoodPrice or returns the chainlink latest answer and switches back to usingChainlinkFallbackUntrusted if chainlink is working again.
        // --- CASE 2: The system fetched last price from Fallback ---
        if (status == Status.usingFallbackChainlinkUntrusted) {
            if (_fallbackIsBroken(fallbackResponse)) {
                _changeStatus(Status.bothOraclesUntrusted);
                return lastGoodPrice;
            }

            /*
             * If Fallback is only frozen but otherwise returning valid data, just return the last good price.
             * Fallback may need to be tipped to return current data.
             */
            if (_fallbackIsFrozen(fallbackResponse)) {
                return lastGoodPrice;
            }

            // If both Fallback and Chainlink are live, unbroken, and reporting similar prices, switch back to Chainlink
            if (
                _bothOraclesLiveAndUnbrokenAndSimilarPrice(
                    chainlinkResponse,
                    prevChainlinkResponse,
                    fallbackResponse
                )
            ) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise, use Fallback price
            return _storeFallbackPrice(fallbackResponse);
        }
        // --- CASE 3: Both oracles were untrusted at the last price fetch ---
        if (status == Status.bothOraclesUntrusted) {
            /*
             * If there's no fallback, only use Chainlink
             */
            if (address(fallbackCaller) == address(0)) {
                // If CL has resumed working
                if (
                    !_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse) &&
                    !_chainlinkIsFrozen(chainlinkResponse) &&
                    !_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)
                ) {
                    _changeStatus(Status.usingChainlinkFallbackUntrusted);
                    return _storeChainlinkPrice(chainlinkResponse.answer);
                } else {
                    return lastGoodPrice;
                }
            }

            /*
             * If both oracles are now live, unbroken and similar price, we assume that they are reporting
             * accurately, and so we switch back to Chainlink.
             */
            if (
                _bothOraclesLiveAndUnbrokenAndSimilarPrice(
                    chainlinkResponse,
                    prevChainlinkResponse,
                    fallbackResponse
                )
            ) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise, return the last good price - both oracles are still untrusted (no status change)
            return lastGoodPrice;
        }
        // --- CASE 5: Using Chainlink, Fallback is untrusted ---
        if (status == Status.usingChainlinkFallbackUntrusted) {
            // If Chainlink breaks, now both oracles are untrusted
            if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) {
                _changeStatus(Status.bothOraclesUntrusted);
                return lastGoodPrice;
            }

            // If Chainlink is frozen, return last good price (no status change)
            if (_chainlinkIsFrozen(chainlinkResponse)) {
                return lastGoodPrice;
            }

            // If Chainlink is live but deviated >50% from it's previous price and Fallback is still untrusted, switch
            // to bothOraclesUntrusted and return last good price
            if (_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)) {
                _changeStatus(Status.bothOraclesUntrusted);
                return lastGoodPrice;
            }

            // If Chainlink and Fallback are both live, unbroken and similar price, switch back to chainlinkWorking and return Chainlink price
            if (
                _bothOraclesLiveAndUnbrokenAndSimilarPrice(
                    chainlinkResponse,
                    prevChainlinkResponse,
                    fallbackResponse
                )
            ) {
                if (address(fallbackCaller) != address(0)) {
                    _changeStatus(Status.chainlinkWorking);
                }
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise if Chainlink is live and deviated <50% from it's previous price and Fallback is still untrusted,
            // return Chainlink price (no status change)
            return _storeChainlinkPrice(chainlinkResponse.answer);
        }
    function _bothOraclesLiveAndUnbrokenAndSimilarPrice(
        ChainlinkResponse memory _chainlinkResponse,
        ChainlinkResponse memory _prevChainlinkResponse,
        FallbackResponse memory _fallbackResponse
    ) internal view returns (bool) {
        // Return false if either oracle is broken or frozen
        if (
            (address(fallbackCaller) != address(0) &&
                (_fallbackIsBroken(_fallbackResponse) || _fallbackIsFrozen(_fallbackResponse))) ||
            _chainlinkIsBroken(_chainlinkResponse, _prevChainlinkResponse) ||
            _chainlinkIsFrozen(_chainlinkResponse)
        ) {
            return false;
        }

        return _bothOraclesSimilarPrice(_chainlinkResponse, _fallbackResponse);
    }
    function _bothOraclesSimilarPrice(
        ChainlinkResponse memory _chainlinkResponse,
        FallbackResponse memory _fallbackResponse
    ) internal view returns (bool) {
        if (address(fallbackCaller) == address(0)) {
            return true;
        }
        // Get the relative price difference between the oracles. Use the lower price as the denominator, i.e. the reference for the calculation.
        uint256 minPrice = EbtcMath._min(_fallbackResponse.answer, _chainlinkResponse.answer);
        if (minPrice == 0) return false;
        uint256 maxPrice = EbtcMath._max(_fallbackResponse.answer, _chainlinkResponse.answer);
        uint256 percentPriceDifference = ((maxPrice - minPrice) * EbtcMath.DECIMAL_PRECISION) /
            minPrice;

        /*
         * Return true if the relative price difference is <= MAX_PRICE_DIFFERENCE_BETWEEN_ORACLES: if so, we assume both oracles are probably reporting
         * the honest market price, as it is unlikely that both have been broken/hacked and are still in-sync.
         */
        return percentPriceDifference <= MAX_PRICE_DIFFERENCE_BETWEEN_ORACLES;
    }
CodingNameKiki commented 10 months ago

Will make a research to confirm that there aren't any other occurring issues with the changes made.

CodingNameKiki commented 10 months ago

l am not sure if this is even possible when chainlink is frozen, but l would rather ask than keep it to myself.

        // --- CASE 4: Using Fallback, and Chainlink is frozen ---
        if (status == Status.usingFallbackChainlinkFrozen) {
            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);
            }

            // If Chainlink is live and Fallback is frozen, just use last good price (no status change) since we have no basis for comparison
            if (_fallbackIsFrozen(fallbackResponse)) {
                return lastGoodPrice;
            }

            // If Chainlink is live and Fallback is working, compare prices. Switch to Chainlink
            // if prices are within 5%, and return Chainlink price.
            if (_bothOraclesSimilarPrice(chainlinkResponse, fallbackResponse)) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise if Chainlink is live but price not within 5% of Fallback, distrust Chainlink, and return Fallback price
            _changeStatus(Status.usingFallbackChainlinkUntrusted);
            return _storeFallbackPrice(fallbackResponse);
        }
CodingNameKiki commented 10 months ago

In our case lets compare the newly made changes, by going over every state with the condition of fallback being address(0).

  1. state chainlinkWorking
            // If Chainlink is broken, try Fallback
            if (_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse)) {
                // If Fallback is broken then both oracles are untrusted, so return the last good price
                if (_fallbackIsBroken(fallbackResponse)) {
                    _changeStatus(Status.bothOraclesUntrusted);
                    return lastGoodPrice;
                }
            // If Chainlink is frozen, try Fallback
            if (_chainlinkIsFrozen(chainlinkResponse)) {
                // If Fallback is broken too, remember Fallback broke, and return last good price
                if (_fallbackIsBroken(fallbackResponse)) {
                    _changeStatus(Status.usingChainlinkFallbackUntrusted);
                    return lastGoodPrice;
                }
            // If Chainlink price has changed by > 50% between two consecutive rounds, compare it to Fallback's price
            if (_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)) {
                // If Fallback is broken, both oracles are untrusted, and return last good price
                // We don't trust CL for now given this large price differential
                if (_fallbackIsBroken(fallbackResponse)) {
                    _changeStatus(Status.bothOraclesUntrusted);
                    return lastGoodPrice;
                }
  1. state usingFallbackChainlinkUntrusted
        if (status == Status.usingFallbackChainlinkUntrusted) {
            if (_fallbackIsBroken(fallbackResponse)) {
                _changeStatus(Status.bothOraclesUntrusted);
                return lastGoodPrice;
            }
  1. state bothOraclesUntrusted
            if (address(fallbackCaller) == address(0)) {
                // If CL has resumed working
                if (
                    !_chainlinkIsBroken(chainlinkResponse, prevChainlinkResponse) &&
                    !_chainlinkIsFrozen(chainlinkResponse) &&
                    !_chainlinkPriceChangeAboveMax(chainlinkResponse, prevChainlinkResponse)
                ) {
                    _changeStatus(Status.usingChainlinkFallbackUntrusted);
                    return _storeChainlinkPrice(chainlinkResponse.answer);
                } else {
                    return lastGoodPrice;
                }
            }
  1. state usingFallbackChainlinkFrozen
            if (_fallbackIsBroken(fallbackResponse)) {
                _changeStatus(Status.usingChainlinkFallbackUntrusted);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // If Chainlink is live and Fallback is frozen, just use last good price (no status change) since we have no basis for comparison
            if (_fallbackIsFrozen(fallbackResponse)) {
                return lastGoodPrice;
            }

            // If Chainlink is live and Fallback is working, compare prices. Switch to Chainlink
            // if prices are within 5%, and return Chainlink price.
            if (_bothOraclesSimilarPrice(chainlinkResponse, fallbackResponse)) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }
  1. state usingChainlinkFallbackUntrusted
            if (
                _bothOraclesLiveAndUnbrokenAndSimilarPrice(
                    chainlinkResponse,
                    prevChainlinkResponse,
                    fallbackResponse
                )
            ) {
                if (address(fallbackCaller) != address(0)) {
                    _changeStatus(Status.chainlinkWorking);
                }
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }
rayeaster commented 10 months ago

l am not sure if this is even possible when chainlink is frozen, but l would rather ask than keep it to myself.

  • Take as example that the system is in state usingFallbackChainlinkFrozen, basically the system checks if chainlink is broken, frozen and if not it checks if the fallback is broken and changes the status to usingChainlinkFallbackUntrusted and uses the latest answer from chainlink. One thing l noticed is that along with checking if chainlink is broken or frozen, the system misses to check if the prev and current price of chainlink changes above the max. So in theory if this every happens instead of switching to bothOraclesUntrusted and using the lastGoodPrice, the system will use the chainlink answer and switch to usingChainlinkFallbackUntrusted.
        // --- CASE 4: Using Fallback, and Chainlink is frozen ---
        if (status == Status.usingFallbackChainlinkFrozen) {
            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);
            }

            // If Chainlink is live and Fallback is frozen, just use last good price (no status change) since we have no basis for comparison
            if (_fallbackIsFrozen(fallbackResponse)) {
                return lastGoodPrice;
            }

            // If Chainlink is live and Fallback is working, compare prices. Switch to Chainlink
            // if prices are within 5%, and return Chainlink price.
            if (_bothOraclesSimilarPrice(chainlinkResponse, fallbackResponse)) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise if Chainlink is live but price not within 5% of Fallback, distrust Chainlink, and return Fallback price
            _changeStatus(Status.usingFallbackChainlinkUntrusted);
            return _storeFallbackPrice(fallbackResponse);
        }

according to Liquity doc, it seems ok https://docs.google.com/spreadsheets/d/18fdtTUoqgmsK3Mb6LBO-6na0oK-Y9LWBqnPCJRp5Hsg/edit#gid=316701608 image

CodingNameKiki commented 10 months ago

Based on the statements in the comment, do we actually need to fix this issue?

https://github.com/code-423n4/2023-10-badger-findings/issues/256#issuecomment-1842572212

GalloDaSballo commented 10 months ago

What is 256 saying that was worth fixing? Basically the same issue?

CodingNameKiki commented 10 months ago

Yes basically the same core issue can occur when the system is in usingFallbackChainlinkFrozen, so we fix both places.

GalloDaSballo commented 10 months ago

@wtj2021 it could be good to write the following (fuzz or invariant)

fetchPrice can never return a different price when called twice or three times in a row

Basically adapt EchidnaPriceFeedTester to have that check, can work on this tomorrow if you don't have the bandwith

NOTE: In reality this invariant will break, but we want to assert that without a change of CL / Fallback, the feed must always return the same value if called more than once, whereas the C4 issues highlight that that's not the case

CodingNameKiki commented 9 months ago

When both oracles were in frozen state but are back alive and there is 5% difference in the prices, the system naturally selects the response of the fallback oracle as more accurate even tho chainlink is the primary oracle.

We can see that when the system is in status chainlinkWorking and chainlink is frozen and the fallback is not broken but frozen. The system returns the lastGoodPrice and switches to status usingFallbackChainlinkFrozen even tho the fallback is frozen as well.

            if (_chainlinkIsFrozen(chainlinkResponse)) {
                // If Fallback is broken too, remember Fallback broke, and return last good price
                if (_fallbackIsBroken(fallbackResponse)) {
                    _changeStatus(Status.usingChainlinkFallbackUntrusted);
                    return lastGoodPrice;
                }

                // If Fallback is frozen or working, remember Chainlink froze, and switch to Fallback
                _changeStatus(Status.usingFallbackChainlinkFrozen);

                if (_fallbackIsFrozen(fallbackResponse)) {
                    return lastGoodPrice;
                }

                // If Fallback is working, use it
                return _storeFallbackPrice(fallbackResponse);
            }

When the system is in usingFallbackChainlinkFrozen and both the chainlink and fallback oracles are frozen, the system will return the lastGoodPrice and keep the status.

But an interesting flow happens when both of the oracles are live and back to work but have a 5% price difference.

            // If Chainlink is live and Fallback is working, compare prices. Switch to Chainlink
            // if prices are within 5%, and return Chainlink price.
            if (_bothOraclesSimilarPrice(chainlinkResponse, fallbackResponse)) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise if Chainlink is live but price not within 5% of Fallback, distrust Chainlink, and return Fallback price
            _changeStatus(Status.usingFallbackChainlinkUntrusted);
            return _storeFallbackPrice(fallbackResponse);

After that the system will be in status usingFallbackChainlinkUntrusted and return only the fallback response as the correct one until the fallback gets back out of the potentially staled phase.

        if (status == Status.usingFallbackChainlinkUntrusted) {
            if (_fallbackIsBroken(fallbackResponse)) {
                _changeStatus(Status.bothOraclesUntrusted);
                return lastGoodPrice;
            }

            /*
             * If Fallback is only frozen but otherwise returning valid data, just return the last good price.
             * Fallback may need to be tipped to return current data.
             */
            if (_fallbackIsFrozen(fallbackResponse)) {
                return lastGoodPrice;
            }

            // If both Fallback and Chainlink are live, unbroken, and reporting similar prices, switch back to Chainlink
            if (
                _bothOraclesLiveAndUnbrokenAndSimilarPrice(
                    chainlinkResponse,
                    prevChainlinkResponse,
                    fallbackResponse
                )
            ) {
                _changeStatus(Status.chainlinkWorking);
                return _storeChainlinkPrice(chainlinkResponse.answer);
            }

            // Otherwise, use Fallback price
            return _storeFallbackPrice(fallbackResponse);
        }

Recommendation

Let the system keep the same status when the fallback oracle is frozen as well.

            // If Chainlink is frozen, try Fallback
            if (_chainlinkIsFrozen(chainlinkResponse)) {
                // If Fallback is broken too, remember Fallback broke, and return last good price
                if (_fallbackIsBroken(fallbackResponse)) {
                    _changeStatus(Status.usingChainlinkFallbackUntrusted);
                    return lastGoodPrice;
                }

                // If Fallback is frozen or working, remember Chainlink froze, and switch to Fallback
-               _changeStatus(Status.usingFallbackChainlinkFrozen);

                if (_fallbackIsFrozen(fallbackResponse)) {
                    return lastGoodPrice;
                }

+               _changeStatus(Status.usingFallbackChainlinkFrozen);

                // If Fallback is working, use it
                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);
            }
rayeaster commented 9 months ago

@CodingNameKiki I think the issue you mentioned around both primary and fallback are frozen and the code decide to choose fallback over primary is valid if we do prefer primary. However, what I am not quite sure about is whether we should change the behavior.

Considering usingFallbackChainlinkFrozen is the only proper status indicating primary (CL) is frozen (but not broken), and Liquity also clearly states in both the code comment and its design doc that "If Fallback is frozen or working, remember Chainlink froze, and switch to Fallback", it might be acceptable to keep it untouched.

image

CodingNameKiki commented 9 months ago

Hey @rayeaster, l think It's good for the system to enforce the right behaviour in this situation, e.g if we choose the primary oracle to have more safe and accurate response the system shouldn't pick the fallback oracle over the chainlink one.

Just to note that the previous issue we fixed related to 256 was also acceptable and as design based on the Liquity doc.

So l think this is good decision to make - whether we want the best for our system or to follow strictly the Liquity's design.

l also did a research and this recommendation doesn't change any other behaviour of the price feed:

                // If Fallback is frozen or working, remember Chainlink froze, and switch to Fallback
-               _changeStatus(Status.usingFallbackChainlinkFrozen);

                if (_fallbackIsFrozen(fallbackResponse)) {
                    return lastGoodPrice;
                }

+               _changeStatus(Status.usingFallbackChainlinkFrozen);

Lets say we decide to keep it as it is now:

                // If both are frozen, just use lastGoodPrice
                if (_fallbackIsFrozen(fallbackResponse)) {
                    return lastGoodPrice;
                }
rayeaster commented 9 months ago

would love to have @dapp-whisperer @GalloDaSballo @wtj2021 insights on this topic

dapp-whisperer commented 9 months ago

When both oracles were in frozen state but are back alive and there is 5% difference in the prices, the system naturally selects the response of the fallback oracle as more accurate even tho chainlink is the primary oracle.

I think this is acceptable due to the threshold. I agree with Kiki that it's not desired behavior - a great find. I'm inclined towards NOFIX due to the status of the project and the fact that we're not using a fallback oracle for the foreseeable future.