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

75378 bc low non exact eip 2718 comparison lets safe head consolidation diverge from exact l1 derivation

Submitted on Apr 28th 2026 at 20:10:49 UTC by @QED for Audit Comp | Base Azul

  • Report ID: #75378

  • Report Type: Blockchain/DLT

  • Report severity: Low

  • 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 compares L1-derived transaction bytes to an unsafe block by decoding the attribute bytes with non-exact EIP-2718 decoding. A derived transaction byte string encode_2718(T) || trailing_bytes compares equal to an unsafe block containing only T. The real ConsolidateTask::execute path then advances the safe head to the clean unsafe block even though the exact payload-building path rejects the same L1-derived bytes with UnexpectedLength.

Vulnerability Details

Invariant the code claims

The Base specs define unsafe block consolidation as an exact match between L1-derived payload attributes and the oldest unsafe block:

docs/specs/pages/reference/glossary.md:696-700
Unsafe block consolidation is the process through which the rollup node attempts
to move the safe L2 head a block forward...
the node verifies that the payload attributes derived from the L1 chain match
the oldest unsafe L2 block exactly.

The derivation spec is explicit for transactions:

The execution spec also says a non-empty payload-attribute transaction list must be used exactly:

Root cause - semantic tx equality instead of exact byte equality

Single batches carry user transactions as opaque Bytes.

SingleBatch::check_batch() rejects empty transactions, deposits, and pre-Isthmus 7702 transactions, but it does not exact-decode each transaction byte string.

The attributes queue then copies those opaque bytes directly into payload attributes:

The vulnerable comparator decodes the bytes with decode_2718 and never checks that the input slice was fully consumed.

That is a semantic transaction comparison. It is not the spec-required exact encoded transaction comparison.

Exact payload paths reject the same bytes

The shared RPC attribute helper checks for unread bytes:

The payload builder also uses exact decoding:

So the consolidation shortcut accepts bytes that the exact build/import path rejects.

Safe-head transition

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

If it matches, the task updates the safe head from the already-imported unsafe block:

Impact Details

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

The bug causes two honest nodes running the same implementation to make different safe_l2 decisions from the same canonical L1 batch data, depending only on whether the matching clean unsafe block was already locally available.

  1. A node that already imported the clean unsafe block T through the unsafe block path / P2P gossip runs safe-head consolidation. The non-exact comparator accepts L1-derived bytes encode_2718(T) || trailing_bytes, and ConsolidateTask advances the safe head to the clean unsafe block.

  2. A node that did not have that unsafe block, or a node rebuilding after reset, processes the same L1-derived attributes through the payload-building path. Exact EIP-2718 decoding rejects the transaction bytes with UnexpectedLength, so that node cannot derive and mark the same block safe from the same L1 data.

This is not merely acceptance of a malformed transaction in one helper. It violates the core consolidation invariant: the safe chain should be a deterministic function of authenticated L1-derived payload attributes. Here, the result also depends on local unsafe-block availability. The two-node localnet PoC below demonstrates this directly: node A has the clean unsafe block and advances safe_l2 to the poisoned target, while node B is isolated from unsafe gossip, observes the same L1 batch block, and remains unable to import the target block.

The required attacker capability is control of the active batcher-authenticated L1 batch stream, or an equivalent batcher/signing fault. This limits ordinary external exploitability, but the affected input is still consensus input that honest rollup nodes must validate identically after batcher authentication. The vulnerability is that validation is not identical across the consolidation and exact payload-building paths.

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

Proof of Concept

Two PoCs are included. All reproduction files are in the unlisted gist below, and the only external source needed is the public base/base repository.

Consolidation harness

Unlisted gist:

Run:

The harness Cargo.toml depends on https://github.com/base/base.git at v0.8.0-rc.28, so it does not require a local audit checkout.

Observed output:

The consolidation harness uses Base's real ConsolidateTask::execute, EngineState, AttributesMatch::check, BasePayloadAttributes::decoded_transactions, and BaseTxEnvelope decoders. The only mock is Base's own MockEngineClient, used to return the already-imported unsafe block to the consolidation task.

Localnet E2E

Run:

The localnet harness applies instrumentation.patch to the audit checkout. The patch adds a devnet test, one test-only DevnetBuilder knob that makes the background batcher sign with a non-authorized key, and one test-only isolated second validator that is not connected to builder EL p2p, flashblocks, or consensus unsafe-block gossip. The patch does not modify AttributesMatch, derivation validation, payload construction, safe-head update logic, transaction decoding, or Engine API behavior. The malicious batch is still submitted by the configured devnet batcher key to the real batch inbox.

run_localnet_poc.sh writes the evidence excerpt to localnet.out and keeps the full raw service log at localnet.out.raw.

Observed localnet evidence:

This drives the full local path: real L1, real Base builder EL, two real Base validator EL/CL pairs, real L2 unsafe block gossip/import for node A, no unsafe-block gossip for node B, real authorized L1 batcher calldata, and live optimism_syncStatus.safe_l2 divergence from the same L1 batch.

Mitigation

Make safe-head transaction comparison exact.

The smallest fix is to replace the non-exact decode in AttributesMatch::check_transactions() with exact decoding:

An even stricter fix is to compare raw encoded bytes: re-encode the block transaction with encode_2718 and require byte-for-byte equality with attr_tx_bytes. That directly implements the spec wording: equality of each encoded transaction.

Add a regression test where attributes.transactions = [encode_2718(T) || trailing_bytes] and block.transactions = [T]. Expected result: AttributesMatch::check() must return MalformedAttributesTransaction or TransactionContent, never Match, and ConsolidateTask must not advance the safe head via L1 consolidation.

Was this helpful?