从MEV大佬的代码工程中,我学到了什么?
时间:2024-09-15 来源:区块链网络 作者:区块律动BlockBeat
原文标题:《To Reverse A Big Brain》 编者按:在逆向 bigbrainchad.eth 的 MEV 合约过程中,我们发现其使用了复杂的代码混淆和堆栈打乱技术,使得自动化工具难以处理。合约中有大量不必要的代码块和循环,并通过动态跳转和时间戳控制进一步增加了复杂性。尽管使用了 Heimdall 和 Tenderly 等工具,进展有限,最终我们转向 Python 进行手动分析,尝试通过逐块解析和堆栈注释寻找突破口,这次逆向工程充满挑战但仍未完全解读。 Bigbrainchad.eth 是一个运行 MEV 合约的账户,其中每笔交易哈希都以 0xbeef 开头,每个函数调用以 codeIsLaw 开头,每个交易输入以 0xdeadbeef 开头。高层来看,它是一个多调用者(multicaller)。但在底层,代码的复杂性与艺术表现相结合,形成了我所见过最复杂的代码混淆之一。 初步攻击 逆向工程智能合约,我们首先从自动化工具开始。每个逆向工程师都浪费了大量时间在那些工具本可以自动完成的任务上。因此我们将从一些流行工具入手。 Heimdall Heimdall 是北欧神话中彩虹桥的守护者,而在以太坊的世界中,它是一个字节码反编译工具。Heimdall 提供了多个反编译层次,所以我们从最高层次的 Solidity 开始。 需要注意的是,Heimdall 输出的并不是一般可以编译的「有效」Solidity,它更像是一个启发式工具,帮助我们更好地理解高层控制流。 heimdall decompile bytecode.hex --include-sol 这么简单吗?没那么容易,字符串中混入了一些空字节,这可能是 bigbrainchad 的疏忽,写了太多汇编代码。我们再深入一层,看看 Yul。 heimdall decompile bytecode.hex --include-yul 你有没有过这种感觉:原本觉得是个有趣的小项目,突然陷入失控状态,深入到 EVM(以太坊虚拟机)的深渊中,你计划好的一整天突然消失,像是「Mr. Stark,我感觉不太好」? Solidity 和 Yul 的输出完全不同。某些条件相符,比如 calldatasize 非零或交易发起者和调用者匹配,但这些条件在 Yul 中并不会导致回滚(revert)。我们再深入一点,进入所谓的控制流图(Control Flow Graph)。 heimdall cfg bytecode.hex 控制流图将智能合约的字节码划分为多个块,依据代码选择不同路径的地方进行分割;这可能是在你需要基于某种错误有条件地回滚交易时,也可能是类似 Uniswap V3 中 tick math 的某个阈值条件。上面展示的完整内容是为了增强戏剧效果,但其中隐藏了一个微妙的问题。 jumpi 指令是条件跳转指令,它告诉 EVM(以太坊虚拟机)「如果」某个条件为非零,则「跳转」到另一处代码继续执行,否则就像什么都没发生一样继续执行下去。通常,这会显示为一个块指向两个其他块,一个代表「为真」条件,另一个代表「为假」条件,类似于以下情况。 如果条件为「真」,则回滚交易,否则成功返回。这在 Solidity 的 require 语句中非常常见。 这非常好,可以帮助我们绘制控制流图,但这里有一个小问题。如果我们向上查看控制流图的上一个块,会看到如下情况。 字节码中不可能存在只有一个结果的条件跳转。 我们看到一个块里只有条件跳转后的单一结果。虽然这在高级语言中是技术上可行的,但在字节码中不应该出现这种情况。每个 jumpi 指令都应该有两个结果,即便其中一个是完全无效的,它也应该在代码中体现出来。那么,这究竟是怎么回事? 我们现在深入一些讨论。一般来说,高级语言如 Solidity 或 Vyper 自己处理所有控制流逻辑,这意味着无法直接使用 jump 或 jumpi 指令。它们只能使用像 if、switch 或函数调用这样的结构,即便是在 Solidity 的「内联汇编」(也称为中间表示,Yul)中也是如此。 这意味着所有的 jump 和 jumpi 指令在编译时都是已知的,它们无法来自于像 calldata 这样不可预测的数据(而这正是我们这里看到的情况)。这暗示着这个合约要么是用汇编语言写的,要么是使用了 Yul 优化器引入前的老版本 Solidity 编写的。严格来说,如果没有使用 Yul 管道,你可以通过 calldata 覆写函数指针以达到这种效果,但在使用「via-ir」(通过中间表示)的情况下,即便是函数指针也不再是直接的跳转目标,而是通过函数 ID 进行分发。 无论是哪种情况,Heimdall 都无法进一步帮助我们,我们需要继续探索其他工具。 Tenderly Tenderly 提供了一些交易跟踪信息,并且在过去的漏洞恢复和响应中起到了关键作用。让我们看看是否能通过它对这个程序的运行方式获得有意义的洞察。我们将使用以下交易哈希:0xbeef0ad930c2f0052758ce9ce579ad7f83bed913ffedb50046c3c674293d1fe5 Tenderly dashboard 没有源代码,所以无法进行调试。不过,它确实包括了从高层次的调用信息。虽然这些稍后可能会有用,但现在我们需要实际的控制流步骤来查看底层到底发生了什么。 Bytegraph 在此之前我没有听说过这个应用程序,但它也提供控制流图,看起来更加有趣,甚至可以通过拖放来操作。让我们将合约的字节码输入进去,看看会有什么发现。 Bytegraph 说实话,这里有其他块,也许是我还不太会使用这个工具,但我可不打算手动找到并连接这些节点。我们继续吧。 EVM Codes 我之前对 EVM dot codes 给予了很多好评(现在依然如此)。它包含了每个 EVM 操作码的信息(如果你想深入研究,建议每一个都读一遍,每一个),并且还有一个可以测试操作码的 playground。让我们试试将字节码和 calldata 插进去,逐步执行我们之前提到的交易。 evm.codes 我们在重新执行之前的交易时遇到了运行时回滚错误。虽然这里没有显示,但错误消息只是一个单词:「time」(时间)。事实证明,这个检查会将区块的时间戳减少到 16 位,然后从中减去 calldata 的第 5 和第 6 字节。就我所知,EVM Codes 没有环境变量覆盖功能,所以这次尝试没成功。 Python3 和一个梦想 到目前为止,进展非常有限,所以让我们试试 Python。我们将逐块分解代码,按 jumpdest、jumpi、jump、stop、revert 和 invalid 进行划分。现在我们先把这些内容写入文件,然后开始添加一些堆栈注释,希望能找到突破口。 Comments indicating stack state 这个过程非常痛苦,但有几件事情变得显而易见。 首先,堆栈调度非常混乱,这可能只能归因于老版本的 Solidity 或者我们第一次遇到的代码混淆——堆栈打乱。 堆栈打乱是通过改变变量在堆栈中的添加、移动和使用顺序,以故意误导逆向工程师的一种手段。例如,考虑以下两个代码片段,它们的功能完全相同。 第一个代码片段很直接,我们将输入推送给 mstore,它将一个值存储在内存中,然后将输入推送给 return,它将内存的一部分返回给调用者。第二个代码片段做了完全相同的事情,但方式不那么直接。 不幸的是,Bigbrainchad 的堆栈打乱技巧要复杂得多。 还有一种代码混淆技术,它将常量分解为其他值,并在运行时进行算术运算来生成实际的常量。将这种技术与堆栈打乱结合起来,你就会发现堆栈中堆积了 30 个看似无用的值,但随着程序的运行,这些值会逐个被重新组合成程序实际需要的值,简直让人抓狂。 传奇工具登场:The GOAT 在这过程中,几个小时后我收到一条消息,@plotchy 开发了一款工具,它通过符号执行创建控制流图,不仅做了其他控制流图工具的工作,还能够映射出那些通过 calldata 等数据派生出的间接跳转。经过一些依赖项的调整,我们得到了如下图所示的结果。 evm-cfg bytecode.hex -o plotchy/cfg.dot --open 太棒了!虽然完整图像是为了增加戏剧效果,但它确实包含了一些不错的高亮显示,帮助我们找到失败和成功的代码路径,并明确标记哪个分支是真(truthy),哪个分支是假(falsy)。不过,这并不总是显而易见的。 然而,这也带来了我们遇到的第二和第三种代码混淆技术。 首先,有大量不必要的代码块。有些跳转仅发生一次,并且只有一个代码块被引用。也许这是老版本 Solidity 的问题,就像微软那样注入了大量膨胀软件,或者这里存在一些代码混淆。 其次,有大量循环。有些循环仅在特定的 calldata 字节具有特定值时发生,另一些是上面提到的那些动态跳转的结果,似乎有多达十个这样的动态跳转。有些循环很短,而另一些几乎贯穿整个合约。也许我有些阴谋论,但其中一些可能只是「蜜罐」(honey pots)。 EVM-CFG 循环 第二次攻击 到目前为止,除了发现一些非常精妙的代码混淆技巧和可能的「心理战」外,还没有太大的成功。因此,让我们尝试另一个角度。我们将回到前面提到的 Tenderly 部分的交易,并尝试从高层次的跟踪信息中推测其发生了什么。 该交易的 calldata 如下所示: 在这笔交易中,MEV 合约只执行了两个操作。 第一个操作是调用了一个代币的 distributeETH 函数,且传递的值为零。由于合约是通用的,因此不可能存储每个合约地址、选择器和调用值,必须通过 calldata 注入。 第二个操作是调用了原始调用者 bigbrainchad.eth。这次没有 calldata,但有调用值。 考虑到这些,我们可以将 calldata 进行如下分解。 前四个字节是装饰性的,合约会忽略它们。此前对该合约的调用实际上使用的是全零 0x00000000,因此在效率最大化的过程中逐渐加入了一些风格化的元素。接下来的两个字节对应的是前面提到的时间戳控制流;这很可能是为了确保交易只会在预期的区块和预期的时间戳内被包含。再接下来的一个字节与我之前提到的控制流蜜罐有关;也许它有更重要的用途,但堆栈太深,我也有些累了。随后还有一些未知字节。 真正有趣的是「目标地址大小」参数。对于调用的目标地址、值和负载,每个都有一个前缀大小。它们使用一个字节表示目标地址和值的大小,并使用两个字节表示负载的大小。系列中的第一个调用是针对 0xf193..65b1,值为零,负载为 0xb8b9b54900,即 distributeETH()。系列中的第二个调用是针对 0xd215..0dfd,即 bigbrainchad.eth,值为 281314951393027 wei,且没有 calldata。 总结 尽管我非常想完成这项工作,发布一些源代码,并制作一个模仿合约,但这个合约让我折服了。要解开的内容太多了,手动跟踪的代码路径也太多,自动化工具面对的歧义也太多。如果你想查看我工作的笔记,可以通过下面的 GitHub 链接查看,但现在,我暂且放弃。 如果 bigbrainchad.eth 看到了这篇文章,祝你好运,你的合约真是一件艺术品。 「原文链接」 |