早期拒绝对抗性 BAL
特别感谢Ansgar 、 Marius 、 Francesco 、 Carl 、 Maria和Jochem的贡献与合作。
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包含两种不同类型的信息:
事务后状态差异—写入操作会用
block_access_index进行注释,因此我们可以准确地知道哪些事务更改了状态的特定部分。区块后状态位置——读取操作(
storage_reads和无*_changes地址)未映射到事务索引。我们知道它们在The Block中的某个位置被访问过,但不知道是由哪个事务访问的。
为什么读取操作要省略事务索引?这样可以节省带宽。当一个事务同时读取和写入一个存储槽时,`storage_changes` 表中的写入条目会包含读取条目,从而避免重复。由于并行化只需要知道访问了哪些状态位置(不需要知道何时访问),因此事务索引是不必要的开销。
这种不对称性对于理解漏洞至关重要:写入操作可以及早验证,但读取操作则不能。
使用 BAL 进行块验证
收到数据块后,客户端使用BAL来执行以下操作:
- 并行执行事务,同时延迟完整的BAL验证,直到事务执行完毕。
- 通过批量 I/O预取所需状态以减少磁盘延迟
- 与执行并行计算后状态根
在 BAL 出现之前,区块验证非常简单:最坏的情况就是区块消耗掉所有可用 gas。忽略有效性不会让情况变得更糟——一旦 gas 达到上限,客户端就会中止交易并将The Block标记为无效。但有了 BAL,情况就不同了:我们必须确保有效区块的最坏情况执行时间不比无效区块更糟。
哪些内容可以早期验证
事务执行期间可以强制执行事务后状态差异验证。由于写入操作映射到事务索引,因此可以根据预期状态变化独立验证每个事务。
哪些事情无法早期验证
在单个交易执行期间,无法验证区块后状态的位置。
事务x读取的存储槽可能被事务y写入。在汇总最终BAL时,写入操作会消耗读取操作(该存储槽仅出现在storage_changes中,而不出现storage_reads中)。存储槽最终是否被声明为读取操作取决于所有事务的写入操作总数。
因此,验证已声明的读取操作需要先执行所有事务。
案例研究:盖斯
例如, Geth使用多个工作 goroutine 并行执行事务并验证其状态差异。对于状态位置(读取)操作,验证工作被延迟到ResultHandler goroutine 中,该 goroutine 会在所有事务完成后聚合结果。
实际操作中:
- 工作节点独立执行事务并验证状态差异。
- 结果流式传输到
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 :
如果将所有剩余的 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),并加重了网络负担——而此时区块的失效却为时已晚。
这实际上意味着我们无法像最坏情况下的有效块那样扩展,而是受到无效块的限制。
为什么不直接过度声明读取操作呢?如果攻击者声明了\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 注入到代码块中,这对于测试非常有用。
解决方案:燃气预算可行性检查
此次攻击利用了两个事实:
- 各个帖子之间并不知道其他帖子发生了什么。
- 只有在执行完所有交易后,状态位置才能得到验证。
但是,我们可以利用一个关键约束:首次访问存储槽至少需要花费冷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有效性的必要条件是:
如果此不等式不成立,则可以立即拒绝The Block。
请点击此处查看该规范的 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" )资源
- EIP公关: https://github.com/ethereum/EIPs/pull/11223
- 最大BAL物品检查: https://github.com/ethereum/execution-specs/pull/2109
- BAL物品上限(EIP) : https://github.com/ethereum/EIPs/pull/11234
- 引擎 API 代理(恶意BAL测试) : https://github.com/nerolation/balrog








