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:
Canonical execution says the account is alive at the end of the block.
revmmarks it asDestroyedChanged.TrieDB::update_accountstreats it as final deletion.The proof executor either deletes the account and computes the wrong state root if the account existed in parent state, or errors with
KeyNotFoundif 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
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.
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.
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.
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:
Attacker deploys a
CREATE2factory and computes future child addressX.In block
N-1, attacker sends dust ETH toX.In block
N, factory deploys childXwithCREATE2, then callsX.kill().Because
Xwas created in the same transaction, EIP-6780 still allowsSELFDESTRUCTto delete it.Later in the same L2 block, the factory redeploys the same child at
Xwith the same salt and initcode.Canonical Base execution ends block
NwithXalive.The merged
revmbundle marksXasDestroyedChanged.The proof DB deletes the parent-trie account and skips reinsertion.
The proof-computed state root omits
X.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:
Attacker deploys a
CREATE2factory and computes future child addressX.Xis not present in the parent state trie.In one L2 block, attacker creates
X, selfdestructs it in the same transaction, then recreates it later in the same block.Canonical execution ends with
Xalive.The proof DB sees
DestroyedChanged.It attempts to delete
Xfrom the parent trie.Because
Xdid not exist in the parent trie, delete returnsKeyNotFound.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:
Related integration-test registration
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?