以太坊存储逆向工程技术

  • wavey0x
  • 发布于 7小时前
  • 阅读 50

本文深入探讨了以太坊存储逆向工程的复杂性,重点介绍了作者在开发 SlotScan.info 时学到的技术,包括交易追踪、解析映射键、解码存储模式以及处理代理合约。文章还强调了编译器优化的影响、存储布局的重要性以及在处理构造函数存储和 Vyper 合约时遇到的特殊情况。

以太坊存储看似简单:32 字节的槽位保存着 32 字节的值。但将这些槽位映射回有意义的变量名才是有趣的地方。特别是当目标不仅仅是生成一个简单的布局,而是要逆向工程交易追踪中的 SSTORE 时。这就是我的项目 SlotScan.info 的目标,也是我将在此分享的学习经验的背景。

SlotScan 交易视图

SlotScan 布局视图

背景

EVM 没有变量名、类型或结构体成员的概念。这些信息只存在于编译器元数据中。并且由于映射槽位是计算为 keccak256(key || baseSlot),因此你处理的是单向函数:给定一个槽位哈希,产生它的键(key)在数学上是无法恢复的。

这篇文章涵盖了我学习和利用的逆向工程 EVM 存储的技术:在运行时捕获哈希原像,解码复杂的存储模式,通过 DELEGATECALL 链追踪交易,以及检测代理。


追踪交易

为了解码交易中的存储变化,我需要三件事:

  1. 发生了什么改变: 哪些槽位发生了变化,以及变化前后的值
  2. 映射键: 产生哈希槽位的原像
  3. 写入顺序: 变化的顺序(一个槽位可能被写入多次)

没有一个单独的 RPC 调用能提供所有这三点。以下是我最终采用的方法。

真实数据: prestateTracer

debug_traceTransaction 是一个 RPC 方法,它可以重放一个交易并返回追踪数据。使用 prestateTracer 选项(在 diff 模式下),它会返回一个简洁的摘要:准确地显示哪些存储槽位发生了变化,以及它们变化前后的值。

这是 什么 发生变化的真实数据。但它只显示了最终状态。如果一个槽位被写入三次,你只能看到第一个和最后一个值。

执行顺序和原像: structLogs

为了获得完整的图景,我需要执行追踪。structLogs 是一种追踪格式,它返回每个 EVM 步骤:操作码、堆栈、内存和程序计数器。从中,我提取:

  • 按执行顺序排列的 SSTORE 操作(捕获中间写入)
  • SHA3 操作 及其内存输入(映射槽位的原像)

SHA3 捕获至关重要。当 EVM 计算 keccak256(key || slot) 时,原像位于内存中。我在哈希计算之前获取它,建立一个哈希 → 原像的查找表。

为什么两者都需要?

prestateTracer 是权威的,但会丢失中间写入。structLogs 捕获一切,但体积庞大(一个复杂的交易可能会产生 2GB 以上的追踪)。我使用 prestateTracer 来识别感兴趣的槽位,然后使用 structLogs 来获取执行顺序和原像。

对于非常大的交易,我会回退到一个自定义的 JS 追踪器,它只捕获 SHA3 操作,将响应保持在 100KB 以下,而不是几 GB。

处理 DELEGATECALL

当合约 A 委托调用合约 B 时,B 的代码会执行,但会写入 A 的存储。获得正确的地址归属需要小心。

structLogs 不包括每个步骤的地址字段,所以我手动跟踪调用堆栈。对于 DELEGATECALL,存储保留在调用者处,而只有代码上下文发生变化。对于常规 CALL,两者都会发生变化。这让我可以将每个 SSTORE 归属到正确的合约。

prestateTracer 可以作为验证。它确切地知道哪个合约中的哪些槽位发生了变化,所以我可以交叉检查我的归属。

解析映射键

上面提到的 SHA3 原像捕获值得更深入的研究。映射槽位计算为 keccak256(key || baseSlot)。对于槽位 5 上的 balances[0xABC...],EVM 将地址与槽位号连接起来进行哈希处理,以生成最终的存储位置:

步骤
变量 槽位 5 上的 mapping(address => uint256) balances
0xABC...123
哈希输入 0xABC...1230x05 (总共 64 字节)
最终槽位 keccak256(input)0x8a3f7b2c9d...

逆向工程问题

在挖掘追踪数据时,我们只能看到这个最终槽位。由于哈希是单向函数,仅给出槽位哈希,用于计算它的键无法仅通过计算来恢复。

当我看到对槽位 0x8a3f7b2c9d... 的 SSTORE 时,我需要弄清楚哪个映射键产生了该哈希。我无法反转哈希本身。

解决方案:在运行时捕获原像

EVM 必须在运行时计算这些哈希。如果我正在追踪执行,我可以在它们发生时捕获它们。

当 EVM 执行 SHA3 时,我抓取:

  • 正在被哈希的内存区域(原像,包含键和基本槽位)
  • 生成的哈希(来自下一步的堆栈)

这给了我一个查找表:哈希 → 原像。当我看到一个对哈希槽的 SSTORE 时,我检查该表。产生该哈希的键就在那里。

编译时优化问题

这种方法效果很好,直到它失效。我遇到了一个追踪中没有 SHA3 的情况。该槽位显然是一个映射(巨大的哈希值),但找不到原像。

罪魁祸首:编译时优化。

address constant REWARD_TOKEN = 0xD533a949740bb3306d119CC777fa900bA034cd52;
mapping(address => uint256) rewards;

function setReward() external {
    rewards[REWARD_TOKEN] = 100;  // No runtime SHA3!
}
solidity
地址常量 REWARD_TOKEN = 0xD533a949740bb3306d119CC777fa900bA034cd52;
mapping(address => uint256) 奖励;

function setReward() external {
    奖励[REWARD_TOKEN] = 100; // 没有运行时 SHA3!
}

在某些情况下,找不到前面的 SHA3 操作码!这是优化时逻辑的结果,编译器看到一个常量用作映射键,它会预先计算哈希并将其嵌入到字节码中。在运行时:CODECOPY → SSTORE。这是编译器的一个很好的 gas 节省技巧,它迫使我们处理一个新的边界情况。

我的解决方案:解析源代码以查找常量地址,并自己预先计算它们的映射哈希。如果运行时捕获失败,则源派生的查找可以填补空白。


解码存储模式

Solidity 在 32 字节的槽位中隐藏了惊人的复杂性。以下是你必须处理才能正确解码存储的模式。

打包变量

小于 32 字节的变量可以共享一个槽位。一个 address (20 字节) + bool (1 字节) + uint32 (4 字节) 都可以放在一个槽位中。

这意味着一个单独的存储写入可以更改多个变量。你不能只解码“已更改的值”。你需要使用布局的偏移量和每个变量的大小信息,将整个槽位解码为它的组成部分。

动态字符串

Solidity 的字符串编码有一个让我绊倒的怪癖。

短字符串 (< 32 字节) 将内容和长度存储在同一个槽位中。最低字节保存 length * 2

长字符串 (>= 32 字节) 的工作方式完全不同。基本槽位存储 length * 2 + 1。实际内容位于 keccak256(baseSlot),可能跨越多个连续的槽位。

你必须在解码之前检测到正在使用哪种编码。我检查基本槽位值的最低位:如果已设置,则它是长字符串。

嵌套映射和结构体数组

这些复合类型是槽位解析变得真正复杂的地方。

对于 mapping(address => mapping(uint256 => Data))

  • 外部查找:keccak256(outerKey || baseSlot) → 中间槽位
  • 内部查找:keccak256(innerKey || intermediateSlot) → 最终槽位

你需要链接原像查找以恢复两个键。

结构体的动态数组增加了另一个维度:

  • 长度存储在基本槽位
  • 数据从 keccak256(baseSlot) 开始
  • 元素 N 位于 dataStart + N * slotsPerElement
  • 每个元素中的各个结构体字段偏移量

当我看到对槽位 0x8f3a... 的写入时,我可能需要追溯多个层级:“这是 proposals 数组中元素 7 内的偏移量 2,这意味着它是 votesFor 字段。”


代理检测

代理破坏了天真的存储解码器,因为布局属于实现,而不是代理。你正在查看的合约不是具有逻辑的合约。

三种代理模式

EIP-1167 最小代理 将实现地址直接嵌入到字节码中。模式 363d3d373d3d3d363d73 后跟 20 个字节是地址。无需存储读取。

EIP-1967 代理 将实现在一个标准槽位存储:0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc。读取该槽位,获取实现。

EIP-1822(较旧的 UUPS) 使用不同的槽位:0xc5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7

字节码缓存陷阱

我最初尝试按字节码哈希缓存存储布局。相同的字节码,相同的布局,对吧?

错误。对于代理来说。

EIP-1167 最小代理之所以有效,是因为实现地址位于字节码中。相同的字节码意味着相同的实现意味着相同的布局。

但是 EIP-1967 和 EIP-1822 代理都共享相同的字节码。实现地址位于存储中,而不是字节码中。不同的实例指向具有不同布局的不同实现。

我现在只对非代理和 EIP-1167 最小代理使用字节码缓存。


存储布局:它们来自哪里

存储布局不存在于链上。它们是编译时的人工产物。

EVM 只知道 32 字节的槽位和 32 字节的值。变量名、类型和结构体成员只存在于链下存在的编译器元数据中。

这意味着你需要经过验证的源代码才能有意义地解码存储。但并非所有“已验证”的合约都是相等的。

Sourcify 存储完整的编译器元数据,包括可用的存储布局。通常只有 Solidity。

Etherscan 存储源代码,但通常不存储布局。许多合同即使只是为了理解其存储也需要本地重新编译。

并且重新编译是脆弱的。使用错误的编译器版本,你会得到不同的槽位分配。错过优化器设置,打包会发生变化。在多文件项目中定位错误的合约,你会得到别人的布局。

精确重现编译器设置不是可选的。这是正确解码和完全胡说八道之间的区别。


边界情况

构造函数存储

合约创建交易需要特殊处理。EVM 在构造函数执行期间知道新合约的地址,但追踪数据不包含它。构造函数期间的每个 SSTORE 都缺少其目标地址,因此我们需要逆向工程归属。

顺序:

  • CREATE/CREATE2 操作码执行
  • 构造函数(initcode)运行,发生 SSTORE
  • 追踪数据在这些操作中省略了合约地址
  • 创建的地址仅在帧返回时出现在堆栈上

我通过扫描 CREATE/CREATE2 操作码来处理这个问题,然后跟踪调用深度何时返回到父级别。此时,创建的地址出现在堆栈上。我映射哪些步骤范围属于每个构造函数,以便我可以追溯地将这些 SSTORE 归属到正确的合约。

Vyper 差异

Vyper 使用不同的类型名称(HashMap 而不是 mapping),不同的打包规则,而且重要的是,不包括标准编译输出中的存储布局。我不得不使用一个实验性的编译器标志来获取布局信息。

字符串编码也不同。Vyper 将长度存储为原始字节数,而不是 Solidity 的 length * 2 + 1 编码。解码器需要知道哪个编译器生成了合约。


主要收获

编译器是真理的来源。 没有精确的编译器元数据,存储解码就是猜测。

需要多次追踪传递。 prestateTracer 为你提供了什么改变了,structLogs 为你提供了执行顺序和 SHA3 原像。单独使用任何一个都不够。

优化会产生盲点。 编译时哈希预计算会破坏天真的追踪分析。如果你的工具依赖于运行时行为,请注意编译时快捷方式。

存储比它看起来更复杂。 打包变量、动态编码、嵌套结构。简单的情况很简单,但边缘情况会复合。


我构建了什么

我构建了 SlotScan,一个应用这些技术来使 EVM 存储可读的工具。

粘贴任何经过验证的合约地址以查看其完整的存储布局和解码值。输入一个交易哈希以查看究竟是什么存储发生了变化,包括变化前/后的值和变量名。


结束语

这里的技术(SHA3 原像捕获、调用堆栈跟踪、多遍跟踪)适用于任何 EVM 存储分析工具。这些模式在 Solidity 和 Vyper、主网和 L2 中都是一致的。

如果你正在构建类似的东西:捕获 SHA3 原像,在运行时失败时解析源代码中的常量,跟踪 DELEGATECALL 的调用堆栈,并且不要为可升级代理缓存布局。

SlotScan 可在 slotscan.info 上找到。EVM 存储的意外复杂性将一个周末项目变成了一个更深层次的东西。希望这些笔记能帮助其他人在相同的地形中导航。

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

0 条评论

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