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

76470 bc medium short zk range proof can be accepted as a full aggregateverifier interval and trigger global zk verifier nullification

Submitted on May 4th 2026 at 15:34:20 UTC by @joohhnnn8 for Audit Comp | Base Azul

  • Report ID: #76470

  • Report Type: Blockchain/DLT

  • Report severity: Medium

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

  • Impacts:

    • A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk

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

Description

Brief/Intro

The ZK proof client can stop early after EndOfSource, but still publish public values that claim the full target range was proven. AggregateVerifier accepts this as a valid ZK proposal because the claimed ending block/root and intermediate roots are not tied to the actual block reached by execution.

This lets an attacker create a ZK-valid but semantically invalid AggregateVerifier game. If honest challengers correct it through the ZK nullification path, the shared ZK verifier is globally nullified, so future ZK games using that verifier revert until Base recovers the ZK path or relies on the TEE path. If the malformed game is not challenged, it can temporarily poison the anchor with an early root labeled as a later L2 block, although this anchor state can be overtaken by a parallel honest game chain.

Vulnerability Details

The issue starts in advance_to_target.

When the derivation pipeline returns PipelineError::EndOfSource, the code tries to make this fatal after Isthmus. The check uses the L2 block number as the input to is_isthmus_active:

is_isthmus_active expects a timestamp. Base L2 block numbers are much smaller than the Isthmus activation timestamp, so this check returns false even after Isthmus. The EndOfSource error is swallowed, and the driver returns the current safe head instead of failing.

The public values then use the claimed target block number/root, not the actual block reached by execution:

An attacker can use a private intermediate interval of 1 so the short execution still provides the number of intermediate roots expected by the on-chain game. The result is an accepted AggregateVerifier game with:

  • l2SequenceNumber = startingBlock + BLOCK_INTERVAL

  • rootClaim = output root from an earlier safe head

  • intermediateRoots = early roots sampled every 1 block

This is not the same as submitting a random invalid proof. A random invalid proof reverts. Here, the ZK proof can be accepted because the ZK program and the on-chain verifier disagree about what block range the proof represents.

Impact Details

Selected impacts:

A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk.

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

The unintended behavior is that AggregateVerifier accepts a ZK proposal that claims a full range while the ZK execution only reached a shorter range.

I self-rate this as Medium because the invalid-anchor path is challengeable and recoverable, and this report does not claim direct fund loss, total shutdown, or permanent bridge freezing. The persistent impact is that the normal permissionless correction path globally nullifies the shared ZK verifier.

There are two relevant outcomes:

  1. If honest challengers are running, they can catch the mismatch at the first intermediate checkpoint and call nullify(). This prevents the malformed proposal from finalizing, but it also sets the shared ZK verifier to nullified. After that, new ZK games using the same verifier revert with Nullified(). Base has to rely on the TEE path or recover the ZK verifier/game configuration.

  2. If the proposal is not challenged before finalization, it can resolve as DEFENDER_WINS and temporarily update the anchor to an early output root under the full claimed L2 block number. This does not create permanent fund freezing: an honest proposer can build a parallel correct game chain from the starting anchor and eventually overtake the poisoned anchor with a higher L2 block number.

This report does not claim direct fund loss, total chain shutdown, or permanent bridge freezing. The issue is that an attacker can create a semantically invalid ZK proposal that should not be accepted, and the normal permissionless correction path globally disables the ZK verifier until recovery.

References

  • base/base: crates/proof/succinct/utils/client/src/client.rs

  • base/base: crates/proof/succinct/utils/client/src/boot.rs

  • base/base: crates/proof/succinct/programs/aggregation/src/main.rs

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

  • base/contracts: src/multiproof/Verifier.sol

Proof of Concept

This PoC has two parts:

1

Part 1: ZK client stops early but keeps the claimed target block

In the scoped base/base repo, add a regression test under:

The test uses a finite mock derivation pipeline. The pipeline can produce a small number of payloads, then returns PipelineError::EndOfSource.

The important test body is:

What this proves:

  • The chain is post-Isthmus by timestamp.

  • The current code checks Isthmus activation using the block number, so the post-Isthmus EndOfSource path does not fail.

  • The mock execution only reaches safe_head + 20.

  • The public values still claim safe_head + 600.

  • The number of intermediate roots matches what the on-chain contract expects for a 600-block game with 30-block checkpoints.

Run:

Expected result:

The fourth test is a control for the challenge path. It shows that an honest proof for the first checkpoint has public values that match the on-chain nullify(index=0) call.

2

Part 2: AggregateVerifier accepts the malformed proposal

In the scoped base/contracts repo, add a Foundry test under:

The test uses the existing BaseTest setup and MockVerifier. The mock verifier is used only to check that AggregateVerifier builds and verifies the expected journal. The root values are synthetic, but the contract flow is real.

The main test is:

What this proves:

  • The game claims the full on-chain block interval.

  • The root claim is actually the output root of an early block.

  • AggregateVerifier accepts the ZK proposal.

  • If the game is not challenged, it can resolve and update the anchor with the early root under the full claimed block number.

Honest challenge at the first checkpoint

The malformed proposal is not unchallengeable. The first intermediate root is already wrong.

In the test setup:

  • The malformed proposal stores root for block 1 at index 0.

  • The contract expects index 0 to correspond to the first checkpoint, block INTERMEDIATE_BLOCK_INTERVAL.

The challenge test is:

What this proves:

  • Honest challengers can catch the malformed proposal immediately.

  • The correction path works.

  • The correction path sets the shared ZK verifier to nullified.

This is why I am not claiming an unchallengeable invalid root.

Global ZK verifier impact

The relevant impact is what happens after the honest correction. The ZK verifier is shared, so nullifying it affects later ZK proposals too.

Control test:

What this proves:

  • After honest nullification, new ZK games revert with Nullified().

  • The issue affects the ZK path globally.

  • TEE remains available, so this is not a full system halt.

Anchor poisoning is recoverable, but still creates disruption

This PoC does not claim permanent fund freezing. The following regression test shows the recovery path.

What this proves:

  • A malformed game can temporarily become the anchor if it is not challenged.

  • A same-height correct game cannot overwrite it immediately, because setAnchorState requires a strictly higher L2 block number.

  • An honest proposer can build a parallel correct chain from the old starting anchor, create a higher-block child game, and overtake the poisoned anchor.

  • Therefore this is not permanent bridge freezing. The persistent impact is the ZK verifier nullification path, and the unchallenged path causes delay/recovery work rather than permanent loss.

Summary

The tests show:

  1. EndOfSource is swallowed after Isthmus because the code passes block number instead of timestamp to is_isthmus_active.

  2. The ZK public values can claim the full target block while execution stopped early.

  3. AggregateVerifier accepts the malformed proposal.

  4. If challenged, the proposal is corrected, but the shared ZK verifier is globally nullified.

  5. New ZK games then revert with Nullified(), while TEE remains available.

  6. If unchallenged, the malformed proposal can temporarily update the anchor.

  7. The poisoned anchor can be overtaken by a parallel honest chain, so this is not permanent bridge freezing.

Was this helpful?