Unfaithful claims: breaking 6 zkVMs

Himanshu Sheoran Valter Wik Himanshu Sheoran, Valter Wik #zkVM

A zkVM verifier should be faithful to one thing above all else: its public claims. Yet we found six systems where this guarantee breaks. Learn how a subtle ordering bug lets an attacker bypass the cryptography entirely and prove mathematically impossible statements.

Unfaithful claims: breaking 6 zkVMs Unfaithful claims: breaking 6 zkVMs

A zkVM verifier should be faithful to its public claims. If a statement about a program’s inputs or outputs is false, verification must fail.

We found six systems where this guarantee breaks. In Jolt, Nexus, Cairo-M, Ceno, Expander, and Binius64, values that affect verification weren’t always bound to the Fiat-Shamir transcript before challenges were generated. This lets an attacker choose those values after seeing the challenges and solve for whatever makes the verifier accept.

The resulting proofs can convince a verifier of mathematically impossible statements, such as a counterexample to Fermat’s Last Theorem. In a blockchain context, this could translate to receiving $1M out of thin air. We’ve included two challenges at the end of this post if you’d like to try implementing the exploits yourself.

What are we even breaking?

A zkVM proof claims that a program executed correctly on public inputs and produced the claimed public output, without revealing the full execution trace. We can write this as a claim that a valid trace TT exists.

  T  :  VM(P,X,T)Y\exists \; T \; : \; \mathsf{VM}(P, X, T) \to Y

Here, PP is the public program or circuit description, XX is its public input, and YY is the claimed public output. The private trace TT contains the registers, memory history, and intermediate values from execution.

The verifier doesn’t replay that execution. It checks algebraic constraints over committed polynomials. A polynomial commitment binds the prover to a polynomial and lets them later prove an evaluation, such as “my polynomial evaluates to 42 at point 7,” without revealing the whole polynomial. We’ll refer to the polynomial commitment scheme as the PCS.

Some systems in this post provide verifiable computation without full zero knowledge. The property we’re breaking in all six is soundness, which requires that false execution claims don’t verify. This is distinct from completeness, which requires that honest executions do verify.

Definition (Faithfulness)

A verifier is faithful when the public statement it accepts is exactly the statement bound into the proof. In this post, every bug lets a prover-controlled claim change after the transcript challenges are known while the proof still verifies.

All six verifiers follow roughly the same process. They start with a public statement and metadata such as sizes, roots, and domains. They read commitments, reduction messages, and claimed evaluations from the proof, reconstruct the Fiat-Shamir challenges, and check the constraint equations at the sampled points. Opening proofs connect those evaluations to the polynomial commitments, and the verifier accepts only if the constraint, opening, and global consistency checks agree.

The ordering of these operations matters. If a value affects a verification equation but isn’t bound before the relevant challenge is sampled, the prover may be able to choose it after learning what the verifier will check.

The building blocks

To see why this is exploitable, let’s work through the protocols involved.

The Fiat-Shamir transform

In an interactive protocol (the type most commonly described in literature), the verifier sends random challenges and the prover responds in real time. That doesn’t work well for blockchains, where there is no live verifier, or for proofs that anyone should be able to verify later.

The Fiat-Shamir transform replaces the verifier’s randomness with a cryptographic hash function. Both parties maintain a transcript, which is the running hash state of the protocol. Adding data to that state is called absorbing it, and deriving a challenge is called squeezing.

After absorbing the public statement, the prover can derive challenges from their own transcript and respond to them without contacting a verifier. The verifier starts with the same statement and replays the same operations to recover the challenges.

The prover absorbs each message into a transcript and squeezes challenges from it instead of talking to a live verifier, sends the proof, and the verifier replays the same absorb-and-squeeze steps against its own transcript before checking.Fiat-Shamir transformThe prover absorbs each message into a transcript and squeezes challenges from it instead of talking to a live verifier, sends the proof, and the verifier replays the same absorb-and-squeeze steps against its own transcript before checking.Verifier transcriptVerifierProver transcriptProverVerifier transcriptVerifierProver transcriptProver
verify(C,r1,R1,r2,R2)
Absorb commitment C
Squeeze challenge r1
Absorb challenge response R1
Squeeze challenge r2
Send proof (C,R1,R2)
Absorb commitment C
Squeeze challenge r1
Absorb challenge response R1
Squeeze challenge r2
Important

Values that affect verification must be bound to the transcript before the challenges governing those checks are derived.

The hash makes the challenges unpredictable before the relevant values are fixed. But if some value VV hasn’t been absorbed when a challenge is squeezed, that challenge is independent of VV. The prover can compute the challenge first, then choose VV to make the verification equation pass. That is the bug class we found in all six systems.

The sumcheck protocol

The sumcheck protocol proves that a polynomial sums to a claimed value over the Boolean hypercube, meaning all inputs in {0,1}n\{0,1\}^n. The claimed sum is

H=x1{0,1}x2{0,1}xn{0,1}g(x1,x2,,xn)H = \sum_{x_1 \in \{0,1\}} \sum_{x_2 \in \{0,1\}} \cdots \sum_{x_n \in \{0,1\}} g(x_1, x_2, \ldots, x_n)

Computing this directly would require the verifier to evaluate all 2n2^n terms. Sumcheck reduces that work to checking one evaluation of the original polynomial, after a sequence of rounds.

Round by round the prover sends a univariate polynomial, the verifier checks it against the running claim and replies with a random challenge, reducing the multivariate sum to a single evaluation that is checked with one opening proof.The sumcheck protocolRound by round the prover sends a univariate polynomial, the verifier checks it against the running claim and replies with a random challenge, reducing the multivariate sum to a single evaluation that is checked with one opening proof.VerifierProverVerifierProver
Claim H=x{0,1}ng(x)
over x=(x1,,xn)
Round 1
g1(X)=x2,,xng(X,x2,,xn)
Verify g1(0)+g1(1)=H
Round 2
g2(X)=x3,,xng(r1,X,x3,,xn)
Verify g2(0)+g2(1)=g1(r1)
Repeat for n rounds...
Final round
Verify g(r1,,rn)=gn(rn)
(single opening proof for a committed polynomial)
H
univariate g1(X)
Random challenge r1
univariate g2(X)
Random challenge r2

In each round, the prover sends a polynomial gi(X)g_i(X) such that gi(0)+gi(1)g_i(0) + g_i(1) equals the previous claim. If the original sum HH is false, the prover must lie about one of these polynomials. Since the verifier picks a random rir_i after receiving gig_i, the lie won’t match the evaluation of the original polynomial except with very low probability.

There is also a communication optimization that will matter for the attacks. A degree-1 polynomial gi(X)=a+bXg_i(X) = a + bX has only two coefficients. Since the verifier already knows the previous claim Hi1H_{i-1}, they can recover bb from aa.

a+(a+b)=Hi1    b=Hi12aa + (a + b) = H_{i-1} \implies b = H_{i-1} - 2a

The prover only needs to send a=gi(0)a = g_i(0), saving 50% of the communication for these coefficients. Substituting the recovered value of bb into the next claim gives us

Hi=gi(ri)=a+bri=a+(Hi12a)ri=a(12ri)+Hi1riH_i = g_i(r_i) = a + b \cdot r_i = a + (H_{i-1} - 2a) \cdot r_i = a(1 - 2r_i) + H_{i-1} \cdot r_i

Notice that this is linear in Hi1H_{i-1}. Applying the same reasoning through all rounds, the final claim is linear in the original HH. If HH isn’t in the transcript, we can hold the challenges fixed and solve for the value of HH that the verifier will accept.

Multilinear extensions

A multilinear extension (MLE) turns a table of values over {0,1}n\{0,1\}^n into a polynomial. It agrees with the table at Boolean inputs and interpolates between them at other field points.

The property we’ll use is that evaluating an MLE at a fixed point is linear in the table entries.

f~(r)=b{0,1}nf(b)eq(b,r)\tilde{f}(\vec{r}) = \sum_{\vec{b} \in \{0,1\}^n} f(\vec{b}) \cdot \text{eq}(\vec{b}, \vec{r})

At a fixed challenge point r\vec{r}, the coefficients eq(b,r)\text{eq}(\vec{b}, \vec{r}) are constants. An attacker who can change the table values after r\vec{r} is known can therefore solve for alternative values that preserve the same evaluation.

Lookup arguments

A zkVM needs to check properties such as whether a byte is in [0,255][0, 255], an opcode decodes correctly, or a memory access agrees with earlier accesses. Adding separate constraints for every check is expensive. Lookup arguments let the system precompute a table of valid tuples and prove that the values used during execution belong to it.

LogUp, a lookup argument based on logarithmic derivatives, expresses multiset relations as sums of fractions. For two multisets AA and BB that should be equal, it checks

aA1za=bB1zb\sum_{a \in A} \frac{1}{z - a} = \sum_{b \in B} \frac{1}{z - b}

at a random challenge zz. Matching multisets give equal sums. Different multisets give different sums with overwhelming probability.

In a zkVM, components produce and consume lookup tuples. The CPU might emit a record saying it read value vv from address aa at time tt, while the memory table consumes the corresponding record. Each component’s claimed_sum is its net contribution to the LogUp sum.

claimed_sumi=j1zemitjk1zconsumek\text{claimed}\_\text{sum}_i = \sum_j \frac{1}{z - \text{emit}_j} - \sum_k \frac{1}{z - \text{consume}_k}

The global check requires iclaimed_sumi=0\sum_i \text{claimed}\_\text{sum}_i = 0, so everything produced must be consumed. However, the claimed_sum values come from the prover. If they aren’t bound before the relevant challenges are derived, the prover can adjust them to make an invalid execution balance.

Solving for an unbound claim

The attacks start by finding a value the prover controls and checking when it enters the transcript. We then trace that value through the verifier to find every equation it affects. With the transcript and challenges held fixed, the unbound values become the unknowns in a system of equations.

For a single value VV, the check has the form f(V)=targetf(V) = \text{target}. If ff is linear, solving it requires only field arithmetic.

Proposition (Unbound linear claim)

If a prover-controlled value VV affects a verifier check as αV+β\alpha V + \beta, and the challenge defining α\alpha and β\beta was sampled before VV was bound, then whenever α0\alpha \ne 0 the prover can choose

V=targetβαV = \frac{\text{target} - \beta}{\alpha}

after seeing the transcript.

This doesn’t require breaking the commitment scheme. We solve for a value that satisfies the existing check and put it into the proof or public statement.

When several unbound values affect several checks, we have to satisfy them together. Gaussian elimination solves a linear system in O(n3)O(n^3) field operations. Some of the systems below introduce nonlinear constraints, which may require techniques such as resultants or Groebner bases.

The six broken systems

Jolt, Nexus, and Ceno leave intermediate claims in the proof unbound. Cairo-M, Expander, and Binius64 have the same problem with public statement data. Let’s start with Jolt to see how the sumcheck optimization turns into an exploit, then work through the differences in the other systems.

Jolt (a16z)

Jolt is a zkVM for RISC-V programs, built by a16z. It uses sumcheck extensively to verify execution constraints, with commitments, opening claims, and the corresponding proofs stored in JoltProof.

JoltProof
JoltProof {
commitments: Vec<Commitment>, // Polynomial commitments to trace
opening_claims: Map<OpeningId, Claim>, // <- THE VULNERABLE VALUES
proofs: Map<Stage, SumcheckProof>, // Sumcheck and opening proofs
...
}

The verifier first initializes the transcript and absorbs the public inputs and outputs, trace length, and commitments. Stages 2–4 derive batching coefficients and verify the batched sumchecks. Stage 5 verifies the polynomial evaluations against the commitments.

The verifier receives the JoltProof and public I/O, sets up the transcript, runs the batched sumchecks, verifies the openings against the commitments, and accepts.Jolt verification flowThe verifier receives the JoltProof and public I/O, sets up the transcript, runs the batched sumchecks, verifies the openings against the commitments, and accepts.

Stage 5: opening verification

Batch-verify polynomial evaluations

Check against commitments

Stages 2-4: batched sumchecks

Derive batching coefficients

Compute BatchedClaim

Verify sumcheck rounds

Stage 1: transcript setup

Initialize transcript

Absorb public I/O, trace length

Absorb commitments

Verifier receives

JoltProof

Public I/O

Accept

Each sumcheck instance provides an input_claim, the value that its polynomial allegedly sums to over the Boolean hypercube. These values come from opening_claims, but they were never absorbed into the transcript before the batching coefficients were derived.

Commitments, public I/O and round messages are absorbed to derive the batching coefficients and sumcheck challenges, but opening_claims is never absorbed, yet H_i from opening_claims feeds the BatchedClaim verification equation.Jolt sumcheck flow with unbound opening claimsCommitments, public I/O and round messages are absorbed to derive the batching coefficients and sumcheck challenges, but opening_claims is never absorbed, yet H_i from opening_claims feeds the BatchedClaim verification equation.

Verification equation

Challenge derivation

Transcript state

Not absorbed

Commitments

Public I/O

opening_claims
(missing)

Round messages

αi
(batching coeffs)

sumcheck challenges

Hi from opening_claims

BatchedClaim = iαiHi

The verifier computes BatchedClaim as a random linear combination of the individual claims HiH_i.

BatchedClaim=iαiHi\text{BatchedClaim} = \sum_i \alpha_i \cdot H_i

The coefficients αi\alpha_i come from the transcript. Since the HiH_i values weren’t in that transcript, the coefficients are independent of the claims they’re meant to check.

Recall the sumcheck compression optimization. The prover omits one coefficient per round, and the verifier reconstructs it using the previous claim. Following this through the rounds makes the final verification equation linear in the input claim HH.

Cfinal=aH+bC_{\text{final}} = a \cdot H + b

Here, aa and bb are determined by the transcript and are independent of HH. The verifier compares CfinalC_{\text{final}} with expected_eval\text{expected}\_\text{eval} from the PCS opening, giving us aH+b=expected_evala \cdot H + b = \text{expected}\_\text{eval}.

Multiple claims are coupled across verification stages, so changing one claim may affect several checks. We can account for all of them by solving a small linear system over a handful of unbound claim values.

Jolt fixed the issue on October 3, 2025 in PR #981.

Nexus

Nexus is a zkVM built on StarkWare’s Stwo prover. It divides verification into components for instruction execution, memory, registers, and other parts of the machine. Each component handles a subset of the execution constraints.

These constraints are polynomial equations describing a valid execution trace, an algebraic intermediate representation (AIR). Nexus uses STARKs to check them with commitments, random sampling, and a low-degree test called FRI. FRI stands for “Fast Reed-Solomon IOP” and checks that a committed function is a low-degree polynomial. This approach doesn’t require a trusted setup.

Like the lookup example above, each Nexus component produces and consumes tuples. Its claimed_sum records the net contribution.

claimed_sumi=j1zproducedjk1zconsumedk\text{claimed}\_\text{sum}_i = \sum_j \frac{1}{z - \text{produced}_j} - \sum_k \frac{1}{z - \text{consumed}_k}

The claimed sums must add to zero so that every produced tuple is consumed. They are included alongside the STARK proof and component sizes in NexusProof.

NexusProof
NexusProof {
stark_proof: {
commitments: [Merkle roots of trace columns]
sampled_values: [polynomial evaluations]
fri_proof: [low-degree test proof]
}
claimed_sum: [FieldElement; NUM_COMPONENTS] // <- VULNERABLE
log_size: [component sizes]
}

The verifier derives lookup elements, an out-of-domain point, and composition coefficients from a transcript containing associated_data, log_sizes, and trace commitments. It checks that the claimed_sum array has the correct length and sums to zero, but never absorbs those values into the transcript.

associated_data, log_sizes and trace commitments derive the lookup elements, OODS point and composition coefficients, while claimed_sum is independent of the transcript yet feeds the verification checks.Nexus verification flow with unbound claimed_sumassociated_data, log_sizes and trace commitments derive the lookup elements, OODS point and composition coefficients, while claimed_sum is independent of the transcript yet feeds the verification checks.

Verification

Not in transcript

Derived challenges

Transcript state

Independent

associated_data

log_sizes

trace commitments

lookup_elements (z)

OODS point

composition coeffs

claimed_sum

Check iclaimed_sumi=0

CP(oods)=expected

The verifier combines the execution constraints into a composition polynomial.

C(x)=iαiconstrainti(x)C(x) = \sum_i \alpha_i \cdot \text{constraint}_i(x)

The verifier checks this polynomial at a random point outside the execution domain. This is the out-of-domain sampling (OODS) test, which requires C(oods_point)=expectedC(\text{oods}\_\text{point}) = \text{expected}.

The LogUp boundary constraints are linear in the claimed sums. Once the challenges are fixed, their contribution to the composition polynomial is linear as well. We therefore need to satisfy both the OODS check and the requirement that the claimed sums add to zero. Together, these form a small linear system.

Nexus fixed the issue on October 24, 2025 in PR #503.

Cairo-M (Kakarot Labs)

Cairo-M, built by Kakarot Labs, is an alternative proof system for the Cairo VM used by Starknet. Like Nexus, it uses LogUp to prove global statements about execution. The unbound values in this case are the public inputs and outputs, boundary registers, clock, and memory roots in public_data.

Cairo-M Proof
Proof {
claim: ComponentSizes,
interaction_claim: LogupClaimsPerComponent,
public_data: { // <- VULNERABLE
initial_registers: { pc, fp },
final_registers: { pc, fp }, // <- forged
clock, // <- forged
initial_root,
final_root, // <- forged
public_memory: { program, input, output }, //output modified
},
stark_proof: [...],
}

The verifier processes the PCS configuration, trace roots, component sizes, and proof-of-work check before drawing the lookup challenges zz and α\alpha. At this point, public_data hasn’t been mixed into the transcript. It is then used in the global lookup check, after which the verifier mixes interaction_claim and verifies the STARK proof.

Prover setup elements are absorbed and lookup challenges drawn, but public_data is not yet in the transcript when it feeds the claimed_sum check, leaving it unbound.Cairo-M verification flowProver setup elements are absorbed and lookup challenges drawn, but public_data is not yet in the transcript when it feeds the claimed_sum check, leaving it unbound.

Verification flow

Not in transcript

Transcript state

Prover setup

challenges

mix claim

Unbound

Mix PCS config

Commit trace roots

Mix claim (component sizes)

Check proof-of-work

Absorb setup elements

Draw lookup challenges z,α

public_data
not yet in transcript

Check:
claimed_sum(relations,public_data)=0

Mix interaction_claim

Verify STARK proof

The public data enters the lookup relations through challenge-weighted encodings of tuples in the denominators. Abstractly, the verifier checks

L(public_data)+(other transcript_bound terms)=0,L=i1z+α,ti(public_data)+βL(\text{public}\_\text{data}) + \text{(other transcript}\_\text{bound terms)} = 0, \\ L = \sum_i \frac{1}{z + \langle \alpha, t_i(\text{public}\_\text{data})\rangle + \beta}

With the challenges fixed, this is a rational equation in the public data. We can still solve it algebraically, but the linear approach from Jolt and Nexus isn’t enough.

The public data participates in verification through extension-field arithmetic, including public-memory entries that take values in the extension field. Finding forged parameters therefore requires solving a coupled system over that extension field.

Cairo-M fixed the issue on October 31, 2025 in commit 92b6740.

Ceno (Scroll)

Ceno is a zkVM by Scroll that uses GKR, a protocol that verifies arithmetic circuits layer by layer with sumcheck. This reduces verification of a large circuit to a few random evaluations.

Ceno divides verification into chips, one per opcode or lookup table. Each chip proves its constraints independently. Values associated with reads, writes, and lookups are batched into a binary tree, whose layers fold pairs of values using random challenges. This is the tower sumcheck.

Read records must match write records after accounting for the initial and final state. Ceno checks this multiset equality with a product rather than a LogUp sum.

ir_out_evalsi=jw_out_evalsj(state factors)\prod_i \text{r}\_\text{out}\_\text{evals}_i = \prod_j \text{w}\_\text{out}\_\text{evals}_j \cdot (\text{state factors})

The evaluations used in these checks are included in ZKVMChipProof.

ZKVMChipProof
ZKVMChipProof {
r_out_evals: [[FieldElement]], // <- VULNERABLE
w_out_evals: [[FieldElement]], // <- VULNERABLE
lk_out_evals: [[FieldElement]], // <- VULNERABLE
tower_proof: [...],
gkr_iop_proof: [...],
}

The r_out_evals, w_out_evals, and lk_out_evals values initialize the tower sumcheck claim, but they are never absorbed into the transcript. The tower claim is claim=jαjout_evalsj\text{claim} = \sum_j \alpha^j \cdot \text{out}\_\text{evals}_j, which is linear in those evaluations.

We also have to satisfy the product check. If we vary x0=r_out_evals[0][0]x_0 = {\text{r}\_\text{out}\_\text{evals}}[0][0] and x1=r_out_evals[0][1]x_1 = {\text{r}\_\text{out}\_\text{evals}}[0][1] while fixing the other evaluations, the constraint becomes

x0x1(rest of product)=targetx_0 \cdot x_1 \cdot (\text{rest of product}) = \text{target}

This is bilinear in (x0,x1)(x_0, x_1). Together with the linear GKR equation, we have two equations in two unknowns.

{a0x0+a1x1+c=0kx0x1+d=0\begin{cases} a_0 x_0 + a_1 x_1 + c = 0 \\ k \cdot x_0 \cdot x_1 + d = 0 \end{cases}

Substituting the linear equation into the product equation reduces the system to a quadratic in one variable, which we can solve with the quadratic formula.

Ceno fixed the issue on March 5, 2026 in PR #1262. The original report is available in issue #1125.

Expander (Polyhedra)

Expander is a GKR-based proof system for arithmetic circuits. Its proof bytes contain a PCS commitment, sumcheck round polynomials for each layer, layer claims claim_x and claim_y, and PCS opening proofs, in that order. The statement values public_input and claimed_v are passed separately.

In Expander’s circuit model, constant gates can reference public input values. During GKR verification, eval_cst() computes their contribution at the sumcheck challenge point.

sum -= GKRVerifierHelper::eval_cst(&layer.const_, public_input, sp);

The coefficients come from challenges stored in the verifier’s scratch pad, sp. The evaluation is a linear combination of the public inputs.

eval_cst=ipublic_input[i]eq(i,r)\text{eval}\_\text{cst} = \sum_i {\text{public}\_\text{input}}[i] \cdot \text{eq}(i, \vec{r})

However, the transcript is built from proof bytes, including the PCS commitment and sumcheck round messages. The separately supplied public_input and claimed_v values are never absorbed.

The transcript is built from proof bytes only; public_input and claimed_v are passed separately and never absorbed, yet they feed the GKR sumcheck check, which is linear in public_input.Expander verification flow with unbound public inputThe transcript is built from proof bytes only; public_input and claimed_v are passed separately and never absorbed, yet they feed the GKR sumcheck check, which is linear in public_input.

GKR sumcheck verification

Passed separately

Transcript

Proof (bytes)

Never absorbed

Never absorbed

PCS commitment

Sumcheck rounds

Layer claims

Opening proofs

Built from
proof bytes only

public_input

claimed_v

check uses
(public_input, claimed_v)

Linear in public_input

The challenge vector r\vec{r} is therefore independent of public_input. We can choose a false statement, such as a forged output, and solve the resulting linear constraints for a modified public input that makes the verifier accept.

Expander fixed the issue on January 21, 2026 in commit 4a8c2be. The claimed 500k bug bounty award is pending.

Binius64

Binius64 is a proof system designed for 64-bit CPUs that operates over binary fields such as F2128\mathbb{F}_{2^{128}}, where addition is XOR. Its shift protocol handles bit shifts and rotations, operations used by hash functions such as SHA-256.

The verifier receives the public witness, containing program inputs and outputs, as a separate parameter.

pub fn verify<F, C>(
constraint_system: &ConstraintSystem,
public: &[Word], // <- NEVER ABSORBED
// ...
) -> Result<VerifyOutput<F>, Error>

The first sumcheck produces γ\gamma and challenge points r_j for bit indices and r_s for shift indices. The verifier then samples inout_eval_point and batch_coeff from the transcript. The public witness hasn’t been bound before these challenges are sampled.

The first sumcheck and sampled challenges drive the verification equations, but public_input is not absorbed into the transcript before it feeds the public_eval MLE, leaving it unbound.Binius64 shift protocol verification flowThe first sumcheck and sampled challenges drive the verification equations, but public_input is not absorbed into the transcript before it feeds the public_eval MLE, leaving it unbound.

Verification flow

Not in transcript

Transcript state

rj

inout_eval

γ

batch_coeff

Unbound

First sumcheck
(produces γ,rj,rs)

Sample inout_eval_point

Sample batch_coeff

public_input
not yet absorbed

public_eval =
MLE(public_input,rj,inout_eval_point)

sum = γ+batch_coeffpublic_eval

Second sumcheck
(verifies batched sum)

Using the unbound public slice, the verifier computes public_eval = MLE(public, r_j, inout_eval_point). This evaluation is linear in the public witness bits.

public_eval=w,bpublic[w][b]eq(b,rj)eq(w,inout_eval_point)\text{public}\_\text{eval} = \sum_{w,b} {\text{public}}[w][b] \cdot \text{eq}(b, r_j) \cdot \text{eq}(w, \text{inout}\_\text{eval}\_\text{point})

The result feeds a second sumcheck through the batched sum γ+batch_coeffpublic_eval\gamma + \text{batch}\_\text{coeff} \cdot \text{public}\_\text{eval}.

Because the challenges are independent of public, an attacker can look for an alternative witness public\text{public}' with the same evaluation. This gives one 128-bit linear constraint over hundreds of witness bits. Under common parameterizations, the system is underconstrained and admits many alternative witnesses, allowing a different public statement to pass the same check.

Binius64 fixed the issue on December 29, 2025 in commit 86a515f.

Why does this keep happening?

Finding the same bug in six independent implementations makes it hard to treat these as isolated mistakes. We found them by examining only a handful of systems, while dozens of zkVMs, proof systems, and recursive verifiers are deployed today.

Academic papers usually describe interactive protocols, where the prover sends a commitment CC, the verifier responds with a random challenge rr, and the prover sends a response RR. Security proofs analyze that interaction, where the ordering is implicit. Papers often leave out the steps needed to make it non-interactive, including hashing CC, the public statement, and any intermediate values before deriving the challenges that depend on them. The implementer has to work out those bindings, which requires understanding the full protocol.

Modularity makes this harder. A zkVM layer may pass a claim to a lookup layer, which passes it to a sumcheck layer. Each can assume that another layer has already bound the value. If none of them does, an unbound claim reaches the verifier.

There is also pressure to avoid unnecessary hashing. Every hash has a cost, and some values can safely be omitted from the transcript. Deciding which ones can be left out requires understanding all the protocols involved and having that reasoning checked by experts.

Ordinary testing is unlikely to find the attacks we’ve described. Unit and integration tests generally run an honest prover, while random fuzzing is very unlikely to stumble onto the values that satisfy the verifier’s equations for a false statement. Finding these bugs requires manual security analysis, and even that can miss them.

Preventing missing bindings

The fixes in these six cases were only one or two lines of code. Finding them required understanding the full verification flow. For an audit, we need to map that flow and check each prover-controlled value against the point where its relevant challenges are derived. The question to ask is, “What if the prover chose this value after seeing the challenges?”

One way to make missing bindings less likely is to merge the proof buffer and transcript. The buffer emulates the communication channel between prover and verifier. Whenever the prover writes a value, that value is automatically absorbed. When the prover needs a challenge, it is squeezed from the current transcript.

The verifier reads the same buffer in the same order, absorbing values as it goes and reproducing the challenges. Halo2 follows this pattern, and Binius also organizes its proof handling around the transcript.

Caution

A merged proof buffer and transcript doesn’t automatically cover statement data passed separately, such as public inputs. Those values still need to be absorbed before sampling any challenges that govern equations depending on them. Binius demonstrates how this can be missed even when proof messages are handled through the transcript.

With dozens of components, each with its own inputs and outputs, “hash everything” is difficult to turn into a precise implementation rule. The responsibility for each binding needs to be explicit. If there is doubt about whether a value can safely be omitted, absorb it.

Responsible disclosure timeline

We notified all six teams. Responses ranged from immediate acknowledgement to delayed fixes, and all reported issues have since been addressed.

System Reported Fixed Response time
Jolt Sep 2025 Oct 3, 2025 <1 week
Nexus Oct 2025 Oct 24, 2025 <1 week
Cairo-M Oct 2025 Oct 31, 2025 <1 week
Ceno Nov 2025 Mar 5, 2026 ~4 months
Binius64 Dec 2025 Dec 29, 2025 <1 week
Expander Nov 2025 Jan 21, 2026? 3 months

Challenges

We’ve prepared two challenges if you’d like to practice implementing these exploits. If you solve either one, follow the instructions in the flag. The first 10 solvers will get a T-shirt.

Your goal is to convince the verifier that you know a counterexample to Fermat’s Last Theorem. Find a,b,c1a, b, c \ge 1 such that a3+b3=c3a^3 + b^3 = c^3. Good luck!

The Jolt handout contains the setup running on the server. Submit your proof by connecting to jolt.chal.osec.io:8960.

The Nexus handout contains the setup running on the server. Submit your proof by connecting to nexus.chal.osec.io:8950.

Now you should have enough margin to prove Fermat wrong.

Read more from our blog

See all

Introducing rCTF v2

We’re releasing rCTF v2, an open-source platform for hosting cybersecurity capture-the-flag competitions.
Arsenii, Jason T., Stepan

Announcing the Save CTFs Fund

OtterSec is committing $100,000 to keep CTFs competitive in the age of AI. We break down why Jeopardy scoring is breaking down, what better formats might look like, and how to apply for sponsorship.
Michael Debono

Subscribe to the blog

New posts from the OtterSec team, straight to your inbox. One email per post, unsubscribe any time.