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

76075 bc medium proof executor unconditionally deletes destroyed and recreated accounts producing invalid state roots that block l1 finalization

#76075 [BC-Medium] Proof executor unconditionally deletes destroyed-and-recreated accounts, producing invalid state roots that block L1 finalization

Submitted on May 2nd 2026 at 14:49:44 UTC by @InfiniteSec for Audit Comp | Base Azul

  • Report ID: #76075

  • 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

Proof executor unconditionally deletes destroyed-and-recreated accounts, producing invalid state roots that block L1 finalization

Brief/Intro

Hi team,

Report 75288 (the ProofsStorage bug) was also submitted by me, so I'd like to clarify why this report should be treated as a separate finding rather than a duplicate.

These are two independent bugs in two completely separate codebases that require two separate fixes. Critically, fixing report 75288 does NOT fix this bug — the attack remains fully exploitable even after that patch is applied.

  • Report 75288 is a bug in base-execution-trie crate (store.rs:536-547). The store_trie_updates_for_block function's is_wiped() → wipe_storage() + continue skips persist_history_batch(), corrupting the ProofsStorage history database.

  • This report is a bug in base-proof-executor crate (db/mod.rs:180-183). The TrieDB::update_accounts function's was_destroyed() → delete + continue unconditionally removes DestroyedChanged accounts from the in-memory state trie, producing an incorrect state root.

The proof executor (TrieDB) has zero dependency on ProofsStorage — no import, no Cargo dependency, no shared data path. TrieDB operates entirely on its own in-memory Merkle Patricia Trie, independent of the MDBX history database. I have verified this by searching the entire crates/proof/executor/src/ directory: there is not a single reference to ProofsStorage, base-execution-trie, or any related type.

This means: even if report 75288 is fully patched (i.e., store_trie_updates_for_block correctly persists new storage slots after a wipe), the proof executor will still unconditionally delete DestroyedChanged accounts at db/mod.rs:180 and produce a wrong state_root. The same SELFDESTRUCT + re-CREATE2 attack transaction will still cause epilogue::validate() to fail with InvalidClaim, blocking L1 finalization.

These two bugs require two independent code changes in two different crates to fully remediate the issue. I respectfully request that this report be evaluated on its own merit as a separate finding.

Thank you.

The TrieDB::update_accounts function in the Base proof executor unconditionally deletes accounts from the state trie when was_destroyed() returns true, skipping the rebuild logic for accounts that were destroyed and recreated within the same block. An attacker only needs to submit ordinary user transactions triggering a CREATE2 + SELFDESTRUCT + re-CREATE2 pattern within a single L2 block. The proof executor then produces a state root that diverges from the canonical chain, causing all TEE and ZK proofs for affected blocks to fail epilogue validation with InvalidClaim, permanently blocking output finalization on L1 and delaying bridge withdrawals until the code is patched.

Vulnerability Details

The vulnerability is in the TrieDB::update_accounts function. This function iterates over all account changes in a BundleState and unconditionally deletes any account for which was_destroyed() returns true:

The continue statement causes all subsequent rebuild logic to be skipped entirely. This includes reading the new account_info, updating the storage trie, computing the storage_root, and reinserting the account into the state trie.

The problem is that revm's was_destroyed() method returns true not only for the Destroyed status but also for DestroyedChanged and DestroyedAgain. DestroyedChanged indicates that an account was destroyed and then recreated within the same block, which is a legitimate EVM state transition. The typical scenario is a SELFDESTRUCT followed by a CREATE2 at the same address within the same block. For DestroyedChanged accounts, the BundleAccount contains a valid account_info (Some(...) rather than None) and newly written storage entries, all of which should be persisted to the trie.

For comparison, the canonical reth implementation (HashedPostState::from_bundle_state) handles this case correctly: it sets hashed_account = account.info (not None) for DestroyedChanged accounts and creates a HashedStorage with wiped=true plus the new storage values. The trie computation then clears old storage before inserting the new state. The proof executor lacks this critical step.

The complete data flow is as follows: an attacker submits two transactions to the same L2 block via eth_sendRawTransaction (port 8545, no authentication required). The first transaction deploys a child contract via CREATE2 and SELFDESTRUCTs it within the same transaction (EIP-6780 permits SELFDESTRUCT for contracts created in the same transaction). The second transaction re-CREATE2s the contract at the same address and writes new storage. After the sequencer includes these transactions in a block, canonical execution correctly produces the right state root. When the proof pipeline processes this block, the proof executor calls trie_db.state_root(&bundle) which enters update_accounts, and at line 180 deletes the recreated account instead of updating it, producing an incorrect state root. The proof for this block can never pass epilogue validation (InvalidClaim), and finalization is permanently blocked.

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 proof executor is the core component inside both the TEE and ZK proof pipelines that computes state roots. TrieDB::state_root() directly producing incorrect results means every block containing a SELFDESTRUCT + re-CREATE2 pattern permanently fails proof verification. An attacker can continuously include such transactions in every L2 block at only gas cost, causing the proof pipeline to produce invalid proofs for each affected block. Both TEE and ZK proof pipelines use the same proof executor code path, so both proof types are affected. Inability to generate valid proofs means the AggregateVerifier on L1 cannot finalize the corresponding outputs, blocking bridge withdrawals that depend on finalized outputs.

References

  • Vulnerable code (was_destroyed → delete → continue): https://github.com/base/base/blob/v0.8.0-rc.24/crates/proof/executor/src/db/mod.rs#L180-L183

  • update_accounts full function: https://github.com/base/base/blob/v0.8.0-rc.24/crates/proof/executor/src/db/mod.rs#L164-L228

  • state_root entry point: https://github.com/base/base/blob/v0.8.0-rc.24/crates/proof/executor/src/builder/assemble.rs#L42

  • Epilogue validation (catches mismatch): https://github.com/base/base/blob/v0.8.0-rc.24/crates/proof/client/src/epilogue.rs#L25-L31

  • TEE proof path: https://github.com/base/base/blob/v0.8.0-rc.24/crates/proof/tee/nitro-enclave/src/server.rs#L154-L166

https://gist.github.com/link-infsec/d2bc57ed42582bbab5b0b29970c96cd7

Proof of Concept

The following end-to-end PoC starts a real Base L2 node, submits signed transactions via the Engine API to build blocks that trigger the SELFDESTRUCT + re-CREATE2 pattern, and then directly calls the real TrieDB::state_root() method (the production code path used by the proof executor to compute state roots), demonstrating that it produces an incorrect state root for DestroyedChanged accounts.

The PoC uses canyon_activated() to ensure SELFDESTRUCT operates with pre-Cancun semantics. After block execution completes, the PoC constructs a BundleState containing a DestroyedChanged status (matching what revm produces after block 2 execution), creates a TrieDB<NoopTrieDBProvider, NoopTrieHinter> instance, populates the initial trie state via state_root(&initial_bundle), and then calls state_root(&destroyed_changed_bundle). This call directly hits the was_destroyed() → delete → continue branch at db/mod.rs:180, which deletes the DestroyedChanged account entirely from the trie and returns EMPTY_ROOT_HASH.

To run:

Setup: place the PoC file at crates/execution/node/tests/it/poc_proof_executor_destroyed_changed.rs, then register the test module in crates/execution/node/tests/it/main.rs:

All required dependencies (base-proof-executor, base-proof-mpt, alloy-trie, alloy-rlp, alloy-consensus) are already present in crates/execution/node/Cargo.toml. No additional dependency changes are needed.

Full PoC source code (`crates/execution/node/tests/it/poc_proof_executor_destroyed_changed.rs`)

Output confirming the vulnerability:

The test starts a real Base L2 node (chain_id=8453) using NodeBuilder + EngineNodeLauncher with the full execution layer. Blocks 1 and 2 are advanced by injecting signed transactions via rpc.inject_tx through the Engine API. Real execution confirms the child contract undergoes SELFDESTRUCT + re-CREATE2 in block 2, producing DestroyedChanged status. The PoC then directly calls the real TrieDB::state_root() method (production code exported by the base_proof_executor crate), which internally calls update_accounts() (db/mod.rs:164) and hits the was_destroyed() → delete → continue branch at line 180, deleting the recreated account entirely from the trie. state_root() returns EMPTY_ROOT_HASH (0x56e8...b421), while the correct state root should be 0xebfd...62b4 (account preserved with new storage). All 3 assertions pass, confirming that the real proof executor code path produces an incorrect state root that will cause epilogue::validate() to throw an InvalidClaim error.

Was this helpful?