Dusk PLONK中的未验证求值漏洞

osecio 发布于 2026-05-01 阅读 11

OtterSec团队发现Dusk Network的PLONK零知识证明实现存在严重漏洞:验证器在最终验证方程中使用了prover提供的四个选择子多项式求值,但未对这些求值进行KZG打开验证。

引言

我们在 dusk-plonk 中发现了一个严重的可靠性漏洞,这是驱动 Dusk Network(市值约 ~6000 万美元)的 PLONK 实现。通过在验证步骤中利用一个缺口,恶意证明者可以为任意虚假声明伪造验证证明,绕过交易电路中的每一条约束。在已上线的 Rusk 网络上,这将允许铸造任意数量的 DUSK 代币,并通过正常的 Phoenix 路径转移伪造的隐私资金。

根本原因在于:证明者将四个公开选择子求值放入了证明结构体中,而验证者在其最终方程式中直接使用了它们,却从未对照验证者密钥中的可信承诺对它们进行过验证。证明者可以将它们设置为任何能使方程式通过的值。

PLONK 的工作原理(简述)


如需严谨论述,请参阅原论文;以下仅涵盖理解该漏洞所需的部分。

证明者希望说服验证者,自己知道满足某个计算(算术电路)的秘密输入,同时不泄露这些输入,且生成的证明应简短并快速验证。

算术电路与约束

算术电路由一系列加法门和乘法门互连而成。一个例子是证明我们知道椭圆曲线上的某个点 (x, y),例如证明 y² = x³ + 7,此处以 𝔽₃₇ 为例:

每个门 i 都有一个左输入 l_i、右输入 r_i 和输出 o_i。证明者的任务是证明自己知道满足每个门的电路连线值。

每个门施加一条约束,PLONK 通过使用充当开关的选择子值,将所有门类型统一为一个表达式:将 q_M 设为 1 使该行成为乘法门,将 q_L 设为 1 使其贡献一个加法项,依此类推。选择子值定义了电路的形状,是公开的,证明者和验证者双方都知道,而电路连线值则是证明者的秘密见证。


这种逐行检查并不能确保门之间的连线一致性(即一个门的输出等于下一个门的输入);PLONK 使用单独的置换论证来处理这个问题,此处不展开讨论。

从多次检查到一次检查

PLONK 并非逐个检查每个门,而是逐列读取执行轨迹,使用 FFT 插值将每组值转换为单个多项式。电路连线值变为见证多项式 f_L(x)、f_R(x)、f_O(x),选择子变为选择子多项式 Q_M(x)、Q_L(x)、Q_R(x) 等,所有这些都在 n 次单位根域 H 上进行插值。在 i 次单位根处计算 f_L(x) 即可恢复第 i 行的左电路连线值。

由于所有列现在都是多项式,整个电路被压缩成一个单一的主约束多项式 F(x),它组合了选择子和见证。如果证明者是诚实的,则 F(x) 在域中的每一行索引处都为零。消失多项式 Z(x) = xⁿ − 1 恰好在这些点上为零,因此如果所有约束都成立,则 Z(x) 能整除 F(x),得到一个商多项式 T(x),满足 F(x) = T(x) · Z(x):

主约束方程 F(x) = T(x) · Z(x),将选择子多项式和见证多项式组合为单一的可除性检查

对每一列在域 H 上进行插值

F 在 H 上全为零,因此 Z(x) = xⁿ − 1 能整除它

伪造的选择子求值使 F(z) = 0 对任意见证成立

一个算术门,每行 i

公开选择子:q_M, q_L, q_R, q_O, q_4, q_C
固定的电路形状,双方都知道

秘密见证:a, b, c, d
证明者的输入

q_M ab + q_L a + q_R b + q_O c + q_4 d + q_C = 0
每一行诚实求和为零

主约束多项式:
F(x) = Q_M(x) a(x) b(x) + Q_L(x) a(x) + Q_R(x) b(x) + Q_O(x) c(x) + Q_4(x) d(x) + Q_C(x)
F(x) = T(x) · Z(x)

可靠性缺口

多项式承诺与开启证明

为了保持证明简短,证明者不直接发送多项式。相反,它发送承诺,即每个多项式的简短密码学指纹(例如使用 KZG 承诺)。当验证者需要某个已承诺多项式在特定点的值时,证明者提供该值以及一个开启证明,以证明该声称的值与先前的承诺一致。

因此,承诺的多项式求值在密码学上是绑定的,证明者无法在不被发现的情况下谎报该值。

归约到单个随机点

在证明者承诺了所有多项式(包括 T(x))之后,验证者选择一个随机挑战点 z(通过 Fiat-Shamir 启发式从交互记录中导出),并在该单一点上检查 F(z) = T(z) · Z(z)。根据 Schwartz-Zippel 引理,如果这在随机点 z 处成立,那么整个多项式恒等式以压倒性概率成立,因此验证者可以在常数时间内检查整个数百万行的电路。

在教科书式的 PLONK 中,选择子多项式是固定电路描述的一部分,但在实际实现中,会在预处理阶段对它们进行承诺,并将这些承诺放入验证者密钥中。当验证者稍后需要它们在 z 处的值时,证明者提供求值声明,这些声明必须通过开启证明与那些承诺进行核对。

安全性论证依赖于一条链:承诺在挑战导出之前就将证明者锁定到多项式上,而开启证明确保求值与这些承诺一致。这条链上的任何一个环节被破坏,都会导致可靠性完全崩溃。

验证者实际上可以信任什么

对于这个漏洞,有一个不变量比其他的更重要:进入最终验证者方程式的每一个标量,要么由验证者本地计算,要么在密码学上绑定到先前的承诺

在实践中,进入验证者方程式的值分为三类。验证者从公开数据(Z_H(z)、L_1(z)、在 z 处的公开输入多项式)本地计算一些值,这些值是安全的,因为证明者从未选择它们。其他值是证明者提供的求值,并附有 KZG 开启证明(a(z)、b(z)、σ_1(z)、a(zω)),这些是安全的,因为开启将它们绑定到先前承诺的多项式上。第三类包括直接用于线性化多标量乘法的验证者密钥承诺([q_M]、[q_O]、[σ_4]),这些是安全的,因为验证者从不信任这些项的裸域元素;它直接使用承诺本身。

任何不属于这三类的项,其本身就是攻击者控制的。


Dusk PLONK 与教科书式 PLONK 的区别

dusk-plonk 并非 2019 年 PLONK 论文的逐字转录。它使用第四条电路连线 d 扩展了算术门,为范围、逻辑和椭圆曲线运算添加了自定义组件,使用了 zω 处的偏移求值,并大量批处理了 KZG 开启。按现代 PLONK 标准来看,这些都不算奇特,但这确实使验证者比论文的最小化展示更难推理。

对这个漏洞而言,重要的部分是公开电路数据证明者关于该数据在随机挑战点处值的声明之间的边界。并行的实现通过将选择子多项式严格排除在证明者控制之外来避免这种歧义。例如,Consensys 的 gnark(最广泛部署的 PLONK 实现之一)从不要求证明者提供选择子求值。相反,验证者将选择子承诺 Ql, Qr, Qm, Qo, Qk 直接纳入线性化多标量乘法,从而确保它们的值在结构上就是密码学绑定的。

Dusk 的自定义组件更为复杂(将选择子与其他求值项相乘),因此它们不能仅仅使用承诺的简单线性组合。它们的架构要求在 z 处计算选择子的值并使用这些标量。但是,虽然他们将这四个选择子求值序列化到了证明结构体中,却从未通过开启证明实际验证过它们与验证者密钥中的承诺是否一致。

查看漏洞的最短途径是下图:安全值通过开启路径流向最终的配对检查,而红色选择子流则在从未触及开启证明的情况下进入验证者逻辑。


Dusk 如何使用 PLONK

Dusk Network 是一个专注于隐私的 L1 区块链。其交易模型有两种模式:

  • Phoenix(隐私):使用零知识证明隐藏金额和参与者,每笔 Phoenix 交易都附带一个 PLONK 证明,证明交易是有效的。
  • Moonlight(透明):基于 BLS 签名的标准账户交易,不涉及 PLONK。

在节点层面,每一笔 ProtocolTransaction::Phoenix 在预验证期间都会经过 verify_proof_with_version()。如果该 PLONK 证明验证通过,该交易就会进入交易内存池,随后可能被挖入区块。Moonlight 路径的交易则通过 BLS 签名验证。

同一个 Phoenix 证明路径涵盖的范围远不止简单的隐私转账。Phoenix 路径下的质押、奖励提取、解除质押以及 Phoenix 到 Moonlight 的转换,都是通过 phoenix() 函数构建 Phoenix 交易的,例如在 phoenix_stake()phoenix_stake_reward()phoenix_unstake()phoenix_to_moonlight() 中。因此,如果 Phoenix 证明验证不可靠,整个隐私交易路径都将暴露:

Rusk 两条交易路径的示意图:Phoenix 交易由 PLONK 证明验证把关,Moonlight 交易由 BLS 签名检查把关

Phoenix 隐私交易仅由 verify_proof_with_version 中的 PLONK 证明检查把关,而 Moonlight 透明交易则由 BLS 签名检查把关。两者都注入交易内存池,但整个隐私路径都依赖于这一个 PLONK 判定结果。

PLONK 证明 – 每项隐私正确性声明的唯一关口
Moonlight – 透明 – BLS 签名检查

交易内存池

PLONK 实现 dusk-plonk 是 Dusk 团队的一个独立库。它是最早的 PLONK 实现之一,开发工作与原论文发布的同一年开始。

Phoenix 交易 PLONK 电路定义在这里。该电路强制执行以下约束集:

电路检查项 被检查的声明
Merkle 树成员资格 每个输入票据哈希都打开到公开的 Merkle 根,因此只有已在票据树中的票据可以被花费
输入票据私钥授权 证明者知道控制每个输入票据的私钥
无效符正确性 每个无效符与相应的票据密钥和位置匹配
输出值承诺正确性 每个公开输出承诺与秘密输出值和盲因子匹配
余额完整性 ∑输入 = ∑输出 + 手续费 + 存款
输入输出的范围检查 所有票据值在 [0, 2⁶⁴−1] 范围内
发送者身份签名 交易载荷由发送者的两个签名密钥分量签名
发送者加密正确性 附加到每个输出票据上的发送者数据是在接收者票据密钥下正确的 ElGamal 加密

Rusk 并非逐个消费这些声明。它通过 verify_proof_with_version() 函数,针对 tx.public_inputs() 消费一个简单的有效/无效证明判定结果。

PLONK 可靠性的崩溃会同时使所有这些约束失效,因为伪造的选择子求值会使整个电路变得无约束,而非针对任何单个检查。


漏洞所在

PLONK 验证过程中,验证者将多项式求值批量处理为单个 KZG 开启证明检查。此批处理中包含的求值(通过 E_evals 提交)有:

  • a_eval, b_eval, c_eval, d_eval(见证)
  • s_sigma_1_eval, s_sigma_2_eval, s_sigma_3_eval(置换)
  • a_w_eval, b_w_eval, d_w_eval(偏移见证)
  • z_eval(置换累加器)

但以下选择子求值未被包含在内:

  • q_arith_eval(算术选择子)
  • q_c_eval(常数选择子)
  • q_l_eval(左选择子)
  • q_r_eval(右选择子)

证明者将四个选择子求值放入证明结构体中。验证者将它们吸收到交互记录中,并且组件验证者代码直接在线性化检查中使用它们(证明结构体交互记录吸收算术组件固定基 ECC 组件)。但是,它们从未与验证者密钥中相应的选择子承诺进行核对,即使这些承诺已经存在。证明者发送任何它想要的值,而验证者就全盘接受。

要理解为什么这四项遗漏是特殊的,最简单的方式是将其与附近两个漏洞的案例进行对比:

  • 不存在证明者提供的 c(zω) 字段。ProofEvaluations 包含 a_w_evalb_w_evald_w_eval,但没有 c_w_eval,因此验证者永远不会消费未绑定的 c(zω) 声明(证明结构体)。
  • 验证者密钥中存在第四个置换承诺 [σ_4],但验证者在线性化 MSM 中直接使用该承诺本身,而不是信任证明者提供的标量 σ_4(z)(置换验证者密钥)。

这四个选择子求值既不符合这些安全模式中的任何一个:它们是证明者提供的标量,被验证者代码直接使用,并且从未出现在 E_evals 中,这使得主方程约束不足:

信任边界图,显示四个选择子求值在未绑定到任何承诺的情况下进入验证者方程

证明中包含十一个承诺、十一个由 KZG 开启证明绑定的求值,以及四个未绑定到任何东西的选择子求值。验证者密钥包含在预处理阶段固定的选择子承诺、置换承诺和域常数。置换求值通过开启证明对照其承诺进行检查,但四个选择子求值被用于验证者方程,而本应绑定它们的选择子承诺却未被使用。

验证者密钥(受信任的,在预处理阶段固定):
选择子承诺 [q_M], [q_L], [q_R], [q_O], [q_C], [q_arith]
置换承诺 [σ_1], [σ_2], [σ_3], [σ_4]
域常数 n, ω, K_1, K_2, K_3

证明(证明者提供,不可信):
11 个承诺,G1 点(见证、商、置换、开启)
11 个求值,已绑定:a_eval, b_eval, …, s_sigma_3_eval, z_eval
4 个选择子求值,未绑定:q_arith_eval, q_c_eval, q_l_eval, q_r_eval


如何利用

由于选择子求值是自由变量,验证方程变成了一个线性方程,证明者可以在事后求解。

证明者承诺任意的见证多项式,无需有效的见证,也承诺任意的商多项式,小的随机线性多项式就足够了。它遵循诚实协议完成所有承诺轮次,导出与验证者将使用的相同挑战。在看到 z_challenge 后,它计算线性化多项式为了通过配对检查应该等于多少,然后求解 q_arith_eval,这是使验证方程平衡的单个自由变量(将 q_c_eval = q_l_eval = q_r_eval = 0):

利用代数:在所有其他项固定后,通过单个域除法求解验证方程中的 q_arith_eval

设定 q_c(z) = q_l(z) = q_r(z) = 0。
一次域除法即可弥合差距。

诚实地推导挑战 z(与验证者相同的交互记录,在发送求值之前已知)。
r(z) 将算术选择子设为零:r_q0 = r_poly(z, q_arith = 0)
算术基础系数:coeff = arith_base(z)
配对检查必须达到的目标:target = -r_0 + PI(z)
求解一个自由变量:q_arith(z) = (target - r_q0) * coeff^(-1)

伪造的证明即可通过。

要实现这一点,可以计算将所有选择子设为零时的线性化多项式 r(x),在 z 处求值,并与目标值进行比较;差值除以 q_arith_eval 的系数,即可通过一次域除法得到所需的值。


对 Dusk Network 的影响

PLONK 是 Phoenix 特定正确性声明的唯一把关者:票据成员资格、所有权、票据承诺、发送者身份签名以及余额完整性都完全编码在电路中。Rusk 在验证证明之前确实会检查其他前置条件,例如无效符唯一性(预验证路径),但对于证明内部的声明,没有第二重验证路径。通过伪造的证明,攻击者可以:

  1. 膨胀代币供应量,通过虚构票据树中不存在的输入票据,并赋予任意值。伪造的证明使网络相信这些票据是真实的,攻击者凭空铸造出 DUSK,随时可以转移给诚实的用户或交易所。
  2. 伪造花费,绕过通常使 Phoenix 输入票据有效的所有权、成员资格和余额检查。
  3. 通过诚实钱包移动伪造的隐私资金,因为一旦伪造的 Phoenix 交易被接受,产生的隐私输出在协议层面无法与合法的 Phoenix 输出区分。

我们通过在本地 Dusk 测试网上进行完整的端到端概念验证,演示了这一点:

  1. 设置单个诚实的 Rusk 节点,创建两个钱包(诚实和恶意),余额均为 0。
  2. 恶意钱包伪造一个 PLONK 证明,从零凭空创建 2000 DUSK
  3. 恶意钱包使用一笔正常(诚实证明的)交易,向诚实钱包转账 1337 DUSK
  4. 诚实节点验证两笔交易并将它们挖入区块。
  5. 诚实钱包显示已确认余额为 1337 DUSK。

端到端概念验证流程:伪造的证明凭空铸造 2000 DUSK,然后将 1337 DUSK 转移到诚实钱包并得到确认

一个恶意钱包伪造了一笔无效的 Phoenix 交易,凭空铸造了 2000 DUSK,并通过一次域除法为其伪造了证明。Dusk 节点接受并将其挖入区块,因为伪造的选择子标量通过了配对检查。然后,恶意钱包通过一笔普通交易向诚实钱包发送了 1337 DUSK,该交易被节点挖入区块,诚实钱包显示已确认余额为 1337 DUSK。

顺序:

  1. 恶意钱包伪造无效交易 → 伪造证明 → 预验证通过,伪造标量进入 MSM,配对检查通过 → 提交伪造的 Phoenix 交易(铸造 2000 DUSK) → 被挖入区块,记入 2000 DUSK
  2. 向诚实钱包转账 1337 DUSK(普通、诚实证明的交易) → 被挖入区块,记入 1337 DUSK → 诚实钱包看到已确认余额 1337 DUSK。

在发现时,DUSK 的市值大约为 ~6000 万美元。整个隐私交易层都面临风险。由于 Phoenix 是保护隐私的,被接受进入隐私池的伪造输出在事后将难以区分,类似于 Neptune Cash 的 Triton VM 漏洞


修复方法

修复方法是将四个选择子求值添加到 KZG 批处理开启检查中,从而对照验证者密钥中已存在的选择子承诺对它们进行验证:

  • 在证明者端扩展 compute_aggregate_witness(),使其也包含 q_arithq_cq_lq_r
  • 在验证者端将它们添加到 E_evals 中,以便对照验证者密钥中的承诺进行检查。

这一工作通过提交 645265b7 完成,该提交于 2026 年 2 月 14 日合并。


为什么之前被遗漏了?

Dusk 的技术栈曾经历过多次严格审计:一份 2023 年 12 月对 dusk-plonk 的审计、一份 2024 年 9 月对 Phoenix 的审计,以及一份 2024 年 9 月 Oak Security 对 Rusk 节点库的审计。Dusk 的公开审计概览总结了更广泛的审计计划。然而,该漏洞仍然未被发现,因为它隐藏在一个非常容易犯的心智模型错误后面。

在多项式层面上,选择子是公开的电路描述。一个在脑海中保持标准 PLONK 模型的审查者自然会认为“选择子在验证者一侧”,然后继续查看其他内容,而忽略了 Dusk 验证者开始消费证明者提供的选择子求值这一架构上的偏离。

这是一个纯粹的证明系统漏洞,而非 Phoenix 电路漏洞;电路约束本身编写正确。失败完全是因为验证者接受了绕过先前建立的基本不变量的证明字段:这些字段既非本地计算,也非密码学绑定到开启证明。

针对此类漏洞的检查是机械性的:列举证明求值结构体中的每一个字段,并验证每个字段要么出现在开启证明批次中,要么由验证者本地计算。

Espresso Systems 的 Jellyfish 中的类似漏洞

在调查 PLONK 实现时,我们在 Espresso Systems 的 jf-plonk 中发现了一个类似的漏洞。具体机制不同,但其利用也归结为用于最终检查的变量未经密码学绑定。

Jellyfish 实现了 UltraPlonk,它在标准 PLONK 基础上扩展了 Plookup 查找参数。Plookup 向证明中添加了 15 个多项式求值。函数 append_plookup_evaluations() 本应将这 15 个全部添加到 Fiat-Shamir 交互记录中,然后再导出批处理挑战 v。然而,它只添加了 15 个中的 6 个,剩下的 9 个求值被用于批处理验证检查,但却不影响 v,因此证明者可以在事后调整它们以使检查通过。

攻击需要修改单个求值(key_table_next_eval),将其修改 δ / (u · v³) 以弥合真实值与预期批处理求值之间的差距,这与 Dusk 的利用一样,归结为一次域除法。

据我们所知,Jellyfish 的 UltraPlonk 模式目前尚未在生产环境中部署。PR #867 修复了该问题,并于 2026 年 3 月 18 日标记为 jf-plonk-v0.8.0


走向标准化

两个独立的 PLONK 实现包含相同类别的漏洞,并且类似的模式出现在 zkVM 中,这一事实表明,这并非仅靠单次审计就能解决的问题。上述检查(对比“已使用的求值”与“已绑定的求值”)是机械性的,可以内置于开发工具、CI 流水线或标准化的 PLONK 验证规范中。

我们正在与 Dusk 团队及其他相关方进行早期讨论,探讨 PLONK 标准化工作可能的形式:一个曲线无关、后端无关的验证协议规范,将求值绑定等不变性明确化并可检查。

当前的现状——每个团队从论文实现自己的 PLONK 变体,并寄希望于审计员能发现他们遗漏的问题——是非常脆弱的。一个共享的、经过充分审查的验证规范将减少此类漏洞的攻击面,并为审计员提供一份具体的检查清单来对照验证。

披露时间线

日期 事件
2026-02-13 报告 Dusk 漏洞
2026-02-14 Dusk 确认收到
2026-02-14 Dusk 修复提交
2026-02-27 公开发布 dusk-rusk-1.6.0
2026-03-16 Jellyfish 修复 PR 已创建 (#867)
2026-03-18 Jellyfish 修复在 #867 中合并,并标记为 jf-plonk-v0.8.0

致谢

我们感谢 Dusk 团队在一天内做出响应,透明地协调修复工作,并就更广泛的标准化问题开展合作。我们也感谢 Espresso Systems 团队在一周内完成了 Jellyfish 的修补程序。

  • 原文链接: osec.io/blog/unverified-...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论