76321 sc medium supplemental technical correction for report 76106 improper l1 head binding in aggregateverifier initial proof verification can finalize an invalid state root on l1
Submitted on May 3rd 2026 at 20:38:40 UTC by @Singapore_Lion for Audit Comp | Base Azul
Report ID: #76321
Report Type: Smart Contract
Report severity: Medium
Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof
Impacts:
Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1
Circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization
Forcing a dispute game into an incorrect resolved state (e.g., DEFENDER_WINS when CHALLENGER_WINS should apply, or vice versa)
Description
Submission Note
This submission is a supplemental technical correction for previously closed Base Azul Audit Competition Report #76106.
It is being submitted separately only because the original report is closed, the mediation UI says mediation is unavailable, and Immunefi Support has not responded before the competition deadline.
If possible, please merge this evidence into Report #76106 and reassess that report rather than treating this as an unrelated new finding.
The project closed Report #76106 as "intended" with the following rationale:
The new supplemental Base action-harness PoC below directly disproves that closure premise. Base's own derivation harness shows that different L1 heads can produce different L2 safe heads when batch data exists between those L1 heads. Therefore, l1_head is not irrelevant metadata; it bounds the L1 data view used by derivation.
I also added focused proof-client tests for the ZK/Succinct path. These tests show that when the L1 data source is exhausted before the requested target, the proof-client derivation helper can return the current safe head as success, while the SP1 public-value struct still commits the requested claimed_l2_block_number. This is supplemental evidence against the statement that the L1 head is irrelevant as long as the proof attests a block number and state root.
Brief / Intro
AggregateVerifier.initializeWithInitData() accepts an initial TEE or ZK proof whose public input is bound to a prover-chosen recent L1 head, instead of requiring that proof to be bound to the dispute game's stored l1Head(). As a result, a valid proof for L1 head H_stale can initialize a game whose immutable game context is H_game = blockhash(block.number - 1). With PROOF_THRESHOLD = 1, this single mismatched proof is enough for the game to resolve and update AnchorStateRegistry, meaning the protocol can finalize an L2 output root on L1 that was never proven for the game's actual L1 context.
Vulnerability Details
Why this report is about a real production smart-contract bug
This report is not claiming forged cryptography, leaked keys, or a test-only issue.
It is about a production on-chain verification bug in AggregateVerifier.sol: the contract builds the initial proof journal using a proof-supplied L1 head, even though the dispute game itself already has an immutable stored L1 head. The vulnerable entrypoint is the public game creation path DisputeGameFactory.createWithInitData(), so the issue is reachable without admin privileges and is inside the in-scope implementation contracts.
The PoC therefore does not need to break SP1, RISC0, or Nitro signatures. It only needs to show the security-relevant contract behavior: AggregateVerifier passes the wrong journal context into the cryptographic verifier and then treats that proof as valid for the game.
No code comment, deployment note, or protocol documentation describes this mismatch as an intentional temporary compatibility behavior. The documented proof flow instead treats the dispute game's stored l1Head() as the binding game context for proof verification.
Broken Invariant
For a dispute game with stored l1Head() == H_game, every proof accepted by that game must be bound to H_game.
initializeWithInitData() violates this invariant because it verifies the initial proof against l1OriginHash parsed from proof calldata, while the game stores a separate immutable l1Head().
Root cause
The root cause is a public-input / contract-state binding mismatch in AggregateVerifier.
DisputeGameFactory stores the dispute game's L1 head at creation time as the parent block hash:
However, AggregateVerifier.initializeWithInitData() does not bind the initial proof to that stored l1Head(). Instead, it parses a proof-supplied L1 origin directly from calldata:
_verifyL1Origin() only checks that l1OriginHash matches a recent canonical L1 block hash. It does not require l1OriginHash == l1Head().raw().
That is inconsistent with the later proof path in the same contract. verifyProposalProof() binds subsequent proofs to the stored game L1 head:
The TEE and ZK verifiers both receive a journal digest that includes the chosen L1 head:
So the contract is not merely storing inconsistent metadata. It is actually asking the cryptographic verifier to validate the proof for H_stale, then recording that proof as the accepted initial proof for a game whose immutable L1 context is H_game.
Why the L1 head is security-critical
This matters because Base's proof pipeline treats l1_head as trust-critical derivation context:
Base's fault-proof specification states that
l1_headis the L1 block hash perceived as the tip of the L1 chain, and that no later L1 data is available to the proof program.The proof client constructs
OracleL1ChainProvider::new(boot.l1_head, ...), so derivation is parameterized by that head.OracleL1ChainProviderstarts header traversal fromself.l1_headand rejects by-number queries above that header withBlockNumberPastHead.The prover request model carries an optional pinned
l1_head; the service validates it, stores it with the proof request, and passes it into witness generation.The ZK boot/public values include
l1Head, and the aggregation program commitsl1Headinto the final aggregation output digest.The Nitro enclave derives
l1_origin_hashfromboot_info.l1_headand includes it in the signedProofJournal.Base's proof docs and proof encoder explicitly treat the game's stored
l1Head()as the L1 context forverifyProposalProof(),challenge(), andnullify().
That means the proof statement is effectively: "this output root is valid under this L1 head." The contract bug changes the accepted on-chain statement into: "this output root is valid under some recent L1 head chosen by the prover, but it will be treated as a proof for the game's stored L1 head."
This last point is important for the closure rationale. Base's own docs say that verifyProposalProof(proofBytes) does not re-read a new L1 origin from calldata and instead uses the l1Head() captured by the factory at clone creation. The proof encoder says the compact proof bytes for AggregateVerifier.nullify(), challenge(), and verifyProposalProof() omit l1OriginHash and l1OriginNumber because those entrypoints already have l1Head stored in CWIA. In other words, the documented non-initial proof flow already assumes the L1 head is the stored game context, not arbitrary irrelevant metadata.
Supplemental evidence: Base action-harness disproves that l1_head is irrelevant
After Report #76106 was closed, I created an additional Base action-harness test to directly test the project's closure rationale.
The test uses Base's own derivation/action harness and shows that two derivation runs with the same starting state and the same sequencer-built L2 block produce different L2 safe heads when the L1 view is capped at different L1 heads:
H_stale: the L1 view is capped at genesis, before the L1 block containing the batch data.H_game: the L1 view includes L1 block 1, which contains the batch data for the same sequencer-built L2 block.Under
H_stale, derivation cannot see the batch-bearing L1 block and remains at L2 genesis.Under
H_game, derivation sees the batch-bearing L1 block and derives L2 block 1.The resulting L2 safe heads differ.
This directly contradicts the closure claim that "it does not matter which L1 block was used." It matters whenever relevant L1 data, such as batch data, deposits, or other derivation inputs, exists between the two L1 heads.
Action-harness PoC file:
Full action-harness PoC code:
Run command:
Observed passing output:
This test is intentionally independent from the Solidity mock-verifier PoC below. The Solidity PoC proves that AggregateVerifier.initializeWithInitData() accepts a mismatched proof context. The Base action-harness PoC proves that the mismatched proof context is security-relevant because the selected l1_head changes the L1 data view and can change the derived L2 safe head.
Supplemental evidence: proof-client target binding in the ZK path
I added three focused local Rust tests to Base's proof-client code to check the follow-up claim that the L1 head is irrelevant "as long as proofs attest to the correct L2 block number and state root."
The relevant production code path is:
WitnessExecutor::run()calls the Succinctadvance_to_target()helper withSome(boot.claimed_l2_block_number).If the derivation data source is exhausted,
advance_to_target()sets the requested target to the current safe-head number and then returnsOk((safe_head, output_root, intermediate_roots)).WitnessExecutor::run()then checks onlyoutput_root == boot.claimed_l2_output_root. It does not assertsafe_head.block_info.number == boot.claimed_l2_block_number.BootInfoStruct::new()commitsl2BlockNumber: boot_info.claimed_l2_block_numberinto the SP1 public values.
The tests prove those points directly:
base-proof-driver::core::tests::end_of_source_downgrades_requested_target_to_current_safe_headrequests target L2 block
1simulates
PipelineError::EndOfSourceobserves successful return of safe head
0
base-proof-succinct-client-utils::client::tests::succinct_advance_to_target_accepts_end_of_source_before_requested_targetrepeats the same behavior through the Succinct helper using Base mainnet rollup config
observes successful return of safe head
0for requested target1
base-proof-succinct-client-utils::boot::tests::boot_info_struct_commits_claimed_target_block_numberconstructs SP1 public values with actual pre/safe-head number
0confirms
l2BlockNumberis still the requestedclaimed_l2_block_number1
This is not presented as a separate impacted asset claim. It is supporting proof-pipeline evidence for the in-scope smart-contract bug: the L1 head is derivation context, and the proof pipeline relies on correct binding between the claimed L2 statement and the L1 data view.
Proof-client test locations:
Run commands:
Observed passing output:
Full proof-client test patch:
Why the closure rationale would only hold under a different proof design
The closure rationale would be correct only if l1_head were not used as a derivation boundary and the proof statement were fully independent of the selected L1 head.
For example, if the proof statement committed to a complete, independently canonicalized derivation transcript and l1_head were used only to prove that some blob or batch data came from some real L1 block, then the exact L1 head might be irrelevant.
That is not how Base's actual derivation/proof flow behaves. In the current flow, l1_head bounds the L1 data view available to derivation. Therefore, a proof generated under H_stale proves the output root under the L1 data view capped at H_stale; it is not generally equivalent to a proof generated under H_game when relevant L1 data exists between those heads.
The action-harness PoC above gives a concrete counterexample: the stale L1 view cannot see the batch-bearing L1 block and remains at L2 genesis, while the later L1 view sees that same batch and derives L2 block 1. The resulting safe heads differ.
Impact Details
This is appropriately classified as Critical.
Attack Preconditions
An attacker can create a game through
DisputeGameFactory.createWithInitData().The attacker supplies a proof bound to a recent canonical L1 head
H_stale.H_stale != game.l1Head()._verifyL1Origin()acceptsH_staleas recent and canonical.The proof is valid for
H_stale.The active multiproof configuration allows the game to resolve once the proof threshold is met.
The accepted proof is then used to resolve the game and update
AnchorStateRegistry.
An attacker can create a dispute game with stored L1 head H_game, but supply an initial proof whose public input is bound to another recent canonical L1 head H_stale. If the L1 data between H_stale and H_game changes derivation inputs such as batch data, deposits, or withdrawals, then the output root proven under H_stale can differ from the output root that should be required under H_game.
The proof remains cryptographically valid for H_stale; it is invalid only with respect to the game context. Nevertheless, AggregateVerifier accepts it as the game's initial proof. AggregateVerifier only allows thresholds 1 or 2, and the referenced multiproof activation configs set PROOF_THRESHOLD=1 for activation. Under that active configuration, one accepted mismatched initial proof is enough for the attacker to move the game to a valid resolved state and update AnchorStateRegistry using a root that was never proven against the dispute game's actual L1 head.
This is the key impact chain: the contract accepts a proof for H_stale, treats it as if it were a proof for H_game, and then finalizes the game on that basis. The bug is therefore not just an inconsistent input check. It is an on-chain proof-context bypass that can finalize an invalid state root on L1 with respect to the dispute game's actual context.
This directly matches the in-scope Critical impacts:
Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1Circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization
This is also distinct from the known Nitro certificate issues. It does not rely on NitroEnclaveVerifier, trustedCertsPrefixLen, certificate revocation, _cacheNewCert(), leaked keys, or invalid proofs. The bug is the on-chain failure to bind initial proof public input to the stored dispute-game state.
Preemptive clarification on production impact and proof generation
This report does not require a forged proof or a malicious verifier. The exploit condition is that the attacker can provide a proof that is valid for H_stale. The contract bug is that AggregateVerifier.initializeWithInitData() accepts that proof as the initial proof for a game whose immutable stored context is H_game.
A production SP1 or TEE proof is not expected to prove an invalid statement under its own L1 head. The vulnerability is that a proof valid under one L1 head is accepted as if it were valid under another L1 head. A full production proof would only be needed to demonstrate a concrete divergent-root instance; it is not required to establish the on-chain public-input binding bug itself.
The Solidity PoC uses asserting verifier contracts only to prove which journal AggregateVerifier constructs and passes into verification. This is sufficient for the on-chain bug because the vulnerable behavior occurs before and around the verifier call: the contract chooses l1OriginHash from prover-controlled calldata as the proof context instead of using the stored l1Head().
The supplemental Base action-harness PoC addresses the remaining security question: whether the selected L1 head is actually relevant to the proof statement. It shows that l1_head bounds the L1 data view available to derivation and can change the resulting L2 safe head. Therefore, a real proof for H_stale is not generally equivalent to a real proof for H_game.
The proof-client tests further address the "correct L2 block number" part of the closure rationale. They show that the ZK/Succinct client path does not simply make l1_head irrelevant by independently enforcing the requested target block against the actual derived safe head. The public-value struct commits the requested block number from BootInfo, while the derivation helper can return the current safe head when the selected L1 data view is exhausted.
Even if Base's official proposer or challenger software normally requests proofs using the game's stored l1Head, that off-chain behavior is not a security boundary. The vulnerable entrypoint is an on-chain public game creation function that accepts arbitrary proof bytes. The contract must enforce that the accepted initial proof is bound to the immutable game context.
This is also not merely a theoretical or unreachable code path. The vulnerable entrypoint is the public game creation flow through DisputeGameFactory.createWithInitData(). The reproduced multiproof activation tasks deploy and register AggregateVerifier as the game implementation, set the respected game type, and use PROOF_THRESHOLD=1 in the checked activation configs. Under that configuration, a single accepted mismatched initial proof can satisfy the threshold and allow the game to resolve.
This is a mainnet-relevant bug in the Base Azul multiproof contracts. If the same AggregateVerifier initial proof path is activated on Base Mainnet, the bug becomes a mainnet proof-context bypass. If a specific production network had not yet activated this exact configuration at the time of review, that would be a deployment-status consideration, not a reason to classify the bug as "intended" or to claim that l1_head is irrelevant. The contract-level binding bug and the derivation relevance of l1_head remain the same and should be fixed before activation.
References
AggregateVerifier.initializeWithInitData()initial proof path:https://github.com/base/contracts/blob/v8.1.0/src/multiproof/AggregateVerifier.solDisputeGameFactory.createWithInitData()public game creation path:https://github.com/base/contracts/blob/v8.1.0/src/dispute/DisputeGameFactory.solAggregateVerifierconstructor threshold bounds (1or2):https://github.com/base/contracts/blob/v8.1.0/src/multiproof/AggregateVerifier.solSepolia multiproof activation config showing
PROOF_THRESHOLD=1:https://github.com/base/contract-deployments/blob/e76fde63ff26d19d47a1f38a746d2b495bc60cd2/sepolia/2026-04-20-activate-multiproof/.envZeronet multiproof activation config showing
PROOF_THRESHOLD=1:https://github.com/base/contract-deployments/blob/e76fde63ff26d19d47a1f38a746d2b495bc60cd2/zeronet/2026-04-01-activate-multiproof/.envActivation scripts registering
AggregateVerifierand setting the respected game type:https://github.com/base/contract-deployments/blob/e76fde63ff26d19d47a1f38a746d2b495bc60cd2/sepolia/2026-04-20-activate-multiproof/script/ActivateMultiproofStack.s.solBase proof contracts spec stating
verifyProposalProof()uses the factory-capturedl1Head()instead of re-reading a new L1 origin:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/docs/specs/pages/protocol/proofs/contracts.md#L312-L314Base challenger spec stating
l1_headis the L1 head hash stored in the game at creation:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/docs/specs/pages/protocol/proofs/challenger.md#L119-L126Base proof encoder stating
verifyProposalProof(),challenge(), andnullify()proof bytes omitl1OriginHash/l1OriginNumberbecausel1Headis already stored in CWIA:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/primitives/src/proof_encoder.rs#L88-L95Base fault-proof spec stating
l1_headdetermines the available L1 data view and no later L1 data is available:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/docs/specs/pages/protocol/fault-proof/index.md#L257-L300OracleL1ChainProviderimplementation parameterized byl1_headand rejecting blocks above that head:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/proof/src/l1/chain_provider.rs#L19-L52Proof driver
advance_to_target()handlingEndOfSourceby changing the requested target to the current safe head:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/driver/src/core.rs#L63-L96Succinct client
advance_to_target()with the sameEndOfSourcetarget adjustment:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/succinct/utils/client/src/client.rs#L62-L110Succinct witness executor passing
claimed_l2_block_numberintoadvance_to_target()and then checking only output-root equality:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/succinct/utils/client/src/witness/executor.rs#L162-L183ZK boot/public value construction and aggregation output committing
l1Head:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/succinct/utils/client/src/boot.rs#L24-L43https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/succinct/programs/aggregation/src/main.rs#L54-L104Nitro enclave proof journal construction deriving
l1_origin_hashfromboot_info.l1_head:https://github.com/base/base/blob/6a1333dd3f75430a4c2b378510d9aade1b507e37/crates/proof/tee/nitro-enclave/src/server.rs#L171-L178Scope / impact definitions:
https://immunefi.com/audit-competition/audit-comp-base-azul/scope/Public AggregateVerifier audit:
https://cantina.xyz/portfolio/b72c7078-f6da-4074-a3bd-4f938f469fb7Base action harness derivation tests:
https://github.com/base/base/tree/main/actions/harness/testsBase derivation specification:
https://github.com/base/base/blob/main/docs/specs/pages/protocol/consensus/derivation.md
Proof of Concept
The attached Foundry PoC demonstrates the exact vulnerable contract behavior: the initial proof path accepts a proof whose journal is bound to a different L1 head than the dispute game's stored l1Head(), and the game can still resolve and update AnchorStateRegistry.
This PoC is sufficient because the bug is not proof forgery. The bug is that AggregateVerifier constructs and accepts the initial proof journal using a prover-supplied L1 head instead of the game's immutable stored L1 head. The asserting verifier is used only to prove which journal the contract passes into verification.
PoC file:
Full PoC code - save as contracts/test/multiproof/L1OriginBindingMismatch.t.sol:
What the test does:
It creates two different recent L1 block hashes:
staleOriginHashas the proof-supplied L1 head, andstoredGameL1Headas the factory-stored game L1 head.It installs asserting verifier contracts that only return
trueifAggregateVerifierpasses the exact expected journal digest.It sets the expected journal to use
staleOriginHash, notstoredGameL1Head.It creates an
AggregateVerifiergame through the normal public initialization flow with an initial TEE proof and then with an initial ZK proof.It verifies that the game stores
storedGameL1Head, notstaleOriginHash, while still accepting the initial proof and incrementingproofCount.It includes negative-control tests showing that the same initialization path reverts if the verifier is configured to expect a journal built from the stored game L1 head instead.
It calls
resolve()andcloseGame()and confirms thatAnchorStateRegistryis updated to the claimed root and block number after finalization.
Relevant PoC assertions:
Run command:
Observed passing output:
The PoC does not rely on forging proofs. It proves the vulnerable contract behavior directly: the initial proof journal is constructed with a proof-supplied L1 head that differs from the immutable game L1 head, yet the contract still accepts the proof, increments proofCount, and finalizes the game to an anchor update.
Recommendation
Bind the initial proof verification path to the dispute game's immutable stored L1 head.
initializeWithInitData() should not use the proof-supplied l1OriginHash as the L1 head passed into _verifyProof(). Instead, the initial proof should be verified against l1Head().raw(), matching the later verifyProposalProof() path.
At minimum, the contract should reject any initial proof whose supplied L1 origin does not equal the stored game L1 head:
An even safer fix is to stop using the proof-supplied L1 head as the verification context entirely and always pass l1Head().raw() into _verifyProof() during initialization, just like in verifyProposalProof():
Regression tests should cover both TEE and ZK initial proofs and assert that initialization reverts when the proof journal is bound to a different recent L1 head than the stored game l1Head().
Image Attachments
The submission UI only permits image attachments. The full Solidity PoC, the full Base action-harness PoC, and the full proof-client test patch are therefore included directly in this report body above.
The attached screenshots are only execution evidence for the commands and rationale described above:
00-report-76106-closure-rationale.png- Project closure rationale for Report #76106, including the claim thatl1_headis irrelevant.01-foundry-l1-origin-binding-poc-4-pass.png- FoundryAggregateVerifierPoC output showing 4 passing tests.02-action-harness-l1-head-relevance-pass.png- Base action-harness output showing that different L1 heads can produce different L2 safe heads.03-proof-driver-end-of-source-pass.png- Proof-driver target-binding test output.04-succinct-client-target-binding-pass.png- Succinct proof-client target-binding test output.
Was this helpful?