> For the complete documentation index, see [llms.txt](https://reports.immunefi.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://reports.immunefi.com/base/75113-sc-medium-zk-challenge-proofs-use-10-block-intermediate-roots-while-aggregateverifier-verifies.md).

# 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**](https://immunefi.com/audit-competition/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:

```rust
ProveBlockRequest {
    start_block_number,
    number_of_blocks_to_prove: candidate.intermediate_block_interval,
    ...
}
```

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:

```rust
pub const DEFAULT_INTERMEDIATE_ROOT_INTERVAL: u64 = 10;
```

and writes that hardcoded value into the SP1 witness:

```rust
let stdin = self.host.witness_generator().get_sp1_stdin(
    witness,
    base_proof_succinct_client_utils::client::DEFAULT_INTERMEDIATE_ROOT_INTERVAL,
)?;
```

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

```rust
if blocks_processed.is_multiple_of(interval) {
    intermediate_roots.push(output_root);
}
```

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

```
root_10 || root_20 || root_30
```

The aggregation program commits those roots into the public values:

```rust
let intermediate_roots: Bytes = agg_inputs
    .boot_infos
    .iter()
    .flat_map(|boot_info| boot_info.intermediateRoots.iter().copied())
    .collect::<Vec<u8>>()
    .into();
```

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

```solidity
_verifyProof(
    proofBytes[1:],
    proofType,
    msg.sender,
    l1Head().raw(),
    startingRoot,
    startingL2SequenceNumber,
    intermediateRootToProve,
    endingL2SequenceNumber,
    abi.encodePacked(intermediateRootToProve)
);
```

The ZK verifier receives the hash of:

```
prover || l1Head || startingRoot || startBlock || root_30 || endBlock || root_30 || configHash || rangeVKey
```

while the official ZK service proves:

```
prover || l1Head || startingRoot || startBlock || root_30 || endBlock || root_10 || root_20 || root_30 || configHash || rangeVKey
```

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:

```
contracts/test/multiproof/AuditZkIntervalMismatch.t.sol
```

Run:

```sh
cd base-azul/contracts
forge test --match-path test/multiproof/AuditZkIntervalMismatch.t.sol -vvv
```

Observed result:

```
Ran 2 tests for test/multiproof/AuditZkIntervalMismatch.t.sol:AuditZkIntervalMismatchTest
[PASS] testOneRootJournalWouldChallengeSuccessfully() (gas: 680062)
[PASS] testZkServiceTenBlockJournalCannotChallengeThirtyBlockOnchainSegmentAndGameFinalizes() (gas: 677556)
Suite result: ok. 2 passed; 0 failed; 0 skipped
```

The test creates a Sepolia-like `AggregateVerifier` with:

```
BLOCK_INTERVAL=600
INTERMEDIATE_BLOCK_INTERVAL=30
```

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.15;

import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol";
import { IDelayedWETH } from "interfaces/dispute/IDelayedWETH.sol";
import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol";
import { Claim, GameStatus, Proposal } from "src/dispute/lib/Types.sol";
import { AggregateVerifier } from "src/multiproof/AggregateVerifier.sol";
import { Verifier } from "src/multiproof/Verifier.sol";
import { BaseTest } from "./BaseTest.t.sol";

contract JournalCheckingVerifier is Verifier {
    bytes32 public expectedJournal;

    constructor(IAnchorStateRegistry anchorStateRegistry) Verifier(anchorStateRegistry) { }

    function setExpectedJournal(bytes32 journal) external {
        expectedJournal = journal;
    }

    function verify(bytes calldata, bytes32, bytes32 journal) external view override notNullified returns (bool) {
        return journal == expectedJournal;
    }
}

contract AuditZkIntervalMismatchTest is BaseTest {
    uint256 internal constant SEPOLIA_BLOCK_INTERVAL = 600;
    uint256 internal constant SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL = 30;
    uint256 internal constant ZK_SERVICE_DEFAULT_INTERMEDIATE_ROOT_INTERVAL = 10;

    JournalCheckingVerifier internal strictZkVerifier;

    function setUp() public override {
        super.setUp();

        strictZkVerifier = new JournalCheckingVerifier(IAnchorStateRegistry(address(anchorStateRegistry)));

        AggregateVerifier sepoliaLikeImpl = new AggregateVerifier(
            AGGREGATE_VERIFIER_GAME_TYPE,
            IAnchorStateRegistry(address(anchorStateRegistry)),
            IDelayedWETH(payable(address(delayedWETH))),
            teeVerifier,
            strictZkVerifier,
            TEE_IMAGE_HASH,
            AggregateVerifier.ZkHashes(ZK_RANGE_HASH, ZK_AGGREGATE_HASH),
            CONFIG_HASH,
            L2_CHAIN_ID,
            SEPOLIA_BLOCK_INTERVAL,
            SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL,
            1
        );

        factory.setImplementation(AGGREGATE_VERIFIER_GAME_TYPE, IDisputeGame(address(sepoliaLikeImpl)));
    }

    function testZkServiceTenBlockJournalCannotChallengeThirtyBlockOnchainSegmentAndGameFinalizes() public {
        bytes32 correctRoot10 = keccak256("correct-root-10");
        bytes32 correctRoot20 = keccak256("correct-root-20");
        bytes32 correctRoot30 = keccak256("correct-root-30");
        bytes32 wrongRoot30 = keccak256("wrong-root-30");

        bytes32[] memory proposedRoots = new bytes32[](SEPOLIA_BLOCK_INTERVAL / SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL);
        proposedRoots[0] = wrongRoot30;
        for (uint256 i = 1; i < proposedRoots.length; i++) {
            proposedRoots[i] = keccak256(abi.encode("proposed-root", i));
        }

        Claim rootClaim = Claim.wrap(proposedRoots[proposedRoots.length - 1]);
        AggregateVerifier game = _createSepoliaLikeGame(rootClaim, proposedRoots);

        Proposal memory startingAnchor = anchorStateRegistry.getStartingAnchorRoot();
        bytes32 zkServiceJournal = _journal(
            ZK_PROVER,
            game.l1Head().raw(),
            startingAnchor.root.raw(),
            uint64(startingAnchor.l2SequenceNumber),
            correctRoot30,
            uint64(startingAnchor.l2SequenceNumber + SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL),
            abi.encodePacked(correctRoot10, correctRoot20, correctRoot30),
            ZK_RANGE_HASH
        );

        bytes32 onchainChallengeJournal = _journal(
            ZK_PROVER,
            game.l1Head().raw(),
            startingAnchor.root.raw(),
            uint64(startingAnchor.l2SequenceNumber),
            correctRoot30,
            uint64(startingAnchor.l2SequenceNumber + SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL),
            abi.encodePacked(correctRoot30),
            ZK_RANGE_HASH
        );

        assertTrue(zkServiceJournal != onchainChallengeJournal);
        assertEq(SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL / ZK_SERVICE_DEFAULT_INTERMEDIATE_ROOT_INTERVAL, 3);

        strictZkVerifier.setExpectedJournal(zkServiceJournal);

        bytes memory zkProof = abi.encodePacked(uint8(AggregateVerifier.ProofType.ZK), bytes("zk-proof"));
        vm.prank(ZK_PROVER);
        vm.expectRevert(AggregateVerifier.InvalidProof.selector);
        game.challenge(zkProof, 0, correctRoot30);

        vm.warp(block.timestamp + game.SLOW_FINALIZATION_DELAY() + 1);
        game.resolve();

        assertEq(uint8(game.status()), uint8(GameStatus.DEFENDER_WINS));
        vm.warp(block.timestamp + 1);
        assertTrue(anchorStateRegistry.isGameClaimValid(IDisputeGame(address(game))));
    }

    function testOneRootJournalWouldChallengeSuccessfully() public {
        bytes32 correctRoot30 = keccak256("correct-root-30");
        bytes32 wrongRoot30 = keccak256("wrong-root-30");

        bytes32[] memory proposedRoots = new bytes32[](SEPOLIA_BLOCK_INTERVAL / SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL);
        proposedRoots[0] = wrongRoot30;
        for (uint256 i = 1; i < proposedRoots.length; i++) {
            proposedRoots[i] = keccak256(abi.encode("proposed-root", i));
        }

        Claim rootClaim = Claim.wrap(proposedRoots[proposedRoots.length - 1]);
        AggregateVerifier game = _createSepoliaLikeGame(rootClaim, proposedRoots);
        Proposal memory startingAnchor = anchorStateRegistry.getStartingAnchorRoot();

        bytes32 onchainChallengeJournal = _journal(
            ZK_PROVER,
            game.l1Head().raw(),
            startingAnchor.root.raw(),
            uint64(startingAnchor.l2SequenceNumber),
            correctRoot30,
            uint64(startingAnchor.l2SequenceNumber + SEPOLIA_INTERMEDIATE_BLOCK_INTERVAL),
            abi.encodePacked(correctRoot30),
            ZK_RANGE_HASH
        );

        strictZkVerifier.setExpectedJournal(onchainChallengeJournal);

        bytes memory zkProof = abi.encodePacked(uint8(AggregateVerifier.ProofType.ZK), bytes("zk-proof"));
        vm.prank(ZK_PROVER);
        game.challenge(zkProof, 0, correctRoot30);
        assertEq(game.counteredByIntermediateRootIndexPlusOne(), 1);
    }

    function _createSepoliaLikeGame(
        Claim rootClaim,
        bytes32[] memory intermediateRoots
    )
        internal
        returns (AggregateVerifier game)
    {
        bytes memory roots;
        for (uint256 i = 0; i < intermediateRoots.length; i++) {
            roots = abi.encodePacked(roots, intermediateRoots[i]);
        }

        bytes memory extraData =
            abi.encodePacked(uint256(SEPOLIA_BLOCK_INTERVAL), address(anchorStateRegistry), roots);
        bytes memory proof = _generateProof("tee-proof", AggregateVerifier.ProofType.TEE);

        vm.deal(TEE_PROVER, INIT_BOND);
        vm.prank(TEE_PROVER);
        game = AggregateVerifier(
            address(
                factory.createWithInitData{ value: INIT_BOND }(
                    AGGREGATE_VERIFIER_GAME_TYPE, rootClaim, extraData, proof
                )
            )
        );
    }

    function _journal(
        address proposer,
        bytes32 l1OriginHash,
        bytes32 startingRoot,
        uint64 startingL2SequenceNumber,
        bytes32 endingRoot,
        uint64 endingL2SequenceNumber,
        bytes memory intermediateRoots,
        bytes32 imageHash
    )
        internal
        view
        returns (bytes32)
    {
        return keccak256(
            abi.encodePacked(
                proposer,
                l1OriginHash,
                startingRoot,
                startingL2SequenceNumber,
                endingRoot,
                endingL2SequenceNumber,
                intermediateRoots,
                CONFIG_HASH,
                imageHash
            )
        );
    }
}
```

## 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.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://reports.immunefi.com/base/75113-sc-medium-zk-challenge-proofs-use-10-block-intermediate-roots-while-aggregateverifier-verifies.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
