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

75437 bc medium jovian min base fee omission lets consolidation promote a non canonical unsafe block as safe

Submitted on Apr 29th 2026 at 05:42:48 UTC by @QED for Audit Comp | Base Azul

  • Report ID: #75437

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • Unintended chain split (network partition)

Description

Brief/Intro

base-consensus safe-head consolidation ignores the Jovian min_base_fee field when comparing L1-derived payload attributes to an already-imported unsafe block. If the unsafe block header encodes the same EIP-1559 elasticity and denominator but a different Jovian min_base_fee, AttributesMatch::check() returns Match. The real ConsolidateTask::execute() path then advances the safe head to that unsafe block even though the header extra_data is not the one produced from the L1-derived attributes.

Vulnerability Details

Jovian extra_data has three fields

Jovian extra_data commits to elasticity, denominator, and min_base_fee.

// crates/common/consensus/src/extra/jovian.rs:26-41
pub fn decode(extra_data: &[u8]) -> Result<(u32, u32, u64), EIP1559ParamError> {
    if extra_data.len() != 17 {
        return Err(EIP1559ParamError::InvalidExtraDataLength);
    }
    if extra_data[0] != VERSION_BYTE {
        return Err(EIP1559ParamError::InvalidVersion(extra_data[0]));
    }
    let denominator: [u8; 4] = extra_data[1..5].try_into().expect("sufficient length");
    let elasticity: [u8; 4] = extra_data[5..9].try_into().expect("sufficient length");
    let min_base_fee: [u8; 8] = extra_data[9..17].try_into().expect("sufficient length");
    Ok((
        u32::from_be_bytes(elasticity),
        u32::from_be_bytes(denominator),
        u64::from_be_bytes(min_base_fee),
    ))
}

Derived payload attributes carry min_base_fee after Jovian activation.

The payload builder uses that value to encode the block header.

Root cause - consolidation discards min_base_fee

AttributesMatch::check_eip1559() decodes Jovian extra_data, drops the third tuple field, and compares only elasticity and denominator.

The only comparison that follows is:

There is no check that attributes.min_base_fee == decoded_header_min_base_fee.

Safe-head promotion path

ConsolidateTask uses AttributesMatch::check(...).is_match() as the gate.

When it returns Match, the task applies the fetched unsafe block as the new safe head.

The wrong header can affect later derivation

Base reconstructs Jovian system configuration from accepted block headers.

The execution chain spec also uses the parent header's Jovian min_base_fee to clamp the next block's base fee.

Impact Details

Severity: High - Unintended chain split (network partition).

A signed unsafe block can differ from the L1-derived Jovian payload attributes only in the header min_base_fee. Nodes that previously imported that unsafe block can promote it to safe through consolidation, while nodes deriving or rebuilding from the L1 attributes build the same block with the canonical min_base_fee and therefore obtain a different header hash.

In a mixed network state, this can create divergent safe-chain hashes from the same L1 data: the consolidation path accepts H(B), while the derivation/build path produces H(A).

This is not harmless metadata drift. Jovian min_base_fee is later read from accepted headers for system-config reconstruction and next-block base-fee computation, so the bad safe header can affect follow-on derivation.

The direct attacker precondition is control of the expected unsafe block signer, or an equivalent faulty signer path. The affected input is still consensus-critical because unsafe blocks are supposed to be independently checked against canonical L1-derived attributes before safe-head promotion.

https://gist.github.com/a-qedaudit/fa9b320184d1e65de35506ff4d2dd685

Proof of Concept

A single localnet PoC is included.

Runnable artifacts (secret gist): https://gist.github.com/a-qedaudit/fa9b320184d1e65de35506ff4d2dd685

Contains only the localnet reproduction kit and instructions: README.md, run_localnet_poc.sh, check_localnet_poc.sh, and instrumentation.patch. Clone the gist and follow the reproduction steps below.

The localnet PoC applies instrumentation.patch to base/base@v0.8.0-rc.28 and starts the real Base devnet through DevnetBuilder. A test-only sequencer hook makes the real sequencer build and sign the first unsafe block with a non-canonical Jovian min_base_fee = 9; the live derived attributes for that block have min_base_fee = 1000000000. The hook only changes unsafe block production by the sequencer; validator derivation, L1 batch handling, AttributesMatch::check, and ConsolidateTask::execute are unmodified. The real validator imports the wrong unsafe block through normal unsafe propagation. The PoC then posts an authorized L1 batch for the same block and observes the validator consolidate that wrong unsafe hash as safe. run_localnet_poc.sh checks that BASE_REPO is exactly e3467a2048881213b56739a54a876efb9c6ea103.

Observed localnet evidence:

This demonstrates the exact invariant break through the normal localnet path. The unsafe block is not manually supplied to ConsolidateTask: it is built and signed by the real sequencer, imported by the real validator through normal unsafe propagation, and later promoted to safe after real authorized L1 batch data is derived. The first sequencer build prints the canonical derived min_base_fee = 1000000000 and the forced unsafe header value 9; the live safe head then advances to the wrong unsafe hash with min_base_fee = 9.

The target block is intentionally transaction-empty (target L1 batch tx count : 0); the mismatch is in the Jovian header field committed by extra_data, so the block hash differs even with identical transaction contents.

Mitigation

Compare Jovian min_base_fee in AttributesMatch::check_eip1559().

One direct fix is to keep the full Jovian decode result and add an explicit comparison:

Add a regression test where Jovian attributes and block header have equal elasticity and denominator but different min_base_fee; AttributesMatch::check() must return a mismatch and ConsolidateTask must not advance the safe head via L1 consolidation.

Was this helpful?