我们将 AI 审计管线对准了 Cloudflare 的 CIRCL 实验性密码学库,并确认了七个真实漏洞,从阈值 RSA 中严重的 float64 精度丢失,到基于属性的加密中完全失效的访问控制。这七个漏洞现均已在上游修复。这是本系列文章的第一篇,后续将介绍我们的智能体在开源密码学项目中发现的更多漏洞。
在 zkSecurity,我们正在构建 zkao,一个 AI 审计智能体。其目标说起来简单做起来难:让 AI 持续审视你的代码,直到其他 AI 工具能发现的所有漏洞都被清除。我们曾在《zkao:持续累积的安全》一文中阐述了这一方法为何重要。
构建 zkao 是一个迭代过程,最终目标是打造一个能够发现所有 AI 可检测漏洞的自动化审计器。这包括构思新思路和新方法,将 zkSecurity 安全研究人员的专业知识系统性地编码进 zkao,确保它能检测到最新、最严重的漏洞而不偏向于基准测试,并且——关键的是——持续进行实验,以理解哪些方法有效、哪些无效、模型如何演进,并加深我们对 AI 漏洞发现的理解。其中一些实验本身产出了值得分享的内容,与产品本身无关,这正是本系列文章的主题。
此外还有第二个动机。这些实验是我们为 zkao 构建基准测试套件的方式,在此过程中,它们不断揭示出大语言模型实际上是如何推理密码学的:它们擅长什么、在哪些方面存在盲区,以及如何放大前者、控制后者。虽然漏洞是可见的输出,但推理模式才是我们最关心的部分。
几个月前,我们开始在选定的代码库上运行实验。我们使用大语言模型扫描了几个开源密码学项目,采用两种配置:
-
仅使用大语言模型,配合简单的提示词。
-
大语言模型配合技能包,这些技能包由我们团队中的专家维护。
随后,针对大语言模型发现真实漏洞的重要项目,我们还运行了 zkao,看它能否自主检测出同样的问题。在大多数情况下,zkao 不仅发现了所有这些问题,还识别出了更复杂、更严重的漏洞。
结果足够理想,我们决定将其整理成文。我们以 Cloudflare 的 CIRCL(一个高级和后量子密码学库)作为本系列的开篇。针对 CIRCL,我们的流水线产生了大量候选发现,其中有七个值得在此报告。这七个漏洞均已在上游修复。其中大部分漏洞已通过 Cloudflare 在 HackerOne 上的漏洞奖励计划得到确认并获得奖金。
需要说明的是: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×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 针对 (Z/nZ)* 中平方子群的 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 验证被按位或开关绕过
这个几乎是一个语言层面的陷阱。在 HPKE 的 verifyPSKInputs 函数中,开关标签使用了按位或来编写:
// 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` 本身才能被 `den` 整除,而对于 $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 服务使用,要么在 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)、共识协议等领域。
We pointed our AI audit pipeline at Cloudflare's CIRCL experimental cryptography library and confirmed seven real bugs, from a critical float64 precision loss in threshold RSA to a complete access-control break in attribute-based encryption. All seven are now fixed upstream. This is the first post in a series on bugs our agents found across open source cryptography.
At zkSecurity we are building zkao, an AI audit agent. The goal is simple to state and hard to do: keep an AI looking at your code, continuously, until no bugs remain that other AI tools can find. We wrote about why that approach matters in zkao: Security That Compounds.
Building zkao has been an iterative process, with the end goal of creating an automated auditor capable of finding all bugs that can be detected by AI. This has involved brainstorming new ideas and techniques, systematically encoding the expertise of zkSecurity's security researchers into zkao, ensuring that it detects the latest and most severe vulnerabilities without being biased toward benchmarks, and, importantly, continuously conducting experiments to understand what works, what does not, how models evolve, and to deepen our understanding of bug finding with AI. Some of those experiments produce things worth sharing on their own, independent of the product, and that is what this post series is about.
There is also a second motivation. These experiments are how we build a benchmark suite for zkao, and along the way they keep surfacing insights into how LLMs actually reason about cryptography: where they are sharp, where they are blind, and how to amplify the former and contain the latter. While the bugs are the visible output, the reasoning patterns are the part we care about most.
A few months ago, we started running experiments on selected codebases. We used LLMs to scan a few open source cryptography projects, in two configurations:
-
LLM only, with a simple prompt.
-
LLM with skills, where the skills were maintained by experts on our team.
Then, for the important projects where the LLMs found real vulnerabilities, we also ran zkao to see whether it could have detected the same issues on its own. In most cases, zkao not only found all of them but also identified more complex and more severe ones.
The results were good enough that we decided to write them up. We are starting this series with Cloudflare's CIRCL, a library of advanced and post-quantum cryptography. On CIRCL, our pipeline produced many candidate findings, and seven of them are worth reporting here. All seven are now fixed upstream. Most of them were confirmed and awarded bounties under Cloudflare's program on HackerOne.
Clarification: the AI produced candidate findings, not final reports. Humans on our team still validated each issue, checked exploitability, minimized the POC where needed, and handled disclosure. That human-in-the-loop step still matters a lot, because AI candidate findings are cheap while trustworthy reports are not.
Minimizing that step is one of the main things zkao is built to do, and while it is still a work in progress, the current version already takes on much of this validation work itself.
Severities and fixes at a glance
One thing worth flagging before the details: the severity an AI assigns to its own finding is noisy. Here is each bug as the AI rated it and as Cloudflare confirmed it once fixed. We also checked that all seven are consistently reproducible by the current version of zkao.
| # | Bug | AI severity | Cloudflare severity | Fix commit | Found by |
|---|---|---|---|---|---|
| 1 | float64 precision loss in TSS/RSA polynomial eval | Critical | Low | f7d2180 | Opus 4.6 + skills |
| 2 | qndleq forgery via prover-controlled SecParam | High | Low | 757dde4 | Opus 4.6 + skills |
| 3 | BLS aggregate missing message distinctness | Medium | High | 9798df7 | Opus 4.6 + skills |
| 4 | DLEQ soundness break via FillBytes sign collision | High | Low | 19848a5 | Opus 4.6 + skills |
| 5 | HPKE PSK validation bypass via bitwise-OR switch | Medium | Medium (Duplicate) | a3b4fa3 | GPT-5.3 + skills |
| 6 | TSS/RSA Lagrange coefficients in int64 | High | Medium | 751e372 | Opus 4.6 + skills |
| 7 | CP-ABE access-control break via AND-share bug | Critical | Critical | def2fd3 | zkao |
The gap between the AI severity and the confirmed severity is itself an interesting insight, and we come back to it at the end. Now the seven bugs, one at a time.
Bug 1: polynomial evaluation in float64
This one lives in CIRCL's threshold RSA implementation (tss/rsa). Threshold signing splits a secret across n players using Shamir-style secret sharing. Deal() evaluates a secret polynomial at each player's index. The coefficients are big.Int, as they should be, but the term x^i was computed like this:
// tss/rsa/rsa_threshold.go
xi:=int64(math.Pow(float64(x),float64(i)))
float64 has a 53-bit mantissa. The moment $x^i$ exceeds $2^{53}$ (roughly $9 \times 10^{15}$), the result is silently rounded before it is ever cast back to an integer. For example, with 100 players and a threshold of 27, evaluating at $x = 100$ with $i = 26$ asks for $100^{26} = 10^{52}$, which overshoots $2^{53}$ by 36 orders of magnitude. Even $x = 20$, $i = 16$ already breaks it.
The consequence is that the polynomial is evaluated incorrectly, so the key shares handed to players are wrong. Depending on the parameters, signature combination either fails outright or produces shares that look fine but do not reconstruct the intended key. Our agent flagged this as critical, since it results in the generation of incorrect key shares, compromising the correctness of the protocol. Cloudflare ultimately assessed the issue as Low severity, based on the low likelihood of the affected conditions occurring in practice.
The fix replaces the floating-point exponentiation with the Horner's-method evaluation the code's own TODO comment had been suggesting all along, keeping everything in big.Int. Commit f7d2180.
Bug 2: a DLEQ proof forgery via a prover-controlled security parameter
This one is in zk/qndleq, CIRCL's DLEQ (discrete-log-equality) proof for the subgroup of squares in $(\mathbb{Z}/n\mathbb{Z})^*$. A DLEQ proof attests that two pairs share the same discrete log; if an attacker can make the verifier accept a proof for a false statement, the proof system is broken.
The challenge in this proof is derived Fiat-Shamir style, and its bit-length is governed by a SecParam. The problem was that SecParam lived inside the Proof struct itself:
typeProofstruct{
Z,C*big.Int
SecParamuint
}
During verification, the code recomputed the challenge using the proof's own SecParam. That field is attacker-controlled. Set SecParam = 1 and the challenge collapses to a single bit, value $0$ or $1$: a coin flip per forgery attempt. Set SecParam = 8 and brute force is about $2^8 = 256$ attempts. Either way, soundness is gone.
This is a clean instance of a recurring pattern: a security parameter that must be fixed by the verifier instead being read out of prover-supplied data. The fix removes SecParam from the proof and makes Verify take it as an explicit argument, so the verifier sets it. Commit 757dde4.
Bug 3: BLS aggregate verification without message distinctness
This is the one bug in the batch the AI underrated. The agent labeled it medium. It is in fact a textbook rogue key attack, a widely known critical-class flaw; we reported it as critical, and Cloudflare confirmed it as high.
VerifyAggregate in sign/bls implements the BLS BASIC aggregation mode. That mode is only secure if all messages in the batch are distinct, which is its defense against rogue key attacks. The function checked the aggregate pairing equation but never checked that the messages were distinct, leaving that critical requirement to the caller.
Without it, the standard rogue key attack applies. An adversary who sees a victim's public key $\mathsf{pk}_v$ and a message $m$ can register $\mathsf{pk}_a = g^{\mathsf{sk}_a} - \mathsf{pk}_v$ and forge an aggregate signature over $(\mathsf{pk}_v, m)$ and $(\mathsf{pk}_a, m)$ without ever knowing the victim's secret key. CIRCL ships no proof-of-possession infrastructure to fall back on, which makes the missing check more dangerous.
Why did the AI call this medium? We don't know. Reading its reasoning, it correctly spotted the missing distinctness check and even named the rogue key attack, but it then anchored on the fact that the BASIC mode's contract puts the distinctness requirement on the caller. It treated "the caller is supposed to handle this" as a mitigation and marked the severity down.
The fix makes VerifyAggregate reject batches built with duplicated messages. Commit 9798df7.
Bug 4: a DLEQ soundness break via a FillBytes sign collision
Back to zk/qndleq for the subtlest, and frankly most interesting, bug in the batch. It does not require touching the proof at all.
Take an honest, valid proof pi for the statement $S_1 = (g, g_x, h, h_x)$, which attests that $\log_g(g_x) = \log_h(h_x) = x$. An attacker who does not know $x$ presents the very same pi to the verifier, but pairs it with a different statement, $S_2 = (g, -g_x, h, h_x)$, where $-g_x$ is the negative big.Int new(big.Int).Neg(gx).
The forged statement is accepted whenever the challenge $c$ is even, because two things line up at once.
Algebraic cancellation. The verifier recomputes its value from $-g_x$, and the sign factors straight out:
$$(-g_x)^c \bmod N = (N - g_x)^c \bmod N = (-1)^c \cdot g_x^c \bmod N.$$
When $c$ is even, $(-1)^c = 1$, so the verifier reconstructs exactly the same intermediate values the honest prover did.
A sign collision in the hash. The challenge is derived by hashing the statement, and the hashing uses FillBytes, which writes the absolute value of a big.Int and strips the sign. So doChallenge(..., -gx, ...) and doChallenge(..., gx, ...) hash to the same thing.
Here, $c$ is even with probability at least $1/2$ (it is just the low bit of the hash output), so the attack succeeds on roughly half of all honestly generated proofs. The verifier walks away convinced that $\log_g(-g_x) = \log_h(h_x)$, which is false. Here is the core of the proof-of-concept:
// 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!
What makes this one stand out is that it is not a single sloppy line. It is the interaction between an algebraic identity ($(-1)^{\text{even}} = 1$) and an innocent-looking serialization choice (FillBytes dropping the sign). Neither is wrong on its own. Together they break soundness. Reasoning across that kind of boundary is exactly where we have been most surprised by what the models surface.
On severity, the agent rated this high because it is a soundness break, but Cloudflare confirmed it as low due to its high attack complexity.
The fix adds a checkBounds step to challenge computation that requires every input to satisfy 0 < x < N. A negative -gx has a negative sign and is rejected before it can do any damage. Commit 19848a5.
Bug 5: HPKE PSK validation bypassed by a bitwise-OR switch
This one is almost a language-level trap. In HPKE's verifyPSKInputs, the switch labels were written with bitwise-OR:
// hpke/util.go
casemodeBase|modeAuth:// 0x00 | 0x02 == 0x02, i.e. only modeAuth
casemodePSK|modeAuthPSK:// 0x01 | 0x03 == 0x03, i.e. only modeAuthPSK
In Go, a case a | b: is a single case whose value is the OR of the two constants, not two cases. So case modePSK | modeAuthPSK is really case 0x03, and modePSK (0x01) matches no case at all. The branch that is supposed to reject a missing PSK in PSK mode is simply skipped.
The effect: SetupPSK(..., nil, nil) proceeds with an empty PSK instead of being rejected. PSK mode is supposed to require PSK material; this silently drops the authentication precondition and lets a deployment run in a weaker mode than it was configured for. The fix is the one-character-class change from OR to comma-separated cases (case modePSK, modeAuthPSK:). Commit a3b4fa3. It was confirmed as a duplicate.
Bug 6: Lagrange coefficients in int64
Back in tss/rsa, once shares are dealt, combining signatures requires Lagrange interpolation. computeLambda built the numerator and denominator of each Lagrange coefficient in 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)
There are actually two completely independent bugs here, either of which is enough to corrupt a signature. The first is overflow: with about 21 players the products run past the int64 ceiling ($\approx 9.2 \times 10^{18}$) and wrap silently, because Go does not panic on integer overflow. computeLambda then returns a wrong, often negative, coefficient.
The second is truncation, and it bites even when nothing overflows. The code computes num / den first and only then multiplies by delta. In Shoup's scheme $\delta \cdot \text{num}$ is guaranteed to be exactly divisible by den, but num alone is not whenever the share indices are non-consecutive, which is the normal case for a $t$-of-$n$ subset. Take a 3-of-5 scheme combining shares $\{1, 3, 5\}$: for one coefficient $\text{num} = (0-3)(0-5) = 15$ and $\text{den} = (1-3)(1-5) = 8$. The buggy order computes $\delta \cdot \lfloor 15/8 \rfloor$, which with $\delta = 120$ is $120$; the correct value $\delta \cdot 15 / 8$ is $225$.
The fix moves the whole computation to big.Int and reorders the multiplication and division so the exact-divisibility guarantee holds. It is commit 751e372. The two issues are unrelated, but the agent reported them together as a single finding, and we left the submission that way, out of respect for the agent's work.
Bug 7: a CP-ABE access-control break from a one-line AND-share mistake
This is the bug zkao found on its own. After confirming the six issues above, we pointed it at the same library to see what it would find, and it reported this one. It is a full break of the access-control guarantee in CIRCL's ciphertext-policy attribute-based encryption (abe/cpabe/tkn20), and Cloudflare confirmed its validity.
Ciphertext-Policy Attribute-Based Encryption (CP-ABE) lets you encrypt a message under a policy such as (location: usa AND department: finance) OR (role: admin). A user holds a key tied to their own attributes, and the scheme guarantees they can decrypt if and only if those attributes satisfy the policy. The finance employee in the USA and the admin can read the message, and nobody else can, even though everyone receives the same ciphertext.
Internally, tkn20 turns the policy into a tree of AND and OR gates with attributes at the leaves, and secret-shares one secret (the key that protects the message) down that tree. The sharing has to follow the boolean logic:
- An OR gate gives the full secret to both children, because satisfying either branch should be enough.
- An AND gate splits the secret, so you need both children to rebuild it. One child gets a random value
r, the other getsparent - r, and onlyr + (parent - r)recovers the parent.
To understand this bug, the AND gate is the only piece you need to keep in mind: each child must get a partial share, and neither child alone should reconstruct the parent.
Here is how share actually handled the AND case:
// 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
The random share is generated and then immediately discarded. In1 is set to zero, and the last line overwrites In0 with parent - In1, which is just parent. So one child receives the whole secret and the other receives nothing. The AND gate is no longer an AND: its first leaf reconstructs the parent on its own.
Note that this does not break correctness. The two shares still sum to the parent (parent + 0 = parent), so any key that satisfies the policy still decrypts, and ciphertexts made by the old code stay compatible. What it breaks is secrecy: a single AND leaf now recovers a secret that was supposed to require both.
What turns it into a full break is where this broken AND gate ends up. To reach CCA security, tkn20 applies a Boneh-Katz transform that wraps every policy in a new outer AND gate, and the left child of that gate is an internal "wildcard" leaf. Every attribute key the authority issues carries that wildcard, so every key satisfies that one leaf. Now put the two facts together: the wildcard leaf is the first child (In0) of an AND gate, and In0 is exactly the child that receives the full secret. Every key therefore holds a single leaf that reconstructs the message key by itself, regardless of the policy!
Although it looks like a simple typo bug, what impressed us was zkao's ability to reason about a complex concept such as CP-ABE and correctly assess its impact. Many LLMs would still identify the typo but dismiss it as a "defense-in-depth" or "code hygiene" issue without reasoning further. This can lead developers to underestimate or overlook the vulnerability.
The fix is one line. Share the parent correctly, so the random share survives in In0 and In1 becomes the complement:
shares[gate.In1].sub(shares[gate.Out],shares[gate.In0])// In1 = parent - random
Now In0 keeps its random value and In1 holds parent - In0, so neither AND leaf carries the secret alone. Commit def2fd3.
A few things we learned
Three observations stuck with us.
The AI is bad at severity, and bad in an asymmetric way. Look again at the table at the top. If we rely on Cloudflare confirmed severity as "ground truths", then in most cases the agent overrated the impact of what it found. But on the BLS bug it went the other way and underrated a widely known critical flaw, labeling a clean rogue key attack as merely medium. We do not have a complete explanation nor solution yet. Our working hypothesis is that it is genuinely hard when the target is a library used across many different applications, as CIRCL is, because the impact depends on downstream callers the model cannot see. We think adding a consistent severity matrix plus an explicit threat-modeling step would help the model reason about impact at the level of the whole system (and its potential integration) rather than the local code. For now, severity remains the part of triage we trust a human with.
In zkao, we temporarily work around this by letting developers clarify their threat model and severity preferences through a user configuration (
zkao.md), and we keep improving zkao's severity setting iteratively. The fact that zkao can consistently produce PoCs also reduces false positives, so we have confidence in the real issues. Still, we are working on improving its default severity assignment in a more systematic way, as this is critical for developers.It is also worth noting that Cloudflare assessed severity through the lens of its bounty program, which weighs whether a bug affects its live services. Bug 2, for example, was rated low even though it completely breaks the proof's soundness. This rating only indicates that the affected code is either unused by Cloudflare services or has limited impact in Cloudflare's environment. It does not mean the impact is just as small in other deployments, which matters especially for a library that many projects may build on.
Model pairings are not symmetric, and the roles can flip. Five of the six bugs were found by Claude Opus 4.6 paired with our skills. Under the same skills and the same system prompt, GPT-5.3 mostly validated rather than discovered; the HPKE bug in the table is the one it surfaced on its own. We did not expect that division of labor to remain stable, and it did not. A few weeks later, we reran the scan with then-newest pairing, Opus 4.7 and GPT-5.4, and the roles had essentially reversed: GPT-5.4 finds more bugs, while Opus 4.7 is only capable of validating them. This is a good reminder not to overfit conclusions to any particular model name. The frontier has moved on again since, and it will keep moving. We will explore this in another post.
This pattern is exactly why we are building zkao to be "model-agnostic", keeping its performance at the best state without your needing to guess which model will be the best next month.
The AI gathers issues but does not always chain them. Bug 6 is the tell. The agent packed two completely independent bugs, an overflow and an integer truncation, into a single finding. Both are real, so this is genuinely useful work. But it presented them side by side rather than reasoning about how they relate, and we have seen the same pattern elsewhere: several true observations gathered together without any explanation or any attempt to chain them into a more impactful exploit.
This is exactly where, in zkao, we have built novel processes to enable the kind of bug composition that chains separate issues into a real end-to-end exploit.
What's next
Our thanks to the CIRCL maintainers, who promptly fixed all reported issues. This is the first post in a series; we will keep publishing confirmed bugs from other projects as they are resolved.
We scanned more than 200 cryptographic projects (choosing from top most-downloaded cryptography crates/packages) and ended up with over a thousand candidate findings. As a result, the biggest bottleneck today is triage. Every reported bug has to be checked by our experts for technical validity before it reaches a project, because we hate AI spam as much as everyone else does. That takes a lot of human effort, because we do not yet fully trust the automated triage flow we are building (it is getting better). So we prioritized some of the most popular projects that are well maintained.
If you maintain a cryptography project and this is interesting to you, we would love to verify alongside you, so that serious bugs get found and fixed in time. We are happy to run a fresh scan if there is not one yet, or if your codebase has changed significantly since the last. Continuous AI coverage of this kind is exactly what zkao is built for. Reach out at zksecurity.xyz/contact.
zkSecurity offers auditing, research, and development services for cryptographic systems including zero-knowledge proofs, MPCs, FHE, consensus protocols and more.