Overview
A Technical Breakdown of Jolt's Cryptography (And The Exploit)

A Technical Breakdown of Jolt's Cryptography (And The Exploit)

The Mathematics of Missing Dory Commitments

July 13, 2026
6 min read
index

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 {Pi}i[m]\{P_i\}_{i\in[m]} over the BN254 scalar field F=Fr\mathbb{F}=\mathbb{F}_r, where each Pi:FnFP_i:\mathbb{F}^n\to\mathbb{F} is the multilinear extension of its values on the boolean hypercube,

Pi(x)  =  b{0,1}nPi(b)k=1n(xkbk+(1xk)(1bk)),xFn.P_i(\mathbf{x}) \;=\; \sum_{\mathbf{b}\in\{0,1\}^n} P_i(\mathbf{b}) \prod_{k=1}^{n}\big(x_k b_k + (1-x_k)(1-b_k)\big),\qquad \mathbf{x}\in\mathbb{F}^n .

Checking the program reduces to checking a system of claims over the {Pi}\{P_i\}.

Sumcheck. The sumcheck step certifies that a polynomial g=iλigig=\sum_i \lambda_i g_i sums to a claimed value CC over the hypercube without the verifier recomputing the sum:

C  =?  x{0,1}ng(x),λi=H(τ)F.C \;\overset{?}{=}\; \sum_{\mathbf{x}\in\{0,1\}^{n}} g(\mathbf{x}),\qquad \lambda_i=\mathcal{H}(\tau)\in\mathbb{F}.

It runs one variable at a time. In round jj the prover sends the univariate restriction

gj(X)  = ⁣ ⁣x{0,1}nj ⁣ ⁣g(r1,,rj1,X,x),g_j(X)\;=\!\!\sum_{\mathbf{x}\in\{0,1\}^{n-j}}\!\! g(r_1,\dots,r_{j-1},X,\mathbf{x}),

the verifier enforces the fold gj(0)+gj(1)=cj1g_j(0)+g_j(1)=c_{j-1} against the running claim, samples a challenge rjFr_j\in\mathbb{F}, and updates cj=gj(rj)c_j=g_j(r_j). After nn rounds the protocol collapses to a vector of opening claims at the random point r=(r1,,rn)\mathbf{r}=(r_1,\dots,r_n).

{Pi(r)=vi}i[m],viF,\big\{\,P_i(\mathbf{r})=v_i\,\big\}_{i\in[m]},\qquad v_i\in\mathbb{F},

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 τ\tau of prover messages:

rj  =  H(τ<j).r_j \;=\; \mathcal{H}(\tau_{<j}).

Security relies on this binding invariant. Anything that can change whether the proof is accepted must be absorbed into τ\tau before the challenge that depends on it:

accept-relevant w:wτ  before  r=H(τ).\forall\,\text{accept-relevant } w:\quad w \rightarrow \tau \ \ \text{before}\ \ r=\mathcal{H}(\tau).

Polynomial commitments (Dory). The openings Pi(r)=viP_i(\mathbf{r})=v_i are discharged by a polynomial commitment scheme. Jolt batches its polynomials under powers of a challenge γ\gamma into one instance (C,r,y)(\mathcal{C}_*,\mathbf{r},y_*) with C=iγiCi\mathcal{C}_*=\sum_i \gamma^{\,i}\mathcal{C}_i, and proves it with Dory.

Dory is a pairing based scheme, also developed by a16z, over G1,G2,GT\mathbb{G}_1,\mathbb{G}_2,\mathbb{G}_T with a pairing e:G1×G2GTe:\mathbb{G}_1\times\mathbb{G}_2\to\mathbb{G}_T. Dory asserts that a committed polynomial CGT\mathcal{C}\in\mathbb{G}_T satisfies P(z)=yP(\mathbf{z})=y with a final pairing identity of the form ke(Lk(z),Rk)=Ce(,e2)\prod_k e\big(L_k(\mathbf{z}),R_k\big)=\mathcal{C}\cdot e(\,\cdot\,,e_2), where e2G2e_2\in\mathbb{G}_2 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 λ\lambda are drawn up front, the round polynomials are folded to produce r\mathbf r, 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 Com(vi;ρi){\mathsf{Com}(v_i;\rho_i)} enter τ\tau, every challenge that will test them is fixed. Write χ=(λ,r,)\boldsymbol{\chi}=(\lambda,\mathbf r,\dots) for those challenges and Φ(v,χ)=0\Phi(\mathbf v,\boldsymbol{\chi})=0 for the BlindFold output constraint the openings must satisfy. There’s no way around this ordering: the output claims are values p(r)p(\mathbf r), and r\mathbf r (like λ\lambda) 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 χ\boldsymbol{\chi} known first, and Φ\Phi affine in each opening, changing one coordinate vkv_k by δ\delta moves the constraint along a straight line,

Φ(v+δ,ek,,χ);=;Φ(v,χ)+δvkΦ,vkΦ0,\Phi(\mathbf{v}+\delta,\mathbf{e}k,,\boldsymbol{\chi}) ;=; \Phi(\mathbf{v},\boldsymbol{\chi}) + \delta\cdot\partial{v_k}\Phi, \qquad \partial_{v_k}\Phi\neq 0,

so the offset that lands it back on zero is just δ=,Φ(v,χ)/vkΦ\delta^\star = -,\Phi(\mathbf v,\boldsymbol{\chi})/\partial_{v_k}\Phi. The prover shifts the hidden opening and re-commits; the only thing that changes is the freshly-formed Com(vk;ρk)\mathsf{Com}(v_k;\rho_k), 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 v\mathbf v whose openings don’t match the real polynomials Pi{P_i} at r\mathbf r.

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 (C, r, y)\big(\mathcal{C}_*,\ \mathbf r,\ y_*\big) with C=iγiCi\mathcal C_*=\sum_i\gamma^i\mathcal C_i, and hands it to verify_zk with the point r\mathbf r but no value, since in ZK the evaluation stays concealed as ycom=Com(y;)y_{\mathrm{com}}=\mathsf{Com}(y_*;\,\cdot\,):

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 e2G2e_2\in\mathbb{G}_2 that anchors the final pairing identity. In the transparent (non-ZK) path, e2e_2 is built from the claimed value which ties both the value yy and the point z\mathbf z into the equation:

(transparent)e2  =  yg2,verify  ke(Lk(z),Rk)  =  Ce(,e2).\textbf{(transparent)}\qquad e_2 \;=\; y\cdot g_2,\qquad \text{verify } \ \prod_k e\big(L_k(\mathbf z),\,R_k\big) \;=\; \mathcal{C}\cdot e\big(\cdot,\,e_2\big).

In ZK mode though, it reads e2e_2 from the proof and discards yy entirely:

(zk)e2    π.e2  (prover-chosen),y unused,ycomπ.ycom.\textbf{(zk)}\qquad e_2 \;\leftarrow\; \pi.e_2 \ \ (\text{prover-chosen}),\qquad y \ \text{unused},\qquad y_{\mathrm{com}}\leftarrow\pi.y_{\mathrm{com}}.
#[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 ycomy_{\mathrm{com}} is a well formed commitment, but they never check that it actually commits to P(z)P_*(\mathbf z). So with e2e_2 as a free group element the prover supplies, the acceptance relation stops depending on z\mathbf z altogether:

π:V(C,z,π)=1  for every zFn.\exists\,\pi:\quad V(\mathcal C_*,\mathbf z,\pi)=1 \ \ \text{for every } \mathbf z\in\mathbb F^{n}.

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 y{0,1}8\mathbf y^*\in\{0,1\}^8 be the public output region. In an honest case: is_seven(6)\textsf{is\_seven}(6) produces y0=0y^*_0=0 (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:

y0y01,rewrite the unique SB/SH/SW/SDpost_value.y^*_0 \mapsto y^*_0 \oplus 1,\qquad \text{rewrite the unique } \mathsf{SB}/\mathsf{SH}/\mathsf{SW}/\mathsf{SD}\to \texttt{post\_value}.

The witness is now internally inconsistent since the BlindFold constraint breaks (Φ(v,χ)0\Phi(\mathbf v,\boldsymbol\chi)\neq 0) An honest prover would abort on AzBzCz\mathbf{Az}\circ\mathbf{Bz}\neq\mathbf{Cz}.

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:

δ=Φ(v,χ)/vkΦ,vkvk+δ  Φ=0,Ξ() fixed.\delta^*=-\,\Phi(\mathbf v,\boldsymbol\chi)/\partial_{v_k}\Phi,\qquad v_k\mapsto v_k+\delta^* \ \Rightarrow\ \Phi=0,\quad \Xi(\cdot)\ \text{fixed}.

The prover now holds a coherent but false claim set, with a forged joint value y^P(r)\hat y_*\neq P_*(\mathbf r).

The forged claim no longer matches the real polynomial at the honest point. But a multilinear polynomial is degree 1\le 1 in each coordinate, so its value moves linearly as you sweep one coordinate. This means you can solve for a single alternate coordinate rr'_\ell at which the real polynomial hits the forged value exactly:

r=r with r=y^P(r ⁣z=0)P(r ⁣z=1)P(r ⁣z=0)  P(r)=y^.\mathbf r' = \mathbf r \ \text{with } r'_\ell = \frac{\hat y_* - P_*(\mathbf r\!\mid_{z_\ell=0})}{P_*(\mathbf r\!\mid_{z_\ell=1}) - P_*(\mathbf r\!\mid_{z_\ell=0})} \ \Longrightarrow\ P_*(\mathbf r')=\hat y_* .

The prover emits a perfectly honest Dory opening at this wrong point, πopen_zk(P,r)\pi\leftarrow\mathsf{open\_zk}(P_*,\mathbf r'). The verifier checks at the honest point r\mathbf r. Since Dory’s ZK check ignores the point, it accepts anyway (V(C,r,π)=1V(\mathcal C_*,\mathbf r,\pi)=1).

This was run against an unchanged verifier and both honest and forged proofs:

honest.binOk (y0=0),poc.binOk (y0=1):6=7.\texttt{honest.bin}\to\textsf{Ok}\ (y^*_0{=}0),\qquad \texttt{poc.bin}\to\textsf{Ok}\ (y^*_0{=}1):\qquad 6 = 7 .

Impact in the zkVM

This is a total break of soundness in ZK mode. For an arbitrary false statement SS there exists an constructible π\pi with Verifyzk(S,π)=1\mathsf{Verify}_{\mathrm{zk}}(S,\pi)=1, against the unchanged verifier. The transparent path, binding both yy (via e2=yg2e_2=y\cdot g_2) and z\mathbf z, 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:

https://github.com/verialabs/Jolt-PoC