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_INTERVALrootClaim = output root from an earlier safe headintermediateRoots = 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:
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 tonullified. After that, new ZK games using the same verifier revert withNullified(). Base has to rely on the TEE path or recover the ZK verifier/game configuration.If the proposal is not challenged before finalization, it can resolve as
DEFENDER_WINSand 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.rsbase/base:crates/proof/succinct/utils/client/src/boot.rsbase/base:crates/proof/succinct/programs/aggregation/src/main.rsbase/contracts:src/multiproof/AggregateVerifier.solbase/contracts:src/multiproof/Verifier.sol
Proof of Concept
This PoC has two parts:
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
EndOfSourcepath 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.
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.
AggregateVerifieraccepts 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
1at index0.The contract expects index
0to correspond to the first checkpoint, blockINTERMEDIATE_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
setAnchorStaterequires 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:
EndOfSourceis swallowed after Isthmus because the code passes block number instead of timestamp tois_isthmus_active.The ZK public values can claim the full target block while execution stopped early.
AggregateVerifieraccepts the malformed proposal.If challenged, the proposal is corrected, but the shared ZK verifier is globally nullified.
New ZK games then revert with
Nullified(), while TEE remains available.If unchallenged, the malformed proposal can temporarily update the anchor.
The poisoned anchor can be overtaken by a parallel honest chain, so this is not permanent bridge freezing.
Was this helpful?