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

75113 sc medium zk challenge proofs use 10 block intermediate roots while aggregateverifier verifies 30 block segments preventing zk correction of invalid proposals

Submitted on Apr 27th 2026 at 09:59:55 UTC by @Brainiac5 for Audit Comp | Base Azul

  • Report ID: #75113

  • Report Type: Smart Contract

  • Report severity: Medium

  • Target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof

  • Impacts:

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

Description

Brief

The Sepolia Azul AggregateVerifier is deployed with a 30-block intermediate proof interval, but the ZK prover service still hardcodes a 10-block intermediate root interval. The challenger correctly reads 30 from the on-chain game implementation and asks the ZK service to prove a 30-block segment, but the ZK service produces a proof journal containing roots at 10, 20, and 30 blocks. AggregateVerifier.challenge() verifies a journal containing only the single 30-block root. The two journals are different, so a valid ZK proof generated by the official ZK path cannot pass challenge()/nullify() for the configured Sepolia multiproof game type.

Vulnerability Details

Sepolia Azul is configured with:

BLOCK_INTERVAL=600
INTERMEDIATE_BLOCK_INTERVAL=30
GAME_TYPE=621

This means one proposal contains 20 intermediate roots, one per 30 L2 blocks.

The challenger does read the on-chain interval and requests a ZK proof for exactly that segment:

For Sepolia type 621, candidate.intermediate_block_interval is 30.

The problem is that ProveBlockRequest has no field for the intermediate-root interval. It only carries start block, number of blocks, sequence window, proof type, session id, prover address, and L1 head. The ZK service then hardcodes:

and writes that hardcoded value into the SP1 witness:

The range executor records an intermediate output root every interval blocks:

So for a 30-block challenge segment, the ZK proof journal contains three intermediate roots:

The aggregation program commits those roots into the public values:

But AggregateVerifier.challenge() verifies only one root for the same 30-block segment:

The ZK verifier receives the hash of:

while the official ZK service proves:

These cannot both be true for the same proof.

Impact Details

This breaks the permissionless ZK dispute path for the configured Sepolia Azul multiproof game type.

Concrete impact chain:

  • A TEE-backed proposal is created for game type 621.

  • A challenger detects that an intermediate output root is wrong.

  • The challenger reads the game implementation interval as 30 and requests a ZK proof for that 30-block segment.

  • The ZK service hardcodes a 10-block intermediate-root interval and produces public values containing three roots.

  • AggregateVerifier.challenge() reconstructs a public-input hash with only one root.

  • The verifier rejects the proof because the public inputs do not match.

  • The invalid proposal cannot be corrected through the advertised permissionless ZK challenge path before finalization.

  • After the slow finalization delay, the unchallenged game resolves DEFENDER_WINS and becomes claim-valid in AnchorStateRegistry.

This is different from a trusted-admin or intentional TEE-only-window argument. The bug is a concrete cross-repo mismatch after the contract is configured for a 30-block multiproof interval, while the ZK proof path is still fixed to 10. The available guardian/TEE paths may reduce severity, but they do not make the permissionless ZK challenge mechanism function.

References

  • Immunefi Base Azul scope: https://immunefi.com/audit-competition/audit-comp-base-azul/scope/

  • Blockchain/DLT target: https://github.com/base/base/tree/v0.8.0-rc.24

  • Smart Contract target: https://github.com/base/contracts/tree/v8.1.0/src/multiproof

  • Sepolia Azul task config sets INTERMEDIATE_BLOCK_INTERVAL=30: contract-deployments/sepolia/2026-04-20-activate-multiproof/.env:14-15

  • Challenger requests candidate.intermediate_block_interval blocks, but does not include the intermediate-root interval in the request: base/crates/proof/challenge/src/driver.rs:624-632

  • ProveBlockRequest has no intermediate_root_interval field: base/crates/proof/zk/client/proto/zk_prover.proto:17-33

  • ZK client hardcodes DEFAULT_INTERMEDIATE_ROOT_INTERVAL = 10: base/crates/proof/succinct/utils/client/src/client.rs:18-19

  • ZK service passes the hardcoded default interval into witness generation: base/crates/proof/zk/service/src/backends/op_succinct/provider.rs:100-103

  • Range executor records intermediate roots every configured interval: base/crates/proof/succinct/utils/client/src/client.rs:188-192

  • Aggregation program commits all intermediate roots into public values: base/crates/proof/succinct/programs/aggregation/src/main.rs:80-111

  • AggregateVerifier.challenge() hashes only abi.encodePacked(intermediateRootToProve) for one 30-block segment: contracts/src/multiproof/AggregateVerifier.sol:511-521

  • _verifyZkProof() builds the verifier journal from those exact intermediateRoots: contracts/src/multiproof/AggregateVerifier.sol:917-932

  • The on-chain segment length is derived from INTERMEDIATE_BLOCK_INTERVAL: contracts/src/multiproof/AggregateVerifier.sol:1018-1024

  • Runnable PoC: contracts/test/multiproof/AuditZkIntervalMismatch.t.sol

Suggested Fix

  • Add an intermediate_root_interval field to ProveBlockRequest.

  • Have the challenger pass candidate.intermediate_block_interval to the ZK service.

  • Remove the hardcoded DEFAULT_INTERMEDIATE_ROOT_INTERVAL from the service path for on-chain dispute proofs.

  • Add an assertion that the public input intermediateRoots.length matches what the target AggregateVerifier will hash for challenge()/nullify().

  • Add an integration test using the Sepolia task values: BLOCK_INTERVAL=600, INTERMEDIATE_BLOCK_INTERVAL=30.

Confidence

Confirmed locally with a runnable Foundry POC. The POC proves the public-input mismatch, proves that the official-style ZK challenge is rejected for the deployed 30-block interval, and proves that the unchallenged game can then resolve DEFENDER_WINS and become claim-valid in AnchorStateRegistry.

Proof of Concept

PoC file:

Run:

Observed result:

The test creates a Sepolia-like AggregateVerifier with:

It then computes both journals:

  • ZK service journal: root_10 || root_20 || root_30

  • On-chain challenge() journal: root_30

The mock verifier accepts only the ZK service journal. challenge() reverts with InvalidProof, then the game is able to pass the slow finalization delay, resolve DEFENDER_WINS, and become claim-valid in AnchorStateRegistry. A second positive-control test switches the mock verifier to accept the one-root on-chain journal and shows that the same challenge call succeeds. This isolates the issue to the public-input encoding mismatch, not to proof bytes, caller permissions, or the game setup.

PoC Code

Expected vs Actual

Expected:

The ZK challenger should generate public inputs using the same intermediate-root interval that AggregateVerifier uses for the relevant game type. For Sepolia type 621, a challenge for one 30-block segment should produce exactly the one root that AggregateVerifier.challenge() verifies, or the contract and ZK prover should both agree on a smaller sub-interval format.

Actual:

The challenger requests a 30-block proof, but the ZK service hardcodes a 10-block intermediate-root interval. The resulting ZK public values include three roots, while AggregateVerifier.challenge() hashes only one root. The proof cannot verify against the on-chain journal.

Why This Is In Scope

This is not a generic zk circuit issue, the mismatch is in Base’s in-scope proof/challenger integration:

  • The challenger reads the deployed game interval but does not pass it to the ZK service.

  • The ZK service API has no field for the interval.

  • The ZK witness path hardcodes 10.

  • The deployed Sepolia Azul contract uses 30.

  • The Solidity challenge path hashes a one-root 30-block journal.

Was this helpful?