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 函数的字节码追踪(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 call 和 cast 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 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 默认 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)