Damn Vulnerable DeFi

Damn Vulnerable DeFiのUnstoppable解説

Damn Vulnerable DeFi - Unstoppable
0xkeesmark

Damn Vulnerable DeFiの1つ目の問題であるUnstoppableについて解説していきます。

もしまだ解いていない方はネタバレになるので、ギブアップした人又は答え合わせしたい人だけ見てくださいね!

問題内容を確認

まずは問題を確認していきましょう!詳しくはこちらのリンクから問題の箇所へ見に行ってください。

There’s a tokenized vault with a million DVT tokens deposited. It’s offering flash loans for free, until the grace period ends.

To pass the challenge, make the vault stop offering flash loans.

You start with 10 DVT tokens in balance.

訳すると

トークン化されたVault(保管庫)には100万DVTトークンが預けられている。そのコントラクトは猶予期間が終了するまで、フラッシュローンを無料で提供している。

チャレンジに合格するには、コントラクトがフラッシュローンをできなくすること。

あなたは10DVTトークンの残高からスタートする。

コードの中身

UnstoppableVault.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "solmate/src/utils/FixedPointMathLib.sol";
import "solmate/src/utils/ReentrancyGuard.sol";
import { SafeTransferLib, ERC4626, ERC20 } from "solmate/src/mixins/ERC4626.sol";
import "solmate/src/auth/Owned.sol";
import { IERC3156FlashBorrower, IERC3156FlashLender } from "@openzeppelin/contracts/interfaces/IERC3156.sol";

/**
 * @title UnstoppableVault
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract UnstoppableVault is IERC3156FlashLender, ReentrancyGuard, Owned, ERC4626 {
    using SafeTransferLib for ERC20;
    using FixedPointMathLib for uint256;

    uint256 public constant FEE_FACTOR = 0.05 ether;
    uint64 public constant GRACE_PERIOD = 30 days;

    uint64 public immutable end = uint64(block.timestamp) + GRACE_PERIOD;

    address public feeRecipient;

    error InvalidAmount(uint256 amount);
    error InvalidBalance();
    error CallbackFailed();
    error UnsupportedCurrency();

    event FeeRecipientUpdated(address indexed newFeeRecipient);

    constructor(ERC20 _token, address _owner, address _feeRecipient)
        ERC4626(_token, "Oh Damn Valuable Token", "oDVT")
        Owned(_owner)
    {
        feeRecipient = _feeRecipient;
        emit FeeRecipientUpdated(_feeRecipient);
    }

    /**
     * @inheritdoc IERC3156FlashLender
     */
    function maxFlashLoan(address _token) public view returns (uint256) {
        if (address(asset) != _token)
            return 0;

        return totalAssets();
    }

    /**
     * @inheritdoc IERC3156FlashLender
     */
    function flashFee(address _token, uint256 _amount) public view returns (uint256 fee) {
        if (address(asset) != _token)
            revert UnsupportedCurrency();

        if (block.timestamp < end && _amount < maxFlashLoan(_token)) {
            return 0;
        } else {
            return _amount.mulWadUp(FEE_FACTOR);
        }
    }

    function setFeeRecipient(address _feeRecipient) external onlyOwner {
        if (_feeRecipient != address(this)) {
            feeRecipient = _feeRecipient;
            emit FeeRecipientUpdated(_feeRecipient);
        }
    }

    /**
     * @inheritdoc ERC4626
     */
    function totalAssets() public view override returns (uint256) {
        assembly { // better safe than sorry
            if eq(sload(0), 2) {
                mstore(0x00, 0xed3ba6a6)
                revert(0x1c, 0x04)
            }
        }
        return asset.balanceOf(address(this));
    }

    /**
     * @inheritdoc IERC3156FlashLender
     */
    function flashLoan(
        IERC3156FlashBorrower receiver,
        address _token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool) {
        if (amount == 0) revert InvalidAmount(0); // fail early
        if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
        uint256 balanceBefore = totalAssets();
        if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
        uint256 fee = flashFee(_token, amount);
        // transfer tokens out + execute callback on receiver
        ERC20(_token).safeTransfer(address(receiver), amount);
        // callback must return magic value, otherwise assume it failed
        if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
            revert CallbackFailed();
        // pull amount + fee from receiver, then pay the fee to the recipient
        ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
        ERC20(_token).safeTransfer(feeRecipient, fee);
        return true;
    }

    /**
     * @inheritdoc ERC4626
     */
    function beforeWithdraw(uint256 assets, uint256 shares) internal override nonReentrant {}

    /**
     * @inheritdoc ERC4626
     */
    function afterDeposit(uint256 assets, uint256 shares) internal override nonReentrant {}
}

ReceiverUnstoppable.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "solmate/src/auth/Owned.sol";
import { UnstoppableVault, ERC20 } from "../unstoppable/UnstoppableVault.sol";

/**
 * @title ReceiverUnstoppable
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract ReceiverUnstoppable is Owned, IERC3156FlashBorrower {
    UnstoppableVault private immutable pool;

    error UnexpectedFlashLoan();

    constructor(address poolAddress) Owned(msg.sender) {
        pool = UnstoppableVault(poolAddress);
    }

    function onFlashLoan(
        address initiator,
        address token,
        uint256 amount,
        uint256 fee,
        bytes calldata
    ) external returns (bytes32) {
        if (initiator != address(this) || msg.sender != address(pool) || token != address(pool.asset()) || fee != 0)
            revert UnexpectedFlashLoan();

        ERC20(token).approve(address(pool), amount);

        return keccak256("IERC3156FlashBorrower.onFlashLoan");
    }

    function executeFlashLoan(uint256 amount) external onlyOwner {
        address asset = address(pool.asset());
        pool.flashLoan(
            this,
            asset,
            amount,
            bytes("")
        );
    }
}

この2つのコントラクトが今回のお題です。テストの方を見たらわかるのですが、ReceiverUnstoppable.solのexecuteFlashLoanを呼び出してflashloanを行っています。なので、処理を辿っていきどこを攻撃すれば、このコントラクトを止めることができるのかを考えていきます。

攻撃の考え方

まず処理を見ていきましょう。最初に呼び出される関数から

function executeFlashLoan(uint256 amount) external onlyOwner {
        address asset = address(pool.asset());
        pool.flashLoan(
            this,
            asset,
            amount,
            bytes("")
        );
    }

ここに関しては、poolのassetを取得してflashloanを呼び出してるのと、onlyOwnerのmodifierが入っているので、攻撃ができなさそうです。

次に呼ばれるのは、UnstoppableVault.solのflashLoan

function flashLoan(
        IERC3156FlashBorrower receiver,
        address _token,
        uint256 amount,
        bytes calldata data
    ) external returns (bool) {
        if (amount == 0) revert InvalidAmount(0); // fail early
        if (address(asset) != _token) revert UnsupportedCurrency(); // enforce ERC3156 requirement
        uint256 balanceBefore = totalAssets();
        if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement
        uint256 fee = flashFee(_token, amount);
        // transfer tokens out + execute callback on receiver
        ERC20(_token).safeTransfer(address(receiver), amount);
        // callback must return magic value, otherwise assume it failed
        if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
            revert CallbackFailed();
        // pull amount + fee from receiver, then pay the fee to the recipient
        ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
        ERC20(_token).safeTransfer(feeRecipient, fee);
        return true;
    }

ここではリバートをかかるところに注目したい。以下にリバートがかかりそうなところをピックアップしてみます。

if (amount == 0) revert InvalidAmount(0); // fail early
if (address(asset) != _token) revert UnsupportedCurrency(); // enforce
if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();
ERC20(_token).safeTransfer(address(receiver), amount);
if (receiver.onFlashLoan(msg.sender, address(asset), amount, fee, data) != keccak256("IERC3156FlashBorrower.onFlashLoan"))
            revert CallbackFailed();
ERC20(_token).safeTransferFrom(address(receiver), address(this), amount + fee);
ERC20(_token).safeTransfer(feeRecipient, fee);

ここを上から順番に見ていきましょう。

まずamountが0になる可能性はテスト上では値を送っているためありません。

次にassetの違いですが、こちらも前の処理にてpoolのassetから取得してきているので、変更の術がありません。

次に行きたいところですが、他は1つを除いてERC20要因か別のコントラクト要因なので、そこを省くと、この関数には1つだけ可能性が残ります。それが、以下です。

if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance();

balanceBeforeは以下から値を取ってますが、このvaultのasset総量を取得しています。

function totalAssets() public view override returns (uint256) {
        assembly { // better safe than sorry
            if eq(sload(0), 2) {
                mstore(0x00, 0xed3ba6a6)
                revert(0x1c, 0x04)
            }
        }
        return asset.balanceOf(address(this));
    }

そして、totalSupplyはトークンの供給量で、それをconverToShares()にて引数のassetに対して、Vaultがどれだけのshareと交換できるか計算します。なので以下の条件になります。

if トークン量に対するshareできる量 != Vaultのasset総量 revert

現在ハッカーはすでに10DVTのトークンを保有しており、UnstoppableVaultコントラクトのロジックも正常に動作している。ということは、コントラクトの保有量を増やしてあげるだけで、このロジックはすぐに崩せることになる。

なぜなら本来vaultへ預けるにはdepositの関数を使用しなければなりません。なので、直接UnstoppableVaultのコントラクトへトークンをtransferすると、預けた後のコントラクト内の計算をスキップすることになります。すると値は帳尻が合わなくなり以下のようになります。

対象transfer前transfer後
トークン量に対するshareできる量10000000000000000000000001000000000000000000000000
Vaultのasset総量 9999900000999990000099991000010000000000000000000

といった具合に、絶対にリバートするようになります!なので、vaultへdeposit関数を呼ばないで直接トークンを送りつけてしまえばいいということになります。

実際の攻撃方法

    it('Execution', async function () {
        /** CODE YOUR SOLUTION HERE */
        await token.connect(player).transfer(vault.address, INITIAL_PLAYER_TOKEN_BALANCE)
    });

回答です!!意外と簡単で、1行だけでコントラクトを止めてしまうことができるという問題でした!

できなかった人は、詳しくなぜそうなるのかとかを追って勉強したらいいと思います!今回登場したのは、ERC20とERC4626なのでその2つの知識も少しは必要になります。

これでハッカーに近づいたので、次の挑戦をしてみましょう!

ABOUT ME
0xkeesmark
0xkeesmark
Security Researcher
記事URLをコピーしました