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
The active
AggregateVerifierimplementation uses the deployed Sepolia-style intervals:BLOCK_INTERVAL=600,INTERMEDIATE_BLOCK_INTERVAL=30,PROOF_THRESHOLD=1.The ZK range/aggregate hashes correspond to the scoped programs above.
The attacker can obtain or generate a valid SP1 aggregate proof for custom boot inputs.
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
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.
The L1 origin blockhash used in the proof prefix is still within the
AggregateVerifierblockhash/EIP-2935 verification window.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
Impact
Impact: Critical - SC-C1 (bypassing ZK proof verification in
AggregateVerifierto 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 blockS + 200but the bridge believes it covers throughS + 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.AggregateVerifieronly 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_numberwithout 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_provevalues. 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 commitclaimed_l2_block_numberwhile the actual derived safe head is lower.
Test Suite Grounding
Existing test coverage:
WitnessExecutor::runvalidatesoutput_root == boot.claimed_l2_output_root, but no test verifies the stronger invariantsafe_head.block_info.number == boot.claimed_l2_block_number.Gap: No test covers the case where
advance_to_targetreturns early due toEndOfSourcewhile the boot input claims a higher target block. Theis_isthmus_activeguard atclient.rs#L106is never tested with a block number input to confirm it correctly rejects theEndOfSourcepath.
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 withcargo run --locked.endofsource_real_advance_poc.rs— calls the realbase_succinct_client_utils::client::advance_to_target()with a lightweight localDriverwhose pipeline returns the realPipelineErrorKind::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:
It calls the real scoped
advance_to_target()implementation, not a reimplementation.The local pipeline returns the real
PipelineError::EndOfSource.crit()error, causing the vulnerable target rewrite insideadvance_to_target().The real function returns the existing safe head at
START + 200even though the requested target wasSTART + 600.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.
The
WitnessExecutor-style epilogue accepts root equality while the missing block-number equality check would fail.The real
BootInfoStruct::new()commitsl2BlockNumber = START + 600while using theSTART + 200output root.The short-range 200-block proof cadence at the hardcoded ZK interval emits
200 / 10 = 20roots, matching the contract's expected600 / 30 = 20roots.The real
AggregationOutputs::abi_encode_packed()digest matches theAggregateVerifier._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?