sherlock-2024-01-ubiquity-m04

[M-04] LibTWAPOracle::update Providing large liquidity will manipulate TWAP, DOSing redeem of uADs

보고서

Summary

TWAP 오라클을 잘못된 방법으로 사용하고 있다. 실제 시간 가중 평균 가격(TWAP)이 아닌, 잔액의 시간 가중 평균에 기반한 순간 가격을 계산한다. (가격이 아닌 풀의 토큰 잔액을 기준으로 계산한다.) 따라서 MetaPool에 일시적으로 많은 양을 예치하는 공격에 의한 가격 조작에 취약하다.

Keyword

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

Vulnerability

LibTWAPOracle.update 에서 IMetapool::get_twap_balances(uint256[2] memory _first_balances, uint256[2] memory _last_balances, uint256 _time_elapsed) 를 호출하여 사용한다. 이는 주어진 파라미터 _first_balances, _last_balances(누적 밸런스)와 time_elapsed 를 사용하여 풀의 예치금에 대한 시간 가중 평균을 계산해주는 함수이다.

    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
                );
 
            // 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;
        }
    }

자본이 많고, 연속하는 2개의 블록을 통제할 수 있는 공격자는 N 블록에서 MetaPool에 대규모 유동성을 추가하고, N + 1 블록에서 제거할 수 있다. 이 때 유동성을 불균형하게 (토큰 비율이 맞지 않게) 제공하면 토큰의 가격이 변동된다.

다음과 같은 시나리오를 생각해보자.

  1. N - 1 블록
    • MetaPool에 10개의 uAD와 10개의 3CRV가 있다. 가격은 균형을 이루고, uAD 출금(uAD를 소각하고 담보를 꺼냄)이 허용된 상태이다.
  2. N 블록
    • Alice가 MetaPool TWAP 과 Uniquity 의 TWAP 을 모두 업데이트 시킨다.
    • 이후 Alice가 100 uAD와 200 3CRV를 MetaPool에 예치한다.
      • 이제 풀에는 110 uAD와 210 3CRV 가 있다.
      • 유동성을 추가했으므로 MetaPool TWAP은 업데이트된다.
  3. N + 1 블록
    • Alice가 2단계에서 추가한 유동성을 제거하여 풀을 다시 10 uAD, 10 3CRV 상태로 만든다.
      • 유동성을 제거했으므로 MetaPool TWAP은 업데이트된다.
    • 하지만 Ubiquity의 TWAP은 업데이트 하지 않는다.
  4. N + 40 블록
    • Alice가 MetaPool TWAP과 Ubiquity의 TWAP을 모두 업데이트 시킨다. (이 사이 블록에서 다른 유저의 인터렉션은 없다고 가정하자)
    # MetaPool._update
    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 블록에서 업데이트/저장된 Uniquity TWAP의 ts.priceCumulativeLast 와 N + 40 블록에서 읽어온 MetaPool의 priceCumulative 를 비교하면 다음과 같다.

  • uAD
    • N 블록에서 Uniquity에 저장한 uAD 누적금(ts.priceCumulativeLast)
      • X (기존 누적금)
    • N + 40 블록 업데이트 시 MetaPool에서 조회한 uAD 누적금
      • X + 12 * 110 + 12 * 40 * 10
        • 110 uAD를 1 블록 유지했으므로 12초 곱하여 누적된다.
        • 10 uAD를 40블록 유지했으므로 12 * 40 초만큼 곱하여 누적된다.
    • 즉, uAD의 _last_balances - _first_balances 는 12 * 110 + 12 * 40 * 10 = 6120
  • 3CRV
    • N 블록에서 Uniquity에 저장한 3CRV 누적금(ts.priceCumulativeLast)
      • Y (기존 누적금)
    • N + 40 블록 업데이트 시 MetaPool에서 조회한 3CRV 누적금
      • Y + 12 * 210 + 12 * 40 * 10
        • 210 3CRV를 1 블록 유지했으므로 12초 곱하여 누적된다.
        • 10 3CRV를 40 블록 유지했으므로 12 * 40 초만큼 곱하여 누적된다.
    • 즉, 3CRV의 _last_balances - _first_balances 는 12 * 210 + 12 * 40 * 10 = 7320

uAD의 가격이 3CRV의 가격보다 낮아졌다. 가격을 정해진 기준보다 낮춘다면 가격이 오를 때까지 uAD를 소각하고 담보를 꺼내는 redeemDollar 를 호출할 수 없게 된다. 따라서, 프로토콜에 DoS 할 수 있다.

    function redeemDollar(
        uint256 collateralIndex,
        uint256 dollarAmount,
        uint256 collateralOutMin
    )
        internal
        collateralEnabled(collateralIndex)
        returns (uint256 collateralOut)
    {
        ...
        // prevent unnecessary redemptions that could adversely affect the Dollar price
        require(
@>          getDollarPriceUsd() <= poolStorage.redeemPriceThreshold,
            "Dollar price too high"
        );
        ...
    }

Impact

상당한 자본이 있고 두 개의 연속된 블록을 제어할 수 있는 공격자는 uAD의 가격을 조작하여 Ubiquity의 출금 기능을 DoS 할 수 있다.

Mitigation

잔액에 대한 TWAP 을 사용하는 대신, Uniswap V3가 하는 것처럼 가격에 대한 TWAP을 계산하는 방법을 도입한다. 누적 spot pirce 대신 누적 balance를 사용한다는 것이 문제.

Memo

엄청 싸우다가 결국 Medium으로 인정되었다. DoS 만으로는 셜록 원칙 상 너무 짧아서 인정이 안되었을 텐데, 디페깅으로 이익을 볼 가능성이 있기 때문에 인정해주었다.


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