75354 bc medium hardcoded 10 block zk witness interval makes 30 block challenge proofs unverifiable
Submitted on Apr 28th 2026 at 17:46:05 UTC by @Tradi3 for Audit Comp | Base Azul
Report ID: #75354
Report Type: Blockchain/DLT
Report severity: Medium
Target: https://github.com/base/base/tree/v0.8.0-rc.28
Impacts:
Direct loss to Base or users ≥ 10% of funds held within Bridge.
Description
Brief/Intro
Base Azul's deployed multiproof verifier is configured for 30-block intermediate checkpoints, but the shipped ZK witness path always builds SP1 stdin with a hardcoded 10-block intermediate-root interval. For a 30-block challenge() or nullify() proof, AggregateVerifier verifies a journal containing exactly one intermediate root, while the official ZK service commits to a journal containing three roots.
That makes the official challenger/prover path produce ZK proofs for different public inputs than the contract verifies. If an invalid TEE proposal needs to be corrected by the ZK fallback path, the bundled service repeatedly generates an unverifiable proof and the invalid proposal can survive the dispute window.
This maps to the Base Azul critical impact: circumventing the dispute/challenge mechanism to prevent correction of an invalid proposal before finalization. Because finalized invalid output roots can be used as the basis for bridge withdrawals, this also matches the selected bridge-funds-at-risk impact.
Reviewer shortcut: the secret gist in the PoC section includes base-azul-source-backed-regression-poc.py. That script reads the scoped source repos directly and verifies the deployed interval, the hardcoded ZK witness interval, the service/protobuf/challenger request path, and the one-root journal checked by AggregateVerifier. It can also be run as python base-azul-source-backed-regression-poc.py --as-regression-test, where it exits non-zero while this mismatch is present.
Vulnerability Details
The deployed Base Azul multiproof parameters are:
base/contract-deploymentsate76fde63ff26d19d47a1f38a746d2b495bc60cd2sepolia/2026-04-20-activate-multiproof/.env:14-16BLOCK_INTERVAL=600INTERMEDIATE_BLOCK_INTERVAL=30PROOF_THRESHOLD=1
The verifier uses that 30-block interval on-chain:
base/contractsat34dbd708c063729caed8d3f5e975963ce458076dsrc/multiproof/AggregateVerifier.sol:520and:586passabi.encodePacked(intermediateRootToProve)into proof verification forchallenge()andnullify(). That is a one-root journal for one intermediate checkpoint.src/multiproof/AggregateVerifier.sol:1022-1023computes the challenged subrange usingINTERMEDIATE_BLOCK_INTERVAL.
The official ZK prover path does not use that interval:
base/baseat4b389f03b4963ec7bbb4d024445af6281a90d9a8crates/proof/succinct/utils/client/src/client.rs:19definesDEFAULT_INTERMEDIATE_ROOT_INTERVAL: u64 = 10.crates/proof/succinct/utils/client/src/client.rs:190records a root whenblocks_processed.is_multiple_of(interval).crates/proof/zk/service/src/backends/op_succinct/provider.rs:100-103passes the hardcodedDEFAULT_INTERMEDIATE_ROOT_INTERVALintoget_sp1_stdin().crates/proof/succinct/validity/src/proof_requester.rs:217-219does the same for proof requests.crates/proof/succinct/programs/range/ethereum/src/main.rs:27reads the interval from stdin, so the program commits to whatever interval the service supplied.crates/proof/challenge/src/driver.rs:626builds ZK requests withnumber_of_blocks_to_prove: candidate.intermediate_block_interval, but the request/API has no field that passes the 30-block journal interval through to the ZK service.
The result is deterministic:
Contract side for a 30-block challenge/nullify proof:
intermediateRoots = root_at_30, 32 bytes.Official ZK service for the same 30-block request:
intermediateRoots = root_at_10 || root_at_20 || root_at_30, 96 bytes.
The SP1 aggregation path commits to a hash over the packed public journal. AggregateVerifier independently constructs the expected packed journal and asks ZKVerifier.verify() to verify that digest. Since the official proof and the contract are using different journal bytes, the proof cannot satisfy the verifier.
Impact
An invalid TEE proposal can require ZK fallback correction. In the shipped stack, the challenger detects the invalid checkpoint and requests a proof for one candidate.intermediate_block_interval range. The ZK service then builds the witness with interval 10, creating a proof for the wrong public journal. The challenge() or nullify() transaction fails proof verification.
Retries do not fix this, because each retry uses the same hardcoded interval. With PROOF_THRESHOLD=1, a malicious or faulty TEE proposal only needs its original proof plus time delay to resolve if the ZK correction path cannot submit a valid proof before finalization.
A custom, manually patched prover that uses interval 30 could work around this. The issue is that the official Base Azul challenger/prover stack as shipped cannot exercise the deployed on-chain ZK dispute path for this configuration.
I checked the published known-vulnerability list and did not find this issue. The closest item is "No block range validation on ProveBlock", but this is different: the range length is the intended 30-block challenge interval; the failure is that the public journal interval inside the official ZK witness generation path is hardcoded to 10 while the verifier expects 30.
Link to Proof of Concept
https://gist.github.com/krutftw/4cced8aa9a16f46c3253f4c82d70596e
Proof of Concept
Secret gist with full report and standalone PoC:
https://gist.github.com/krutftw/4cced8aa9a16f46c3253f4c82d70596e
The attached gist contains:
base-azul-zk-interval-mismatch-report.mdbase-azul-zk-interval-mismatch-poc.pybase-azul-source-backed-regression-poc.py
Recommended reviewer PoC
Clone the three scoped repositories.
Run the source-backed regression PoC:
This script reads the actual scoped source files and deployed config. It verifies:
deployed
INTERMEDIATE_BLOCK_INTERVAL=30DEFAULT_INTERMEDIATE_ROOT_INTERVAL=10official ZK service passes the default interval into
get_sp1_stdin()range program reads the interval from stdin
challenger only passes
number_of_blocks_to_prove = candidate.intermediate_block_intervalProveBlockRequesthas nointermediate_root_intervaloverrideAggregateVerifier.challenge()/nullify()verify a one-root checkpoint journal
Expected output from the scoped revisions:
The same script can be run as a CI regression test with:
In regression mode it exits with status 1 while the vulnerable mismatch is present.
### Minimal standalone model
The second PoC models only public-input construction. It does not need to run SP1, because the failure is before execution correctness: the proof is created for a different public journal than the verifier checks.
Steps:
Download the PoC from the secret gist.
Run
python base-azul-zk-interval-mismatch-poc.py.Observe that the deployed contract journal and official ZK service journal differ for both a 30-block challenge/nullify proof and a 600-block proposal proof.
Expected output:
Why this proves the issue:
AggregateVerifier.challenge()/nullify()verify one deployed intermediate checkpoint, so the contract-side public input contains one root for a 30-block proof.The official ZK service passes
DEFAULT_INTERMEDIATE_ROOT_INTERVAL = 10into SP1 stdin, so the proof commits to three roots for the same 30-block proof request.A proof generated for the 96-byte journal cannot verify against the contract's 32-byte journal digest.
The same mismatch exists for full proposal proofs: the contract expects 20 intermediate roots for 600/30, while the official ZK path emits 60 roots for 600/10.
Was this helpful?