For the complete documentation index, see llms.txt. This page is also available as Markdown.

75527 bc medium proofsstorage silently drops newly written storage slots for destroyed and recreated accounts breaking proof generation

Submitted on Apr 29th 2026 at 16:42:00 UTC by @InfiniteSec for Audit Comp | Base Azul

  • Report ID: #75527

  • Report Type: Blockchain/DLT

  • Report severity: Medium

  • Target: https://github.com/base/base/tree/v0.8.0-rc.28

  • Impacts:

    • 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 via BaseProofsStateProviderRef, 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-547
for (hashed_address, storage) in sorted_post_state.storages {
    if append_mode && storage.is_wiped() {
        let mut ro = self.storage_hashed_cursor(hashed_address, block_number - 1)?;
        let keys =
            self.wipe_storage(tx, block_number, hashed_address, || Ok(ro.next()?))?;
        hashed_storage_keys.extend(keys);
        // Skip any further processing for this hashed_address
        continue;   // <-- skips persistence of new slots
    }
    let keys = 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.

The PoC demonstrates the full end-to-end impact chain in 9 phases:

1

Launch a real Base node + ProofsExEx + HTTP RPC

Launches a real Base node with BaseProofsExEx execution extension and an HTTP JSON-RPC endpoint.

2

Send attack transactions

Sends attack transactions via eth_sendRawTransaction HTTP calls.

3

Advance blocks

Advances blocks via Engine API, simulating op-node CL driving block production.

4

Wait for ExEx sync

Waits for the ExEx async sync loop to process both blocks.

5

Query canonical state

Queries canonical state via eth_getStorageAt HTTP call: slot[1] = 0xCAFE (correct).

6

Query ProofsStorage

Queries ProofsStorage showing slot[1] = None — data lost.

7

Re-execute the block

Re-executes block 2 against ProofsStorage as state backend — state_root diverges from the canonical header.

8

Generate and verify proof

Generates Merkle account proof from ProofsStorage via BaseProofsStateProviderRef::proof() and calls AccountProof::verify(canonical_state_root) — verification fails, proving proofs are invalid.

9

Compute OutputRoot

Computes OutputRoot using the OP Stack standard construction — canonical and corrupted OutputRoots diverge, proving AggregateVerifier on L1 would reject the output, blocking finalization and freezing bridge withdrawals.

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.

References

  • Vulnerable code store_trie_updates_for_block wipe+continue: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/db/store.rs#L536-L547

  • Storage trie node wipe+continue: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/db/store.rs#L507-L520

  • store_trie_updates_append_only hardcoded append_mode=true: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/db/store.rs#L590-L591

  • BaseProofsStateProviderRef::storage reads from corrupted history: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/provider.rs#L201-L217

  • LiveTrieCollector::execute_and_store_block_updates production entry point: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/live.rs#L52-L136

  • LiveTrieCollector creates BaseProofsStateProviderRef for block execution: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/live.rs#L85-L89

  • BaseProofsExEx installs LiveTrieCollector in sync loop: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/exex/src/lib.rs#L312-L326

  • BaseProofsExEx process_block calls execute_and_store_block_updates: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/exex/src/lib.rs#L525

  • ChangeSet construction logic: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/db/store.rs#L560-L565

  • fetch_trie_updates reads history: https://github.com/base/base/blob/v0.8.0-rc.24/crates/execution/trie/src/db/store.rs#L705

Proof of Concept

The following end-to-end PoC launches a real Base node (with BaseProofsExEx execution extension and HTTP JSON-RPC endpoint), sends attack transactions via standard eth_sendRawTransaction HTTP calls, advances blocks via Engine API, waits for the async ExEx pipeline to process blocks, then queries canonical state via eth_getStorageAt HTTP calls and compares with corrupted ProofsStorage internal state. It then re-executes the block to prove state_root divergence, generates Merkle proofs and verifies they are invalid, and computes OutputRoot to prove L1 finalization would be blocked. The test covers the complete attack chain from user transaction submission through to invalid proof generation and L1 output rejection:

1

Launch real Base node + ProofsExEx + HTTP RPC

NodeBuilder + EngineNodeLauncher + RpcServerArgs

2

Send attack transactions via eth_sendRawTransaction HTTP calls

3

Advance blocks via Engine API

(simulating op-node CL driving block production)

4

Wait for ExEx async sync loop

to process both blocks

5

Query canonical state via eth_getStorageAt HTTP call

slot[1] = 0xCAFE (correct)

6

Query ProofsStorage

slot[1] = None (dropped by bug)

7

Re-execute block 2 with ProofsStorage as state backend

state_root diverges from canonical header

8

Generate Merkle account proof from ProofsStorage

BaseProofsStateProviderRef::proof(), same code path as eth_getProof RPC

9

Compute corrupted OutputRoot

keccak256(version || wrong_state_root || bridge_storage_root || block_hash) diverges from canonical OutputRoot

To run:

Full PoC source code (located at crates/execution/node/tests/it/poc_wipe_storage_loss_e2e.rs):

Output (node starts at http://127.0.0.1:34711/, all transactions sent via HTTP JSON-RPC):

The PoC launches a real Base node (NodeBuilder + EngineNodeLauncher + BaseProofsExEx) with an HTTP JSON-RPC endpoint on a random port. Attack transactions are sent via standard eth_sendRawTransaction HTTP calls to the node's transaction pool (tx hashes visible in Phase 2-3 output), processed through the full OP Stack transaction pipeline (including L1 info deposit transactions), and packaged into blocks by the node's payload builder. When the ExEx async sync loop receives new block notifications, it calls store_trie_updates() to persist the trie diff to MdbxProofsStorage, triggering the wipe+continue bug at store.rs:536-547 which drops the slot[1]=0xCAFE written by the recreated child contract.

Phase 5 queries canonical on-chain state via standard eth_getStorageAt HTTP calls, returning the correct 0xCAFE. Phase 6 directly queries ProofsStorage (the exact same backend used by proof generation pipelines), which returns None — the slot value is lost. Phase 7 re-executes block 2 with ProofsStorage as the state backend (BaseProofsStateProviderRef, identical to live.rs:85-89), using the node's own evm_config — the computed state_root diverges. Phase 8 calls BaseProofsStateProviderRef::proof() (the exact same code path as the eth_getProof RPC endpoint at proofs.rs:70-76) to generate a Merkle Patricia Trie account proof from corrupted ProofsStorage, then calls AccountProof::verify(canonical_state_root) — verification fails, proving that proofs generated by TEE/ZK provers from corrupted ProofsStorage are invalid. Phase 9 computes the OutputRoot using the OP Stack standard construction (keccak256(version || state_root || bridge_storage_root || block_hash), identical to output_root.rs:45-52) for both canonical and corrupted state roots — the OutputRoots diverge, proving that AggregateVerifier on L1 would reject the output, blocking finalization and freezing bridge withdrawals.

Was this helpful?