Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours beyond standard difficulty adjustments
Description
Brief/Intro
The store_trie_updates_for_block function in ProofsStorage silently discards all newly written storage slot values for accounts that were destroyed and recreated within the same L2 block. An attacker only needs to submit a single transaction via eth_sendRawTransaction containing a SELFDESTRUCT followed by a CREATE2 to the same address. This causes the address to be incorrectly persisted as "wiped" rather than "wiped then recreated with new state" in the ProofsStorage historical database. Since all TEE and ZK proof generation pipelines rely on this historical state, proofs for affected blocks will produce invalid results, blocking output finalization on L1 and delaying bridge withdrawals until operators manually rebuild the ProofsStorage database.
Vulnerability Details
When a contract is destroyed via SELFDESTRUCT and then recreated via CREATE2 at the same address within the same block, the EVM execution engine produces an AccountStatus::DestroyedChanged status. The upstream HashedStorage::from_plain_storage correctly represents this as HashedStorage{wiped: true, storage: <new_slots>}, where wiped reflects the destruction semantics and the storage map contains the new slot values written by the recreated contract.
The problem occurs in the store_trie_updates_for_block function. When iterating over the sorted HashedPostState storages, the function writes a tombstone for wiped accounts and then immediately continues, completely skipping the persist_history_batch call that should persist the new slot values:
// crates/execution/trie/src/db/store.rs:536-547for(hashed_address,storage)insorted_post_state.storages {ifappend_mode&&storage.is_wiped(){letmutro=self.storage_hashed_cursor(hashed_address,block_number-1)?;letkeys=self.wipe_storage(tx,block_number,hashed_address,||Ok(ro.next()?))?;hashed_storage_keys.extend(keys);// Skip any further processing for this hashed_addresscontinue;// <-- skips persistence of new slots}letkeys=self.persist_history_batch(tx,block_number,storage.storage_slots_ref().iter().map(|(key,val)|(hashed_address,*key,Some(StorageValue(*val)))),append_mode,)?;hashed_storage_keys.extend(keys);}
The same pattern also appears in the storage trie node processing loop (store.rs lines 507-520), where nodes.is_deleted && append_mode triggers a wipe followed by continue, also skipping persistence of new trie nodes.
The append_mode parameter is always true in the production code path. store_trie_updates_append_only calls the function with a hardcoded true:
The complete data flow is as follows: a user submits a transaction triggering SELFDESTRUCT + CREATE2 through the EL JSON-RPC port 8545. After the sequencer includes it in an L2 block, the execution engine produces a BundleState containing AccountStatus::DestroyedChanged. LiveTrieCollector executes the block and generates a HashedPostState, verifies the in-memory state root (which is correct at this point), then calls store_trie_updates to persist the diff. This is where the bug triggers and the new slot values are dropped.
The skipped slot keys never enter the ChangeSet returned by the function, so fetch_trie_updates can never recover the lost data. unwind_history and replace_updates also depend on the same BlockChangeSet and have no self-healing path. The downstream BaseProofsStateProviderRef::storage reads directly from the corrupted HashedStorageHistory and returns None for lost keys, propagating incorrect historical state to all consumers generating state roots, storage proofs, and witnesses.
Impact Details
This vulnerability falls under the Blockchain/DLT category, corresponding to Immunefi v2.3 High severity: "Temporary freezing of network transactions by delaying one block by 500% or more of the average block time of the preceding 24 hours."
The corrupted historical state in ProofsStorage causes BaseProofsStateProviderRef to return incorrect storage values for affected accounts at affected block heights. Every TEE and ZK proof generation request covering the affected block reads from the corrupted historical state and produces an invalid proof. Invalid proofs cannot pass AggregateVerifier validation on L1 to finalize disputed outputs, which blocks bridge withdrawals that depend on finalized outputs.
The attack cost is extremely low, requiring only the gas fee for a single SELFDESTRUCT + CREATE2 transaction on L2 (well under 0.01 ETH). An attacker can repeat this operation in consecutive blocks to continuously pollute the proof window, delaying finalization for hours or even days until operators manually rebuild the ProofsStorage database from canonical chain state. All nodes running the ProofsStorage ExEx are affected, including validator nodes and proof infrastructure.
LiveTrieCollector block execution and persistence entry point: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/live.rs#L94-L119
Proof of Concept
The following end-to-end PoC launches a real Base L2 node, builds blocks via the Engine API, and injects signed transactions to fully demonstrate the attack chain. The PoC creates a child contract via CREATE2 and writes storage in block 1, then SELFDESTRUCTs the child and re-CREATE2s it with new storage in block 2. It then constructs the corresponding HashedPostState (wiped=true + new slot) for block 2, feeds it into MdbxProofsStorage::store_trie_updates, and verifies that the new slot is dropped due to the wipe+continue bug.
To run:
Full PoC source code (located at crates/execution/node/tests/it/poc_wipe_storage_loss.rs):
Output confirming the vulnerability:
The test launches a real Base L2 node (chain_id=8453) using NodeBuilder + EngineNodeLauncher with a full execution layer. Block 1 injects a CREATE2 transaction via rpc.inject_tx, and block 2 injects both a SELFDESTRUCT and a re-CREATE2 transaction. The node advances blocks through the Engine API via engine_forkchoiceUpdated and engine_newPayload. After real execution produces the DestroyedChanged state, the corresponding HashedPostState (wiped=true + new slot[1]=0xCAFE) is fed into MdbxProofsStorage::store_trie_updates. Verification shows that slot[1] is completely absent from ProofsStorage (MISSING), while block 1's slot[0]=0xBEEF (48879) is stored correctly. This confirms that the wipe_storage() + continue statement causes permanent loss of new storage values.