sherlock-2024-01-flat-money-m01

[M-01] Fees are ignored when checks skew max in Stable Withdrawal / Leverage Open / Leverage Adjust

보고서

Summary

담보 토큰 출금, 레버리지 오픈, 레버리지 조정 시, skew가 최대에 도달하는지 확인할 때 (볼트에 재예치되는)수수료를 무시하고 계산한다. 안전한 상황에도 트랜잭션을 revert 시킨다.

Keyword

arithmetic error

Vulnerability

사용자가 스테이블 LP로 출금하면 볼트에 남아있는 총 스테이블 담보의 양이 업데이트 된다. 그런 다음 출금 수수료를 계산하고, 시스템이 너무 롱포지션에 치우치지 않도록 checkSkewMax 함수를 호출한다.

    function executeWithdraw(
        address _account,
        uint64 _executableAtTime,
        FlatcoinStructs.AnnouncedStableWithdraw calldata _announcedWithdraw
    ) external whenNotPaused onlyAuthorizedModule returns (uint256 _amountOut, uint256 _withdrawFee) {
        uint256 withdrawAmount = _announcedWithdraw.withdrawAmount;
 
        uint32 maxAge = _getMaxAge(_executableAtTime);
 
        uint256 stableCollateralPerShareBefore = stableCollateralPerShare(maxAge);
        _amountOut = (withdrawAmount * stableCollateralPerShareBefore) / (10 ** decimals());
 
        // Unlock the locked LP tokens before burning.
        // This is because if the amount to be burned is locked, the burn will fail due to `_beforeTokenTransfer`.
        _unlock(_account, withdrawAmount);
 
        _burn(_account, withdrawAmount);
 
@>      vault.updateStableCollateralTotal(-int256(_amountOut));
 
        uint256 stableCollateralPerShareAfter = stableCollateralPerShare(maxAge);
 
        // Check that there is no significant impact on stable token price.
        // This should never happen and means that too much value or not enough value was withdrawn.
        if (totalSupply() > 0) {
            if (
                stableCollateralPerShareAfter < stableCollateralPerShareBefore - 1e6 ||
                stableCollateralPerShareAfter > stableCollateralPerShareBefore + 1e6
            ) revert FlatcoinErrors.PriceImpactDuringWithdraw();
 
            // Apply the withdraw fee if it's not the final withdrawal.
@>          _withdrawFee = (stableWithdrawFee * _amountOut) / 1e18;
 
            // additionalSkew = 0 because withdrawal was already processed above.
@>          vault.checkSkewMax({additionalSkew: 0});
        } else {
            // Need to check there are no longs open before allowing full system withdrawal.
            uint256 sizeOpenedTotal = vault.getVaultSummary().globalPositions.sizeOpenedTotal;
 
            if (sizeOpenedTotal != 0) revert FlatcoinErrors.MaxSkewReached(sizeOpenedTotal);
            if (stableCollateralPerShareAfter != 1e18) revert FlatcoinErrors.PriceImpactDuringFullWithdraw();
        }
 
        emit FlatcoinEvents.Withdraw(_account, _amountOut, withdrawAmount);
    }

executeWithdraw 함수가 끝나면 다시 볼트 담보로 withdrawFee 만큼을 업데이트 한다. (출금 수수료는 볼트에 다시 예치) 그리고 키퍼에게 수수료를 전달하고, (amountOut - totalFee) 만큼의 담보가 사용자에게 전달된다.

    function _executeStableWithdraw(address account) internal returns (uint256 amountOut) {
        ...
 
@>      (amountOut, withdrawFee) = IStableModule(vault.moduleAddress(FlatcoinModuleKeys._STABLE_MODULE_KEY))
            .executeWithdraw(account, order.executableAtTime, stableWithdraw);
 
@>      uint256 totalFee = order.keeperFee + withdrawFee;
 
        // Make sure there is enough margin in the position to pay the keeper fee and withdrawal fee
        if (amountOut < totalFee) revert FlatcoinErrors.NotEnoughMarginForFees(int256(amountOut), totalFee);
 
        // include the fees here to check for slippage
@>      amountOut -= totalFee;
 
        if (amountOut < stableWithdraw.minAmountOut)
            revert FlatcoinErrors.HighSlippage(amountOut, stableWithdraw.minAmountOut);
 
        // Settle the collateral
@>      vault.updateStableCollateralTotal(int256(withdrawFee)); // pay the withdrawal fee to stable LPs
@>      vault.sendCollateral({to: msg.sender, amount: order.keeperFee}); // pay the keeper their fee
@>      vault.sendCollateral({to: account, amount: amountOut}); // transfer remaining amount to the trader
 
        emit FlatcoinEvents.OrderExecuted({account: account, orderType: order.orderType, keeperFee: order.keeperFee});
    }

executeWithdraw 에서 checkSkewMax 를 호출할 때, 재예치되는 출금 수수료를 고려하지 않았다. 출금으로 인한 담보 업데이트 checkSkewMax 출금 수수료 재예치 담보 업데이트 한다. 즉, checkSkewMax 에서는 출금 수수료로 인한 담보 증가액은 고려하지 않는다. 즉 실제보다 skew 가 높게 측정되므로, checkSkewMax 에서 트랜잭션이 취소될 수 있다.

다음과 같은 상황을 생각해보자.

  • skewFractionMax 는 120%, stableWithdrawFee는 1% 라고 가정하자.
  • 앨리스는 100개의 담보를 예치하고 밥은 크기 100의 레버리지 포지션을 연다.
    • 현재 볼트에 100개의 담보가 있고, skew는 0, skew 비율은 100% 이다.
  • 앨리스가 16.8개의 담보를 출금하려고 하고, 출금 수수료는 0.168이다. 출금 후 볼트에 83.368개의 담보가 있을 것으로 예상되므로, skewFraction은 119.5%로 skewFractionMax보다 작다.
  • 그러나 프로토콜이 skew max를 확인할 때 withdrawFee를 포함하지 않고 계산하므로, skewFraction이 120.19%로 skewFractionMax보다 높기 때문에 출금 트랜잭션이 취소된다.

동일한 이슈가 레버리지 오픈과 레버리지 조정 기능에도 존재한다. skew max를 계산할 때 tradeFee 가 무시된다.

Impact

스테이블 출금/레버리지 오픈/레버리지 조정을 잘못 revert 한다.

Mitigation

skew max를 계산할 때 볼트에 재예치되는 수수료를 고려한다.


tags: bughunting, flat money, smart contract, solidity, solo issue, arithmetic error, severity medium