枚举所有 69,788,231 个以太坊合约

  • zellic
  • 发布于 2025-05-16 17:30
  • 阅读 12

本文介绍了Zellic团队构建EVM trackooor项目时,如何解决以太坊上所有已部署合约的检索问题。

在构建 EVM trackooor↗ 时,我们想到扫描以太坊上未初始化的合约,以寻找潜在的易受攻击的合约。

我们的想法是在所有合约上调用 init() 的变体,但我们首先必须解决另一个挑战 —— 我们如何检索以太坊上部署的每一个合约?

事实证明,这个问题根本不是那么简单的。据我们所知,没有现有的公共资源或 RPC 端点可以列举所有已部署的合约,这意味着我们必须自己解决这个问题,采取多种方法来解决这个问题。

动机 —— 为什么要这样做?

除了能够尝试初始化所有合约之外,拥有以太坊上每个已部署合约的合约地址和字节码的数据集也很有用。

通过拥有这个数据集,我们可以

  • 轻松查询包含特定字节码的所有合约,
  • 对所有已部署的合约执行静态分析,以发现漏洞,
  • 通过绘制随时间变化的合约数据来发现有趣的趋势和统计数据,

等等。

在 Zellic,我们相信开源软件和数据可以使 Web3 社区受益,这就是为什么我们公开发布 合约数据集↗ 和用于生成数据集的 源代码↗

解决问题

我们如何获得以太坊上部署的所有合约?为什么这是一个不简单的问题?

让我们回顾一下解决这个问题的三种方法。

朴素的方法

首先,让我们考虑最朴素的方法 —— 遍历每个交易,寻找部署交易。如果一个交易是部署交易,我们会记下合约和字节码。我们可以通过 eth_getBlockByNumber 来做到这一点,从区块号 1 开始,循环遍历每个区块中的交易。如果一个交易没有 to 地址,那么它就是一个部署交易,我们可以使用另一个 RPC 调用 eth_getTransactionReceipt 来获取已部署的合约地址。

获取所有已部署合约的朴素方法图

然而,有一个很大的问题 —— 这不适用于作为 内部交易↗ 一部分部署的合约。

让我们看一个例子。UniswapV3Factory↗ 是一个设计用来部署 Uniswap V3 池的合约。

一个使用 factory 部署池的交易看起来像这样:

请注意,这并不是一个部署交易,因为 to 地址不是 null;这是一个普通的合约调用,但它仍然部署了一个合约!事实上,许多合约可以通过内部交易来部署,只需一个交易。

朴素的方法不能处理这些场景,这些场景在区块链上很常见。这就是为什么获取所有已部署的合约并不像看起来那么容易。

更技术地说,我们需要捕获 CREATECREATE2 操作码执行和部署合约的每个实例。

以前的方法 —— 智能合约嘉年华

两年前,我们通过使用修改过的 Geth↗ 实例从创世区块执行以太坊节点完整同步来解决这个问题。

添加了一个 tracer 来捕获 CREATECREATE2 操作码执行的每个实例,并且每个已部署合约的合约地址和区块号都会在完整节点同步时被记录下来。

这个项目被称为 智能合约嘉年华,你可以在 这里↗ 阅读更多关于它的信息。

新方法 —— 部署扫描

现在我们回到构建 EVM trackooor↗ 的部分,并在看到 DeltaPrime 漏洞利用↗ 后尝试初始化所有以太坊合约。

当然,这意味着我们首先必须获得所有合约的列表,这促使我们再次解决这个问题。我们称这个项目为 部署扫描

然而,这一次,我们采取了不同的方法。我们决定尝试使用 RPC 调用到一个已经同步的完整节点,而不是在完整节点同步时索引合约。有几个 API 看起来很有希望用于我们的目的,例如 Debug API 和 Trace API。

我们首先考虑 debug API,因为它具有 debug_traceTransaction,它返回给定交易的所有内部调用的信息。

它返回的结构看起来像这样:

type TraceResult struct {
    From    common.Address `json:"from"`
    To      common.Address `json:"to"`
    Gas     uint64         `json:"gas"`
    GasUsed uint64         `json:"gasUsed"`
    Input   []byte         `json:"input"`
    Output  []byte         `json:"output"`
    Value   *big.Int       `json:"value"`
    Type    string         `json:"type"`
    Calls   []TraceResult  `json:"calls"`
}

请注意,Calls 包含一个相同类型的列表。

为了获得在给定交易中部署的合约,我们将调用 debug_traceTransaction 并在调用中检查 Type。但是,如果 Type 是一个 CALLDELEGATECALL,那么 Calls 将包含关于这些调用的信息,所以我们必须递归地遍历这些调用。

从本质上讲,从 trace 中提取已部署合约的递归函数看起来像这样:

func recursivelyGetDeployedContracts(txTraceResult shared.TraceResult) {
    switch txTraceResult.Type {
    case "CALL", "DELEGATECALL":
        // 遍历每个内部调用的 trace 结果(如果有)并递归
        for _, tr := range txTraceResult.Calls {
            recursivelyGetDeployedContracts(tr)
        }
    case "CREATE", "CREATE2":
        contract := txTraceResult.To
        bytecode := txTraceResult.Output
        recordContractBytecode(contract, bytecode)
    default:
    }
}

然而,在实践中,这被证明是相当慢的。即使使用本地完整节点,似乎也需要几个月才能完成。这是有道理的 —— 这种方法在每个交易上都调用 debug_traceTransaction,考虑到有数十亿个交易,这需要大量的 RPC 调用。

幸运的是,有一些方法可以获得整个区块的 trace,而不仅仅是单个交易。

Debug API 和 Trace API 实际上都提供了 debug_traceBlockByNumbertrace_block,它们分别返回整个区块的 trace —— 非常适合我们的目的。我们可以只对整个区块执行一个 RPC 调用,而不是检索一个区块,循环遍历它的交易,并跟踪每个交易。

我们决定使用 Trace API 的 trace_block,因为它返回的数据结构也消除了对递归函数的需求。但是,如果在一定次数的尝试后 Trace API 失败,我们将回退到 Debug API 的 debug_traceTransaction

现在,我们使用一个运行在我们代码所在的同一服务器上的 Erigon↗ 完整节点,并运行 trace_block 来处理从创世区块开始的每个区块,我们成功地在大约五天内检索了所有已部署的合约及其字节码。

但这并非一帆风顺。它经历了无数次的尝试和多次优化,并且仍然多次中断。

另一个问题是如何存储数百万个合约和字节码。我们简要地尝试了 Redis,但在它反复崩溃后,我们切换到 PostgreSQL,幸运的是它成功了。

所有合约和字节码的数据集可以在 这里↗ 找到。所有数据都是截至 21850000 区块(2025 年 2 月 15 日)的最新数据。

统计

除了合约地址和字节码之外,我们还记录了每个合约的部署区块号和时间戳,这使我们能够生成图表,显示过去 10 年以太坊合约的增长情况。

截至 21850000 区块(2025 年 2 月 15 日),以太坊上已经部署了 69,788,231 个合约。

我们还生成了一个每日部署合约数量的图表,这使我们能够将我们的数据与 Etherscan 的以太坊每日部署合约图表↗ 进行比较,结果几乎完全匹配,这支持了我们数据的有效性。

我们生成的另一个图表是随时间部署的唯一字节码的数量。你可以看到,截至 2025 年,已经部署了近 7000 万个合约,但只有大约 250 万个唯一字节码,这表明以太坊上部署的许多合约具有相同的字节码。

我们还生成了以下图表:

更有趣的统计数据

在绘制了一些合约部署的数据后,我们注意到了一些有趣的趋势,例如唯一合约部署的巨大峰值以及我们之前的数据集之间的差异,这促使我们进行了调查。

2016 年状态膨胀攻击的残余

有趣的是,在 2016 年 10 月 4 日左右,每日部署的唯一字节码数量↗ 出现了一个巨大的峰值 —— 一天部署了 16,000 个唯一合约!

如你所见,这严重扭曲了图表的比例。

作为参考,这几乎是下一个最高值的两倍,而下一个最高值是几个月前的 2024 年。

我们决定进行调查,并找到了 这个地址↗ 部署了大量合约。

它部署的合约非常相似,但字节码略有不同。每个合约在部署时都会被发送 1 Wei。

该合约除了回退外没有其他功能,在收到来自 这个其他地址↗ 的任何调用时会自毁。当它自毁时,这 1 Wei 将被 强制发送↗ 到一个似乎类似于随机字符串的合约地址,例如 rMdWeRyXiNkAaAaStLbU8t3g3t4q6u1a1a9a9a9a,这些可能是无人拥有的地址。

其中一些合约已经被自毁了。例如,查看 这个交易↗,它自毁了 500 个这样的合约。

这可能是一种 状态膨胀↗ 攻击,这是一种拒绝服务攻击,旨在通过用数据淹没区块链状态来崩溃或损害以太坊网络的性能。

在 2016 年第三季度,SELFDESTRUCT 被滥用来反复向空帐户发送以太币,迫使大量帐户被包含在区块链状态数据中。这在当时是可能的,因为 SELFDESTRUCT 的 gas 成本为 0 gas 单位,并且 SELFDESTRUCT 的行为允许单个合约在单个交易中多次自毁,强制将以太币发送到多个现在需要包含在状态中的空地址。

部署者可能正在尝试类似的事情 —— 时间框架相符,因为这些合约是在 2016 年 10 月 16 日部署的,也就是 EIP-150↗ 的两天前,该提案增加了包括 SELFDESTRUCT 在内的许多操作码的 gas 成本,以防止未来发生此类攻击。

绘制自毁合约图

在使用我们的新方法成功检索所有合约后,我们希望通过将其与之前的 智能合约嘉年华数据库↗ 进行比较来验证我们的数据。

一个值得注意的差异是智能合约嘉年华与部署扫描中的合约数量。截至 2023 年 3 月,智能合约嘉年华记录了 30,586,657 个合约,但是如果你查看上面的部署扫描合约图,你会看到累计部署的合约约为 5500 万个。

那么为什么智能合约嘉年华中没有记录这些合约中的 2500 万个呢?

我们最初认为可能出了什么问题,但是后来我们意识到智能合约嘉年华和部署扫描记录的合约之间存在差异:智能合约嘉年华会从其数据集中删除自毁的合约,而部署扫描则不会对自毁做任何处理。

当时,2500 万个合约自毁似乎是不合理的,但是我们还是决定修改部署扫描来记录自毁以验证这一点。

然而,当我们重新运行部署扫描时,我们一直遇到内存不足 (OOM) 错误,大约在 2420000 区块,2016 年 10 月 —— 与状态膨胀攻击的时间段相同!

经过一些调试后,我们意识到并修复了这个问题。以前,对于每个合约部署和自毁,我们都会运行一个函数来异步地将数据插入到 PSQL 数据库中。然而,在 2016 年的状态膨胀攻击期间,每个区块都有数万个自毁,实际上在我们的服务器上创建了数十万个线程,这导致了 OOM。

事实证明,即使在我们记录自毁之前,将合约记录到 PSQL 数据库也是一个重要的瓶颈。对每个区块的批量记录数据的快速代码更改解决了这个问题,并且实际上使部署扫描更快,只需三天即可完成。

现在,我们终于可以绘制自毁合约图了。

确实有令人惊讶的合约自毁数量,这支持了智能合约嘉年华和部署扫描的数据都是有效的。

你可能想知道,为什么在发生状态膨胀攻击的 2016 年,自毁的数量似乎微不足道?

原因是状态膨胀攻击多次自毁了同一个合约,而我们只记录了每个合约自毁的区块号。因此,无论同一个合约自毁了多少次,它都只会被记录为图表上的一个自毁。

谁部署的合约最多?

除了自毁之外,我们还记录了每个合约的 EOA 部署者和合约部署者(如果适用),以查看哪个地址部署的合约最多。

对于 to 为空的正常部署交易,只会记录 EOA 部署者,而对于通过其他合约通过 EOA 发起的内部交易部署的合约,我们会同时记录 EOA(发起交易的)和部署合约的合约。

这样,我们可以区分 EOA 部署合约和合约部署其他合约。

在 EOA 中,地址 0xFfff46…↗ 部署的合约最多 —— 接近 290 万个!

然而,这包括通过 EOA 发起的交易部署的其他合约。

那么,仅通过部署交易直接由 EOA 部署的合约呢?

我们发现 Poloniex: Deposit Funder↗ 直接部署了 401,555 个合约。它总共有 401,923 个交易,这意味着几乎所有的交易都是部署交易。

但与内部部署的合约相比,EOA 通常部署的合约数量很少。

那么哪个合约部署的合约最多?

结果是 1inch: CHI token↗ 部署了惊人的 1000 万个合约。

这是内部部署合约的很大一部分!

CHI token 部署这么多合约的原因是利用 gas 退款↗ 的机制。基本上,如果一个交易导致合约自毁或使存储无效,则该交易最多可以退还 50% 的 gas 费用。CHI token 背后的想法是让用户在 gas 便宜的时候铸造这些代币,这会部署合约。然后,当 gas 昂贵且用户想要执行交易时,他们会将 CHI token 与他们的交易一起销毁,自毁已部署的合约,并通过 gas 退款机制减少交易的 gas 费用。

我们还可以看到,许多这些合约都以开头零开头,以 节省 gas↗

初始化所有合约

有了有组织的合约数据库,我们终于可以迭代合约列表并尝试初始化它们。我们称这个项目为 Initscan

我们的流程如下:

  1. 迭代所有合约,每次从 PSQL 数据库中检索一定数量的合约。
  2. 对于每个合约,使用 RPC 调用 trace_call 来调用初始化函数签名的各种变体,例如 init()init(address)init(address,uint256) 等。
  3. 每个 trace_call 都会返回调用是否成功,如果成功,则返回调用本应引起的状态更改。在状态更改中,我们会寻找我们的 from 地址或我们在 calldata 中提供的地址,例如 init(0xabc...)。我们这样做是因为 init 函数通常设置状态变量,例如 admin 或 governor,因此既成功又将状态变量设置为我们的地址之一的调用强烈表明初始化已成功。
  4. 如果调用成功并且存在包含我们地址之一的状态更改,我们将使用不相关/随机的 calldata 执行另一个 trace_call,以进一步确认我们初始化了一些东西。这是为了确保我们不仅仅是访问了一个也导致包含我们地址之一的状态更改的回退函数。
  5. 最后,如果所有这些检查都通过了,我们将记录包含任何原生以太币或 ERC-20 资金的合约,以过滤具有价值的合约。

发现

Initscan 比部署扫描需要更长的时间才能完成,因为我们正在对每个合约多次执行 trace_call,这几乎是 10 亿次 RPC 调用。

那么,我们发现了什么?

有很多合约拥有 USDT,如果 USDT 遵守 ERC-20 标准并在转账时返回 bool 值,那么这些合约本来可以被利用的。但它没有,导致 USDT 永远被锁定。然而,合约资金相对较小,约为 20 美元。

其他拥有数十至数百美元的合约没有提取 ERC-20 代币的方法,这些代币主要是 USDT 和 USDC。

我们确实发现了一些可利用的合约,其中资金约为 10 美元。正如预期的那样,它们具有设置 owner 地址的初始化函数,并且从未调用过这些函数,从而允许任何人简单地成为所有者。然后,所有者可以提取资金,包括原生以太币和 ERC-20 代币。

我们还发现了一个未初始化的合约,其中包含大约 5,000 美元的 ETH 资金,由 Bounce Finance↗ 部署。该合约是一个代理,它委托给一个具有两个初始化函数的实现合约,其中一个从未被调用过。

虽然目前资金没有直接风险,但如果在大约 6 年前(当合约仍在使用时)发现这一点,则可能导致拒绝服务和窃取用户资金。

我们向 Bounce Finance 报告了这一点,这是他们的回应:

此合约是 4 年前部署的,并且已经初始化。

在交易初始化模拟期间,它确实显示为“成功”,但实际的链上交易失败。

结论

从想要扫描以太坊上未初始化合约的想法开始,我们解决了列出现有以太坊上的每个合约的单独挑战,并发现了一些有趣的统计数据!

虽然我们没有从初始化合约中发现许多严重或高价值的发现,但我们生成了一个庞大的以太坊上所有已部署合约的数据集,这对于其他事情(例如扫描字节码模式和静态分析)非常有用。

我们很好奇更广泛的 Web3 社区将如何使用这些数据,因为我们开源了我们的数据库、代码和方法。

此外,这个 项目↗ 和博客文章是我在 Zellic 实习期间完成的,在那里我能够将我的 CTF 经验应用于现实世界的安全挑战。

如果你是一名具有 CTF 经验的学生,正在寻找机会从事像这样的酷区块链安全项目,我们鼓励你申请加入我们的团队!

关于我们

Zellic 专门从事新兴技术的安全保护。我们的安全研究人员已经发现了最有价值的目标中的漏洞,从财富 500 强公司到 DeFi 巨头。

开发人员、创始人和投资者信任我们的安全评估,以便快速、自信地交付产品,而不会出现严重漏洞。凭借我们在现实世界攻防安全研究方面的背景,我们能够发现其他人遗漏的东西。

联系我们↗ 进行比其他审计更好的审计。真正的审计,而不是橡皮图章。

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

0 条评论

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