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:
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:
pub const DEFAULT_INTERMEDIATE_ROOT_INTERVAL: u64 = 10;
and writes that hardcoded value into the SP1 witness:
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:
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:
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:
_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
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.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_intervalblocks, but does not include the intermediate-root interval in the request:base/crates/proof/challenge/src/driver.rs:624-632 ProveBlockRequesthas nointermediate_root_intervalfield: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 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-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_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:
contracts/test/multiproof/AuditZkIntervalMismatch.t.sol
Run:
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
// 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.