76065 sc medium endofsource handling lets a zk range proof claim an unreached l2 block
Submitted on May 2nd 2026 at 13:45:18 UTC by @Paludo0x for Audit Comp | Base Azul
Report ID: #76065
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/Intro
The ZK range client can return success without deriving the L2 block it is supposed to prove. The bug is a cross subsystem failure between the range proof boot inputs, advance_to_target(), and the AggregateVerifier journal binding: EndOfSource is converted into success because the post-Isthmus guard checks an L2 block number as if it were a timestamp. The result is a valid ZK journal that can bind an unreached target block to the stale safe-head output root.
Vulnerability Details
The range proof is supposed to start from an agreed safe output root, derive blocks until claimed_l2_block_number, compute the target output root, and compare it with claimed_l2_output_root. If derivation cannot reach the target because the L1 data source is exhausted, the proof must fail. In rc.28 it does not always fail: the client rewrites the target to the current safe head and then returns success.
Attack path
A proposer chooses a real but insufficient L1 origin/checkpoint, so the derivation source ends before the claimed L2 target block.
The range client enters
advance_to_target()withtarget = claimed_l2_block_number.The pipeline returns
EndOfSource.advance_to_target()changestargetto the current safe head block number.The intended post-Isthmus rejection does not trigger because it passes
l2_safe_head.block_info.numbertocfg.is_isthmus_active(), which expects a timestamp.The function loops once and returns success for the safe head, not for the originally claimed target.
WitnessExecutor::run()compares the stale safe head output root againstclaimed_l2_output_root; if the claim was set to the safe head root, the check passes.The range program commits a journal whose
l2BlockNumberis still the original claimed target, whilel2PostRootis the stale safe head root.Aggregation and
AggregateVerifierbind that journal into the ZK proof path, allowing the invalid root claim to be treated as proved.
Step-by-step vulnerability explanation
BootInfo marks the claimed output root and target block as user submitted inputs that must be verified by execution.
The range client is the verifier for these fields. It must not succeed unless it has actually derived the target block.
The failure is in advance_to_target(). On EndOfSource, it rewrites the requested target to the current safe-head block.
The last check is meant to make this behavior invalid after Isthmus/interoperability rules. It is ineffective because is_isthmus_active() expects a timestamp, but the code passes the L2 block number. On Base Sepolia, a timestamp is around 1_776_708_000, while the L2 block number is around 40_000_000, so the check returns false and the error is suppressed.
WitnessExecutor::run() then treats the returned safe head root as the proof result. It only compares the returned root with the claimed_l2_output_root supplied by user; it does not check that the returned block number is still the requested target.
This check can be satisfied by setting claimed_l2_output_root to the agreed safe head output root. The returned safe_head.block_info.number may be lower than boot.claimed_l2_block_number, but that mismatch is not rejected.
The range program commits the original boot info after run() succeeds. The committed public values therefore keep the claimed target block, even though derivation stopped at the safe head.
BootInfoStruct::new() uses boot_info.claimed_l2_block_number and boot_info.claimed_l2_output_root for the final journal. It does not use the lower safe_head.block_info.number returned by advance_to_target().
The onchain AggregateVerifier then binds the ZK proof to that journal hash.
The failure is that the ZK client can produce public values for a target block it never reached. AggregateVerifier._verifyL1Origin (contracts/src/multiproof/AggregateVerifier.sol:963) constrains l1OriginHash to match an actual L1 blockhash within ~8192 blocks (EIP-2935 window, ~27 hours), but this does not mitigate the bug: the attacker chooses any recent valid l1_head and sets claimed_l2_block_number arbitrarily higher than the L2 blocks derivable from that L1 head. The pipeline reaches EndOfSource regardless of l1_head recency, the buggy guard suppresses the error, and the journal is committed.
Impact Details
For the Smart Contract asset, this maps to the Critical impact Forging or bypassing TEE or ZK proof verification in AggregateVerifier to finalize an invalid state root on L1. The proof is cryptographically valid for the program execution, but the program did not verify the semantic claim. It accepted the safe head root as the output root for a later claimed L2 block. The onchain sink is AggregateVerifier. Once the ZK verifier accepts a proof for that journal, AggregateVerifier treats the root claim as proved and OptimismPortal2.proveWithdrawalTransaction() can use the resulting game as the root validity source for withdrawals.
The practical attack is to create or support an AggregateVerifier game for the next interval using a stale safe head root as rootClaim, while selecting an L1 origin/checkpoint that is real but does not contain enough data to derive the claimed target. The ZK range client should reject that as "target not derivable"; instead, it can commit the stale root for the claimed target block. If this proof is submitted through the ZK path, the dispute game can treat an invalid state root as proved.
This does not require compromising a TEE signer, a governance key, or a private prover key. It uses normal proof inputs and an under-constrained success path in the public ZK range client.
Proof of Concept
The exploit requires two demonstrations. The off-chain prover must produce the unsafe journal (PoC 1, Rust). The on-chain stack must accept that journal without any additional semantic check (PoC 2, Foundry). Together they form the full chain from the buggy advance_to_target() to a withdrawal-valid game accepted by OptimismPortal2.
PoC 1: off-chain prover (Rust)
Run:
The PoC builds a minimal driver whose pipeline always returns EndOfSource. It uses the real Base Sepolia rollup config and sets the safe-head timestamp after Isthmus activation. The test proves three facts:
cfg.is_isthmus_active(safe_timestamp)is true.cfg.is_isthmus_active(safe_number)is false, matching the production bug.advance_to_target()returns success atsafe_numbereven though the requestedtarget_numberis higher.
The final assertion constructs the same BootInfoStruct committed by the range program and shows the unsafe public value:
So the proof journal can claim an unreached target block while carrying the stale safe-head root.
PoC 2: on-chain sink (Foundry)
Run from the base/contracts scope repo:
This PoC models the SP1 verifier boundary with a journal-bound verifier. It is not an always true mock: it returns true only for the exact imageId, proof bytes, and journal that AggregateVerifier reconstructs. The test first sets a wrong journal and proves that game creation reverts with InvalidProof. It then sets the exact ZK journal for the claimed root and proves the on-chain sink:
AggregateVerifier.initializeWithInitData()accepts the journal-bound ZK proof.The game has
zkProver == address(this)andproofCount == 1.OptimismPortal2.proveWithdrawalTransaction()accepts the game whose root claim equals the output root proof.provenWithdrawals[withdrawalHash][submitter]stores thisAggregateVerifiergame.After the game resolves as
DEFENDER_WINS,AnchorStateRegistry.isGameClaimValid(game)returns true.
This closes the smart contract leg of the exploit. If the offchain ZK client emits the unsafe journal shown by PoC 1, the L1 contracts have no later check that the claimed L2 block was actually reached by derivation.
POC1 - RUST
POC2 - SOLIDITY
Was this helpful?