sherlock-2024-01-ubiquity-m03

[M-03] LibUbiquityPool::mintDollar/redeemDollar reliance on arbitrarily short TWAP oracle may be inefficient for preventing depeg

보고서

Summary

uAD를 발행/소각하기 위해 토큰 가격을 Curve TWAP 오라클에서 얻는다. 이 때, 너무 짧은 TWAP window를 사용하면 오라클을 조작할 수 있고, 이를 통해 토큰 발행/소각을 방해하거나 발행/소각이 언블록되어야 하는 것을 해제하여 디페깅을 할 수 있다.

Keyword

curve oracle, price oracle, depegging, twap oracle, stablecoin, oracle manipulation

Vulnerability

LibTWAPOracle.consult는 메타풀의 가격을 사용한다.

    function consult(address token) internal view returns (uint256 amountOut) {
@>      TWAPOracleStorage memory ts = twapOracleStorage();
 
        if (token == LibAppStorage.appStorage().dollarTokenAddress) {
            // price to exchange 1 Ubiquity Dollar to 3CRV based on TWAP
@>          amountOut = ts.price0Average;
        } else {
            require(token == ts.token1, "TWAPOracle: INVALID_TOKEN");
            // price to exchange 1 3CRV to Ubiquity Dollar based on TWAP
@>          amountOut = ts.price1Average;
        }
    }

메타풀 가격은 LibTWAPOracle.update 에서 캐싱한다. 이전 캐싱 이후 ~ 메타풀이 업데이트된 시점을 TWAP window로 사용한다. 이 때 TWAP window가 너무 짧으면 가격을 조작할 수 있다.

    function currentCumulativePrices()
        internal
        view
        returns (uint256[2] memory priceCumulative, uint256 blockTimestamp)
    {
        address metapool = twapOracleStorage().pool;
@>      priceCumulative = IMetaPool(metapool).get_price_cumulative_last();
@>      blockTimestamp = IMetaPool(metapool).block_timestamp_last();
    }
 
    function update() internal {
        TWAPOracleStorage storage ts = twapOracleStorage();
        (
            uint256[2] memory priceCumulative, // @audit-info [A + 1500 * 12, B + 500 * 12]
            uint256 blockTimestamp
        ) = currentCumulativePrices();
        if (blockTimestamp - ts.pricesBlockTimestampLast > 0) {
            // get the balances between now and the last price cumulative snapshot
            uint256[2] memory twapBalances = IMetaPool(ts.pool)
@>              .get_twap_balances(
                    ts.priceCumulativeLast, // @audit-info [A, B]
                    priceCumulative,
@>                  blockTimestamp - ts.pricesBlockTimestampLast
                );
 
            // price to exchange amountIn Ubiquity Dollar to 3CRV based on TWAP
@>          ts.price0Average = IMetaPool(ts.pool).get_dy(
                0,
                1,
                1 ether,
                twapBalances
            );
 
            // price to exchange amountIn 3CRV to Ubiquity Dollar based on TWAP
@>          ts.price1Average = IMetaPool(ts.pool).get_dy(
                1,
                0,
                1 ether,
                twapBalances
            );
            // we update the priceCumulative
            ts.priceCumulativeLast = priceCumulative;
@>          ts.pricesBlockTimestampLast = blockTimestamp;
        }
    }

다음 예를 살펴보자.

  • 블록 N 에서 메타풀 초기 상태
    • reserveA: 1000
    • reserveB: 1000
  • 블록 N+1 에서 메타풀 상태 (+12초)
    • reserveA: 1500
    • reserveB: 500

메타풀에 상호작용이 일어나면 _update 함수가 호출되어 price_cumulative_last 를 업데이트 한다. 매 블록에서 상호작용이 일어나 업데이트 되었다고 하자.

    def _update():
        """
        Commits pre-change balances for the previous block
        Can be used to compare against current values for flash loan checks
        """
        elapsed_time: uint256 = block.timestamp - self.block_timestamp_last
        if elapsed_time > 0:
            for i in range(N_COINS):
                _balance: uint256 = self.balances[i]
@>              self.price_cumulative_last[i] += _balance * elapsed_time
                self.previous_balances[i] = _balance
            self.block_timestamp_last = block.timestamp

N 블록에서 LibTWAPOracle.update를 호출하고, N+2 블록에서 다시 LibTWAPOracle.update 를 호출, TWAP을 조회했을 때 다음과 같을 것이다.

  • ts.priceCumulativeLast: [A, B]
  • priceCumulative (currentCumulativePrices 리턴값): [A + 1500 * 12, B + 500 * 12]
    • 메타풀 업데이트 시 _balance * elapsed_time 가 추가되므로, N + 1 블록에서 업데이트되었음
  • blockTimestamp - ts.pricesBlockTimestampLast = 12
    • N 블록에서 LibTWAPOracle.update를 호출했으므로 ts.pricesBlockTimestampLast 는 T, 메타풀은 N + 1 블록에서 업데이트 되었으므로 블록 타임 12초이므로 blockTimestamp는 T + 12초

위 값을 파라미터로 get_twap_balances([A, B], [A + 1500 * 12, B + 500 * 12], 12)를 호출하면 [(A + 1500 * 12 - A) / 12, (B + 500 * 12 - B) / 12] = [1500, 500] 으로, 단순히 이전 블록의 메타풀 balance 를 리턴한다.

    @view
    @external
    def get_twap_balances(_first_balances: uint256[N_COINS], _last_balances: uint256[N_COINS], _time_elapsed: uint256) -> uint256[N_COINS]:
        balances: uint256[N_COINS] = empty(uint256[N_COINS])
        for x in range(N_COINS):
@>          balances[x] = (_last_balances[x] - _first_balances[x]) / _time_elapsed
        return balances

Impact

두 개의 연속된 블록을 제어할 수 있는 악의적인 사용자는 다음 블록의 TWAP을 어떤 상태로든 조작할 수 있다. 이를 통해 다음과 같은 일이 가능하다.

  • 다음 블록에서 토큰 발행/소각을 DoS 할 수 있다.
  • 다음 블록에서 토큰 발행/소각이 가능하게 언블록하고 uAD를 디페깅할 수 있다.

Mitigation

TWAP window를 충분히 키운다. 15분~30분 사이를 주로 권장하는 것 같다.

    function update() internal {
        TWAPOracleStorage storage ts = twapOracleStorage();
        (
            uint256[2] memory priceCumulative,
            uint256 blockTimestamp
        ) = currentCumulativePrices();
        if (blockTimestamp - ts.pricesBlockTimestampLast > 0) {
            // get the balances between now and the last price cumulative snapshot
            uint256[2] memory twapBalances = IMetaPool(ts.pool)
                .get_twap_balances(
                    ts.priceCumulativeLast,
                    priceCumulative,
-                   blockTimestamp - ts.pricesBlockTimestampLast
+                   15 minutes
                );
            ...
        }
        ...
    }

tags: bughunting, ubiquity, smart contract, solidity, curve oracle, price oracle, depegging, twap oracle, stablecoin, oracle manipulation, severity medium