早期拒絕對抗性 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








