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

75972 bc medium zk range proofs can stop at endofsource but still commit the requested target block allowing a short range proof to finalize an invalid longer range output root

Submitted on May 2nd 2026 at 00:13:49 UTC by @Oxastronatey for Audit Comp | Base Azul

  • Report ID: #75972

  • 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

Immunefi Impact

Impact ID: SC-C1 Description: Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1 Severity: Critical


Summary

The ZK range program is supposed to prove that the claimed L2 output root corresponds to claimed_l2_block_number. Instead, the guest can halt derivation on PipelineError::EndOfSource, check only that the output root equals claimed_l2_output_root, and then commit claimed_l2_block_number from untrusted boot input into the public values. AggregateVerifier later hashes that claimed block number into the ZK journal and accepts the SP1 proof, so a valid proof for a shorter derivation range can satisfy a 600-block onchain proposal.

The exploit shape is: prove only 200 L2 blocks, emit 20 intermediate roots at the ZK guest's hardcoded 10-block interval, submit those same 20 roots as the contract's expected 600/30 roots, and set the root claim to the actual 200-block output root. The contract sees the right number of roots and the last root equals rootClaim, while the ZK public value falsely says the ending L2 sequence is start + 600.

This is a proof statement integrity bug across the in-scope base/base v0.8.0-rc.28 offchain ZK guest programs and the in-scope base/contracts v8.1.0 AggregateVerifier, where the offchain guest commits a claimed block number that it never actually derived to, and the onchain contract trusts it.


Root Cause

1) The range guest uses a hardcoded 10-block intermediate-root interval, not the on-chain INTERMEDIATE_BLOCK_INTERVAL

The offchain ZK witness generator hardcodes a 10-block interval for intermediate root recording, independent of the on-chain INTERMEDIATE_BLOCK_INTERVAL parameter.

crates/succinct/utils/client/src/client.rs#L18-L19

The witness generation service passes this hardcoded constant directly into SP1 stdin:

crates/proof/zk/service/src/backends/op_succinct/provider.rs#L100-L103

The Sepolia activation task sets BLOCK_INTERVAL=600, INTERMEDIATE_BLOCK_INTERVAL=30, and PROOF_THRESHOLD=1:

sepolia/2026-04-20-activate-multiproof/.env#L14-L16

That means the contract expects exactly 600 / 30 = 20 intermediate roots. A short proof of only 200 blocks at the hardcoded 10-block interval emits 200 / 10 = 20 roots — the same count.


2) On EndOfSource, the guest rewrites the target to the current safe head instead of failing the proof

When the derivation pipeline exhausts its L1 data source, the advance_to_target function silently downgrades the target block number to the current safe head and continues the loop. An intended Isthmus-mode guard exists but is called with a block number instead of a timestamp, rendering it ineffective.

crates/succinct/utils/client/src/client.rs#L80-L109

The is_isthmus_active function operates on timestamps, confirming the type mismatch:

crates/consensus/genesis/src/rollup.rs#L216-L220


3) The proof only checks root equality, not that the claimed block number was actually reached

After advance_to_target returns, the witness executor checks that the output root matches the boot-claimed output root, but never checks that the safe head block number equals the boot-claimed block number.

crates/succinct/utils/client/src/witness/executor.rs#L158-L179

The function then returns the original boot input, not a corrected version reflecting the actual safe head reached:

crates/succinct/utils/client/src/witness/executor.rs#L198


4) The committed public values use the requested block number, not the actual reached block

The BootInfoStruct constructed for SP1 commitment uses boot_info.claimed_l2_block_number directly from the untrusted boot input, regardless of how many blocks were actually derived.

crates/succinct/utils/client/src/boot.rs#L35-L57

The range program commits this struct immediately after executor.run accepts the short derivation:

crates/succinct/programs/range/utils/src/lib.rs#L63-L71


5) Aggregation preserves the forged claimed ending block and produces the on-chain journal digest

The aggregation program verifies each range proof's public values, then consolidates them. It does not independently validate that the claimed block numbers match actual derivation — it trusts the range proofs' committed values.

crates/succinct/programs/aggregation/src/main.rs#L44-L52

The consolidated boot info propagates the forged end block:

crates/succinct/programs/aggregation/src/main.rs#L80-L97

The aggregate outputs become the on-chain journal digest:

crates/succinct/programs/aggregation/src/main.rs#L102-L118


6) AggregateVerifier only checks the intermediate-root count/endpoint indirectly and then trusts the ZK proof

The contract checks that the last intermediate root matches rootClaim, but does not independently verify the number of intermediate roots against the expected BLOCK_INTERVAL / INTERMEDIATE_BLOCK_INTERVAL:

src/multiproof/AggregateVerifier.sol#L344-L349

The contract enforces that l2SequenceNumber() equals startingOutputRoot.l2SequenceNumber + BLOCK_INTERVAL, then passes this to _verifyProof:

src/multiproof/AggregateVerifier.sol#L367-L403

The ZK journal hash and verification accept the forged values:

src/multiproof/AggregateVerifier.sol#L904-L933

src/multiproof/zk/ZKVerifier.sol#L32-L45


7) With PROOF_THRESHOLD=1, the invalid proposal can resolve as DEFENDER_WINS

src/multiproof/AggregateVerifier.sol#L456-L466


Internal Pre-conditions

  1. The active AggregateVerifier implementation uses the deployed Sepolia-style intervals: BLOCK_INTERVAL=600, INTERMEDIATE_BLOCK_INTERVAL=30, PROOF_THRESHOLD=1.

  2. The ZK range/aggregate hashes correspond to the scoped programs above.

  3. The attacker can obtain or generate a valid SP1 aggregate proof for custom boot inputs.

  4. The chosen L1 head has enough data to derive a shorter range (e.g. 200 blocks) but not the full 600 blocks.

External Pre-conditions

  1. The attacker can pay the proposal bond and proof-generation cost. No owner, manager, Security Council, sequencer, TEE registrar, or Base infrastructure compromise is required.

  2. The L1 origin blockhash used in the proof prefix is still within the AggregateVerifier blockhash/EIP-2935 verification window.

  3. No honest challenger submits a valid counterproof before finalization. This is the same dispute window assumption the Critical impact definition is testing; the bug is that the proof system accepts a false statement as valid.

Attack Path

1

Start from a valid anchor/root at L2 block S

For example, use S = 40,307,663 from the Sepolia .env.

2

Choose a real L1 head that only contains enough data to derive S + 200

It must not contain enough data to derive S + 600.

3

Run the SP1 range proof with custom boot inputs

  • boot.claimed_l2_block_number = S + 600

  • boot.claimed_l2_output_root = outputRoot(S + 200)

  • intermediate_root_interval = 10

4

advance_to_target() handles EndOfSource by rewriting the target

At client.rs#L95, it rewrites the target to S + 200. The is_isthmus_active guard at client.rs#L106 fails because it receives a block number instead of a timestamp.

5

The loop continues and returns the shorter safe head

The function returns at client.rs#L80-L88 when tip_cursor.l2_safe_head.block_info.number >= S + 200.

6

WitnessExecutor::run() accepts the short output root

It accepts because the returned root equals boot.claimed_l2_output_root; it never checks that the returned safe-head number is S + 600.

7

BootInfoStruct::new() commits the forged block number

It commits l2BlockNumber = S + 600 and l2PostRoot = outputRoot(S + 200).

8

The range proof and aggregate proof preserve the forged values

The range proof commits this at lib.rs#L71, and the aggregate proof propagates it at main.rs#L92 and main.rs#L108.

9

Create an AggregateVerifier game with mismatched root and sequence number

Use rootClaim = outputRoot(S + 200) while the clone immutable l2SequenceNumber() is S + 600.

10

The root-count expectation still matches

600 / 30 = 20, exactly matching the 200 / 10 = 20 roots emitted by the ZK guest.

11

ZKVerifier.verify() accepts the SP1 proof

proofCount becomes 1, meeting PROOF_THRESHOLD.

12

The game resolves as DEFENDER_WINS

After the slow finalization delay, resolve() sees proofCount >= PROOF_THRESHOLD and sets DEFENDER_WINS.

13

Result

An invalid L2 output root for block S + 200 is finalized on L1 as the root for block S + 600.


Impact

  • Impact: Critical - SC-C1 (bypassing ZK proof verification in AggregateVerifier to finalize an invalid state root on L1). Downstream, if bridge withdrawal logic consumes the finalized root, the same root-for-wrong-block finalization can become an SC-C2-style bridge-drain primitive: the attacker crafts a withdrawal proof against the finalized output root, which is valid for block S + 200 but the bridge believes it covers through S + 600.

  • Likelihood: Medium-high - the exploit requires generating a valid SP1 proof (nontrivial cost) but is fully permissionless. No trusted role compromise, no race condition, no brute-force. The attacker controls the L1 head selection and boot inputs.

  • Severity: Critical.


Negative Check

  • The official proposer/prover service does not save this because onchain verification cannot assume the official host path was used. The attacker can run the public SP1 range program (crates/succinct/programs/range/utils/src/lib.rs) and aggregate program (crates/succinct/programs/aggregation/src/main.rs) independently with custom boot inputs. AggregateVerifier only receives the resulting journal digest through _verifyZkProof().

  • This is NOT the public SP1 soundness issue (known issue #2 in the Known Vulnerabilities in Base Azul document). The SP1 proof can be perfectly sound here; the bug is that the guest statement proves the wrong thing — it commits claimed_l2_block_number without verifying derivation actually reached that block. The known issue is the SP1 recursion-circuit advisory for versions v6.0.0 through v6.0.2 (GHSA-63x8-x938-vx33), not this Base-specific public-value mismatch.

  • This is NOT known issue #5 ("No Block Range Validation on ProveBlock" in the Known Vulnerabilities in Base Azul document). That known issue is about service request validation for zero/max/overflowing number_of_blocks_to_prove values. This candidate works with normal production-scale ranges: 600 contract blocks and a 200-block early derivation stop.

  • This is NOT GF-2 (chain-id binding: base/base#2419, commit eba5b07a). Even if chain ID is correctly bound, the guest can still commit claimed_l2_block_number while the actual derived safe head is lower.


Test Suite Grounding

  • Existing test coverage: WitnessExecutor::run validates output_root == boot.claimed_l2_output_root, but no test verifies the stronger invariant safe_head.block_info.number == boot.claimed_l2_block_number.

  • Gap: No test covers the case where advance_to_target returns early due to EndOfSource while the boot input claims a higher target block. The is_isthmus_active guard at client.rs#L106 is never tested with a block number input to confirm it correctly rejects the EndOfSource path.


Mitigation

Patch the guest first; the on-chain contract cannot safely distinguish a sound proof of the wrong statement from a correct proof.

1. In WitnessExecutor::run() — add block number check:

2. In advance_to_target() — fail closed on EndOfSource in proof mode:

If the EndOfSource behavior must remain for non-proof clients, split it behind an explicit mode flag and make proof/zkVM execution fail closed.

3. Fix the is_isthmus_active guard — pass timestamp, not block number:

4. Bind the intermediate-root interval into the proof statement. The range guest, aggregate guest, and AggregateVerifier should agree on the same INTERMEDIATE_BLOCK_INTERVAL, or the aggregate public values should include the interval and the contract should reject mismatches.

Proof of Concept

Click to expand Proof of Concept

PoC type: Rust executable integration PoC using the in-scope base-succinct-client-utils crate.

Create the following two files from the embedded snippets below in any temporary local directory:

  • run_poc.sh — installs the executable example into the scoped crate and runs it with cargo run --locked.

  • endofsource_real_advance_poc.rs — calls the real base_succinct_client_utils::client::advance_to_target() with a lightweight local Driver whose pipeline returns the real PipelineErrorKind::Critical(PipelineError::EndOfSource).

How to run:

BASE_AZUL_REPO must point to the root of the extracted Base Azul base/base offchain workspace, not to the PoC directory. That workspace root must contain crates/succinct/utils/client/Cargo.toml. The runner does not require an RPC URL, a private key, public Sepolia/mainnet interaction, or an SP1 proving cluster. It uses the reviewer-provided local workspace only.

Expected successful output shape:

The first runner paths will reflect the reviewer's local machine. The security-relevant output is that real advance_to_target() returns START + 200 after being asked to reach START + 600, then the real public-value/journal structs commit START + 600 with the START + 200 output root.

What the PoC proves:

  1. It calls the real scoped advance_to_target() implementation, not a reimplementation.

  2. The local pipeline returns the real PipelineError::EndOfSource.crit() error, causing the vulnerable target rewrite inside advance_to_target().

  3. The real function returns the existing safe head at START + 200 even though the requested target was START + 600.

  4. The real timestamp-based Isthmus API returns active for the safe-head timestamp but inactive when called with the block number, matching the vulnerable guard.

  5. The WitnessExecutor-style epilogue accepts root equality while the missing block-number equality check would fail.

  6. The real BootInfoStruct::new() commits l2BlockNumber = START + 600 while using the START + 200 output root.

  7. The short-range 200-block proof cadence at the hardcoded ZK interval emits 200 / 10 = 20 roots, matching the contract's expected 600 / 30 = 20 roots.

  8. The real AggregationOutputs::abi_encode_packed() digest matches the AggregateVerifier._verifyZkProof() journal layout.

Runner

base_azul_endofsource_poc/run_poc.sh

Executable Example

base_azul_endofsource_poc/endofsource_real_advance_poc.rs

Dependencies: Rust/Cargo compatible with the scoped Base Azul workspace. No RPC URL, no private key, no public Sepolia/mainnet interaction, no SP1 proving cluster needed.

Was this helpful?