This PoC contains a PoC for a soundness bug in Jolt's zk verifier. It chains 2 bugs together to forge a proof for the following program:
#[jolt::provable(heap_size = 128, max_trace_length = 1024)]
fn is_seven(x: u64) -> bool {
x == 7
}honest.bin: input 6, output false (is_seven(6) == false, truth)
poc.bin: input 6, output true (is_seven(6) == true, forgery)
This affects ZK mode only (--features zk). The non-ZK verifier is not affected by this issue.
Run:
./run_repro.shThe script:
- Installs Rust.
- Clones
https://github.com/a16z/jolt. - Checks out commit
78807efcd1f7a5279da4e8ebfa00f7c30d572afa(latest commit at the time of writing) - Builds the PoC with
--features zkand runs it against the verifier.
Expected output:
honest.bin: verified (is_seven(6) == false)
poc.bin: verified (is_seven(6) == true)
poc.bin verifying is the soundness break.
The proofs were produced offline by a malicious prover and are checked here by the stock, unmodified jolt_verifier::verify. verifier_preprocessing.bin is the shared verifier preprocessing (same SRS for both proofs).
The Stage 8 batch opening is the single check that ties the committed witness polynomials to their claimed evaluations at the verifier's Fiat–Shamir point.
In ZK mode this goes through PCS::verify_zk in crates/jolt-verifier/src/stages/stage8/verify.rs:136:
let hiding_evaluation_commitment = PCS::verify_zk(
&joint_commitment,
pcs_opening_point.as_slice(), // honest Fiat–Shamir point
&proof.joint_opening_proof,
&preprocessing.pcs_setup,
transcript,
)?;The Dory ZK verification does not enforce that proof was actually produced at pcs_opening_point. A proof generated at a chosen point P' is accepted when the verifier checks it at the honest point P (P' != P). The point is appended to the transcript afterwards via bind_zk_opening_inputs, so Fiat–Shamir stays consistent but the proof's evaluation relation is never constrained to that point.
Contrast the clear path (stage8/verify.rs:203), which is sound:
PCS::verify(
&joint_commitment,
pcs_opening_point.as_slice(),
joint_claim, // claimed value IS passed and checked
&proof.joint_opening_proof,
&preprocessing.pcs_setup,
transcript,
)?;Here Dory binds (commitment, point, value) together. A wrong point or wrong value is rejected.
It is important to note that since most of the Dory implementation is in a dependency, this can also be patched there. However, it does seem that there is a draft PR in the Dory repo from April 29th that claims this should be patched on the caller side:
The end-to-end forgery (poc.bin) chains the point binding gap with the fact that, in ZK mode, opening claims are carried as hiding commitments and verified only relationally by BlindFold:
- Run
is_seven(6)honestly (outputfalse), flip the public-output byte totrue, and rewrite the single store instruction that writes it. The rest of the execution still computesfalse, so the witness is now internally inconsistent. The public input region is untouched. - Adjust one affine output opening so the BlindFold output constraint stays satisfiable after the flip (the hidden ZK opening claims give the prover this freedom).
- Prove the Stage 8 opening at an attacker chosen point where the joint polynomial evaluates to the forged claim, and rely on the missing point binding so the verifier accepts it against the honest point.
The tampered trace alone is unprovable (honest proving fails the BlindFold R1CS). The steps above turn it into an accepting proof. The forgery lives entirely on the prover side. Nothing in the verifier is modified.
This has now been fixed in: a16z/dory#23 and a16z/jolt#1664