我们将 AI 审计流水线对准了 Cloudflare 的 CIRCL 实验性密码学库,并确认了七个真实漏洞,从阈值 RSA 中严重的 float64 精度丢失,到基于属性的加密中完全访问控制失效。所有七个漏洞现已在上游修复。这是本系列文章的第一篇,后续将介绍我们的智能体在开源密码学项目中发现的其他漏洞。
在 zkSecurity,我们正在构建 zkao——一个 AI 审计智能体。其目标说起来简单做起来难:让 AI 持续审查你的代码,直到其他 AI 工具能发现的所有漏洞都被清除。我们在《zkao:持续累积的安全性》一文中阐述了这一方法为何重要。
构建 zkao 是一个迭代过程,最终目标是打造一个能够发现所有 AI 可检测漏洞的自动化审计器。这涉及构思新思路和新方法,将 zkSecurity 安全研究人员的专业知识系统性地编码进 zkao,确保它能检测最新、最严重的漏洞而不偏向基准测试,并且——关键的是——持续开展实验,以理解哪些方法有效、哪些无效、模型如何演进,并加深我们对 AI 漏洞发现的理解。其中一些实验本身产出了值得分享的内容,与产品无关,这正是本系列文章的主题。
还有第二个动机。这些实验是我们为 zkao 构建基准测试套件的方式,在此过程中,它们不断揭示出大语言模型实际上如何推理密码学问题:它们擅长什么、在哪些方面存在盲区,以及如何放大前者、控制后者。虽然漏洞是可见的输出,但我们最关心的是推理模式。
几个月前,我们开始在选定的代码库上运行实验。我们使用大语言模型扫描了几个开源密码学项目,采用两种配置:
-
仅使用大语言模型,配合简单的提示词。
-
大语言模型配合技能包,这些技能包由我们团队的专家维护。
随后,针对那些大语言模型发现了真实漏洞的重要项目,我们还运行了 zkao,以检验它能否独立检测出同样的问题。在大多数情况下,zkao 不仅发现了所有这些问题,还识别出了更复杂、更严重的漏洞。
结果足够理想,我们决定将其整理成文。我们从这个系列开始,聚焦于 Cloudflare 的 CIRCL——一个先进和后量子密码学库。在 CIRCL 上,我们的流水线产生了大量候选发现,其中有七个值得在此报告。这七个漏洞现均已在上游修复。其中大部分已在 HackerOne 上通过 Cloudflare 的项目得到确认并获得赏金。
需要澄清的是:AI 产出的是候选发现,而非最终报告。我们团队的人类成员仍然对每个问题进行了验证,检查了可利用性,在必要时最小化了概念验证代码,并负责了披露流程。这个人在回路中的步骤仍然非常重要,因为 AI 的候选发现成本低廉,而可信赖的报告则不然。
最小化这一步骤正是 zkao 构建的主要目标之一。虽然它仍在完善中,但当前版本已经自行承担了大部分此类验证工作。
严重程度与修复概览
在详细说明之前,有一件事值得指出:AI 为其自身发现所分配的严重程度是存在噪声的。以下是每个漏洞的 AI 评级,以及 Cloudflare 在修复后确认的严重程度。我们还验证了当前版本的 zkao 能够稳定复现全部七个漏洞。
| # | 漏洞 | AI 严重程度 | Cloudflare 严重程度 | 修复提交 | 发现者 |
|---|---|---|---|---|---|
| 1 | TSS/RSA 多项式求值中的 float64 精度损失 | 严重 | 低 | f7d2180 | Opus 4.6 + 技能 |
| 2 | 通过证明者控制的 SecParam 实现的 qndleq 伪造 | 高 | 低 | 757dde4 | Opus 4.6 + 技能 |
| 3 | BLS 聚合缺少消息唯一性 | 中 | 高 | 9798df7 | Opus 4.6 + 技能 |
| 4 | 通过 FillBytes 符号碰撞导致的 DLEQ 可靠性破坏 | 高 | 低 | 19848a5 | Opus 4.6 + 技能 |
| 5 | 通过按位或开关绕过 HPKE PSK 验证 | 中 | 中(重复) | a3b4fa3 | GPT-5.3 + 技能 |
| 6 | int64 中的 TSS/RSA 拉格朗日系数 | 高 | 中 | 751e372 | Opus 4.6 + 技能 |
| 7 | 通过与共享漏洞导致的 CP-ABE 访问控制破坏 | 严重 | 严重 | def2fd3 | zkao |
AI 评估的严重程度与最终确认的严重程度之间的差距本身就是一个有趣的洞见,我们将在文末回到这一点。现在来看这七个漏洞,逐一分析。
漏洞 1:float64 中的多项式求值
该漏洞存在于 CIRCL 的阈值 RSA 实现(tss/rsa)中。阈值签名使用 Shamir 秘密共享方案将秘密拆分为 n 个参与方。Deal() 函数在每个参与方的索引处对一个秘密多项式进行求值。系数应为 big.Int 类型,但 x^i 项的计算方式如下:
// tss/rsa/rsa_threshold.go
xi:=int64(math.Pow(float64(x),float64(i)))
float64 的尾数为 53 位。当 $x^i$ 超过 $2^{53}$(约 $9 \times 10^{15}$)时,结果会在被转换回整数之前被静默舍入。例如,对于 100 个参与方和阈值为 27 的情况,在 $x = 100$ 且 $i = 26$ 时求值,需要计算 $100^{26} = 10^{52}$,这比 $2^{53}$ 超出了 36 个数量级。即使是 $x = 20$、$i = 16$ 的情况也已经破坏了该计算。
其后果是多项式求值错误,因此分发给参与方的密钥分片也是错误的。根据参数的不同,签名组合要么完全失败,要么生成看似正常但无法重构出预期密钥的分片。我们的智能体将此标记为严重问题,因为它会导致生成错误的密钥分片,从而破坏协议的正确性。Cloudflare 最终将该问题评估为低严重性,理由是受影响的条件在实际中出现的可能性较低。
修复方案将浮点数幂运算替换为代码自身的 TODO 注释一直建议的霍纳求值法,使所有计算保持在 big.Int 类型中。提交编号:f7d2180。
漏洞 2:通过证明者控制的安全参数伪造 DLEQ 证明
该漏洞存在于 zk/qndleq 中,这是 CIRCL 针对 $(\mathbb{Z}/n\mathbb{Z})^*$ 中平方子群的 DLEQ(离散对数相等性)证明。DLEQ 证明用于证明两个数对共享相同的离散对数;如果攻击者能让验证者接受一个关于虚假陈述的证明,则该证明系统就被攻破了。
该证明中的挑战值采用 Fiat-Shamir 方式推导,其比特长度由 SecParam 控制。问题在于 SecParam 位于 Proof 结构体内部:
typeProofstruct{
Z,C*big.Int
SecParamuint
}
在验证过程中,代码使用证明自身的 SecParam 参数重新计算了挑战值。该字段是攻击者可控制的。将 SecParam 设为 1,挑战值就坍缩为单个比特,值为 $0$ 或 $1$:每次伪造尝试相当于抛一次硬币。将 SecParam 设为 8,暴力破解大约需要 $2^8 = 256$ 次尝试。无论哪种情况,可靠性都不复存在。
这是一个反复出现的模式的典型实例:本应由验证者固定的安全参数,却被从证明者提供的数据中读取出来。修复方案是将 SecParam 从证明中移除,并使 Verify 函数将其作为一个显式参数接收,从而由验证者来设定该参数。提交编号 757dde4。
漏洞 3:未要求消息互异的 BLS 聚合验证
这是该批次中 AI 低估的一个漏洞。智能体将其标记为中等风险。实际上,这是一个教科书式的恶意密钥攻击,是广为人知的关键级缺陷;我们将其报告为关键级,而 Cloudflare 确认其为高风险。
sign/bls 中的 VerifyAggregate 函数实现了 BLS BASIC 聚合模式。该模式仅在批次中所有消息互异时才是安全的,这是其防御恶意密钥攻击的手段。该函数检查了聚合配对等式,但从未检查消息是否互异,将这一关键要求留给了调用方。
缺少这一检查,标准的恶意密钥攻击即可实施。攻击者看到受害者的公钥 $\mathsf{pk}_v$ 和消息 $m$ 后,可以注册 $\mathsf{pk}_a = g^{\mathsf{sk}_a} - \mathsf{pk}_v$,并在完全不知晓受害者私钥的情况下,伪造针对 $(\mathsf{pk}_v, m)$ 和 $(\mathsf{pk}_a, m)$ 的聚合签名。CIRCL 没有附带任何可用的持有证明基础设施作为后备方案,这使得缺失检查的问题更加危险。
为什么 AI 将其判定为中等风险?我们不得而知。阅读其推理过程,它正确发现了缺失的消息互异性检查,甚至指出了恶意密钥攻击,但随后它固着于 BASIC 模式的约定将消息互异性要求放在了调用方身上这一点。它将“调用方本应处理此问题”视为一种缓解措施,从而降低了严重性评级。
修复方案使 VerifyAggregate 函数拒绝包含重复消息的批次。提交编号 9798df7。
漏洞 4:通过 FillBytes 签名碰撞导致的 DLEQ 可靠性破坏
回到 zk/qndleq,这是这批漏洞中最微妙、也最有趣的一个。它完全不需要触碰证明本身。
取一个针对声明 $S_1 = (g, g_x, h, h_x)$ 的诚实有效证明 $\pi$,该声明证明了 $\log_g(g_x) = \log_h(h_x) = x$。一个不知道 $x$ 的攻击者向验证者出示完全相同的 $\pi$,但将其与另一个声明 $S_2 = (g, -g_x, h, h_x)$ 配对,其中 $-g_x$ 是负的 big.Int,即 new(big.Int).Neg(gx)。
只要挑战值 $c$ 是偶数,这个伪造的声明就会被接受,因为此时两件事同时成立。
代数消去。验证者根据 $-g_x$ 重新计算其值,符号因子直接提取出来:
$$(-g_x)^c \bmod N = (N - g_x)^c \bmod N = (-1)^c \cdot g_x^c \bmod N.$$
当 $c$ 为偶数时,$(-1)^c = 1$,因此验证者重建出的中间值与诚实证明者计算出的完全相同。
哈希中的符号碰撞。挑战值通过对声明进行哈希运算得到,而哈希过程使用了 FillBytes,该函数写入 big.Int 的绝对值并丢弃符号。因此,doChallenge(..., -gx, ...) 和 doChallenge(..., gx, ...) 会哈希出相同的结果。
这里,$c$ 为偶数的概率至少为 $1/2$(它只是哈希输出的最低位),因此攻击在大约一半的诚实生成的证明上都能成功。验证者会确信 $\log_g(-g_x) = \log_h(h_x)$,而这是错误的。以下是概念验证的核心:
// honest proof for (g, gx, h, hx), selected to have an even challenge c
gxNeg:=new(big.Int).Neg(gx)// -gx, attacker needs no knowledge of x
forgedAccepted:=proof.Verify(g,gxNeg,h,hx,N)// accepted!
这个漏洞之所以突出,是因为它并非某一行代码的粗心大意。它是一个代数恒等式($(-1)^{\text{偶数}} = 1$)与一个看似无害的序列化选择(FillBytes 丢弃符号)之间的相互作用。单独来看,两者都没有问题。但结合在一起,它们就破坏了可靠性。跨越这种边界进行推理,正是模型所发现的最令我们惊讶的地方。
在严重性方面,智能体将其评为高,因为这是一个可靠性漏洞,但 Cloudflare 将其确认为低,因为其攻击复杂度很高。
修复方法是在挑战值计算中增加了一个 checkBounds 步骤,要求每个输入都满足 0 < x < N。负的 -gx 带有负号,在造成任何损害之前就会被拒绝。提交为 19848a5。
Bug 5:HPKE PSK 校验被按位或的 switch 语句绕过
这个几乎是一个语言层面的陷阱。在 HPKE 的 verifyPSKInputs 函数中,switch 标签使用了按位或(bitwise-OR)来编写:
// hpke/util.go
casemodeBase|modeAuth:// 0x00 | 0x02 == 0x02, i.e. only modeAuth
casemodePSK|modeAuthPSK:// 0x01 | 0x03 == 0x03, i.e. only modeAuthPSK
在 Go 语言中,`case a | b:` 是一个单独的 case,其值为两个常量的按位或结果,而不是两个独立的 case。因此 `case modePSK | modeAuthPSK` 实际上是 `case 0x03`,而 `modePSK (0x01)` 无法匹配任何 case。本应在 PSK 模式下拒绝缺失 PSK 的分支被直接跳过了。
影响:`SetupPSK(..., nil, nil)` 会使用空 PSK 继续执行,而不是被拒绝。PSK 模式本应要求提供 PSK 材料;这个漏洞静默地丢弃了身份验证前提条件,让部署以比配置更弱的模式运行。修复方法是将 OR 改为逗号分隔的 case(`case modePSK, modeAuthPSK:`),这是一个字符级别的改动。提交 a3b4fa3。该漏洞被确认为重复报告。
Bug 6:int64 中的拉格朗日系数
回到 tss/rsa,一旦份额分配完毕,组合签名需要拉格朗日插值。computeLambda 使用 int64 构建每个拉格朗日系数的分子和分母:
// tss/rsa/rsa_threshold.go
num:=int64(1)
den:=int64(1)
for_,s:=rangeS{
jprime:=int64(s.Index)
ifjprime==j{continue}
num*=i-jprime// overflows int64 for moderate player counts
den*=j-jprime
}
lambda.Div(big.NewInt(num),big.NewInt(den))// truncating integer division
lambda.Mul(delta,&lambda)
这里实际上存在两个完全独立的 bug,任何一个都足以破坏签名。第一个是溢出:当大约有 21 个参与者时,乘积会超过 int64 的上限(约 $9.2 \times 10^{18}$)并静默地回绕,因为 Go 在整数溢出时不会触发 panic。computeLambda 随后返回错误的、通常为负数的系数。
第二个是截断,即使没有发生溢出也会触发。代码先计算 `num / den`,然后才乘以 `delta`。在 Shoup 的方案中,$\delta \cdot \text{num}$ 保证能被 `den` 整除,但仅当份额索引不连续时,`num` 本身并不一定能被整除——而这正是 $t$-of-$n$ 子集的常见情况。以 3-of-5 方案组合份额 $\{1, 3, 5\}$ 为例:对于一个系数,$\text{num} = (0-3)(0-5) = 15$,$\text{den} = (1-3)(1-5) = 8$。错误的计算顺序得到 $\delta \cdot \lfloor 15/8 \rfloor$,当 $\delta = 120$ 时结果为 $120$;而正确的值 $\delta \cdot 15 / 8$ 应为 $225$。
该修复将整个计算迁移到 big.Int,并重新排列了乘法和除法的顺序,从而保证了精确可整除性。这是提交 751e372。这两个问题互不相关,但智能体将它们作为单一发现一并报告,我们出于对其工作的尊重,保留了这样的提交方式。
漏洞 7:由一行 AND 共享错误导致的 CP-ABE 访问控制失效
这是 zkao 自行发现的漏洞。在确认了上述六个问题后,我们将其指向同一个库以观察它能发现什么,它报告了此漏洞。该漏洞完全破坏了 CIRCL 的密文策略属性基加密(abe/cpabe/tkn20)中的访问控制保证,Cloudflare 已确认其有效性。
密文策略属性基加密(CP-ABE)允许你根据一条策略对消息进行加密,例如(地点:美国 AND 部门:财务)OR(角色:管理员)。用户持有与其自身属性绑定的密钥,该方案保证用户当且仅当其属性满足策略时才能解密。身处美国的财务员工和管理员可以读取消息,而其他任何人都无法读取,即使所有人都收到相同的密文。
在内部,tkn20 将策略转化为一棵由 AND 和 OR 门组成的树,叶子节点为属性,并将一个秘密(保护消息的密钥)沿该树进行秘密共享。共享过程必须遵循布尔逻辑:
- OR 门将完整秘密分发给两个子节点,因为满足任意一个分支就足够了。
- AND 门则拆分秘密,因此你需要两个子节点才能重建它。一个子节点获得随机值 r,另一个获得 parent - r,只有 r + (parent - r) 才能恢复父节点。
要理解这个漏洞,你只需记住 AND 门:每个子节点必须获得部分份额,且任何一个子节点单独都无法重建父节点。
以下是 share 实际处理 AND 情况的方式:
// abe/cpabe/tkn20/internal/tkn/formula.go
caseAndgate:
shares[gate.In0],err=randomMatrixZp(rand,k.rows,k.cols)// In0 = random r
...
shares[gate.In1]=newMatrixZp(k.rows,k.cols)// In1 = 0
shares[gate.In0].sub(shares[gate.Out],shares[gate.In1])// In0 = parent - 0
随机份额生成后立即被丢弃。In1 被设为零,最后一行用 parent - In1 覆盖 In0,结果就是 parent。因此一个子节点获得了完整秘密,而另一个一无所获。AND 门不再是 AND:它的第一个叶子节点独自就能重建父节点。
请注意,这并不会破坏正确性。两个份额相加仍然等于父节点(父节点 + 0 = 父节点),因此任何满足策略的密钥依然能解密,旧代码生成的密文也保持兼容。它破坏的是保密性:单个 AND 叶子节点现在就能恢复原本需要两者共同才能获取的秘密。
真正导致全面攻破的,是这个有缺陷的 AND 门所处的位置。为了实现 CCA 安全性,tkn20 应用了 Boneh-Katz 变换,将每个策略包裹在一个新的外部 AND 门中,而该门的左子节点是一个内部“通配符”叶子节点。权威机构颁发的每个属性密钥都携带这个通配符,因此每个密钥都能满足该叶子节点。现在将两个事实结合起来:通配符叶子节点是 AND 门的第一个子节点(In0),而 In0 恰好是接收完整秘密的那个子节点。因此,每个密钥都持有一个单独的叶子节点,该节点能独立重建消息密钥,无论策略如何!
虽然这看起来像是一个简单的拼写错误,但令我们印象深刻的是 zkao 能够推理 CP-ABE 这样复杂的概念并正确评估其影响的能力。许多大语言模型仍然能识别出这个拼写错误,但会将其视为“纵深防御”或“代码规范”问题而不做进一步推理。这可能导致开发者低估或忽视该漏洞。
修复只需一行代码。正确共享父节点,使随机份额保留在 In0 中,而 In1 成为其补数:
shares[gate.In1].sub(shares[gate.Out],shares[gate.In0])// In1 = parent - random
现在 In0 保留其随机值,In1 持有 parent - In0,因此两个 AND 叶子节点都无法单独携带秘密。提交 def2fd3。
我们学到的一些东西
有三点观察令我们印象深刻。
AI 在严重性评估方面表现不佳,而且这种不佳是单向的。再看一下顶部的表格。如果我们以 Cloudflare 确认的严重性作为“真实基准”,那么在大多数情况下,智能体高估了其发现的影响。但在 BLS 漏洞上,情况则相反,它低估了一个广为人知的严重缺陷,将一个明显的恶意密钥攻击标记为仅中等严重。我们目前还没有完整的解释或解决方案。我们的工作假设是,当目标是一个被许多不同应用程序使用的库(如 CIRCL)时,评估严重性确实很困难,因为影响取决于模型无法看到的下游调用方。我们认为,添加一个一致的严重性矩阵,再加上一个明确的威胁建模步骤,将有助于模型从整个系统(及其潜在集成)的层面推理影响,而不是仅局限于局部代码。目前,严重性评估仍是分类工作中我们信任人类处理的部分。
在 zkao 中,我们通过让开发者通过用户配置文件(zkao.md)明确其威胁模型和严重性偏好来临时解决这个问题,并持续迭代改进 zkao 的严重性设置。zkao 能够持续生成概念验证(PoC)也减少了误报,因此我们对真实问题有信心。尽管如此,我们仍在努力以更系统化的方式改进其默认严重性分配,因为这对开发者至关重要。
同样值得注意的是,Cloudflare 是通过其漏洞赏金计划的视角来评估严重性的,该计划衡量漏洞是否影响其在线服务。例如,漏洞 2 被评为低严重性,尽管它完全破坏了证明的可靠性。这个评级仅表明受影响的代码要么未被 Cloudflare 服务使用,要么在其环境中影响有限。这并不意味着在其他部署中影响同样小,这对于许多项目可能依赖的库来说尤其重要。
模型配对并非对称,且角色可能互换。六个漏洞中有五个是由 Claude Opus 4.6 配合我们的技能发现的。在相同的技能和系统提示词下,GPT-5.3 主要起到验证而非发现作用;表格中的 HPKE 漏洞是它独立发现的。我们原本不认为这种分工能保持稳定,事实也确实如此。几周后,我们用当时最新的配对——Opus 4.7 和 GPT-5.4——重新运行了扫描,结果角色基本发生了反转:GPT-5.4 发现了更多漏洞,而 Opus 4.7 仅能验证它们。这很好地提醒我们,不要过度依赖某个特定模型名称来下结论。前沿技术此后又向前推进了,而且还会继续推进。我们将在另一篇文章中探讨这一点。
正是这种模式促使我们构建 zkao,使其“与模型无关”,始终保持最佳性能,而无需你猜测下个月哪个模型会最好。
AI 会收集问题,但并不总是将它们串联起来。漏洞 6 就是例证。智能体将两个完全独立的漏洞——一个溢出和一个整数截断——打包成了一个发现。这两个漏洞都是真实存在的,因此这确实是有用的工作。但它只是将它们并列呈现,而没有推理它们之间的关系,我们在其他地方也看到了同样的模式:多个真实的观察结果被收集在一起,却没有给出任何解释,也没有尝试将它们串联成更具影响力的利用方式。
这正是我们在 zkao 中构建全新流程的地方,以实现将独立问题串联成真正端到端漏洞利用的漏洞组合能力。
下一步计划
感谢 CIRCL 维护者,他们迅速修复了所有报告的问题。这是本系列的第一篇文章;我们将持续发布其他项目中已确认并修复的漏洞。
我们扫描了超过 200 个加密项目(从下载量最高的加密库/包中选取),最终获得了上千个候选发现。结果,目前最大的瓶颈是分类排查。每个报告的漏洞在提交给项目方之前,都必须由我们的专家进行技术有效性验证,因为我们和其他人一样讨厌 AI 垃圾信息。这需要大量人力投入,因为我们对自己正在构建的自动化分类流程尚未完全信任(不过它正在变得更好)。因此,我们优先处理了一些维护良好且最受欢迎的项目。
如果您维护一个加密项目并且对此感兴趣,我们很乐意与您一同验证,以便及时发现并修复严重漏洞。如果尚未进行过扫描,或者自上次扫描以来您的代码库发生了重大变化,我们很乐意重新进行一次扫描。这种持续的 AI 覆盖正是 zkao 的构建目标。请通过 zksecurity.xyz/contact 联系我们。
zkSecurity 为加密系统提供审计、研究和开发服务,涵盖零知识证明、MPC、FHE、共识协议等。
了解更多 →