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
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):
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.
The executor epilogue checks output_root only, not the safe-head block number.
crates/succinct/utils/client/src/witness/executor.rs:158-180:
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):
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 hitsEndOfSourceatM;client.rs:101rewritestarget = M; the loop returns(safe_head@M, output_root@M);executor.rs:172accepts because the prover suppliedclaimed_l2_output_root = root@M;BootInfoStruct::newcommits journal{l2BlockNumber: N, l2PostRoot: root@M, l1Head: H}; SP1 verifies;nullify/challengeaccepts.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-pinnedl1Head = H. Their guest also hitsEndOfSourceatMand producesoutput_root = root@M. The epilogue check atexecutor.rs:172then evaluatesoutput_root (root@M) != claimed_l2_output_root (root@N)and the executor returns the validation error. No valid SP1 proof committingendingRoot = root@Nfor journal-boundl1OriginHash = Hexists, because no execution under that l1Head reaches stateN.
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 callDisputeGameFactory.create()(external payable, no auth modifier) andMultiProofDisputeGame.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 blockTwhereblockhash(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], whereN = startingL2SequenceNumber + BLOCK_INTERVALis 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 perBLOCK_INTERVALwindow; honest proposers normally wait for batches to land before creating).Multiproof game type registered as
respectedGameType()onOptimismPortal.
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.
Recommended Fix
crates/succinct/utils/client/src/witness/executor.rsepilogue — assertsafe_head.block_info.number == boot.claimed_l2_block_numberimmediately after theoutput_rootcheck, before returningOk((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 whosel2BlockNumberis past where execution actually reached.crates/succinct/utils/client/src/client.rs:106— passdriver.cursor.read().l2_safe_head().block_info.timestamp(a Unix timestamp) tois_isthmus_active, not.number(a block height). Restores the intended Isthmus guard. Alternatively, remove theEndOfSourcefallback entirely so the loop propagates the error unconditionally.crates/succinct/utils/client/src/boot.rs::BootInfoStruct::new— accept the actually-reachedsafe_head.block_info.numberrather thanboot_info.claimed_l2_block_number, or assert their equality at construction. Makes the inflation impossible to introduce at the journal-build site.
Link to Proof of Concept
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/withlib.rssymlinked from~/known-bugs/base/chain-validity-l1head-short-unchallengeable/poc_test.rs.ShortPipelineis a realbase_consensus_derive::Pipeline + SignalReceiver + DriverPipelineimpl whosestep()always returnsStepResult::StepFailed(PipelineErrorKind::Critical(PipelineError::EndOfSource)). This is the attacker as a real type implementing the production pipeline trait — the sameMissingPreimageProvider/MaliciousOracleidiom used elsewhere in this scope. The witness's L1 history reaches L2 blockMonly; the contract-pinnedendingL2SequenceNumber = N > M.StubExecutoris a realbase_proof_driver::Executorimpl.execute_payloadandcompute_output_rootare never called for these tests because EndOfSource fires on the firstproduce_payloadcall, but the type must be a realExecutorto satisfyDriver::new.epilogue_realis a verbatim copy ofcrates/succinct/utils/client/src/witness/executor.rs:172-180. The staticepilogue_source_matchestest asserts the in-tree code matches the copy and that the missing block-number reconciliation is still absent.Both tests build the same
DriverwithShortPipeline+StubExecutor+PipelineCursorseeded atsafe_head=M,safe_head_output_root=root@M. They then call the realDriver::advance_to_target(cfg, Some(N), |_,_|{}). The two tests differ only inboot.claimed_l2_output_root.
Observed asymmetry + on-chain journal-hash equivalence
Test A (
attacker_proof_passes_under_short_l1head): realDriver::advance_to_target(target=N)returnsOk((safe_head=M, output_root=root@M))via the EndOfSource silent-target-rewrite branch. Withboot.claimed_l2_output_root = root@M, realepilogue_realreturnsOk; realBootInfoStruct::newcommits journal{l2BlockNumber=N, l2PostRoot=root@M}. The forged proof is structurally generable.Test B (
honest_counter_proof_blocked_under_short_l1head): identical driver setup; realDriver::advance_to_target(target=N)again returnsOk((safe_head=M, output_root=root@M)). Withboot.claimed_l2_output_root = root@N(the value an honest challenger would need to commit to invokenullify(zk_proof, K, root@N)), realepilogue_realreturnsErr("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-craftedAggregationOutputs{l2PostRoot=root@M, endingL2SequenceNumber=N, ...}(percrates/proof/succinct/programs/aggregation/src/main.rs:117), and (2) the contract-assembled_verifyZkProofjournal hash =keccak256(abi.encodePacked(proposer, l1OriginHash, ..., CONFIG_HASH, ZK_RANGE_HASH))(persrc/multiproof/AggregateVerifier.sol:920-927v8.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'snullify(_, K, root@M)would assemble;SP1_VERIFIER.verifyProoftherefore 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?