This is a technical analysis of the Dory vulnerability that was found and poc’d by our agent. This blog goes much deeper into the mathematics behind how the exploit actually worked. If you’ve landed in the wrong blog, you can read how our AI found it here.
TL;DR
- Ordering of Fiat-Shamir in Sumcheck
- Missing constraint in the Dory proving library
- Exploiting the constraint and backsolving from the Fiat-Shamir ordering
is_seven(6) == true(6 == 7)
About Us
At Veria Labs, we build AI pentesting agents that automatically find and fix security vulnerabilities in your application. Founded by members of the #1 competitive hacking team in the U.S., we’ve found critical vulnerabilities in every company we’ve worked with, from small startups to enterprise giants.
Think we can help secure your systems? We’d love to chat! Book a call here.
What is Jolt?
We’ve covered Jolt in our other blog but in brief, Jolt is a zkVM for RISC-V built as a general purpose zkVM for proving arbitrary executions. While it can be used as a standalone library, Jolt is expected to be the backbone of the new Zero L1 chain currently being developed by LayerZero.
Jolt will facilitate private payments on the Zero network. This allows anyone to verify transactions without seeing details like wallet addresses, amounts, etc.
Vulnerability Details
This section covers the math, the vulnerabilities, and how they were chained. As always, this may get quite messy.
Background
From a program to polynomials: A zkVM proves “this program ran correctly and produced this output.” Jolt encodes the execution trace as a family of multilinear polynomials over the BN254 scalar field , where each is the multilinear extension of its values on the boolean hypercube,
Checking the program reduces to checking a system of claims over the .
Sumcheck. The sumcheck step certifies that a polynomial sums to a claimed value over the hypercube without the verifier recomputing the sum:
It runs one variable at a time. In round the prover sends the univariate restriction
the verifier enforces the fold against the running claim, samples a challenge , and updates . After rounds the protocol collapses to a vector of opening claims at the random point .
Sumcheck is interactive however. To turn this non-interactive, Jolt applies a Fiat-Shamir transcript across all steps to sample the randomness. Jolt derives every challenge by hashing the running transcript of prover messages:
Security relies on this binding invariant. Anything that can change whether the proof is accepted must be absorbed into before the challenge that depends on it:
Polynomial commitments (Dory). The openings are discharged by a polynomial commitment scheme. Jolt batches its polynomials under powers of a challenge into one instance with , and proves it with Dory.
Dory is a pairing based scheme, also developed by a16z, over with a pairing . Dory asserts that a committed polynomial satisfies with a final pairing identity of the form , where encodes the claimed value.
The Ordering of Fiat-Shamir in Sumcheck
The ZK sumcheck fixes its random challenges before the prover commits to the hidden output claims. The batching coefficients are drawn up front, the round polynomials are folded to produce , and only afterwards are the hidden output claims added into the transcript:
let batching_coeffs = transcript.challenge_vector(sumcheck_instances.len()); // λ ← H(τ) [early]// … n sumcheck rounds fold r_1..r_n out of τ …let oc_committed = pedersen_gens.commit_chunked(&output_claim_values, rng); // Com(v_i; ρ_i)transcript.append_commitments(b"output_claims_coms", &output_claims_commitments); // τ += Com(v) [late]By the time the openings enter , every challenge that will test them is fixed. Write for those challenges and for the BlindFold output constraint the openings must satisfy. There’s no way around this ordering: the output claims are values , and (like ) is the sumcheck’s challenge stream, so the claims don’t exist until the challenges that produce them are already drawn. Binding the openings is intended to be left entirely to the Stage 8 opening proof.
Technically, that does give a modified prover some help. With known first, and affine in each opening, changing one coordinate by moves the constraint along a straight line,
so the offset that lands it back on zero is just . The prover shifts the hidden opening and re-commits; the only thing that changes is the freshly-formed , which nothing has constrained yet:
let delta_i = (target_output_claim - base_eval) * derivative_i.inverse().unwrap(); // δ* = -Φ/∂Φoutput_claim_values[output_index] += delta_i; // v_k ↦ v_k + δ**opening_value += delta_i;But by design this isn’t supposed to work: the Stage 8 Dory opening is supposed to reject any whose openings don’t match the real polynomials at .
The Vulnerability: Missing Constraint in Dory Dependency
Jolt calls the Dory library for all proving/verifying related to Dory. In Dory’s zk verifier, it never ties the proof to the point it is supposed to be evaluated at. Stage 8 first collapses all the committed polynomials into a joint opening with , and hands it to verify_zk with the point but no value, since in ZK the evaluation stays concealed as :
let hiding_evaluation_commitment = PCS::verify_zk( &joint_commitment, pcs_opening_point.as_slice(), // (C*, r) ; no y* &proof.joint_opening_proof, &preprocessing.pcs_setup, transcript)?;Following this call into the Dory function dory-pcs::verify_evaluation_proof in the dependency, the whole thing turns on a single group element that anchors the final pairing identity. In the transparent (non-ZK) path, is built from the claimed value which ties both the value and the point into the equation:
In ZK mode though, it reads from the proof and discards entirely:
#[cfg(feature = "zk")]let (e2, is_zk) = match (&proof.e2, &proof.y_com) { (Some(pe2), Some(yc)) => { /* sub-proofs about y_com only */ (*pe2, true) } (None, None) => (setup.g2_0.scale(&evaluation), false), _ => return Err(DoryError::InvalidProof),};#[cfg(not(feature = "zk"))]let (e2, _is_zk) = (setup.g2_0.scale(&evaluation), false); // e2 binds `evaluation`The side proofs check that is a well formed commitment, but they never check that it actually commits to . So with as a free group element the prover supplies, the acceptance relation stops depending on altogether:
The Full Exploit
For the PoC, our agent setup a simple Rust program of:
fn is_seven(x: u64) -> bool { x == 7}with the goal of forging a proof to claim is_seven(6) == true.
Since the vulnerability allows an attacker to forge a Dory proof, the first observation on the ordering of the sumcheck can be backsolved before abusing the Dory commitment. The attack runs in 2 main steps. Let be the public output region. In an honest case: produces (false).
First, flip the public output bit (from false to true) and rewrite the single store instruction that writes it, so the trace claims the flipped value:
The witness is now internally inconsistent since the BlindFold constraint breaks () An honest prover would abort on .
Because the sumcheck never bound the claim values, solve for the offset that puts the broken constraint back to zero and shift the hidden output opening by it:
The prover now holds a coherent but false claim set, with a forged joint value .
The forged claim no longer matches the real polynomial at the honest point. But a multilinear polynomial is degree in each coordinate, so its value moves linearly as you sweep one coordinate. This means you can solve for a single alternate coordinate at which the real polynomial hits the forged value exactly:
The prover emits a perfectly honest Dory opening at this wrong point, . The verifier checks at the honest point . Since Dory’s ZK check ignores the point, it accepts anyway ().
This was run against an unchanged verifier and both honest and forged proofs:
Impact in the zkVM
This is a total break of soundness in ZK mode. For an arbitrary false statement there exists an constructible with , against the unchanged verifier. The transparent path, binding both (via ) and , is unaffected.
Since Jolt is used as a general purpose zkVM as well as being the backbone of the Zero L1 chain, this makes it the highest impact for a zkVM. On the blockchain, depending on how it will be used, can most likely be used to forge false transactions or introduce an infinite mint.
Remediation
Patching the Dory vulnerability can be done in either the library or Jolt itself. Bind the opening point. The cleanest fix would be to directly patch this on the Dory library side. That said, there is a comment that claims the caller must bind this before calling any Dory functions. So, to fix this on the Jolt side, we can apply the patch:
--- a/crates/jolt-dory/src/scheme.rs+++ b/crates/jolt-dory/src/scheme.rs@@ fn open_zk(poly, point, _eval, setup, hint, transcript) { let ark_point: Vec<ArkFr> = point.iter().rev().map(jolt_fr_to_ark).collect();+ // Bind the opening point before Dory derives its challenges.+ transcript.append(&LabelWithCount(b"zk_dory_point_bind", point.len() as u64));+ for p in point {+ p.append_to_transcript(transcript);+ } let mut dory_transcript = JoltToDoryTranscript::new(transcript);--- a/crates/jolt-dory/src/scheme.rs+++ b/crates/jolt-dory/src/scheme.rs@@ fn verify_zk(commitment, point, proof, setup, transcript) { let ark_commitment = jolt_gt_to_ark(&commitment.0);+ // Mirror the prover: bind the same point before verification draws challenges.+ transcript.append(&LabelWithCount(b"zk_dory_point_bind", point.len() as u64));+ for p in point {+ p.append_to_transcript(transcript);+ } let mut dory_transcript = JoltToDoryTranscript::new(transcript);With this in place an honest proof still verifies, and a proof produced at a different point is rejected:
verify at correct point = Ok(...)verify at wrong point = Err(VerificationFailed)Conclusion
This is a result of feature and a missing check that were chained together to fulfill the full exploit chain. While each on there own are not exploitable, exploiting both at the same time allows for complete proof forgery. The Dory vulnerability was reported to a16z which was quickly patched.
Fixes from the team are listed here:
On the Jolt side, https://github.com/a16z/jolt/pull/1664 and on the Dory side: https://github.com/a16z/dory/pull/23
PoC
All files related to the PoC can be found in this Github repo: