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

76294 bc medium isthmus withdrawals root validator silently accepts malformed blocks producing peer divergence between honest base nodes processing identical sequencer payloads chain level fork

Submitted on May 3rd 2026 at 18:34:58 UTC by @v4rvl for Audit Comp | Base Azul

  • Report ID: #76294

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • Unintended permanent chain split requiring hard fork (network partition requiring hard fork)

Description

Summary

The Base EL post-execution validator that enforces the Isthmus invariant "every accepted block's header.withdrawals_root matches the post-execution L2-to-L1 message-passer storage root" fails open whenever the parent block's state is unreachable through the narrow BlockchainProvider.state_by_block_hash lookup.

This produces peer divergence between two honest Base nodes processing the same sequencer payload sequence. Whether the validator silent-passes or actually runs the withdrawals-root check is per-node-state-dependent: it depends on whether each node has issued a forkchoice update (FCU) for the parent block at the moment the malformed child arrives. Different FCU timing across honest nodes — which is the normal operating mode of the network — is sufficient to fork the canonical chain.

A sequencer-controlled malformed child payload (withdrawals_root set to a value that does NOT match the post-execution storage root) is silently accepted by nodes whose parent is in the engine-tree's in-memory state but not yet promoted via FCU; the same payload is correctly rejected by nodes that have already FCU'd the parent. The accepting nodes promote the malformed block to head/safe/finalized via forkchoiceUpdatedV3 and propagate the poisoned rootClaim into L1 dispute-game inputs. The rejecting nodes hold the parent as their canonical head.

The PoC is a deterministic two-node integration test built from the in-tree production validator pipeline (OpEngineValidator + BaseEngineValidator + EngineApiTreeHandler) that drives the same sequence to both nodes and proves: Node A accepts and promotes the malformed child as canonical; Node B rejects it. The two nodes' canonical chains differ. This is peer divergence, not a single-node validation slip.

What the attack does

1

1. Burst-emit blocks 1 and 2 via newPayloadV4.

Block 1 is a normal pre-parent. Block 2 is the parent of the malformed child. Both blocks are well-formed.

2

2. Some nodes (Class A) have not yet issued FCU for the parent.

The parent is stored in the engine-tree's in-memory state but has not been promoted into BlockchainProvider.canonical_in_memory_state. This is the normal mode during fast sequencer output, derivation pipeline batch processing, and unsafe-head catch-up.

3

3. Other nodes (Class B) have already FCU'd the parent.

The parent is canonical via the narrow provider.

4

4. Sequencer submits the malformed child via newPayloadV4(child).

child.withdrawals_root = 0x99..99 (any value that does NOT match the post-execution L2-to-L1 message-passer storage root). The Isthmus invariant says this MUST be rejected by every honest node.

5

5. Class A nodes silently accept.

Inside validate_block_post_execution_with_hashed_state, self.provider.state_by_block_hash(parent_hash) returns Err (parent is in tree_state only, not in canonical_in_memory_state or pending_state or DB). The validator hits the FIXME early-return at line 134 and returns Ok(()). The malformed child is treated as Valid.

6

6. Class B nodes correctly reject.

self.provider.state_by_block_hash(parent_hash) returns Ok(state) (parent is canonical). The validator runs isthmus::verify_withdrawals_root_prehashed, computes the expected storage root, compares to header.withdrawals_root = 0x99..99, and returns ConsensusError::Other("L2 withdrawals root mismatch, header: 0x9999...9999, exec_res: 0x56e81f..."). The malformed child is Invalid.

7

7. Sequencer submits forkchoiceUpdatedV3({head, safe, finalized} = malformed_child_hash).

Class A nodes promote the malformed child as canonical (head = malformed_child_hash). Class B nodes reject the FCU with links to previously rejected block and stay at head = parent_hash.

8

8. Network partition.

Class A's canonical L2 chain contains the malformed block; Class B's does not. Both classes' fork-choice rules see their local chain as locally valid. The L1 dispute-game pulls Class A's poisoned rootClaim from optimism_outputAtBlock → op-proposer → DisputeGameFactory.create(). Class B's view of L2 disagrees with the L1 rootClaim.

Impact

Direct technical impact: an EL-layer chain split between honest Base nodes. Two nodes running the audit-pinned source code, processing the same Engine API messages from the same sequencer, partition into accepting-the-malformed-block vs rejecting-the-malformed-block based purely on per-node FCU timing — which is variable and uncontrollable in normal operation.

Downstream consequences:

  • Chain split. Class A and Class B have different canonical L2 heads. Bridge withdrawal proofs against Class A's chain do not match L1 if Class B's view is the one that drives the dispute resolution, and vice versa.

  • Poisoned L1 rootClaim. optimism_outputAtBlock (post-Isthmus branch) constructs the output root from header.withdrawals_root directly — no recomputation. The poisoned root flows to op-proposer, then to DisputeGameFactory.create() as an L1 rootClaim.

  • Bridge withdrawal integrity break. L2 withdrawal storage proofs are constructed against the actual L2 trie. The L1 OptimismPortal2.proveWithdrawalTransaction path uses the L1-finalized output root to authorize them. If the finalized output root is the poisoned one (Class A) and the actual L2 trie state is what Class B sees, withdrawal proofs become unverifiable or the wrong L2 state authorizes withdrawals.

  • Recovery requires manual intervention. Both Class A and Class B see their chains as locally valid; fork-choice does NOT automatically converge them. The accepting nodes' tree contains a malformed block with a withdrawals_root that has no possible honest preimage. Re-aligning the network requires operator intervention to roll back Class A nodes (which is the textbook "hard fork to recover" scenario).

  • Catch-net is reactive, not preventive. The fault-proof challenger at crates/proof/executor/src/builder/assemble.rs and crates/proof/challenge/src/validator.rs recomputes from the L2 trie and CAN challenge the bad rootClaim — but only AFTER the chain has already partitioned. By the time the challenge resolves, the network has already split, op-proposer has already submitted the bad rootClaim, and bridge users have already seen inconsistent state for the duration of the dispute window. The catch-net is defense-in-depth; it does not eliminate the chain-split itself.

Affected user / protocol surface:

  • Every honest Base node running the validator at audit pin (rc28) or HEAD.

  • L1 dispute-game inputs (rootClaim for DisputeGameFactory.create()).

  • Bridge withdrawal finalization integrity.

  • Network-level chain consensus.

Trigger preconditions:

  • The actor must be sequencer-role (or a buggy sequencer can produce the malformed payload accidentally; the bug fires either way).

  • The Isthmus hardfork must be active (rc28 has Isthmus active from genesis under the test config; mainnet has Isthmus active per chain spec).

  • The honest network must include nodes with different FCU timing relative to the malformed-child arrival — which is the normal operating mode of the sequencer + L2 nodes. Not an exotic state.

Source code (from v0.8.0-rc.28)

crates/execution/node/src/engine.rs, lines 124–152:

BlockchainProvider::state_by_block_hash checks three lookup paths in order: (1) DB history, (2) pending_state (only stores the one block immediately following canonical head), (3) in_memory_state.blocks (populated by FCU). For a parent that is the second unconfirmed block in a burst — first block gets the pending_state slot, second goes into tree_state only — none of the three paths find it. Err is returned. The validator silent-passes.

Class A nodes (no FCU for the parent) hit this path. Class B nodes (FCU'd the parent) do not — for them the parent is in in_memory_state.blocks, the lookup succeeds, and the real check runs.

POC and test and recommended fixes in section below (#3)

Proof of Concept

The PoC is a tokio integration test that builds two independent production validator pipelines (EngineApiTreeHandler + BaseEngineValidator + OpEngineValidator) from the in-tree audit source and drives the same Engine API message sequence to both, with the only difference being whether each node issues a forkchoice update before the malformed child arrives.

Save the following as actions/harness/tests/fn7_peer_divergence.rs in a v0.8.0-rc.28 checkout of base/base (same crate path as the existing actions/harness/tests/ integration tests). The test runs ~60-90 seconds and prints structured progress.

Test command

From the repo root of the v0.8.0-rc.28 checkout:

Expected output

The test asserts every step. If it passes, the divergence is proven.

What this PoC proves

The PoC drives the production validator pipelineEngineApiTreeHandler + BaseEngineValidator + OpEngineValidator constructed from the in-tree audit source — for two independent nodes with their own DBs, providers, and canonical-in-memory-state. Each assertion maps to a specific outcome:

Step
Assertion
What it proves

1

pp_status_a == Valid AND pp_status_b == Valid

Both nodes' validator pipelines work correctly on a well-formed pre-parent. Baseline sanity.

2

p_status_a == Valid AND p_status_b == Valid

Both nodes' validator pipelines work correctly on a well-formed parent. The bug is NOT a generic "validator broken" issue.

3

head_a_before == 0 AND head_b_before >= 2

Differential canonical state achieved. Node A has not FCU'd (parent is in tree_state only); Node B has FCU'd (parent is in canonical_in_memory_state). This is the precondition for divergence.

4

child_status_a == Valid

The bug, half 1. Node A silently accepts the malformed child because the FIXME early-return fires when the parent state is unreachable through the narrow BlockchainProvider.

5

child_status_b != Valid (specifically Invalid with L2 withdrawals root mismatch error)

The bug, half 2. Node B correctly rejects the same malformed child because the parent IS reachable, the real isthmus::verify_withdrawals_root_prehashed runs, and 0x99..99 is correctly identified as not matching the post-execution storage root.

6

fcu_a_child.payload_status == Valid AND final_head_a == 3

Node A promotes the malformed child to canonical head. Its L2 chain now contains a block whose header.withdrawals_root is impossible.

7

fcu_b_child.payload_status != Valid AND final_head_b == 2

Node B refuses to promote a previously-rejected block. Its canonical head stays at the parent.

In plain terms:

  • Steps 1–2 prove the pipelines work. If the validators were generically broken, Steps 1 and 2 would also fail. They don't.

  • Step 3 proves the precondition is reachable. Both nodes are running honest, audit-pinned source code. The only difference is FCU timing — which is operationally normal across the network.

  • Steps 4–5 prove peer divergence on newPayloadV4. The same payload bytes produce different PayloadStatus results on two honest nodes. This is the chain-level fork.

  • Steps 6–7 prove the divergence is durable. After FCU, Node A's canonical head is the malformed block; Node B's is the parent. The two nodes' canonical L2 chains are different. Fork-choice does NOT auto-converge them — both views are locally valid.

The PoC does NOT depend on any mock validator, fake EVM, or replaced state provider. Every component is the real production validator constructed from the in-tree source code at v0.8.0-rc.28.

Suggested fix

The validator must consult the engine-tree's broader in-memory state, not only the narrow BlockchainProvider.canonical_in_memory_state. Two options:

Option A — pass an engine-tree-aware state provider. Change the OpEngineValidator constructor in crates/execution/node/src/node.rs (line 1167 area) to receive a state provider that walks both the canonical chain AND EngineApiTreeState::tree_state():

Option B — fail closed on unreachable parent. If the in-memory parent state is provably unreachable after exhaustive lookup (true cache miss), the validator should return an error rather than silent-pass:

A real parent-unavailable condition is a critical-path failure that should never silently accept a block. Failing closed is conservative; the tree handler will retry with the parent once it's available.

Was this helpful?