Skip to content
On this page

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

关键选择器:

选择器签名说明
0xd56d229dnftContract()view — 返回 NFTFlags 地址
0x23df6bd6mintFlag(uint256)主要目标函数
0x423afa66allowedMinters(address)view — 检查地址是否在 minter 白名单中
0xb5fd282ainventory(address)view — 返回 inventory mapping
0x418f35ccgetCurrentPosition()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内容地址
0nftContract (S2 NFTFlags)0xbBd5C94316AF56EcD2e31d6F48F037a5f75253aA
1inventory contract0x5f90e8c1205C448b81Ca57cffdca39bc3b0B35f4
2quest contract0xc555ce58bAD74a2E0CD289aE23225a80bf47e7e3
3dungeon contract0xd1A85e9e62387E8A4Ccb9baBab0Cd97d9E76B7de
4victory contract0x84503496E6ED4F68E84E4Ba6B4Fc5A4d4A4145d3
5goldToken contract0x84710c7E262B09fa3aAF97F2741E7CE6Fb11A54b
6heroNFT contract0x792ad60632A1A63DaDb14fEa9AC3c7FB944406C6

阶段 3:函数逻辑分析

mintFlag 函数的字节码追踪(selector 0x23df6bd6 → dispatch → function body):

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)   → pulls 1e18 from caller
4. heroNFT.tokenURI(tokenId) → stringToUint()   → parse token URI to integer
5. inventory.setValue(parsedValue)              → sets 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)    → mint flag!

阶段 4:ECDSA 签名分析

合约包含一个 minters mapping,只有白名单地址才能作为 player 参数调用。需要找到 minter 的私钥。

Minter 地址来自 storage 分析或 Hardhat 默认 mnemonic 推导(index 12):

  • Minter 地址: 0xFABB0ac9d68B0B445fB7357272Ff202C5651694a
  • 私钥推导: Hardhat 默认 mnemonic "test test test test test test test test test test test junk" 的 account 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 callcast send --gas-limit 的 gas 估算都返回 revert: Invalid signature。但实际广播交易后成功了。

陷阱 4.2: "Not a minter" 错误(player 参数选择)

使用自己的 EOA 地址作为 player 参数时,合约检查 minters[player] 失败(EOA 不在 minter 白名单中)。

陷阱 4.3: RPC 限流导致超时(exit code 143)

连续的 cast storagecast call 命令触发 OP Mainnet RPC 限流,导致命令超时退出。

5. 陷阱的原因

原因 5.1: 签名验证模拟与执行的差异

EVM 节点在执行 eth_call(模拟)和 eth_sendRawTransaction(实际执行)时可能返回不同的结果。这可能是由于:

  1. 签名消息的时间依赖性:合约的签名验证可能使用了 block.timestampblockhash,模拟时的区块与实际打包时的区块不同
  2. EIP-191 前缀编码差异eth_signpersonal_sign 使用不同的消息前缀格式,模拟可能对格式更严格
  3. OP 节点实现差异:Optimism 的 eth_call 实现可能与实际执行有细微差异

原因 5.2: Minter 白名单设计

合约的 minters mapping 包含预授权地址。只有 Hardhat 默认 mnemonic 的特定 index 地址被列入。玩家需要识别并获取该地址的私钥。

Hardhat 默认 account 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 手动设置 gas 上限直接发送交易:

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 address
// 错误做法
const player = "<YOUR_EOA>"; // my EOA (not in 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. 技术要点

要点说明
字节码反向工程从无源码合约推导完整函数逻辑 — dispatch table 分析、storage slot 映射、函数体追踪
ECDSA 签名恢复ecrecover 从签名恢复地址,用于权限验证
EIP-191 签名格式"\x19Ethereum Signed Message:\n" + len(message) + message
Hardhat 默认账号固定 mnemonic 使得所有测试账号的私钥公开可知
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

交易哈希: 0xd63ec18e5e7e7864e37fdf8faac457ab87115f189aa64cbec1461d4236e8e876Minted token: 0x67 (103)

Built with AiAda