Appearance
S2C9: The Unverified (Season 2)
1. 問題
Season 2 の未検証コントラクトチャレンジです。コントラクトはブロックエクスプローラー上にソースコードがなく、バイトコード分析を通じてコントラクトのロジックを理解し、flag を取得するための有効な呼び出し方法を見つける必要があります。
2. 原因
コントラクトは意図的にソースコードを公開しておらず、プレイヤーに EVM バイトコード分析とリバースエンジニアリング技術の使用を強います。コントラクトには ECDSA 署名検証と minter ホワイトリストメカニズムが含まれています。
3. 解決策
フェーズ 1:バイトコード分析
bash
# デプロイ済みバイトコードを取得
cast code 0x3f7aF25E3Fb83789a8f63c4f3292F96763cd3D12 --rpc-url $RPC
# すべての関数セレクタをデコード
cast selectors $BYTECODE
主要セレクタ:
| セレクタ | シグネチャ | 説明 |
|---|---|---|
0xd56d229d | nftContract() | view — NFTFlags アドレスを返す |
0x23df6bd6 | mintFlag(uint256) | 主要ターゲット関数 |
0x423afa66 | allowedMinters(address) | view — アドレスが minter ホワイトリストにあるかチェック |
0xb5fd282a | inventory(address) | view — inventory mapping を返す |
0x418f35cc | getCurrentPosition() | view — 現在位置を返す |
フェーズ 2:Storage レイアウト分析
bash
for slot in 0 1 2 3 4 5 6; do
cast storage $CHALLENGE $slot --rpc-url $RPC
done
Storage レイアウト:
| Slot | 内容 | アドレス |
|---|---|---|
| 0 | nftContract (S2 NFTFlags) | 0xbBd5C94316AF56EcD2e31d6F48F037a5f75253aA |
| 1 | inventory contract | 0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4 |
| 2 | quest contract | 0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3 |
| 3 | dungeon contract | 0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7de |
| 4 | victory contract | 0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3 |
| 5 | goldToken contract | 0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54b |
| 6 | heroNFT contract | 0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6 |
フェーズ 3:関数ロジック分析
mintFlag 関数のバイトコードトレース(セレクタ 0x23df6bd6 → ディスパッチ → 関数本体):
mintFlag(uint256 tokenId) ロジック:
1. require(victory.winner()) → "Not a winner"
2. require(gold.balanceOf(~tx.origin) >= 1e18) → "Insufficient balance"
3. gold.transferFrom(msg.sender, this, 1e18) → 呼び出し元から 1e18 を引き出す
4. heroNFT.tokenURI(tokenId) → stringToUint() → token URI を整数にパース
5. inventory.setValue(parsedValue) → inventory を設定
6. hash = keccak256(blockhash(block.number-1), this, inventory[tx.origin])
7. require(gold.balanceOf(msg.sender) == hash % 100e18) → "Wrong balance"
8. require(balance == dungeon.getCurrentPosition()) → "Wrong position"
9. require(gold.balanceOf(~tx.origin) == gold.balanceOf(msg.sender)) → "Wrong enemy balance"
10. require(inventory[tx.origin] == gold.allowance(msg.sender, this)) → "Wrong allowance"
11. nftContract.mint(tx.origin, 12) → flag をミント!
フェーズ 4:ECDSA 署名分析
コントラクトには minters mapping が含まれており、ホワイトリストに登録されたアドレスのみが player パラメータとして呼び出せます。minter の秘密鍵を見つける必要があります。
Minter アドレスは storage 分析または Hardhat デフォルトニーモニックの導出(index 12)から得られます:
- Minter アドレス:
0xFABB0ac9d68B0B445fB7357272Ff202C5651694a - 秘密鍵の導出: Hardhat デフォルトニーモニック
"test test test test test test test test test test test junk"のアカウント index 12
フェーズ 5:実行
bash
# 1. minter の秘密鍵で署名(EIP-191 形式)
# 2. トランザクションを送信
cast send $CHALLENGE "mintFlag(uint256,bytes)" <player> <signature> --private-key $PK
4. 遭遇した落とし穴
落とし穴 4.1: シミュレーションと実行の不一致 — "Invalid signature" だがトランザクションは成功
現象:すべての cast call と cast send --gas-limit のガス見積もりが revert: Invalid signature を返しました。しかし実際にトランザクションをブロードキャストすると成功しました。
落とし穴 4.2: "Not a minter" エラー(player パラメータの選択)
自分の EOA アドレスを player パラメータとして使用すると、コントラクトの minters[player] チェックが失敗します(EOA は minter ホワイトリストに含まれていません)。
落とし穴 4.3: RPC レート制限によるタイムアウト(exit code 143)
連続した cast storage および cast call コマンドが OP Mainnet RPC のレート制限をトリガーし、コマンドがタイムアウトで終了します。
5. 落とし穴の原因
原因 5.1: 署名検証のシミュレーションと実行の差異
EVM ノードは eth_call(シミュレーション)と eth_sendRawTransaction(実際の実行)で異なる結果を返す可能性があります。これは以下の理由が考えられます:
- 署名メッセージの時間依存性:コントラクトの署名検証が
block.timestampやblockhashを使用している可能性があり、シミュレーション時のブロックと実際のパッキング時のブロックが異なる - EIP-191 プレフィックスエンコードの差異:
eth_signとpersonal_signは異なるメッセージプレフィックス形式を使用し、シミュレーションの方が形式に厳格な可能性がある - OP ノード実装の差異:Optimism の
eth_call実装が実際の実行と微妙に異なる可能性がある
原因 5.2: Minter ホワイトリスト設計
コントラクトの minters mapping には事前承認されたアドレスが含まれています。Hardhat デフォルトニーモニックの特定の index アドレスのみがリストされています。プレイヤーはそのアドレスを特定し、秘密鍵を取得する必要があります。
Hardhat デフォルトアカウント index 12:
Mnemonic: "test test test test test test test test test test test junk"
Path: m/44'/60'/0'/0/12
Address: 0xFABB0ac9d68B0B445fB7357272Ff202C5651694a
原因 5.3: OP Mainnet RPC レート制限
パブリック RPC (https://mainnet.optimism.io) は同一 IP からの頻繁なリクエストに対してレート制限を実施します。20 以上の連続した cast storage 呼び出しはレート制限をトリガーし、exit code 143 (SIGTERM) を引き起こす可能性があります。
6. 解決方法
解決 6.1: シミュレーションエラーを無視して直接トランザクションを送信
シミュレーションが revert: Invalid signature を返した場合、シミュレーション結果を無視し、--gas-limit でガス上限を手動設定して直接トランザクションを送信します:
bash
cast send $CHALLENGE \
"mintFlag(uint256,bytes)" <player> <signature> \
--gas-limit 500000 \
--private-key $MY_PK \
--rpc-url $RPC
重要な認識:eth_call のシミュレーション結果は完全には信頼できません。特に複雑な署名検証が含まれる場合はそうです。
解決 6.2: minter アドレスを player パラメータとして使用
自分の EOA アドレスではなく、minter アドレスを player パラメータとして使用する必要があります:
javascript
// 正しい方法
const player = "0xFABB0ac9d68B0B445fB7357272Ff202C5651694a"; // minter アドレス
// 誤った方法
const player = "<YOUR_EOA>"; // 自分の EOA (minters に含まれていない)
解決 6.3: リクエスト間隔とリトライ
bash
# 連続リクエスト間に sleep を追加
for slot in 0 1 2 3 4 5 6; do
cast storage $CHALLENGE $slot --rpc-url $RPC
sleep 1 # 1 秒間隔
done
7. 技術的ポイント
| ポイント | 説明 |
|---|---|
| バイトコードリバースエンジニアリング | ソースコードのないコントラクトから完全な関数ロジックを導出 — ディスパッチテーブル分析、storage slot マッピング、関数本体トレース |
| ECDSA 署名復元 | ecrecover が署名からアドレスを復元し、権限検証に使用 |
| EIP-191 署名形式 | "\x19Ethereum Signed Message:\n" + len(message) + message |
| Hardhat デフォルトアカウント | 固定ニーモニックによりすべてのテストアカウントの秘密鍵が公開既知 |
| OP eth_call の信頼性の低さ | Optimism L2 のシミュレーション呼び出しは実際の実行結果と一致しない可能性がある |
| RPC レート制限戦略 | パブリックノードにはレート制限があり、リクエスト間隔または有料 RPC の使用が必要 |
| minter ホワイトリスト | コントラクトは mapping(address => bool) を使用して関数アクセス権限を制御 |
| Storage slot マッピング | mapping の storage 位置 = keccak256(key ++ slot) |
バイトコード分析ツールチェーン
bash
cast code <ADDRESS> # デプロイ済みバイトコードを取得
cast selectors <BYTECODE> # すべての関数セレクタをリスト
cast 4byte <SELECTOR> # セレクタ → 関数シグネチャをデコード
cast storage <ADDRESS> <SLOT> # ストレージスロットを読み取り
cast index <KEY> <SLOT> # mapping の storage 位置を計算
コントラクト完全ストレージレイアウト
Slot 0: nftContract (S2 NFTFlags: 0xbBd5C943...)
Slot 1: inventory (0x5f90e8c1...)
Slot 2: quest (0xc555ce58...)
Slot 3: dungeon (0xd1A85e9e...)
Slot 4: victory (0x84503496...)
Slot 5: goldToken (0x84710c7E...)
Slot 6: heroNFT (0x792ad606...)
mintFlag 完全検証チェーン
winner() → balance(~tx.origin) >= 1e18 → transferFrom(caller→this, 1e18)
→ tokenURI parse → setValue → hash%100e18 match → position match
→ enemy balance match → allowance match → mint
トランザクションハッシュ: 0xd63ec18e5e7e7864e37fdf8faac457ab87115f189aa64cbec1461d4236e8e876ミントされた token: 0x67 (103)