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=621This 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
30and 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_WINSand becomes claim-valid inAnchorStateRegistry.
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.24Smart Contract target:
https://github.com/base/contracts/tree/v8.1.0/src/multiproofSepolia Azul task config sets
INTERMEDIATE_BLOCK_INTERVAL=30:contract-deployments/sepolia/2026-04-20-activate-multiproof/.env:14-15Challenger requests
candidate.intermediate_block_intervalblocks, but does not include the intermediate-root interval in the request:base/crates/proof/challenge/src/driver.rs:624-632ProveBlockRequesthas nointermediate_root_intervalfield:base/crates/proof/zk/client/proto/zk_prover.proto:17-33ZK client hardcodes
DEFAULT_INTERMEDIATE_ROOT_INTERVAL = 10:base/crates/proof/succinct/utils/client/src/client.rs:18-19ZK service passes the hardcoded default interval into witness generation:
base/crates/proof/zk/service/src/backends/op_succinct/provider.rs:100-103Range executor records intermediate roots every configured interval:
base/crates/proof/succinct/utils/client/src/client.rs:188-192Aggregation program commits all intermediate roots into public values:
base/crates/proof/succinct/programs/aggregation/src/main.rs:80-111AggregateVerifier.challenge()hashes onlyabi.encodePacked(intermediateRootToProve)for one 30-block segment:contracts/src/multiproof/AggregateVerifier.sol:511-521_verifyZkProof()builds the verifier journal from those exactintermediateRoots:contracts/src/multiproof/AggregateVerifier.sol:917-932The on-chain segment length is derived from
INTERMEDIATE_BLOCK_INTERVAL:contracts/src/multiproof/AggregateVerifier.sol:1018-1024Runnable PoC:
contracts/test/multiproof/AuditZkIntervalMismatch.t.sol
Suggested Fix
Add an
intermediate_root_intervalfield toProveBlockRequest.Have the challenger pass
candidate.intermediate_block_intervalto the ZK service.Remove the hardcoded
DEFAULT_INTERMEDIATE_ROOT_INTERVALfrom the service path for on-chain dispute proofs.Add an assertion that the public input
intermediateRoots.lengthmatches what the targetAggregateVerifierwill hash forchallenge()/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_30On-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?