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

75950 bc medium destroyedchanged accounts make proof execution diverge from canonical l2 state or halt

Submitted on May 1st 2026 at 21:14:39 UTC by @adeolu for Audit Comp | Base Azul

  • Report ID: #75950

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk

Description

Summary

A valid canonical Base block can contain an account that is destroyed and then recreated, but the proof executor writes back the post-block state incorrectly.

The stateless proof executor applies revm::BundleState updates to a Merkle Patricia Trie in TrieDB::update_accounts. It currently uses:

if bundle_account.was_destroyed() {
    self.root_node.delete(&account_path, &self.fetcher)?;
    self.storage_roots.remove(address);
    continue;
}

This treats every account that was destroyed at any point during the block as absent at the end of the block.

That is not what revm's DestroyedChanged status means. DestroyedChanged means the account was destroyed and then modified or recreated. Canonical state application must wipe old storage, but if final account info exists, it must still write the final recreated account back into the state trie.

Concretely:

  1. Canonical execution says the account is alive at the end of the block.

  2. revm marks it as DestroyedChanged.

  3. TrieDB::update_accounts treats it as final deletion.

  4. The proof executor either deletes the account and computes the wrong state root if the account existed in parent state, or errors with KeyNotFound if the account did not exist in parent state.

The clean impact is:

That means the proof path can derive a proof-world state where a live recreated contract/account is missing. The block is valid on Base, but the proof executor cannot faithfully recompute its state/output root.

The PoC demonstrates two variants:

Vulnerability Detail

1

DestroyedChanged is a final-present state, not a final deletion

revm uses DestroyedChanged for an account that was destroyed and then modified or recreated. was_destroyed() returns true for Destroyed, DestroyedAgain, and DestroyedChanged because old storage must be treated as wiped.

That predicate is correct for deciding whether to wipe storage. It is not sufficient for deciding whether the account is absent at the end of the block.

2

The proof DB treats was_destroyed() as final deletion

Source: db/mod.rs:179-184

The continue is the bug. For DestroyedChanged, it skips:

and also skips rebuilding the storage root and reinserting the account into the state trie.

3

The bad state root is used to seal the proof-executed block

Source: builder/assemble.rs:41-43

So a wrong writeback in TrieDB::state_root becomes the block header state_root.

4

The accepted TEE path still reaches this code

The accepted Nitro path constructs the proof pipeline with BaseEvmFactory, not the unused FPVM precompile factory, but the state writeback still goes through StatelessL2Builder and TrieDB.

Source: tee/nitro-enclave/src/server.rs:154

Source: proof/src/executor.rs:78-84

Variant A: Prefunded Address Produces a Noncanonical Root

The strongest path is to make the target account exist in the parent trie before the destroy/recreate block. A dust balance is enough: CREATE2 collision checks fail on nonzero nonce or nonempty code, not on a positive balance.

Attack outline:

  1. Attacker deploys a CREATE2 factory and computes future child address X.

  2. In block N-1, attacker sends dust ETH to X.

  3. In block N, factory deploys child X with CREATE2, then calls X.kill().

  4. Because X was created in the same transaction, EIP-6780 still allows SELFDESTRUCT to delete it.

  5. Later in the same L2 block, the factory redeploys the same child at X with the same salt and initcode.

  6. Canonical Base execution ends block N with X alive.

  7. The merged revm bundle marks X as DestroyedChanged.

  8. The proof DB deletes the parent-trie account and skips reinsertion.

  9. The proof-computed state root omits X.

  10. The canonical state root includes X.

The PoC reproduces the state-root side of this attack with the real TrieNode and TrieDB::state_root path. It creates a parent trie where X exists, builds a DestroyedChanged bundle with final account info, then compares the buggy proof root against a canonical delete-then-reinsert root.

PoC:

Variant B: No Parent Account Halts Proof Execution

The same bug also has a liveness variant. If the account is newly created, destroyed, and recreated without being present in the parent trie, the proof DB still enters the was_destroyed() branch. It tries to delete a missing account from the state trie.

On an empty parent root, TrieNode::delete returns KeyNotFound. The state-root computation fails before block sealing.

Attack outline:

  1. Attacker deploys a CREATE2 factory and computes future child address X.

  2. X is not present in the parent state trie.

  3. In one L2 block, attacker creates X, selfdestructs it in the same transaction, then recreates it later in the same block.

  4. Canonical execution ends with X alive.

  5. The proof DB sees DestroyedChanged.

  6. It attempts to delete X from the parent trie.

  7. Because X did not exist in the parent trie, delete returns KeyNotFound.

  8. Proof state-root computation halts.

PoC:

Impact

The PoC proves four concrete effects:

  • A valid L2 transaction pattern can trigger the bug.

  • Canonical node state and proof executor state diverge.

  • The proof executor can fail to prove a valid block.

  • The derived output root can differ from the canonical output root.

The key impact is:

Canonical execution ends the block with the recreated account alive. The proof executor can instead derive a proof-world state where that live account is missing, or fail before it can compute the state root. The block is valid on Base, but the proof executor cannot faithfully recompute its state/output root.

The unintended smart contract behavior is not that the live Base chain executes the contract incorrectly. It is that proof-side state represents the final smart contract/account state incorrectly. Canonical Base says contract/account X exists at the end of the block with the nonce, code, and balance of the recreated account. The proof executor can instead derive that X does not exist at the end of the block.

That affects proof-side logic that depends on the post-block state root or account existence. For example, the proof world says EXTCODESIZE(X) == 0, EXTCODEHASH(X) is empty, or the account proof for X is absent, while canonical Base says X has code and exists. The Medium impact is therefore L2 network/proof code deriving unintended smart contract state with no concrete funds directly at risk.

The bug can make the stateless proof executor derive an L2 post-state/output root that does not match canonical Base. Any smart contract logic that accepts that derived proof state would reason over incorrect account existence, code, or storage.

The wrong-root variant is the more severe one. It allows proof execution to continue with a state root that does not match canonical Base execution.

The no-parent-account variant is a proof liveness failure. It can make otherwise valid block ranges unprovable by the affected stateless proof executor until the bug is patched or the range is bypassed by another proof system.

Proof of Concept

Two PoC layers were added. The first proves that the destroy/recreate pattern is valid canonical Base node behavior with real signed transactions. The second proves that the proof executor's TrieDB::state_root writeback is not equivalent to that canonical behavior.

These snippets include every import, helper, integration-test registration, and command needed to run the PoC.

PoC 1: canonical node accepts destroy/recreate and leaves the child live

This node e2e PoC drives a real BaseNode with real signed transactions and proves both variants are valid canonical EVM behavior. It deploys a fixed CREATE2 factory, sends two same-block user transactions, and verifies that the canonical node includes both transactions and leaves the recreated child account alive. The first test prefunds the future child address in the parent state. The second leaves the child absent from the parent state.

No Cargo.toml change is required.

File:

Full contents:

Path:

The integration test target must import the new test module:

From the repository root, run:

Observed result:

PoC 2: proof executor drops or halts on DestroyedChanged

This proof-executor PoC drives the vulnerable state-root writeback directly. It does not mock the vulnerable function. It uses:

No Cargo.toml change is required.

File:

Full contents:

From the repository root, run:

Observed result:

Was this helpful?