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):
let mut attributes = match driver.pipeline.produce_payload(tip_cursor.l2_safe_head).await {
Ok(attrs) => attrs.take_inner(),
Err(PipelineErrorKind::Critical(PipelineError::EndOfSource)) => {
warn!(target: "client", "Exhausted data source; Halting derivation and using current safe head.");
// Adjust the target block number to the current safe head, as no more blocks
// can be produced.
if target.is_some() {
target = Some(tip_cursor.l2_safe_head.block_info.number); // <-- silently rewrites the prover-supplied target to whatever block was reached
};
// If we are in interop mode, this error must be handled by the caller.
// Otherwise, we continue the loop to halt derivation on the next iteration.
if cfg.is_isthmus_active(driver.cursor.read().l2_safe_head().block_info.number) { // <-- guard receives a block NUMBER where the function expects a UNIX TIMESTAMP
return Err(PipelineError::EndOfSource.crit().into());
}
continue;
}
Err(e) => {
error!(target: "client", "Failed to produce payload: {:?}", e);
return Err(DriverError::Pipeline(e));
}
};
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:
pub fn is_isthmus_active(&self, timestamp: u64) -> bool { // <-- parameter is documented and used as a Unix timestamp
self.hardforks.isthmus_time.is_some_and(|t| timestamp >= t)
|| self.is_jovian_active(timestamp)
}
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:
let (safe_head, output_root, intermediate_roots) = advance_to_target(
&mut driver,
rollup_config.as_ref(),
Some(boot.claimed_l2_block_number),
intermediate_root_interval,
)
.await?;
////////////////////////////////////////////////////////////////
// EPILOGUE //
////////////////////////////////////////////////////////////////
if output_root != boot.claimed_l2_output_root { // <-- only output-root equality is enforced
return Err(anyhow!(
"Failed to validate L2 block #{number} with claimed output root {claimed_output_root}. Got {output_root} instead",
number = safe_head.block_info.number,
output_root = output_root,
claimed_output_root = boot.claimed_l2_output_root,
));
}
// <-- NO assertion that safe_head.block_info.number == boot.claimed_l2_block_number
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:
pub fn new(
boot_info: BootInfo,
l2_pre_block_number: u64,
intermediate_roots: Vec<B256>,
) -> Self {
Self {
l1Head: boot_info.l1_head,
l2PreRoot: boot_info.agreed_l2_output_root,
l2PostRoot: boot_info.claimed_l2_output_root,
l2PreBlockNumber: l2_pre_block_number,
l2BlockNumber: boot_info.claimed_l2_block_number, // <-- copied verbatim from prover input; never compared to the block actually reached
rollupConfigHash: hash_rollup_config(&boot_info.rollup_config),
intermediateRoots: Bytes::from(
intermediate_roots
.iter()
.flat_map(|root| root.as_slice())
.copied()
.collect::<Vec<u8>>(),
),
}
}
The on-chain AggregateVerifier pins the journal-bound l1OriginHash to the immutable game.l1Head().
src/multiproof/AggregateVerifier.sol:904-944 (v8.1.0):
function _verifyZkProof(
bytes calldata proofBytes,
address proposer,
bytes32 l1OriginHash, // <-- caller of _verifyZkProof always passes l1Head().raw()
bytes32 startingRoot,
uint64 startingL2SequenceNumber,
bytes32 endingRoot,
uint64 endingL2SequenceNumber,
bytes memory intermediateRoots
)
internal
view
{
bytes32 journal = keccak256(
abi.encodePacked(
proposer,
l1OriginHash, // <-- bound to game's stored l1Head; SP1 verifier must match this exact value
startingRoot,
startingL2SequenceNumber,
endingRoot,
endingL2SequenceNumber,
intermediateRoots,
CONFIG_HASH,
ZK_RANGE_HASH
)
);
// Validate the proof.
if (!ZK_VERIFIER.verify(proofBytes, ZK_AGGREGATE_HASH, journal)) revert InvalidProof();
}
src/multiproof/AggregateVerifier.sol:548-588 (nullify — same shape as challenge):
function nullify(
bytes calldata proofBytes,
uint256 intermediateRootIndex,
bytes32 intermediateRootToProve
)
external
{
...
(bytes32 startingRoot, uint64 startingL2SequenceNumber, uint64 endingL2SequenceNumber) =
_getStartingIntermediateRootAndL2SequenceNumbers(intermediateRootIndex);
_verifyProof(
proofBytes[1:],
proofType,
msg.sender,
l1Head().raw(), // <-- l1Head is fixed at game creation; cannot be widened by any later caller
startingRoot,
startingL2SequenceNumber,
intermediateRootToProve,
endingL2SequenceNumber,
abi.encodePacked(intermediateRootToProve)
);
...
}
src/multiproof/AggregateVerifier.sol:963-983:
function _verifyL1Origin(bytes32 l1OriginHash, uint256 l1OriginNumber) internal view {
if (l1OriginNumber >= block.number) {
revert L1OriginInFuture(l1OriginNumber, block.number);
}
bytes32 actualHash;
uint256 blockAge = block.number - l1OriginNumber;
// Prefer blockhash() over EIP-2935 when possible since it's cheaper (no external call).
if (blockAge <= BLOCKHASH_WINDOW) {
actualHash = blockhash(l1OriginNumber); // <-- only existence-of-L1-block check; NO check that this L1 block contains enough batches to derive endingL2SequenceNumber
} else {
actualHash = HISTORY_STORAGE_CONTRACT.staticcall(
abi.encodeWithSignature("get(uint256)", l1OriginNumber)
);
}
if (actualHash != l1OriginHash) revert InvalidL1OriginHash(l1OriginHash, actualHash);
}
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
sequenceDiagram
autonumber
participant Att as Attacker (proposer)
participant L2 as L2 sequencer
participant L1 as Ethereum L1
participant DGF as DisputeGameFactory
participant Game as MultiProof Game
participant Chal as Honest Challenger
L2->>L1: publish batches for L2 blocks ..M (lands in L1 block T-K)
Note over L2,L1: batches for L2 (M, N] pending; not yet in L1
Att->>L1: tx(DGF.create(rootClaim=root@M, extraData={l2BlockNumber=N})) high gas
L1->>DGF: include in block T
DGF->>Game: clone with l1Head = blockhash(T-1) -- batch-incomplete history
Note over Game: l1Head() permanently returns blockhash(T-1)
L2->>L1: batches for (M, N] land in block T+1, T+2, ... (irrelevant; game.l1Head is frozen)
Att->>Att: build SP1 proof under witness keyed to l1Head=blockhash(T-1)
Note over Att: guest runs, hits EndOfSource at M, retargets to M, exits Ok with output_root=root@M
Att->>Game: submit proof; game initialized; bond posted
Note over Chal: 7-day window opens
Chal->>Chal: read canonical L2 via local node; compute root@N; observe proposed root@M ≠ root@N
Chal->>Chal: try to build SP1 counter-proof for journal {endingRoot=root@N, l1OriginHash=blockhash(T-1)}
Note over Chal: guest hits EndOfSource at M; output_root=root@M ≠ claimed=root@N;<br/>executor.rs:172 returns Err; NO valid proof exists
Chal--xGame: cannot call nullify(proof, K-1, root@N) — no proof to submit
Note over Game: SLOW_FINALIZATION_DELAY elapses with no nullification
Game->>Game: resolve() -> DEFENDER_WINS
Game-->>Att: bond returned; (l2SequenceNumber=N, rootClaim=root@M) finalized as anchor
Impact Details
Live trigger probe (2026-05-04):
$ curl -s https://mainnet.base.org -X POST -H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1}' | jq -r .result
0x2b6d671 # block 45,602,417 — far below isthmus_time = 1,746,806,401
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):
if output_root != boot.claimed_l2_output_root { // <-- under fixed game.l1Head, this check is the wall that prevents honest counter-proofs from being generated
return Err(anyhow!(
"Failed to validate L2 block #{number} with claimed output root {claimed_output_root}. Got {output_root} instead",
number = safe_head.block_info.number, // <-- safe_head is M
output_root = output_root, // <-- output_root is root@M
claimed_output_root = boot.claimed_l2_output_root, // <-- challenger's required claim is root@N -> Err
));
}
src/multiproof/AggregateVerifier.sol:560-572 (where the corrupted anchor is consumed by the post-challenge resolution path; quoted from nullify):
if (counteredByIntermediateRootIndexPlusOne > 0) {
if (intermediateRootIndex != counteredByIntermediateRootIndexPlusOne - 1) {
revert InvalidIntermediateRootIndex();
}
if (intermediateRootToProve != intermediateOutputRoot(intermediateRootIndex)) {
revert IntermediateRootMismatch(intermediateRootToProve, intermediateOutputRoot(intermediateRootIndex)); // <-- honest challenger never reaches this point because they cannot produce a valid SP1 proof for root@N
}
if (proofType != ProofType.ZK) revert InvalidProofType();
}
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
cd ~/known-bugs/base/chain-validity-l1head-short-unchallengeable
./run_poc.sh
./check.sh
Expected verdict:
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Test S (epilogue source matches simulated logic): PASS
Test A (attacker proof passes under short l1Head): PASS
Test B (honest counter-proof blocked under short l1Head): PASS
Test C (contract journal hash matches SP1 commitment): PASS
VERDICT: ASYMMETRY_DEMONSTRATED_AND_ON_CHAIN_BRIDGED