WitnessExecutor::run calls advance_to_target with the claimed L2 block number:
It then validates only the output root:
But it never checks that:
That means the proof can succeed even if derivation stopped before the claimed target block.
The issue becomes critical because run returns the original boot_clone:
Then run_range_program commits this original boot info publicly:
And BootInfoStruct::new uses the claimed block number from BootInfo:
So the public proof output can claim:
“I proved output root R at L2 block N”
even though the program only derived up to an earlier block M < N.
The driver explicitly allows early termination when the derivation pipeline runs out of data.
In base-0.8.0-rc.28/crates/proof/driver/src/core.rs:
If target = Some(boot.claimed_l2_block_number) and the pipeline reaches EndOfSource, the driver silently changes the target to the current safe head.
Then it returns successfully:
This is dangerous for a proof program. A proving circuit/program must fail if it cannot reach the exact public target it claims to prove.
Exploit
Assume the real derivation can only reach block 110.
The attacker supplies boot data like this:
The witness contains enough data to derive blocks 101..110, but not enough data to derive up to 1_000_000.
1
WitnessExecutor::run asks the driver to derive to block 1_000_000
WitnessExecutor::run requests derivation to the claimed block number.
2
The driver derives only to block 110
Derivation stops at the last available block.
3
The pipeline hits EndOfSource
The data source is exhausted.
4
The driver silently changes the target to block 110
The target is adjusted to the current safe head.
5
The driver returns (safe_head = block 110, output_root = R110)
The returned output root matches the derived head.
6
WitnessExecutor::run checks only output_root == boot.claimed_l2_output_root
This passes because the attacker set:
7
The function returns the original boot_clone, which still says claimed_l2_block_number = 1_000_000
The original claimed block number is preserved.
8
run_range_program commits a public BootInfoStruct saying l2BlockNumber = 1_000_000 and l2PostRoot = R110
The proof has now falsely attested that R110 is the output root at block 1_000_000.
Impact
This is a proof soundness failure.
A malicious prover can generate a valid zkVM proof for a false L2 range statement. Specifically, they can claim that an output root corresponds to a later L2 block number without actually deriving or executing blocks up to that number.
This can allow:
The aggregation program does not fix this. It verifies the proof digest of each BootInfoStruct and checks sequencing between public boot infos, but it has no independent knowledge of the actual block number reached inside WitnessExecutor::run.
So once the range proof commits the wrong l2BlockNumber, aggregation trusts it.
Root cause
WitnessExecutor::run treats root equality as sufficient:
Err(PipelineErrorKind::Critical(PipelineError::EndOfSource)) => {
warn!(target: "client", "Exhausted data source; Halting derivation and using current safe head.");
// Adjust the target block number to the current safe head, as no more blocks
// can be produced.
if target.is_some() {
target = Some(tip_cursor.l2_safe_head.block_info.number);
};
continue;
}