codehawks-2023-07-dsc-h01

[H-01] Theft of collateral tokens with fewer than 18 decimals

보고서

Summary

담보 토큰이 소수점 18자리를 이용한다고 가정하여 계산 값이 틀렸다. 이로 인해 담보 토큰을 예치하면 더 많은/적은 DSC 토큰을 받거나, 작은 가치의 DSC을 소각하며 다량의 소수점 18자리 미만 담보 토큰을 탈취할 수 있다.

Keyword

token decimals, arithmetic error, erc20

Vulnerability

DSCEngine.getUsdValue() 함수는 담보 토큰의 가치를 USD로 환산하는 기능을 제공한다. 반대로 DSCEngine.getTokenAmountFromUsd() 함수는 USD를 담보 토큰의 양으로 환산하는 기능을 제공한다. 이 때, 두 함수 모두 담보 토큰의 소수점이 18자리임을 가정한다.

하지만 프로젝트에서 명시한 담보 토큰 중에는 WBTC가 포함된다. 이는 소수점 8자리를 이용한다. 소수점 18자리가 아닌 담보 토큰을 이용한다면 계산의 결과가 올바르지 않다.

getUsdValue 함수

return ((uint256(price) * ADDITIONAL_FEED_PRECISION) * amount) / PRECISION; 에서 담보토큰의 가치를 USD 양을 환산한다. 이 때, PRECISION 상수값인 1e18으로 나누어 계산된 USD의 정수값만을 취하고자 한다.

amount는 담보 토큰의 양으로, 정수값만 잘라내는 계산이 올바르게 이루어지기 위해서는 소수점 18자리여야 한다.

    uint256 private constant PRECISION = 1e18;
    uint256 private constant ADDITIONAL_FEED_PRECISION = 1e10;

    function getUsdValue(address token, uint256 amount) public view returns (uint256) {
        AggregatorV3Interface priceFeed = AggregatorV3Interface(s_priceFeeds[token]);
        (, int256 price,,,) = priceFeed.staleCheckLatestRoundData();
        // 1 ETH = $1000
        // The returned value from CL will be 1000 * 1e8
        return ((uint256(price) * ADDITIONAL_FEED_PRECISION) * amount) / PRECISION;
    }

이 함수는 반복문을 돌며 유저의 모든 담보 토큰의 가치를 계산할 때 사용된다. 따라서 모든 담보 토큰이 개수 표현을 위해 소수점 18자리를 이용해야 정상적으로 처리된다.

    function getAccountCollateralValue(address user) public view returns (uint256 totalCollateralValueInUsd) {
        // loop through each collateral token, get the amount they have deposited, and map it to
        // the price, to get the USD value
        for (uint256 i = 0; i < s_collateralTokens.length; i++) {
            address token = s_collateralTokens[i];
            uint256 amount = s_collateralDeposited[user][token];
            totalCollateralValueInUsd += getUsdValue(token, amount);
        }
        return totalCollateralValueInUsd;
    }

하지만 소수점 18자리를 사용하지 않는 담보 토큰도 존재하므로 계산시 숫자가 맞지 않게 된다. WBTC와 같이 더 적은 자리의 소수점을 이용하는 담보 토큰은 실제보다 적은 가치를 가진 것으로 취급된다.

이 함수는 DSC를 발행 또는 소각하는 이동이 발생할 때마다 호출되며, 과담보 상태를 유지하는 지 확인하기 위해 담보의 총 가치를 계산하는 데 이용된다. 소수점이 18자리가 아닌 담보 토큰을 예치하면 실제 가치보다 더 적은/많은 양의 DSC 토큰을 얻게 된다.

getTokenAmountFromUsd 함수

return (usdAmountInWei * PRECISION) / (uint256(price) * ADDITIONAL_FEED_PRECISION); 에서 담보 토큰의 양을 계산하며 소수점을 18자리로 맞춘다.

파라미터로 받은 usdAmountInWei는 소수점 18자리로 표현된 USD 양이다.

price는 Chainlink 가격 피드에서 가져온 값으로, 담보 토큰의 USD 가격이다. Chainlink 가격 피드가 소수점 8자리를 이용하므로 ADDITIONAL_FEED_PRECISION 상수값 1e10을 곱해 소수점 18자리로 변환한다.

    uint256 private constant PRECISION = 1e18;
    uint256 private constant ADDITIONAL_FEED_PRECISION = 1e10;
 
    function getTokenAmountFromUsd(address token, uint256 usdAmountInWei) public view returns (uint256) {
        // price of ETH (token)
        // $/ETH ETH ??
        // $2000 / ETH. $1000 = 0.5 ETH
        AggregatorV3Interface priceFeed = AggregatorV3Interface(s_priceFeeds[token]);
        (, int256 price,,,) = priceFeed.staleCheckLatestRoundData();
        // ($10e18 * 1e18) / ($2000e8 * 1e10)
        return (usdAmountInWei * PRECISION) / (uint256(price) * ADDITIONAL_FEED_PRECISION);
    }

담보 토큰의 개수는 소수점 18자리로 표현된다고 가정하고 계산되는데, WBTC와 같이 더 적은 자리의 소수점을 이용하는 경우 getTokenAmountFromUsd는 원래 의도보다 많은 양의 토큰양을 리턴하게 된다. (WBTC decimal이 1e8이므로 1e10배 많은 양을 이용하게 됨)

getTokenAmountFromUsd 함수는 liquidate 함수에서 이용한다. liquidate 함수는 담보의 가치가 떨어져 특정 유저의 200% 과담보가 깨지면 유저의 담보를 청산하는 기능이다. 호출자의 DSC 토큰을 소각하며 과담보가 깨진 유저의 담보 토큰을 받아간다.

이 때 18자리 미만 소수점을 이용하는 담보 토큰을 수령한다면 실제 DSC 토큰의 가치보다 많은 양의 담보 토큰을 받아갈 수 있다. getTokenAmountFromUsd 에서 USD 양에 해당하는 담보 토큰의 양을 계산할 때 더 크게 계산되기 때문이다.

    function liquidate(address collateral, address user, uint256 debtToCover)
        external
        moreThanZero(debtToCover)
        nonReentrant
    {
        uint256 startingUserHealthFactor = _healthFactor(user);
        if (startingUserHealthFactor >= MIN_HEALTH_FACTOR) {
            revert DSCEngine__HealthFactorOk();
        }
 
        uint256 tokenAmountFromDebtCovered = getTokenAmountFromUsd(collateral, debtToCover);
 
        uint256 bonusCollateral = (tokenAmountFromDebtCovered * LIQUIDATION_BONUS) / LIQUIDATION_PRECISION;
        uint256 totalCollateralToRedeem = tokenAmountFromDebtCovered + bonusCollateral;
        _redeemCollateral(user, msg.sender, collateral, totalCollateralToRedeem);
 
        _burnDsc(debtToCover, user, msg.sender);
 
        uint256 endingUserHealthFactor = _healthFactor(user);
        if (endingUserHealthFactor <= startingUserHealthFactor) {
            revert DSCEngine__HealthFactorNotImproved();
        }
        _revertIfHealthFactorIsBroken(msg.sender);
    }

Impact

소수점 18자리가 아닌 담보 토큰을 예치하면 더 많은/적은 DSC 토큰을 받는다. 소수점 18자리 미만인 예치된 담보 토큰을 탈취할 수 있다.

Mitigation

토큰의 decimal 정보를 가져와 계산값을 보정한다.


tags: bughunting, codehawks, smart contract, solidity, token decimals, arithmetic error, erc20, severity high