code4rena-2023-06-angle-protocol-h01

[H-01] Possible reentrancy during redemption/swap

보고서

Summary

담보 토큰이 ERC777인 경우 redeem/swap으로 담보 토큰을 받을 시 tokensReceived 훅을 통해 재진입이 가능하고, 재진입을 통해 담보 해제를 요청하면 실제보다 더 많은 담보 토큰을 돌려받을 수 있다.

Keyword

erc777, reentrancy, theft, stablecoin, defi

Vulnerability

Redeemer 컨트랙트에서 담보 토큰을 맡기고 agToken을 얻을 수 있고, redeem 기능을 이용하면 agToken을 burn하며 담보 토큰을 되돌려받을 수 있다. redeem을 호출하는 엔트리포인트는 여러개 있지만, 모두 내부적으로 _redeem를 호출하며 이 함수에 핵심 로직이 있다.

    function _redeem(
        uint256 amount,
        address to,
        uint256 deadline,
        uint256[] memory minAmountOuts,
        address[] memory forfeitTokens
    ) internal returns (address[] memory tokens, uint256[] memory amounts) {
        ...
        (tokens, amounts, subCollateralsTracker) = _quoteRedemptionCurve(amount);
        ...
 
        IAgToken(ts.agToken).burnSelf(amount, msg.sender);
 
        address[] memory collateralListMem = ts.collateralList;
        uint256 indexCollateral;
        for (uint256 i; i < amounts.length; ++i) {
            if (amounts[i] < minAmountOuts[i]) revert TooSmallAmountOut();
            // If a token is in the `forfeitTokens` list, then it is not sent as part of the redemption process
            if (amounts[i] > 0 && LibHelpers.checkList(tokens[i], forfeitTokens) < 0) {
                Collateral storage collatInfo = ts.collaterals[collateralListMem[indexCollateral]];
                if (collatInfo.onlyWhitelisted > 0 && !LibWhitelist.checkWhitelist(collatInfo.whitelistData, to))
                    revert NotWhitelisted();
                if (collatInfo.isManaged > 0)
                    LibManager.release(tokens[i], to, amounts[i], collatInfo.managerData.config);
                else IERC20(tokens[i]).safeTransfer(to, amounts[i]);
            }
            if (subCollateralsTracker[indexCollateral] - 1 <= i) ++indexCollateral;
        }
        emit Redeemed(amount, tokens, amounts, forfeitTokens, msg.sender, to);
    }

돌려받을 담보 토큰의 수는 _quoteRedemptionCurve 함수를 통해 계산된다.

    function _quoteRedemptionCurve(
        uint256 amountBurnt
    )
        internal
        view
        returns (address[] memory tokens, uint256[] memory balances, uint256[] memory subCollateralsTracker)
    {
        ...
 
        (collatRatio, stablecoinsIssued, tokens, balances, subCollateralsTracker) = LibGetters.getCollateralRatio();
        
        ...
 
        uint256 balancesLength = balances.length;
        for (uint256 i; i < balancesLength; ++i) {
            // The amount given for each token in reserves does not depend on the price of the tokens in reserve:
            // it is a proportion of the balance for each token computed as the ratio between the stablecoins
            // burnt relative to the amount of stablecoins issued.
            // If the protocol is over-collateralized, the amount of each token given is inversely proportional
            // to the collateral ratio.
            balances[i] = collatRatio >= BASE_9
                ? (amountBurnt * balances[i] * (uint64(yRedemptionCurveMem[yRedemptionCurveMem.length - 1]))) /
                    (stablecoinsIssued * collatRatio)
                : (amountBurnt * balances[i] * penaltyFactor) / (stablecoinsIssued * BASE_9);
        }
    }

받을 담보 토큰 수는 balances[i] = collatRatio >= BASE_9 ? (amountBurnt * balances[i] * (uint64(yRedemptionCurveMem[yRedemptionCurveMem.length - 1]))) / (stablecoinsIssued * collatRatio) : (amountBurnt * balances[i] * penaltyFactor) / (stablecoinsIssued * BASE_9); 로 계산되며, 계산에 사용된 balances[i]에는 컨트랙트에 예치된 담보 토큰 수가 있다.

이제 취약점에 대하여 생각해보자. _redeem 함수에서는 담보 토큰이 ERC777일 가능성을 고려하지 않았다. IERC20(tokens[i]).safeTransfer(to, amounts[i])로 토큰을 직접 전송하거나 LibManager.release를 호출해 토큰을 이동할 때, 이 토큰이 ERC777이라면 토큰 수신자 측의 tokensReceived 훅이 실행될 수 있다. 이를 이용해 재진입이 가능하고 이를 이용해 더 많은 담보를 가져올 수 있다.

다음과 같은 시나리오로 공격이 가능하다.

  1. 2개의 담보 colA와 colB 토큰을 사용한다고 가정하자. Transmuter 컨트랙트는 colA와 colB토큰을 각각 1000개씩 가지고 있다. Alice는 20개의 agToken을 가지고 있다.
  2. Alice가 10개의 agToken을 주며 redeem을 호출하면 10개의 colA와 colB를 받아야 한다.
  3. colA 토큰은 ERC777이라고 가정하자. colA 토큰을 먼저 받고, tokensReceived 훅을 이용하여 redeem(10)를 재호출한다.
  4. 2번째 redeem에서, 총 담보 양은 colA = 990, colB = 1000인 상태이다. colB가 아직 첫번째 redeem에서 보내지지 않았기 때문이다.
  5. Alice는 2번째 redeem에서 원래 받아야하는 양보다 더 많은 담보를 상환 받는다.

동일하게, ERC777로 인한 재진입 이슈가 swap 에서도 일어날 수 있을 것이다.

Impact

원래보다 많은 양의 담보 토큰을 상환받음

Mitigation

_redeem_swap 함수에 재진입 가드를 추가


tags: bughunting, angle protocol, smart contract, solidity, stablecoin, defi, reentrancy, erc777, erc20, severity high