Skip to content
On this page

止まらない?いや、止められる!:Unstoppable Vault 攻略ガイド

Damn Vulnerable DeFi v4 の CTF チャレンジ「Unstoppable」に挑戦しましょう!このチャレンジでは、100万 DVT トークンが預けられた、一見安全に見える「Unstoppable Vault」のフラッシュローン機能を停止させることを目指します。

チャレンジの概要

「Unstoppable Vault」は、猶予期間中、無料のフラッシュローンを提供しています。しかし、開発者は完全なパーミッションレス化の前に、テストネットでライブベータを実施することにしました。そのために、フラッシュローン機能の稼働状況を監視するための「UnstoppableMonitor」コントラクトが用意されています。

プレイヤーは、初期残高10 DVT トークンからスタートし、このフラッシュローン機能を停止させる必要があります。つまり、Vault がフラッシュローンを提供できなくなるように仕向けるのです。

Vulnerability の探求

「UnstoppableVault.sol」のコードを見てみましょう。

solidity
    /**
     * @inheritdoc ERC4626
     */
    function totalAssets() public view override nonReadReentrant returns (uint256) {
        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

        // transfer tokens out + execute callback on receiver
        ERC20(_token).safeTransfer(address(receiver), amount);

        // callback must return magic value, otherwise assume it failed
        uint256 fee = flashFee(_token, amount);
        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;
    }

注目すべきは flashLoan 関数内のこの部分です。

solidity
        uint256 balanceBefore = totalAssets();
        if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); // enforce ERC4626 requirement

ここで、flashLoan の実行前に、totalAssets() (Vault が実際に保有しているトークン量) と convertToShares(totalSupply) (Vault の発行済みシェア数から換算されるべきトークン量) が一致するかどうかをチェックしています。ERC4626 標準に準拠していれば、通常この二つは一致するはずです。

しかし、もし何らかの方法で totalAssets() の値だけが convertToShares(totalSupply) よりも少なくなる 状況を作り出せれば、この revert InvalidBalance() が発動し、フラッシュローンの実行が阻止されます。

では、どうすれば totalAssets() だけを減らすことができるでしょうか?

「UnstoppableMonitor.sol」には、checkFlashLoan という関数があります。

solidity
    function checkFlashLoan(uint256 amount) external onlyOwner {
        require(amount > 0);

        address asset = address(vault.asset());

        try vault.flashLoan(this, asset, amount, bytes("")) {
            emit FlashLoanStatus(true);
        } catch {
            // Something bad happened
            emit FlashLoanStatus(false);

            // Pause the vault
            vault.setPause(true);

            // Transfer ownership to allow review & fixes
            vault.transferOwnership(owner);
        }
    }

この checkFlashLoan 関数は、フラッシュローンが失敗した場合、Vault を一時停止し、所有権をコントラクトのオーナー(この場合はデプロイアー)に移譲します。

ここで、UnstoppableMonitor コントラクトの onFlashLoan 関数を見てみましょう。

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

        ERC20(token).approve(address(vault), amount); // <-- ここに注目!

        return keccak256("IERC3156FlashBorrower.onFlashLoan");
    }

onFlashLoan 関数内で、ERC20(token).approve(address(vault), amount); が実行されています。これは、モニターコントラクトが受け取ったフラッシュローン額を Vault に承認する操作です。

攻略法

  1. totalAssets() を減らす: Vault が保有するトークン (asset) を、UnstoppableMonitor 側でフラッシュローンを受け取った直後に、Vault 自身に送り返すのではなく、Vault の外部に転送する 必要があります。 しかし、onFlashLoan のコールバック内で、ERC20(token).approve(address(vault), amount); が実行されると、Vault は SafeTransferLib を使用して receiver (この場合はモニターコントラクト) からトークンを引き出し、その処理の中で totalAssets() が更新されます。

    ここで鍵となるのは、UnstoppableVaultflashLoan 関数に戻り、convertToShares(totalSupply) のチェックの直前に totalAssets() が取得されている点です。

    このギャップを突くために、プレイヤーは INITIAL_PLAYER_TOKEN_BALANCE (10 DVT) を Vault に預け直す だけで十分です。

    • UnstoppableVaultdeposit 関数は、afterDeposit 内部で whenNotPaused をチェックします。
    • しかし、deposit 関数の内部で、afterDeposit が呼び出されるのは、_mint の後です。
    • UnstoppableVault_mint 関数は、_update を呼び出しますが、_update の中で afterDeposit が呼び出されるわけではありません。

    しかし、deposit 関数では、asset.transferFrom(msg.sender, address(this), amount); が実行され、Vault の totalAssets() が更新されます。

    deposit 関数が実行された後、Vault に預けられた INITIAL_PLAYER_TOKEN_BALANCE は Vault の totalAssets に計上されます。

    ところが、UnstoppableVaultdeposit 関数は、_update 関数を呼び出します。そして _update 関数は、afterDeposit を呼び出しますが、afterDepositnonReentrant であり、whenNotPaused をチェックします。

    ここで重要なのは、UnstoppableVaultdeposit 関数が、_update を呼び出す前に asset.transferFrom(msg.sender, address(this), amount); を実行し、totalAssets() を更新する点です。

    この totalAssets() の更新と、convertToShares(totalSupply) のチェックの間のわずかな時間差、もしくは deposit 関数自体のロジックの隙間を突くことができれば、totalAssets() の値が convertToShares(totalSupply) よりも大きくなってしまう状況を作り出せます。

  2. テストケースのコード:

    solidity
    function test_unstoppable() public checkSolvedByPlayer {
        // プレイヤーが保持する初期トークンを Vault に預け直す
        token.transfer(address(vault), INITIAL_PLAYER_TOKEN_BALANCE);
        // これにより、totalAssets() が totalSupply() から換算される値よりも大きくなる
        assertNotEq(vault.totalAssets(), vault.convertToShares(vault.totalSupply()));
    }
    

    このコードでは、プレイヤーが初期に持っていた10 DVT トークンを Vault に預け直します。これにより、Vault の totalAssets() は増加しますが、convertToShares(totalSupply) の計算結果は、この追加の預け入れを直接反映しない(あるいは、deposit 関数内のロジックの隙間により、一時的に不整合が生じる)ため、二つの値に差が生じます。

    この差が生じると、UnstoppableVaultflashLoan 関数内で if (convertToShares(totalSupply) != balanceBefore) revert InvalidBalance(); が発動し、フラッシュローンが失敗します。

  3. フラッシュローンの失敗と監視: フラッシュローンが失敗すると、「UnstoppableMonitor」コントラクトの checkFlashLoan 関数内の catch ブロックが実行されます。これにより、Vault は一時停止 (setPause(true)) され、所有権はデプロイアーに移譲されます。

まとめ

「Unstoppable」チャレンジは、ERC4626 標準の挙動と、フラッシュローンにおけるコールバック処理のタイミングを理解することが鍵となります。プレイヤーが持つ少量のトークンを戦略的に Vault に預け直すことで、Vault の内部状態に一時的な不整合を作り出し、フラッシュローンの実行を阻止し、最終的に Vault を停止させることに成功します。

これであなたも、Unstoppable Vault を止めることができるはずです!

Built with AiAda