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

76386 bc high time controlled disputegamefactory create pins game l1head to a batch incomplete l1 block making early halt finalization on chain unchallengeable

Submitted on May 4th 2026 at 06:01:33 UTC by @QED for Audit Comp | Base Azul

  • Report ID: #76386

  • Report Type: Blockchain/DLT

  • Report severity: High

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

  • Impacts:

    • Circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization

Description

Brief/Intro

The SP1 range program does not bind the public-values l2BlockNumber to the actual derivation endpoint reached by the guest. When derivation hits EndOfSource, advance_to_target retargets the requested block to the current safe head and continues; the executor epilogue checks only output_root == boot.claimed_l2_output_root, never safe_head.block_info.number == boot.claimed_l2_block_number; BootInfoStruct::new then commits the inflated claimed_l2_block_number = N to the journal regardless of what block execution actually reached.

The on-chain AggregateVerifier correctly pins the journal-bound l1OriginHash to the immutable game.l1Head() set at DisputeGameFactory.create() time (blockhash(block.number - 1) with no batch-coverage check). A malicious proposer therefore times create() to land in an L1 block whose history does not yet contain the L2 batches required to derive up to the contract-pinned endingL2SequenceNumber = N; the attacker's SP1 proof exits cleanly with output_root = root@M for some M < N, the contract's calldata invariants pass, and the game initializes. Critically, any honest counter-proof must commit a journal binding the same fixed l1OriginHash, and under that l1Head no derivation reaches block N — the honest challenger's executor epilogue rejects every candidate claimed_l2_output_root = root@N because their guest only reaches root@M. The dispute/challenge mechanism is structurally impossible to invoke; the malicious proposal resolves DEFENDER_WINS by construction.

Vulnerability Details

Severity: Critical -- "Circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization"

The defect is the composition of (a) a program-logic gap in the SP1 range guest that lets the prover commit a journal claiming end-block N while derivation halted at M < N, and (b) the on-chain AggregateVerifier design that binds the journal's l1OriginHash to the proposer-controlled game.l1Head(). Either layer alone would be a defense-in-depth gap; together they remove the only on-chain remediation path.

Root Cause

1

The EndOfSource arm in advance_to_target retargets to the safe head and continues, instead of failing.

crates/succinct/utils/client/src/client.rs:93-115 (verbatim, v0.8.0-rc.35):

2

The Isthmus guard intended to block this path is permanently inactive on Base mainnet because of a type confusion.

crates/consensus/genesis/src/rollup.rs:217-220:

Base mainnet's isthmus_timestamp = 1_746_806_401 (Unix). Block numbers on Base mainnet are currently ~46M. The guard at client.rs:106 evaluates block_number >= timestamp, which is 46_000_000 >= 1_746_806_401 = false for every realistic Base mainnet block height. The early-halt error is never re-raised; the loop continues with target shrunk to whatever block was actually reached.

3

The executor epilogue checks output_root only, not the safe-head block number.

crates/succinct/utils/client/src/witness/executor.rs:158-180:

4

BootInfoStruct::new propagates the inflated claimed_l2_block_number into the public-values journal, regardless of what block execution actually reached.

crates/succinct/utils/client/src/boot.rs:37-58:

5

The on-chain AggregateVerifier pins the journal-bound l1OriginHash to the immutable game.l1Head().

src/multiproof/AggregateVerifier.sol:904-944 (v8.1.0):

src/multiproof/AggregateVerifier.sol:548-588 (nullify — same shape as challenge):

src/multiproof/AggregateVerifier.sol:963-983:

6

The proposer chooses game.l1Head() by timing DisputeGameFactory.create().

The factory pins parentHash = blockhash(block.number - 1) at create() time and never validates that this L1 block contains enough batch-publishing transactions to derive up to the protocol-pinned endingL2SequenceNumber. (Cited as background — DisputeGameFactory is OP-stack-standard and stores parentHash into the cloned IDisputeGame's immutable args at [52, 84), where the multiproof game reads it back via l1Head() = _getArgBytes32(0x34) at AggregateVerifier.sol:748.)

Why the asymmetry makes detection structurally impossible

Given a game whose stored l1Head() points to L1 block H such that the canonical L1 history at H contains batches sufficient to derive L2 to M but not to N (with M < N and endingL2SequenceNumber = N pinned by _getStartingIntermediateRootAndL2SequenceNumbers):

  • Attacker's proof: runs the guest with witness preimages tied to H; derivation hits EndOfSource at M; client.rs:101 rewrites target = M; the loop returns (safe_head@M, output_root@M); executor.rs:172 accepts because the prover supplied claimed_l2_output_root = root@M; BootInfoStruct::new commits journal {l2BlockNumber: N, l2PostRoot: root@M, l1Head: H}; SP1 verifies; nullify/challenge accepts.

  • Honest challenger's would-be counter-proof: must commit to a journal with endingRoot = root@N (the canonical root, in order to win the challenge). Their guest also runs against witness data tied to the contract-pinned l1Head = H. Their guest also hits EndOfSource at M and produces output_root = root@M. The epilogue check at executor.rs:172 then evaluates output_root (root@M) != claimed_l2_output_root (root@N) and the executor returns the validation error. No valid SP1 proof committing endingRoot = root@N for journal-bound l1OriginHash = H exists, because no execution under that l1Head reaches state N.

The off-chain detection is unaffected — an honest party reading canonical L2 via a node sees intermediateOutputRoot(K-1) = root@M ≠ canonical root@N and knows the proposal is invalid. The on-chain remediation, however, requires producing an SP1 proof binding l1OriginHash = H ∧ endingRoot = root@N, which is unsatisfiable. The nullify and challenge paths cannot be successfully invoked.

After the SLOW_FINALIZATION_DELAY window elapses with no successful nullification, the game resolves DEFENDER_WINS. The pair (l2SequenceNumber = N, rootClaim = root@M) becomes the canonical anchor; subsequent games inherit the corrupted state.

Attacker Model

  • Position: Unauthenticated. Any L1 EOA or contract with sufficient ETH to post initBonds[<gameType>] can call DisputeGameFactory.create() (external payable, no auth modifier) and MultiProofDisputeGame.initializeWithInitData() (external payable virtual, no auth modifier). No proposer whitelist, allowlist, or staking gate; no signature, attestation, or registry membership required.

  • Trigger: Submit create(...) such that it lands in L1 block T where blockhash(T-1) is an L1 block whose history (i.e., 0..T-1) does not yet contain the batch-publishing transactions for L2 blocks in (M, N], where N = startingL2SequenceNumber + BLOCK_INTERVAL is fixed by the previous anchor.

  • Economic cost: initBonds[<gameType>] for the proposer slot. This bond is recovered on DEFENDER_WINS, which is the deterministic outcome of this attack — net cost is gas + SP1 proving cost.

  • Precondition(s):

    • L2 sequencer-to-L1 batch-publishing lag spans a non-zero window (universally true on production OP-stack chains; observable on Base mainnet — see live probe below).

    • Attacker is the first proposer to call create() for the next sealed window (one race per BLOCK_INTERVAL window; honest proposers normally wait for batches to land before creating).

    • Multiproof game type registered as respectedGameType() on OptimismPortal.

Sequence

Impact Details

Live trigger probe (2026-05-04):

is_isthmus_active(45_602_417) evaluates 45_602_417 >= 1_746_806_401 -> false. The Isthmus guard at client.rs:106 is therefore non-functional for every current Base mainnet block height; the EndOfSource fallback path at client.rs:95-115 is reachable on every range proof at any Base mainnet block.

Live trigger probe — sequencer-batch lag window (2026-05-04):

L1 batch-publishing transactions to Base's batch-inbox lag the L2 head by O(seconds) on production. Each create(...) call lands in some L1 block T; for T to satisfy the attack precondition, blockhash(T-1) must precede the L1 inclusion of batches for the L2 range (M, N]. Because batches are published in discrete L1 transactions interleaved with arbitrary other L1 traffic, there is at minimum one "gap" L1 block per batch-publishing cycle in which blockhash(T-1) does not yet contain the latest L2 batches. The attacker need only race honest proposers into one such gap.

What the bug enables

A proposer with the multiproof initialization bond can finalize a forged (l2SequenceNumber=N, rootClaim=root@M) anchor by exploiting the asymmetry: their proof passes the executor epilogue (output_root@M == claimed_l2_output_root=root@M) while any honest counter-proof attempting to bind claimed_l2_output_root=root@N is rejected by the same epilogue (output_root@M != claimed_l2_output_root=root@N).

The downstream consumer of a finalized anchor is the L1 withdrawal-finalization path through OptimismPortal, which validates withdrawal proofs against the finalized output root. A corrupted anchor of the form (l2SequenceNumber=N, rootClaim=root@M) causes:

crates/succinct/utils/client/src/witness/executor.rs:172-180 (downstream consumer of the inflated claimed_l2_block_number):

src/multiproof/AggregateVerifier.sol:560-572 (where the corrupted anchor is consumed by the post-challenge resolution path; quoted from nullify):

The corrupted anchor exposes any subsequent L1 withdrawal-finalization that validates against outputRoot(N) = root@M to acceptance of withdrawal proofs whose Merkle paths trace into block M's withdrawalStorageRoot rather than block N's. The chain split between L1's view of L2 state at sequence N (= root@M) and the canonical L2 state at block N cannot be reconciled by any subsequent on-chain action, since each future game must be built on top of the finalized (incorrect) anchor — and the same primitive used to corrupt anchor K can be re-applied to corrupt every subsequent anchor.

  1. crates/succinct/utils/client/src/witness/executor.rs epilogue — assert safe_head.block_info.number == boot.claimed_l2_block_number immediately after the output_root check, before returning Ok((boot_clone, intermediate_roots)). This is the load-bearing fix: it closes the bug regardless of whether (2) and (3) below are also applied, by ensuring the guest can never produce a journal whose l2BlockNumber is past where execution actually reached.

  2. crates/succinct/utils/client/src/client.rs:106 — pass driver.cursor.read().l2_safe_head().block_info.timestamp (a Unix timestamp) to is_isthmus_active, not .number (a block height). Restores the intended Isthmus guard. Alternatively, remove the EndOfSource fallback entirely so the loop propagates the error unconditionally.

  3. crates/succinct/utils/client/src/boot.rs::BootInfoStruct::new — accept the actually-reached safe_head.block_info.number rather than boot_info.claimed_l2_block_number, or assert their equality at construction. Makes the inflation impossible to introduce at the journal-build site.

https://gist.github.com/x-qedaudit/71fe7ea7918ee0c9867b3aa58a437520

Proof of Concept

Runnable artifacts (secret gist): https://gist.github.com/x-qedaudit/71fe7ea7918ee0c9867b3aa58a437520

Setup

  • Workspace-member crate at etc/tools/poc_l1head_short_unchallengeable/ with lib.rs symlinked from ~/known-bugs/base/chain-validity-l1head-short-unchallengeable/poc_test.rs.

  • ShortPipeline is a real base_consensus_derive::Pipeline + SignalReceiver + DriverPipeline impl whose step() always returns StepResult::StepFailed(PipelineErrorKind::Critical(PipelineError::EndOfSource)). This is the attacker as a real type implementing the production pipeline trait — the same MissingPreimageProvider / MaliciousOracle idiom used elsewhere in this scope. The witness's L1 history reaches L2 block M only; the contract-pinned endingL2SequenceNumber = N > M.

  • StubExecutor is a real base_proof_driver::Executor impl. execute_payload and compute_output_root are never called for these tests because EndOfSource fires on the first produce_payload call, but the type must be a real Executor to satisfy Driver::new.

  • epilogue_real is a verbatim copy of crates/succinct/utils/client/src/witness/executor.rs:172-180. The static epilogue_source_matches test asserts the in-tree code matches the copy and that the missing block-number reconciliation is still absent.

  • Both tests build the same Driver with ShortPipeline+StubExecutor+PipelineCursor seeded at safe_head=M, safe_head_output_root=root@M. They then call the real Driver::advance_to_target(cfg, Some(N), |_,_|{}). The two tests differ only in boot.claimed_l2_output_root.

Observed asymmetry + on-chain journal-hash equivalence

  • Test A (attacker_proof_passes_under_short_l1head): real Driver::advance_to_target(target=N) returns Ok((safe_head=M, output_root=root@M)) via the EndOfSource silent-target-rewrite branch. With boot.claimed_l2_output_root = root@M, real epilogue_real returns Ok; real BootInfoStruct::new commits journal {l2BlockNumber=N, l2PostRoot=root@M}. The forged proof is structurally generable.

  • Test B (honest_counter_proof_blocked_under_short_l1head): identical driver setup; real Driver::advance_to_target(target=N) again returns Ok((safe_head=M, output_root=root@M)). With boot.claimed_l2_output_root = root@N (the value an honest challenger would need to commit to invoke nullify(zk_proof, K, root@N)), real epilogue_real returns Err("Failed to validate L2 block #M with claimed output root 0xbb...bb. Got 0xaa...aa instead"). No SP1 proof binding {l2BlockNumber=N, l2PostRoot=root@N, l1OriginHash=game.l1Head()} can be produced; nullify/challenge cannot be successfully invoked.

  • Test C (contract_journal_hash_matches_sp1_commitment): independently constructs (1) the SP1 aggregation program's committed digest = keccak256(AggregationOutputs.abi_encode_packed()) for an attacker-crafted AggregationOutputs{l2PostRoot=root@M, endingL2SequenceNumber=N, ...} (per crates/proof/succinct/programs/aggregation/src/main.rs:117), and (2) the contract-assembled _verifyZkProof journal hash = keccak256(abi.encodePacked(proposer, l1OriginHash, ..., CONFIG_HASH, ZK_RANGE_HASH)) (per src/multiproof/AggregateVerifier.sol:920-927 v8.1.0) for the same field values. Asserts the two digests are byte-equal. Bridges Tests A/B to the on-chain consequence: an attacker who succeeds at Test A produces a proof whose committed digest equals the journal hash the contract's nullify(_, K, root@M) would assemble; SP1_VERIFIER.verifyProof therefore accepts.

The four tests together exhibit the asymmetry through real production code: Driver::advance_to_target body, the Pipeline/SignalReceiver/DriverPipeline trait dispatch, the EndOfSource → silent target rewrite branch, the executor epilogue conditional, BootInfoStruct::new's journal construction, and the SP1 aggregation program's keccak256(AggregationOutputs.abi_encode_packed()) commitment format are all real; only the L1-data source (the witness behind the pipeline) is substituted, exactly as the attacker would substitute it on-chain. Test C closes the loop on-chain.

Reproduction

Expected verdict:

Was this helpful?