找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1583

积分

0

好友

228

主题
发表于 6 天前 | 查看: 18| 回复: 0

登链社区

表面上看,以太坊的存储很简单:32字节的槽位对应着32字节的值。然而,真正有趣且富有挑战性的是,如何将这些抽象的数字槽位映射回有意义的变量名。特别是当你的目标不仅仅是生成一个简单的存储布局,而是要逆向工程交易追踪中的每一次 SSTORE 操作。这正是我开发SlotScan.info项目的初衷,也是本文所要分享的技术经验背景。

image-20240930222847819.png示例:SlotScan.info 中的交易视图
image-20240930222847819.png示例:SlotScan.info 中的布局视图

背景

EVM(以太坊虚拟机)本身没有变量名、类型或结构体成员的概念,这些信息只存在于链下的编译器元数据中。由于映射(Mapping)的存储槽位是通过keccak256(key || baseSlot)这样的单向哈希函数计算得出的,理论上,仅凭一个槽位哈希值是无法反推出其原始键(key)的。

本文将详细探讨我在逆向工程 EVM 存储过程中学习和应用的关键技术,包括:如何在运行时捕获哈希原像、解码复杂的存储模式、通过 DELEGATECALL 链追踪交易,以及如何准确检测代理合约。

追踪交易

为了解码一笔交易中的所有存储变化,我需要获取三方面信息:

  1. 发生了什么改变:哪些存储槽位发生了变化,变化前和变化后的值是什么。
  2. 映射键(Key)是什么:生成哈希槽位的原始输入(原像)。
  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 通过 DELEGATECALL 调用合约 B 时,B 的代码得以执行,但存储写入的目标却是 A 的存储空间。因此,正确归属存储变化的地址需要格外小心。

structLogs 数据不包含每个执行步骤的地址字段,所以我需要手动跟踪调用堆栈。对于 DELEGATECALL,存储上下文(地址)保持不变,只有代码上下文发生变化。而对于常规 CALL,两者都会切换。通过跟踪这些规则,我可以将每个 SSTORE 操作归属到正确的合约地址。

prestateTracer 的输出可以作为验证依据。它明确知道哪个合约的哪些槽位发生了变化,因此我可以交叉检查我的归属逻辑是否正确。

解析映射键(Mapping Key)

前面提到的 SHA3 原像捕获技术值得深入探讨。映射的存储槽位计算公式为keccak256(key || baseSlot)。例如,对于位于槽位 5 的映射balances[0xABC...],EVM 会将地址键与槽位号连接后进行哈希计算,以生成最终的存储位置:

步骤 说明
变量 mapping(address => uint256) balances 位于槽位 5
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; // 没有运行时 SHA3!
}

在某些情况下,你找不到前面的 SHA3 操作码!这是编译器优化逻辑的结果:当编译器识别到映射键是一个常量时,它会预先计算好哈希值,并将其作为常量直接嵌入到合约字节码中。在运行时,过程简化为:CODECOPY → SSTORE。这是编译器一个优秀的 Gas 节约技巧,但也迫使我们处理这个新的边界情况。

我的解决方案是:解析源代码以查找常量地址,并自己预先计算它们的映射哈希值。当运行时捕获失败时,这个源自源代码的查找表可以填补空白。你可以使用 Python 脚本或类似工具来自动化这一过程。

解码存储模式

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

打包变量

小于 32 字节的变量可以共享同一个存储槽位。例如,一个address (20 字节) + 一个bool (1 字节) + 一个uint32 (4 字节) 可以全部打包进一个槽位。

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

动态字符串

Solidity 的字符串编码有一个容易让人出错的特性。

  • 短字符串 (< 32 字节):内容和长度存储在同一个槽位中。最低有效字节存储的是长度 * 2
  • 长字符串 (>= 32 字节):工作方式完全不同。基础槽位存储长度 * 2 + 1。实际内容则从keccak256(baseSlot)开始,可能跨越多个连续的槽位。

在解码之前,你必须检测正在使用的是哪种编码方式。我的方法是检查基础槽位值的最低位:如果该位被置为 1,则表明这是一个长字符串。

嵌套映射和结构体数组

这些复合类型使得槽位解析变得真正复杂。

对于mapping(address => mapping(uint256 => Data))这样的嵌套映射:

  • 外层查找:keccak256(outerKey || baseSlot) → 得到一个中间槽位。
  • 内层查找:keccak256(innerKey || intermediateSlot) → 得到最终的存储槽位。

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

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

  • 数组长度存储在基础槽位。
  • 数据从keccak256(baseSlot)开始。
  • 第 N 个元素位于dataStart + N * slotsPerElement
  • 每个元素内部,还需根据结构体字段的偏移量进行定位。

因此,当我看到对槽位0x8f3a...的写入时,我可能需要回溯多个层级:“这是proposals数组中第 7 个元素内的偏移量 2,意味着它是votesFor字段。” 理解这种复杂的 数据结构 关系是逆向工程的关键。

代理检测

代理合约会破坏简单的存储解码逻辑,因为实际的存储布局属于实现合约(Implementation),而不是代理合约本身。你正在查看的合约(代理)并不是包含业务逻辑的合约。

三种常见的代理模式
  1. EIP-1167 最小代理:将实现合约地址直接嵌入到代理合约的字节码中。字节码模式363d3d373d3d3d363d73后面紧跟的20个字节就是地址。无需读取存储。
  2. EIP-1967 代理:将实现合约地址存储在一个标准化的槽位中:0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc。读取该槽位即可获得地址。
  3. 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 的长度 * 2 + 1编码。解码器需要知道合约是由哪个编译器生成的。

主要收获

  • 编译器是终极真相源:没有精确的编译器元数据,存储解码就如同盲人摸象。
  • 需要多遍追踪分析:prestateTracer 告诉你什么改变了,structLogs 告诉你如何以及以何顺序改变的。单独使用任何一个都是不够的。
  • 优化会产生盲点:编译时的哈希预计算会破坏依赖运行时行为的追踪分析。如果你的工具依赖于运行时捕获,请留意这些编译时的“捷径”。
  • 存储远比看起来复杂:变量打包、动态编码、嵌套结构体。简单情况很简单,但边界情况会相互叠加,使问题复杂化。

结束语

本文介绍的技术(SHA3 原像捕获、调用堆栈跟踪、多遍追踪分析)适用于任何 EVM 存储分析工具。这些模式在 Solidity 和 Vyper、主网和各 Layer 2 网络上都是一致的。

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

探索 EVM 存储中意想不到的复杂性,将一个周末项目变成了对区块链底层更深层次的钻研。希望这些经验能帮助其他开发者在相同的技术领域中更好地导航。




上一篇:技术人副业指南:如何构建稳定收入的自动化业务系统
下一篇:智能合约审计前技术准备清单:优化测试与文档以节省30%成本
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-24 19:21 , Processed in 0.285239 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表