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

74944 bc high flashblocks cached execution accepts skipped transactions engine level consensus split

Submitted on Apr 26th 2026 at 04:14:50 UTC by @nord0x for Audit Comp | Base Azul

  • Report ID: #74944

  • Report Type: Blockchain/DLT

  • Report severity: High

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

  • Impacts:

    • Unintended chain split (network partition)

Description

Bug Description

crates/execution/engine-tree/src/cached_execution.rs line 58–66:

// cached_execution.rs:58-66 (literal)
if let Some(prev_cached_hash) = prev_cached_hash {
    // all previous transactions from start of block to prev_cached_hash are cached,
    // so only check if the previous transaction is cached
    if !pending_blocks.has_transaction_hash(prev_cached_hash) {
        warn!(
            prev_cached_hash = ?prev_cached_hash,
            "Not using cached results - previous transaction not cached",
        );
        return None;
    }
}

has_transaction_hash is HashMap::contains_key (pending_blocks.rs:384-386):

It checks whether the hash exists anywhere in the cache — it never checks whether the requested transaction is the immediate successor of that hash in the cached pending order.

The comment at line 59 reveals the developer's intent: they assumed payloads always arrive in the same order as the pending cache. But nothing in the code enforces that assumption — has_transaction_hash is a membership test, not a position test.

A payload body [deposit, nonce1] that omits nonce0:

Step
Cached node
Vanilla node

Lookup deposit in cache

Found → guard passes

N/A (no cache)

Execute nonce1

Returns cached result (computed when nonce0 was present)

Re-executes from state → nonce 1 too high, expected 0

Verdict

Valid

Invalid

Same parent state, same payload, different verdict. Hard consensus split.

Root Cause

CachedExecutor::execute_transaction_without_commit at line 183 derives prev_tx_hash from the incoming payload body's ordering (self.txs), not from the pending flashblocks order:

This prev_tx_hash is passed to FlashblocksCachedExecutionProvider::get_cached_execution_for_tx, which only checks if that hash exists somewhere in the cache (lines 58–66 above). If it does, the provider returns the cached ResultAndState unconditionally (lines 80–81):

The cached result for nonce1 was computed in a world where nonce0 had already executed and incremented the sender's nonce from 0 to 1. When the malicious payload skips nonce0, the EVM state hasn't seen that increment — but the cached node trusts the stale result and commits it anyway.

Affected code:

  1. crates/execution/engine-tree/src/cached_execution.rs:58-66 — existence-only guard

  2. crates/execution/engine-tree/src/cached_execution.rs:80-81 — unconditional cache return

  3. crates/execution/engine-tree/src/cached_execution.rs:183prev_tx_hash from payload body, not cache order

  4. crates/execution/flashblocks/src/pending_blocks.rs:384-386contains_key with no ordering

  5. crates/execution/engine-tree/src/validator.rs:767-784 — wires CachedExecutor into the engine pipeline

  6. crates/client/flashblocks-node/src/extension.rs:46-53 — enables flashblocks-aware validator

Impact

A sequencer or unsafe_block_signer crafts an engine_newPayloadV4 body that omits one or more transactions present in the cached pending block. Every flashblocks-cached node accepts it as Valid and advances its chain tip. Every non-cached node rejects it as Invalid.

  • Hard consensus split between cached and non-cached validators. Irreconcilable chain tips.

  • Invalid state commitment. The cached node accepts a block whose execution results were computed under assumptions that don't hold (the skipped transaction's state effects are missing). The PoC proves the cached node returns Valid for this block while the vanilla node returns Invalid — the two nodes disagree on whether the block belongs to the canonical chain.

Precondition: the victim node runs with FlashblocksConfig.cached_execution = true. This flag defaults to false (see config.rs:24) and must be explicitly enabled. However, cached execution is the performance optimization that makes flashblocks viable at production throughput — operators running the flashblocks extension are expected to enable it.

Severity

High — Unintended chain split (network partition).

The PoC launches three real Base nodes from the same genesis, mines a shared parent block, then submits the same engine_newPayloadV4 payload to both the cached-flashblocks node and the independent vanilla node. The cached node returns Valid; the vanilla node returns Invalid. Same parent state, same payload, opposite verdicts. This is a network partition by definition.

The attacker model is the sequencer or unsafe_block_signer — the entity that constructs engine_newPayloadV4 payloads in Base's architecture. No external network access or privilege escalation is required beyond the ability to author payload bodies, which is the sequencer's core function.

Importantly, the divergence occurs between honest validators — the cached node and the vanilla node are both behaving correctly according to their own execution logic. A sequencer bug that drops or reorders transactions (without any malicious intent) would trigger the same split. The vulnerability is in the cache validation logic, not in the sequencer's behavior.

https://gist.github.com/drawrowfly/48f7ec42fd3c0a4343f85a962d621844

Proof of Concept

Launches three real Base v0.8.0-rc.15 nodes — one reference, one cached-flashblocks victim, one independent vanilla. All start from the same genesis. The PoC mines a shared parent block, primes the victim's cache with [deposit, nonce0, nonce1], then submits [deposit, nonce1] (skipping nonce0) to both victim and independent node.

Build & run

Or use the provided run_poc.sh which handles all of this automatically(can be found in github gist that is attached to the report)

Output

PoC code

Cargo.toml

main.rs

Was this helpful?