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

75423 sc medium zk range client accepts truncated post azul execution allowing invalid aggregateverifier state roots

Submitted on Apr 29th 2026 at 03:01:08 UTC by @Brainiac5 for Audit Comp | Base Azul

  • Report ID: #75423

  • Report Type: Smart Contract

  • Report severity: Medium

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

  • Impacts:

    • Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1

Description

Brief

The SP1 range client handles EndOfSource incorrectly after Isthmus/Azul. It checks fork activation using an L2 block number where the rollup config expects an L2 timestamp. Because Base Sepolia block numbers are much smaller than fork timestamps, the post-Azul proof client treats a missing data source as if it were allowed pre-Isthmus truncation. A prover can therefore stop derivation early, still commit the requested later L2 block number in public values, and produce an aggregation journal that AggregateVerifier accepts for a full proposal/challenge window.

Asset/location:

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

  • Smart Contract impact surface: contracts/src/multiproof/AggregateVerifier.sol

Vulnerability Details

In base-rc28/crates/succinct/utils/client/src/client.rs, advance_to_target() handles PipelineError::EndOfSource like this:

RollupConfig::is_isthmus_active() expects a timestamp, not a block number. On Base Sepolia after Azul, the safe-head timestamp is after Isthmus, but the L2 block number is still around 40_000_000, far below the Isthmus timestamp. The guard returns false and the client continues instead of rejecting the truncated proof.

The next issue is that the witness executor only checks the output root, not that derivation reached the requested block:

Then BootInfoStruct::new() commits the requested boot.claimed_l2_block_number, not the returned safe-head block number:

So if a malicious prover derives only to block N + 20, sets claimed_l2_output_root = root(N + 20), and sets claimed_l2_block_number = N + 600, the range proof public values can claim a 600-block transition while only executing 20 blocks.

This becomes exploitable against AggregateVerifier because the range program’s intermediate_root_interval is prover input and is not separately bound to the onchain interval. Sepolia uses:

So the contract expects 20 intermediate roots for a 600-block proposal. A malicious prover can run the range program with intermediate_root_interval = 1, derive only the first 20 blocks, and produce exactly 20 intermediate roots. The byte shape matches what AggregateVerifier expects, but the roots correspond to blocks N+1..N+20, not N+30..N+600.

The aggregation program then commits those roots into the same packed journal shape used by AggregateVerifier:

AggregateVerifier has no way to tell that the 20 roots came from one-block spacing instead of 30-block spacing, because the ZK public values do not bind the interval used inside the range client.

Impact Details

This allows an invalid ZK proposal for game type 621:

  1. Start from a valid anchor root at block N.

  2. Derive only the first 20 L2 blocks.

  3. Stop the preimage/data source after block N + 20.

  4. The buggy EndOfSource branch accepts the truncation even though Base Sepolia is post-Azul.

  5. The proof commits:

    • l2PreBlockNumber = N

    • l2BlockNumber = N + 600

    • l2PostRoot = root(N + 20)

    • intermediateRoots = root(N+1)..root(N+20)

  6. The aggregation digest equals the journal that AggregateVerifier verifies for a 600-block proposal.

  7. Because Sepolia activation uses PROOF_THRESHOLD=1, a single accepted ZK proof is enough for the game to resolve DEFENDER_WINS after the finalization delay.

The resulting root is invalid for the claimed L2 sequence number. It is a real canonical root for an earlier block, but it is being accepted as the root for a later block. That breaks the binding between L2 sequence number and output root, which is the exact binding the ZK proof is supposed to enforce.

This does not require:

  • TEE compromise.

  • Guardian/admin inaction for the proof to be accepted.

  • RPC/admin port exposure.

  • A trusted signer mistake.

Manual blacklisting or a later counter-proof may be possible operational mitigation, but the vulnerability is that the proof system accepts the invalid proof in the first place.

Why This Is In Scope

This is in the base/base v0.8.0-rc.28 ZK proof code and affects the smart-contract verifier path in AggregateVerifier.

The impact is not an operational RPC exposure or a trusted-admin action. The proof program itself accepts an incomplete derivation and produces public values that match the onchain verifier journal for a longer transition than was executed.

Suggested Fix

  • In advance_to_target(), call cfg.is_isthmus_active() with the safe-head timestamp, not the safe-head block number.

  • In proof mode, do not lower a requested target after EndOfSource; return an error when a target was requested and the pipeline cannot reach it.

  • After advance_to_target(), assert safe_head.block_info.number == boot.claimed_l2_block_number.

  • Include the effective intermediate_root_interval or expected root spacing/count in the committed public values and onchain journal.

  • In the aggregation program, reject range proofs whose intermediateRoots.len() does not match (l2BlockNumber - l2PreBlockNumber) / expected_interval.

References

  • base-rc28/crates/succinct/utils/client/src/client.rs:95

  • base-rc28/crates/succinct/utils/client/src/client.rs:100

  • base-rc28/crates/succinct/utils/client/src/client.rs:106

  • base-rc28/crates/succinct/utils/client/src/witness/executor.rs:158

  • base-rc28/crates/succinct/utils/client/src/witness/executor.rs:172

  • base-rc28/crates/succinct/utils/client/src/boot.rs:45

  • base-rc28/crates/succinct/utils/client/src/boot.rs:47

  • base-rc28/crates/succinct/programs/aggregation/src/main.rs:80

  • base-rc28/crates/succinct/programs/aggregation/src/main.rs:102

  • contracts/src/multiproof/AggregateVerifier.sol:511

  • contracts/src/multiproof/AggregateVerifier.sol:917

  • contracts/src/multiproof/AggregateVerifier.sol:932

  • contracts/test/multiproof/AuditZkEndOfSourceTruncation.t.sol:52

  • contracts/test/multiproof/AuditZkEndOfSourceTruncation.t.sol:67

  • contracts/test/multiproof/AuditZkEndOfSourceTruncation.t.sol:92

  • contracts/test/multiproof/AuditZkEndOfSourceTruncation.t.sol:107

  • contract-deployments/sepolia/2026-04-20-activate-multiproof/.env:14

  • contract-deployments/sepolia/2026-04-20-activate-multiproof/.env:15

  • contract-deployments/sepolia/2026-04-20-activate-multiproof/.env:16

Confidence

Confirmed locally with runnable Rust and Foundry PoCs. The Rust PoC proves the root cause, truncated execution, incorrect public values, and exact journal match. The Foundry PoC proves that this journal is sufficient for AggregateVerifier to resolve DEFENDER_WINS and become claim-valid on L1.

Proof of Concept

There are two PoCs:

  • A Rust PoC proving the ZK range/aggregation public-value bug.

  • A Foundry PoC proving the resulting journal is enough for AggregateVerifier to create, resolve, and accept a 600-block game whose root came from only the first 20 blocks.

PoC 1 - ZK Client And Journal Construction

PoC worktree:

PoC file:

I added the base-protocol test-utils feature in this local PoC so the test can build encoded L1-info deposit transactions. The only required dependency change for PoC 1 is this line in base-rc28/crates/succinct/utils/client/Cargo.toml:

Run:

Observed result:

The most important test is audit_truncated_twenty_block_range_can_commit_sepolia_600_block_proposal.

It proves:

  • Base Sepolia is post-Isthmus by timestamp.

  • The buggy guard checks the block number and treats the same safe head as pre-Isthmus.

  • The client derives only 20 blocks.

  • The public values still commit l2BlockNumber = start + 600.

  • The 20 one-block roots have the exact byte length expected by Sepolia AggregateVerifier.

  • The aggregation digest equals the packed journal that AggregateVerifier verifies for the 600-block proposal.

Full PoC 1 source

PoC 2 - AggregateVerifier Accepts The Truncated Journal As A Claim-Valid Game

PoC file:

Run:

Observed result:

This test uses a strict journal-checking verifier instead of a permissive mock verifier. The verifier only returns true when the journal equals the exact journal produced by the truncated 20-block proof path.

Full PoC 2 source

This PoC is intentionally not a theft PoC. It proves the in-scope verifier impact: the invalid state root becomes claim-valid through the same AggregateVerifier lifecycle used by withdrawals.

Expected vs Actual

Expected:

After Azul/Isthmus, a ZK proof for a requested target block must fail if the derivation pipeline cannot reach that target. The proof must bind the actual derived safe-head block number, the requested ending L2 block number, and the interval/count of intermediate roots.

Actual:

The range client accepts EndOfSource, lowers the internal target to the current safe head, and returns success. Public values still commit the originally requested target block number. By choosing intermediate_root_interval = 1, a prover can provide the same number of intermediate roots that AggregateVerifier expects for 30-block spacing, while those roots actually correspond to one-block spacing.

Was this helpful?