早期拒绝对抗性 BAL

本文为机器翻译
展示原文

早期拒绝对抗性 BAL

特别感谢AnsgarMariusFrancescoCarlMariaJochem的贡献与合作。

EIP-7928 (块级访问列表,BAL)通过要求构建者声明块执行期间访问的所有状态位置及其状态更改,实现了块验证的并行执行和批量 I/O。这使得客户端能够预取状态并并行执行事务。

然而,当前的BAL规范可能会引入验证不对称性,这种不对称性可能会被利用来强制执行不必要的块验证工作。这虽然不会破坏执行并行化,但会抵消批量 I/O 预取的优势,同时还会显著增加带宽和执行成本。

简而言之:无效区块的失效速度可能不及有效区块(最坏情况下的区块)的验证速度。这阻碍了我们的扩展能力。客户端可以通过简单的 gas 预算检查来缓解这个问题。针对 EIP 的 PR 草稿可以在这里找到。

为了理解这个问题及其解决方案,让我们先来了解一下 BAL 的工作原理。

背景: BAL结构

BAL是按账户组织的:

[address,storage_changes, # slot → [(block_access_index, post_value)] storage_reads, # [slot] balance_changes,nonce_changes,code_changes]

BAL包含两种不同类型的信息:

  1. 事务后状态差异写入操作会用block_access_index进行注释,因此我们可以准确地知道哪些事务更改了状态的特定部分。

  2. 区块后状态位置——读取操作storage_reads和无*_changes地址)映射到事务索引。我们知道它们在The Block中的某个位置被访问过,但不知道是由哪个事务访问的。

为什么读取操作要省略事务索引?这样可以节省带宽。当一个事务同时读取和写入一个存储槽时,`storage_changes` 表中的写入条目会包含读取条目,从而避免重复。由于并行化只需要知道访问了哪些状态位置(不需要知道何时访问),因此事务索引是不必要的开销。

这种不对称性对于理解漏洞至关重要:写入操作可以及早验证,但读取操作则不能。

使用 BAL 进行块验证

收到数据块后,客户端使用BAL来执行以下操作:

  • 并行执行事务,同时延迟完整的BAL验证,直到事务执行完毕。
  • 通过批量 I/O预取所需状态以减少磁盘延迟
  • 与执行并行计算后状态根

在 BAL 出现之前,区块验证非常简单:最坏的情况就是区块消耗掉所有可用 gas。忽略有效性不会让情况变得更糟——一旦 gas 达到上限,客户端就会中止交易并将The Block标记为无效。但有了 BAL,情况就不同了:我们必须确保有效区块的最坏情况执行时间不比无效区块更糟。

哪些内容可以早期验证

事务执行期间可以强制执行事务后状态差异验证。由于写入操作映射到事务索引,因此可以根据预期状态变化独立验证每个事务。

截图日期:2026-01-30 08-41-01
屏幕截图,拍摄于 2026-01-30 08-41-01,尺寸 941×427,大小 47.7 KB

哪些事情无法早期验证

在单个交易执行期间,无法验证区块后状态的位置

事务x读取存储槽可能被事务y写入。在汇总最终BAL时,写入操作会消耗读取操作(该存储槽仅出现在storage_changes中,而不出现storage_reads中)。存储槽最终是否被声明为读取操作取决于所有事务的写入操作总数。

因此,验证已声明的读取操作需要先执行所有事务。

案例研究:盖斯

例如, Geth使用多个工作 goroutine 并行执行事务并验证其状态差异。对于状态位置(读取)操作,验证工作被延迟到ResultHandler goroutine 中,该 goroutine 会在所有事务完成后聚合结果。

截图日期:2026-01-29 18-18-41
屏幕截图,日期:2026年1月29日 18:18:41,分辨率:1014×682,大小:58.8 KB

实际操作中:

  • 工作节点独立执行事务并验证状态差异。
  • 结果流式传输到ResultHandler收集已访问的存储槽。
  • 只有在所有事务完成后,处理程序才能验证声明的读取操作与实际访问操作是否一致。

这种延迟验证会造成攻击机会。

袭击

现在让我们来看看恶意区块生成器可能会利用什么漏洞。

设置

让:

  • G G = 阻塞气体极限
  • G_{\mathrm{tx}} G t x = 最大单笔交易 gas 限制(≈ 2^{24} 2 24,根据EIP-7825
  • g_{\mathrm{sload}} = 2100 g s l o a d = 2100 = 冷态SLOAD的最低气体成本

攻击构造

对抗性提议者分两步构建区块:

步骤 1:声明虚拟存储读取

storage_reads中声明一个存储槽集合S

|S| = \left\lfloor \frac{G - G_{\mathrm{tx}}}{g_{\mathrm{sload}}} \right\rfloor
| S | = G G t x g s l o a d

如果将所有剩余的 gas(在预留一个最大 gas 交易后)用于SLOADs ,则该The Block可以容纳的最大不同冷存储读取次数。

步骤 2:包含仅计算交易

包含一个或多个满足以下条件的交易:

  • 气体使用量 ≈ G_{\mathrm{tx}} G t x
  • S S中没有任何插槽可供访问
  • 纯计算(例如,算术循环、哈希)

生成的BAL在语法上有效,并且符合所有 gas 限制,但是声明的存储读取在执行期间从未被访问。

为什么这会造成问题

客户端会在并行执行交易的同时开始预取数据。从任何单个交易的角度来看,它们无法在所有交易执行完毕之前使The Block失效。

结果:一个无效区块完全停用了有用的 I/O 预取功能,使BAL膨胀到数百 KiB(在 60M 区块 gas 限制下为 0.6 MiB),并加重了网络负担——而此时区块的失效却为时已晚。

截图日期:2026-01-30 11-07-55
屏幕截图,日期:2026年1月30日 11:07:55,分辨率:1228×421,大小:39.8 KB

这实际上意味着我们无法像最坏情况下的有效块那样扩展,而是受到无效块的限制。

为什么不直接过度声明读取操作呢?如果攻击者声明了\left\lfloor \frac{G}{g_{\mathrm{sload}}} \right\rfloor G g s l o a d如果使用全部gas 预算,客户端可以在任何交易因未访问存储的操作而浪费 gas 时立即使The BlockThe Block。

不妨了解一下BALrog ,它是 Engine API 的一个简单代理,可以将最坏情况下的 BAL 注入到代码块中,这对于测试非常有用。

解决方案:燃气预算可行性检查

此次攻击利用了两个事实:

  1. 各个帖子之间并不知道其他帖子发生了什么。
  2. 只有在执行完所有交易后,状态位置才能得到验证。

但是,我们可以利用一个关键约束:首次访问存储槽至少需要花费冷SLOAD成本(2100 gas,或使用EIP-2930访问列表时为 2000 gas,最终值取决于EIP-7981 )。

不变式

在执行过程中,客户端会定期(例如,每 8 笔交易)进行验证:

  • 已经使用了多少天然气
  • 已访问哪些已声明的存储槽
  • 还剩下多少已声明的读取次数

然后,令:

  • R_{\mathrm{declared}} R d e c l a r e d = BAL中已声明状态读取的数量
  • R_{\mathrm{seen}} R s e e n = 已访问次数
  • R_{\mathrm{剩余}} = R_{\mathrm{声明}} - R_{\mathrm{已见} } R r e m a i n i n g = R d e c la r e d R s e e n
  • G_{\mathrm{剩余}} G r e m a i n i n g = 剩余块气体

BAL有效性的必要条件是:

G_{\mathrm{剩余}} \ge R_{\mathrm{剩余}} \cdot 2100
GREMAING RMAING 2100

如果此不等式不成立,则可以立即拒绝The Block。

截图日期:2026-01-30 07-33-54
屏幕截图,拍摄于 2026-01-30 07-33-54,分辨率 1014×682,大小 65.1 KB

请点击此处查看该规范的 PR 草案。

好处

gas 预算检查只需一次最大 gas 交易(约 1600 万 gas / 约 1 秒)即可尽早拒绝恶意区块。BAL执行保持不变,保留了所有并行化优势。BAL 格式无需更改,有效区块的批量 I/O 功能也已完全恢复。实现复杂度极低——只需在现有结果处理程序中进行简单的记账——且周期性算术检查带来的性能开销几乎可以忽略不计。

考虑过的替代方案

另一种方法是使用首次访问事务索引来注释只读访问,这样可以使 BAL 具有自描述性并简化验证逻辑。gas 预算方法也能实现类似的早期拒绝特性,但它是在执行期间进行额外的计费,而不是在BAL中添加额外的数据。将索引映射到读取操作平均会向BAL中添加 4% 的额外(压缩)数据。

另一种方法是按照状态在The Block中被访问的先后顺序对BAL中的状态位置进行排序。然而,这样做会引入额外的复杂性,并且会使针对BAL进行验证变得更加困难。

gas 预算可行性检查提供了一种简单有效的缓解措施:通过验证剩余的 gas 是否足以覆盖剩余的已声明读取,客户端可以提前拒绝恶意块,而无需更改BAL格式或牺牲并行化优势。

示例结果处理程序逻辑

MIN_GAS_PER_READ = 2100 # cold SLOAD cost CHECK_EVERY_N_TXS = 8 def result_handler ( block, bal, tx_results_channel ): # Count expected storage reads from the BAL (once, upfront) expected_reads = sum ( len (acc.storage_reads) for acc in bal.accounts)accessed_slots = set ()total_gas_used = 0 for i, result in enumerate (tx_results_channel, start= 1 ): # Merge this transaction's accessed slots accessed_slots.update(result.accessed_slots)total_gas_used += result.gas_used # Periodic feasibility check if i % CHECK_EVERY_N_TXS == 0 :remaining_gas = block.gas_limit - total_gas_usedunaccessed_reads = expected_reads - len (accessed_slots)min_gas_needed = unaccessed_reads * MIN_GAS_PER_READ if min_gas_needed > remaining_gas: raise Exception( "BAL infeasible: " f" {unaccessed_reads} reads need {min_gas_needed} gas, " f"only {remaining_gas} left" )

资源


相关赛道:
来源
免责声明:以上内容仅为作者观点,不代表Followin的任何立场,不构成与Followin相关的任何投资建议。
喜欢
84
收藏
14
评论