2026 年 10 月 17 日,星期六,UTC 18:05,一位研究者给 Apple、Chrome、微软和 Mozilla 的根计划,以及证书颁发机构 eMudhra,发了同一封邮件。附件里是一行字「We have a problem here - Wayne」,和对这行字的八个签名。每个签名都能用一个中间 CA 的公钥验证通过,这些中间 CA 由 eMudhra 运营,受各大平台信任。其中六个可以给网站签发证书,两个可以签发代码签名证书。12

这位只以 Wayne 署名的研究者没有入侵任何系统。不到两个小时之前,Wayne 在公开的 CA 证书数据库上跑了一个测试,发现这些私钥可以直接从证书本身算出来。Wayne 在事故单里写的时间线从 UTC 16:00 开始,第一行只有一句:「Started playing with some new toys」,开始玩几个新玩具。1 这八把私钥是 156、155、115、230、4、56、66 和 237。

这几个数就是私钥的全部。一把 P-384 私钥本该是一个随机数,大小和这条曲线的群阶相当,而群阶写出来有 116 位。这八把,每一把都装得进一个字节:按曲线使用的 48 字节写出来,就是 47 个零字节,后面再跟一个字节。拿到其中任何一把的人,都可以给任何网站签一张证书;在封锁下发之前,只要它被记录进 CT 日志,Chrome、Firefox、Apple 各平台和 Windows 都会接受它。

邮件到达 100 分钟之后,Chrome 封锁了那六个 TLS 中间 CA;UTC 午夜之前,Firefox 也封锁了。五天之内,Mozilla 发布了去掉它们上方那个根的信任库,Chrome 排定了同样的移除,eMudhra 宣布退出 TLS 证书业务。

反应之快,是这个故事里让人安心的部分,它周围的一切就没那么让人安心了。CA 私钥是在全程记录的仪式上生成的:在为防篡改而设计的硬件安全模块里,有见证人在场,每年接受审计。这样一套流程,怎么会产出一把值为 4 的私钥?系统早在十六个月前就为这几把密钥报过错,为什么一切照常运转?

回答这些问题,需要先把机制讲清楚:一张证书承诺了什么,它怎样被签发,客户端怎样决定相信它,椭圆曲线签名又是怎样工作的。每一块我只讲到这次事故用得上的程度。能用这次事故里的真实证书的地方就用真实证书,用不了的地方,换一条小到可以手算的曲线。三张可交互的图让你先顺着一条证书链走一遍,再在那条曲线上一步步数到私钥,最后亲手签名和验证;文末的实验让你在自己的机器上签出一条证书链,并还原真实的私钥。

事故相关的事实来自 Mozilla 的事故单、Google 和 Mozilla 的安全博客、各根计划公布的列表以及 eMudhra 自己的声明,截至 2026 年 10 月 22 日。随着调查继续,其中一些还会变。

泄露了什么
八把中间 CA 私钥,六把用于 TLS,两把用于代码签名
曲线
P-384,一把私钥本该有 116 位十进制数
私钥
4、56、66、115、155、156、230 和 237
生成于
2025 年 4 月 30 日,同一场密钥仪式
发现于
2026 年 10 月 17 日,只凭公开的证书
封锁
Chrome 在报告后 100 分钟,Firefox 在 UTC 午夜之前
仍未查明
弱密钥究竟是怎样生成的

一张证书承诺了什么

在一次使用证书的完整 TLS 1.3 握手里,客户端需要四件不同的事同时成立。围绕 CA 事故的大多数混乱,都来自把它们混为一谈。3

  1. 过去某个时候,某个 CA 审核了一份申请,然后用自己的私钥签了一句话:这个名字对应这把公钥,用于这些用途,有效期到这一天。这句话就是证书。
  2. 现在,客户端从网站证书往上构建一条路径,直到一个它本来就信任的根,沿途检查每一个签名和每一条约束。这是路径验证。
  3. 现在,服务器对这次连接的握手签名,证明自己持有证书里那把公钥对应的私钥。这是 CertificateVerify 消息。
  4. 双方协商出新的会话密钥,用它们加密流量。网页内容既不由 CA 的密钥加密,也不由网站的密钥加密。

这份清单里出现了三个不同的签名,由三把不同的密钥,签在三组不同的字节上。证书签名请求(CSR)由申请者自己的私钥签名,用来表明申请者持有这把私钥。证书由签发者的私钥签名,签的是证书主体编码后的确切字节。CertificateVerify 由网站的私钥签名,签的是到此为止的握手摘要。有人说 CA 是「用私钥加密证书的哈希」,这既把前两者混在了一起,也把操作本身说错了。讲 ECDSA 的那一节会说明真正发生的是什么。

这些签名背后站着好几方,每一方担保的东西,都比人们通常以为的少。

角色实际做什么不等于什么
域名持有者(订户)生成网站密钥对,提交 CSR,证明自己控制这个名字域名验证不证明合法所有权,也不证明经营诚实
注册机构(RA)按 CA 的政策收集和核对申请,常常就是 CA 的一部分通常不持有任何签名密钥
证书颁发机构(CA)决定每张证书写什么,做验证,签名,发布吊销信息它决定不了谁信任它
根 CA 与中间 CA根把权力委派给中间 CA,中间 CA 负责日常签发「中间」是路径里的位置,不是另一种文件格式
根计划Mozilla、Chrome、Apple、微软:选择信任哪些根、用于哪些用途,制定政策,推送紧急封锁CA 不能宣布自己受信任
依赖方浏览器、操作系统、App、curl、代码签名验证器:做出最终决定不同的软件不共用一个信任库,也不共用一种获知吊销的方式
审计、CCADB、CT 日志审计 CA 的做法;公共 CA 数据库(CCADB)公开每家 CA 的层级;证书透明度(CT)日志记录签发出的证书一条 CT 记录是记录,不是担保

eMudhra 是一家印度公司,经营数字签名证书、电子签名服务,并以 emSign 品牌提供公共信任的证书。十月这次事故只涉及八把中间 CA 私钥。公司说根私钥没有受影响,公开记录里也没有相反的证据。4 但根计划还是移除了它的一个根,原因就在上表「依赖方」那一行:不同的软件用不同的方式得知坏证书,有的软件永远不会得知。5

三层证书链的解剖

根证书、中间证书和网站证书共用同一种结构:X.509 第 3 版。哪张是根、哪张是网站证书,由几个字段和客户端的策略决定,与文件扩展名无关,也与名字里有没有「Root」无关。在签名内部和网络上传输时,证书是 DER,一种 ASN.1 的二进制编码;PEM 是同样的字节,做了 Base64 编码,夹在 BEGIN 和 END 两行之间。6

Certificate ::= SEQUENCE {
    tbsCertificate        TBSCertificate,
    signatureAlgorithm    AlgorithmIdentifier,
    signatureValue        BIT STRING
}
 
TBSCertificate ::= SEQUENCE {
    version               [0] Version,
    serialNumber          CertificateSerialNumber,
    signature             AlgorithmIdentifier,
    issuer                Name,
    validity              Validity,
    subject               Name,
    subjectPublicKeyInfo  SubjectPublicKeyInfo,
    extensions            [3] Extensions
}

TBS 的意思是「待签名」(to be signed)。签发者对 tbsCertificate 的确切 DER 字节签名,再把算法和签名附在后面。证书主体里改动一个字节,名字里的一个字母,或者有效期里的一秒,签名就再也验不过。签名不覆盖它自己,所以它可以事后附上。

字段根 CA中间 CA网站证书(叶子)
subject根的名字中间 CA 的名字申请者;可能有 CN,但名称检查看的是 SAN
issuer通常等于自己的 subject它上面那一级 CA签它的中间 CA
subjectPublicKeyInfo根的公钥中间 CA 的公钥网站的公钥
basicConstraintsCA:TRUECA:TRUE,常带 pathlen:0CA:FALSE
keyUsagekeyCertSign、cRLSignkeyCertSign、cRLSigndigitalSignature
extendedKeyUsage通常没有可能把这个 CA 限定为 serverAuth 或 codeSigningTLS 服务器为 serverAuth
subjectAltName(SAN)无无DNS:example.test、DNS:www.example.test
密钥标识符自己公钥的 SKI自己公钥的 SKI,以及指向根公钥的 AKI自己公钥的 SKI,以及指向中间 CA 公钥的 AKI
签名由谁做出根自己的私钥根的私钥中间 CA 的私钥

最后两行要连起来读,整个事故就发生在这里。网站证书里装的是网站的公钥,但上面的签名来自中间 CA 的私钥。中间证书里装的是中间 CA 的公钥,由根的私钥签名。没有任何证书包含私钥。每把密钥的私钥一半留在持有者手里,公钥一半写进证书公之于众,谁都可以下载。

下面是八张之一,和它在公开记录里的样子完全一样:EM DV TLS CA - G3B,只保留了要紧的部分。7

$ openssl x509 -in EM-DV-TLS-CA-G3B.pem -noout -text
        Serial Number:
            a4:ae:e6:78:f2:33:62:b4:c0:e4:60:b7:72:f2:14
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: C=IN, O=eMudhra Technologies Limited, CN=emSign Root TLS CA - G3
        Validity
            Not Before: Apr 30 15:54:08 2025 GMT
            Not After : Apr 28 18:30:00 2040 GMT
        Subject: C=NL, O=eMudhra BV, CN=EM DV TLS CA - G3B
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (384 bit field, 192 bit security level)
                pub:
                    04:e7:9e:d9:c0:4a:97:73:c4:73:5e:51:11:1d:a8:5a:
                    c2:bd:5e:64:ab:37:4b:80:e1:2c:bc:20:38:ec:1e:4f:
                    ...
                ASN1 OID: secp384r1
        X509v3 extensions:
            X509v3 Authority Key Identifier:
                E1:3A:AB:21:7C:F1:E3:4D:54:AE:D4:72:B7:FD:8B:59:42:20:90:54
            X509v3 Subject Key Identifier:
                E2:1E:2F:E7:1C:19:39:0E:55:4B:62:C1:20:24:57:35:24:8A:25:14
            X509v3 Extended Key Usage:
                TLS Web Server Authentication, TLS Web Client Authentication
            X509v3 Key Usage: critical
                Certificate Sign, CRL Sign
            X509v3 Basic Constraints: critical
                CA:TRUE, pathlen:0

它的日期就是密钥仪式那一天,有效期十五年。它的公钥是 pub: 后面那 97 个字节:一个表示「未压缩」的 04,接着是 48 字节的 x 坐标和 48 字节的 y 坐标。攻击者需要的,就只有这个点。

掌握权力的字段

basicConstraints: CA:TRUE 让一张证书的密钥可以给别的证书签名。如果证书带有 keyUsage 扩展,验证器还要求其中有 keyCertSign 位,才会用这把密钥去验证书的签名。这些字段是验证器在检查路径时执行的权限,它们不改变密钥在数学上能做什么。网站的私钥可以给任何字节签名,证书主体也不例外;正确的验证器会拒绝结果,因为网站自己的证书写着 CA:FALSE,也没有 keyCertSign。文末第一个实验就会做出这样一张伪造证书,并展示它被拒绝。8

pathLenConstraint 限制在一条有效路径里,这个 CA 下面还能再出现多少个中间 CA,pathlen:0 表示一个也不能有。带 pathlen:0 的中间 CA 仍然可以签任意多张网站证书,它限制的是深度,不是数量。extendedKeyUsage 限制用途:六个 TLS 中间 CA 带的是服务器认证和客户端认证,两个代码签名中间 CA 只有代码签名,根计划还会在此之上加自己的用途规则。

nameConstraints 可以把一个中间 CA 限定在某棵名字子树里,比如 example.com 之下的一切。八张证书都没有这个扩展。对公共 CA 的签发用中间 CA 来说这很正常,也正因为如此,这几把密钥中的每一把都能为互联网上的任何名字背书。

密钥标识符把一条链串在一起。一张证书的主体密钥标识符(SKI)指明它自己的公钥。在这几张证书上,SKI 是公钥比特的 SHA-1 哈希,也就是 RFC 5280 建议的第一种方法:对上面那个点做哈希,得到的正是 E2:1E:2F:E7...。授权密钥标识符(AKI)指明签了这张证书的那把密钥。上面的 AKI E1:3A:AB:21... 就是 emSign Root TLS CA - G3 的 SKI。9 客户端靠这种匹配快速找到上一级。它只是找上一级的线索,什么也证明不了,能证明事情的只有签名验证。

为什么自签名的根不是循环论证

根证书通常是自签名的:issuer 等于 subject,签名可以用证书里自带的公钥验证。这看起来像一个圈,一份文件给自己担保。如果根之所以被信任是因为这个自签名,那它确实就是一个圈。

但信任不是从这里来的。任何人一条命令就能做出一张自签名证书,名字随便写,写成某家真实 CA 的名字也行。自签名只证明,做这张证书的人当时握有对应的私钥。信任来自证书之外:根计划、管理员或用户事先决定,这把公钥是用于这些用途的信任锚,再把这个决定放进信任库发布出去。RFC 5280 的路径验证把信任锚当作输入,从不自己推导出信任锚。信任锚甚至不必是一张证书,一个名字加一把公钥就够了。10

这次事故正好说明这个决定有多「本地」。emSign Root TLS CA - G3 在微软的信任库里是根,在别处都不是。Chrome、Mozilla 和 Apple 的信任库里没有它。在那里,这几个 TLS 中间 CA 要到达受信任的根,只能经过一张交叉证书,也就是另一个根为同一把密钥签发的第二张证书(下文还会细说):四家根计划都信任的 emSign ECC Root CA - G3,为 emSign Root TLS CA - G3 签发过这样一张。11 同一个 CA,在 Windows 上是根,在其他地方只是一条更长路径中的一环。浏览器信任一张证书,不是因为证书上写着「Root」,而是因为它信任自己随附的那几把具体的公钥。

根平时离线保存,很少使用,日常工作交给中间 CA。这样分工,是为了某个中间 CA 出问题时,吊销它就能解决,不必动根,也不必动根下面的其他一切。这两类密钥通常都放在硬件安全模块(HSM)里:这种设备在内部生成密钥,拒绝以明文导出,按请求签名。「密钥从不离开 HSM」是一条政策,不是物理定律。HSM 可以包装(wrap)一把密钥,也就是用另一把密钥把它加密,以便备份或者转移到另一台 HSM。eMudhra 的麻烦,正是从这样一次测试开始的。

签发做了什么:从 CSR 到证书

一份 PKCS #10 格式的 CSR 有三部分:请求本身(一个主体名、一把公钥,以及请求的扩展之类的可选属性),一个签名算法,以及用对应私钥对请求做的签名。12

CertificationRequestInfo:  subject, subjectPublicKeyInfo, attributes
signatureAlgorithm:        for example ecdsa-with-SHA384
signature:                 made with the private key that matches subjectPublicKeyInfo

这个签名只证明一件事:制作 CSR 的时候,有人能够使用这把公钥对应的私钥。它不证明对域名的控制,也不授权任何东西。CA 把 CSR 当作一份请求来读,自己决定最终内容(序列号、有效期、签发者、名字、约束和用途),再用自己的密钥对结果签名。

一张公共的域名验证(DV)TLS 证书,签发过程是这样的:

  1. 网站运营者生成密钥对和 CSR,私钥留在运营者自己的系统里。
  2. CA 检查申请者是否控制每个名字,例如通过一次 ACME 验证,并检查域名的 CAA 记录,其中可以列出允许为它签发的 CA。1314
  3. CA 构造证书主体,让签发密钥对它签名。对公共 TLS,CA 先把一张预证书提交到证书透明度日志,再把日志返回的签名回执 SCT 嵌进最终证书,这样要求 CT 的客户端才会接受它。15
  4. 运营者安装证书和中间证书,私钥自己保管。

HSM 就在第 3 步。CA 的软件从来见不到签发私钥。它和 HSM 保持一个会话,用句柄、标签或 ID 指代密钥对象。它构造待签名的字节,请 HSM 用它指名的那个对象签名,拿回组成一个 ECDSA 签名的两个数。

sequenceDiagram
    participant Op as 网站运营者
    participant RA as CA 前端
    participant CA as CA 签名服务
    participant HSM as HSM
    participant CT as CT 日志
    Op->>Op: 生成密钥对,私钥自己保管
    Op->>RA: CSR(名字、公钥、签名)
    RA->>RA: 检查 CSR 的签名
    RA->>Op: 域名控制验证
    Op-->>RA: 完成验证
    RA->>RA: 查询 CAA
    RA->>CA: 批准的名字、公钥、证书模板
    CA->>CA: 构造预证书主体
    CA->>HSM: 用密钥对象「EM DV TLS CA - G3B」签名
    HSM-->>CA: 签名 (r, s)
    CA->>CT: 提交预证书
    CT-->>CA: SCT
    CA->>CA: 构造带 SCT 的最终证书主体
    CA->>HSM: 用同一个密钥对象签名
    HSM-->>CA: 签名 (r, s)
    CA-->>Op: 证书

注意 HSM 不知道什么。它不知道一个密钥对象属于哪张证书,它用被要求的那个对象签名。如果软件,或者一个跑脚本的人,指错了对象,HSM 就用错的密钥签名,而 HSM 内部没有任何东西能察觉。

中间 CA 的证书也是这样签发的,只是高了一层,而且是在密钥仪式上完成,不经过在线服务。中间 CA 的密钥对在 HSM 里生成,用它做出 CSR,再把根的密钥从离线存放处取出来,签发中间 CA 的证书。CA/浏览器论坛的《基线要求》要求有书面的密钥生成脚本和仪式报告,对许多 CA 密钥,还要求合格审计师到场见证,或者全程录像。16

这场仪式里,所有分量都压在一次比对上:CSR 里的公钥,必须逐字节地等于仪式批准的那个密钥对象的公钥。没有做的,正是这次比对。

客户端怎样构建路径

TLS 服务器通常发送自己的证书和中间证书,不发根。客户端已经有它信任的根,而一个从网络上收到的根什么也证明不了。接下来客户端要拼出一条从叶子到某个信任锚的路径。它会用到好几条线索,没有一条本身就是证明。17

线索用来做什么局限
下级的 issuer 与候选者的 subject缩小候选范围名字不唯一;重新签发和交叉签名的 CA 共用名字
下级的 AKI 与候选者的 SKI在同名候选者中挑出正确的密钥可选,而且只是线索
AIA caIssuers一个可以下载上级证书的 URL客户端是否去取,各不相同
服务器发来的证书,以及本地缓存现成的候选者每一张仍要像其他证书一样验证

RFC 5280 把构建路径和验证路径分开。客户端怎样搜索候选路径,各家实现不同;验证一条候选路径则有明确规定:从信任锚往下,用上级的公钥验证每一个签名,检查每一个有效期,检查每个上级是否有权充当 CA(basicConstraints、keyUsage、pathlen),并执行名称约束和策略。另外,按 RFC 9525 的规则,检查叶子证书的 SAN 是否包含它连接时用的名字。18 匹配名字和密钥标识符只是让搜索更快,永远不会让一个错的签名变成对的。

你可以看一个真实的客户端这样做。TLS Companion 一步一步回放一次录制下来的 Go TLS 1.3 握手,用的是为录制而生成的三层证书链:TLS Companion Root CA 签 Issuing CA E1,Issuing CA E1 签 tls-companion.example。第 11 步是服务器的 Certificate 消息,里面有叶子证书和中间证书,没有根。第 18 步是客户端在 verifyServerCertificate 里,把证书链交给 crypto/x509,构建并检查一条通往它配置中那些根的路径。

交叉签名就是第二张证书

一张 X.509 证书只有一个签发者和一个签名,所以不可能有一张证书同时被两个根签过。所谓一个 CA「被交叉签名」,是第二个签发者做了第二张证书:主体名相同,公钥相同,但签发者名字、序列号、有效期和签名都是它自己的。因为密钥相同,这个 CA 签出的每一张证书,用哪个版本都能验证通过,客户端也就能从同一张叶子证书出发,构建出通往两个不同根的两条路径。

Sectigo 给这八个 eMudhra 中间 CA 都做了交叉签名。下面是它为同一个 EM DV TLS CA - G3B 签发的版本:7

$ openssl x509 -in EM-DV-TLS-CA-G3B-sectigo.pem -noout -subject -issuer -serial -dates \
    -ext subjectKeyIdentifier,authorityKeyIdentifier
subject=C=NL, O=eMudhra BV, CN=EM DV TLS CA - G3B
issuer=C=GB, ST=Greater Manchester, L=Salford, O=Comodo CA Limited, CN=AAA Certificate Services
serial=A024FDF4BA665C785CE4A020483858B7
notBefore=May 14 00:00:00 2025 GMT
notAfter=Dec 31 23:59:59 2028 GMT
X509v3 Authority Key Identifier:
    A0:11:0A:23:3E:96:F1:07:EC:E2:AF:29:EF:82:A5:7F:D0:30:A4:B4
X509v3 Subject Key Identifier:
    E2:1E:2F:E7:1C:19:39:0E:55:4B:62:C1:20:24:57:35:24:8A:25:14

主体相同,SKI 相同,所以密钥也相同。签发者、序列号和有效期不同。签名算法也不同:AAA Certificate Services 是一个 RSA 根,所以这张装着 ECDSA P-384 公钥的证书,带的是一个 RSA 签名 sha256WithRSAEncryption。证书里公钥的算法和证书上签名的算法是两个独立的字段。有人问到底是哪把密钥弱,答案是 subjectPublicKeyInfo 里的那一把。

这第二张证书给了弱密钥第二条通往信任的路。AAA Certificate Services 已经从 Chrome 和 Mozilla 的信任库里移除,但 Apple 和微软仍然信任它,所以在这两家的平台上,Sectigo 的副本通向一个它们自己的受信任根。1911

下面这张图画的是 EM DV TLS CA - G3B 的真实证书链,也就是上面打印出来的那张证书,下面挂一张示意用的 example.test 网站证书。选中一张卡片看它的字段,再追踪它的签发者、AKI 或签名,上一级里必须与之相符的字段就会亮起来。然后切到「交叉签名」,Sectigo 的根为同一把密钥又签了一张证书;再切到「弱密钥」,一张用这个中间 CA 私钥伪造的叶子证书,两条链接都过得去。图里把 emSign Root TLS CA - G3 画成根,这是 Windows 看到的样子;在其他地方,要经过 emSign ECC Root CA - G3 签发的交叉证书才能到达这个 CA,图里没有画出这一段。

图 1顺着签名往上走
留意层与层之间的两股线:虚线是名字(签发者 = 主体,AKI = SKI),实线是签名。在「交叉签名」里,Sectigo 的 AAA Certificate Services 为同一把 EM DV TLS CA - G3B 密钥又签了一张证书,叶子证书于是有两条路;在「弱密钥」里,一张用 d = 156 伪造的叶子证书两条链接都过得去。

CertificateVerify 证明了什么,一把 CA 私钥又值多少

证书链上的每一个签名,都是在签发时做出的,比你这次连接早了几天甚至几年。证书是公开文件,谁都可以下载一张再发出去。所以在一次使用证书的完整 TLS 1.3 握手里,服务器还必须在这次连接上证明,自己持有叶子证书里那把公钥对应的私钥。它用 CertificateVerify 消息来证明。20

服务器签的是这样一块数据:64 个字节的 0x20(空格),上下文字符串 TLS 1.3, server CertificateVerify,一个零字节,以及到此为止的握手记录的哈希。这 64 个空格是为了防范旧版 TLS。在 TLS 1.2 里,服务器签的数据开头是客户端选的 32 个字节,后面跟着服务器自己的 32 个随机字节。攻击者能选前一半,选不了后一半,所以从 TLS 1.2 握手里拿到的签名,没有一个以 64 个空格开头,也就没有一个能冒充 TLS 1.3 的 CertificateVerify。握手记录里有客户端的随机数和双方的密钥交换份额,所以这个签名只属于这一次连接,不能重放到别的连接里。客户端用叶子证书里的公钥验证它,随后的 Finished 消息再用由握手派生的密钥,为整个握手做认证。

叶子证书的密钥和会话的密钥是两把不同的密钥,寿命也不同。一张证书里的 P-384 签名密钥可能要用上好几个月,会话密钥却来自为这一次连接新做的 X25519 或混合密钥交换,用完就丢。证书密钥负责认证,从不加密网页。在 TLS Companion 的录制里,第 12 步是服务器构造并签出这块数据,第 19 步是客户端在第 18 步证书链通过之后,用证书里的密钥检查这个签名。

现在比较两把被盗的密钥。

偷到网站私钥的攻击者,可以冒充这个网站。在证书过期或者网站把它吊销之前,攻击者能为这张证书上的名字完成 CertificateVerify,能触及的范围,就是这一张证书上的名字。

握有中间 CA 私钥的攻击者,不需要任何网站的私钥。他们自己生成一对密钥,为随便哪个名字写一份证书主体,用 CA 私钥签名。名字没有任何限制:这个中间 CA 没有名称约束,本该拦住他们的验证流程运行在 CA 自己的系统里,而他们根本没碰那些系统。手里握着刚做好的叶子证书的私钥,他们像任何正规服务器一样完成 CertificateVerify。客户端的每一项检查都会通过:证书链验证通过,名字匹配,握手签名验证通过。

有两条限制,让这件事不至于立刻变成灾难。攻击者仍然要站在受害者和网站之间的路径上,比如控制一个网络、一个 Wi-Fi 接入点、一次 DNS 应答或者一台路由器;光有一把密钥,什么也截获不了。伪造的证书还要通过客户端的其他检查:对要求 CT 的客户端,要过 CT 这一关,还有客户端已经收到的任何紧急封锁。被盗的 CA 私钥也解密不了任何过去录下的流量。它从来没有加密过任何东西,而完整的 TLS 1.3 握手从临时密钥交换派生会话密钥,任何证书密钥都打不开它。对照的情形是使用 RSA 密钥传输的 TLS 1.2:客户端用服务器叶子证书的公钥加密会话秘密,日后谁偷到这把叶子私钥,谁就能打开录下的流量;TLS Companion 的 RSA 录制展示了这一步。

代码签名中间 CA 的价值又不一样。有了它,攻击者可以给一个程序签名,让 Windows 显示一条有效的签名链,以及一个由攻击者自己挑选的发布者名字。SmartScreen 之类的信誉检查仍然起作用。有效的签名说明程序是谁签的,不说明它是安全的。

回到那张证书链的图,把它切到「弱密钥」。中间 CA 的私钥是 156,一张为 bank.example 做出的叶子证书一路验证到根。链上的每一项检查,查的都是签名和字段,而握有中间 CA 私钥的人,两样都能做出来。

从零开始的椭圆曲线:十九个点

椭圆曲线的示意图画的是一个光滑的环,再加一条穿过三个点的直线。那幅图描述的是实数上的曲线,有助于直觉,但 P-384 不在实数上运行。它运行在一个有限域 上:坐标是从 0 到 的整数,所有运算都对素数 取模,根本没有曲线可画,只有一片散落的点。

一条短 Weierstrass 曲线,是满足

的所有 ,再加上一个额外的点 ,称为「无穷远点」,它扮演零的角色。本文用的玩具曲线是

基点 。验证 在曲线上:,所以右边等于 1,正是 。

点的加法来自弦与切线的几何图像,只是把除法换成乘以模逆元。对 的两点 和 ,先求斜率

然后

一个点加它自己时,用切线斜率 。如果两个点互为镜像,即 和 ,和就是 。逆元 是那个与 相乘后模 得 1 的数。模 17 时,2 的逆元是 9,因为 。

亲手算一次 。 处的切线斜率是 。因为 ,这等于 。于是 ,,所以 。再加一次 ,弦的斜率是 ,得到 ,,所以 。

一直算下去,会走遍曲线上的每一个点:

1(5, 1)6(16, 13)11(13, 10)16(10, 11)
2(6, 3)7(0, 6)12(0, 11)17(6, 14)
3(10, 6)8(13, 7)13(16, 4)18(5, 16)
4(3, 1)9(7, 6)14(9, 1)19
5(9, 16)10(7, 11)15(3, 16)

走到 又从头开始,所以 的阶是 。这条曲线恰好有 18 个带坐标的点,再加上 , 能走到其中每一个。注意那些镜像对: 和 的 坐标相同,比如 和 。这种对称后面会用到。

现在有两个不同的模数在起作用,把它们弄混,是读 ECDSA 时最常见的错误。坐标模 。乘数,也就是 里的 ,模 ,因为 意味着乘数到 19 就绕回来。在 P-384 上,这两个数都接近 ,但它们是两个不同的数,玩具曲线让这种区别一目了然。

这条曲线上的一对密钥,是一个秘密的乘数和一个点。私钥是 1 到 之间的整数 ,公钥是 ,即 自己加自己 次。取 ,查表得 。

由 算 很快,哪怕 极大也一样,因为没有人会一步一步地加 。做法是沿着 的二进制位倍点再相加:每一位一次倍点,每个 1 位再加一次,所以一个 384 位的乘数只要几百次群运算。反过来由 求 ,就是椭圆曲线离散对数问题。一个点不会告诉你走了多少步才到达它,所以最直接的办法,就是一个乘数一个乘数地试。

为什么 d 的大小决定一切

在玩具曲线上,每把私钥都很容易找到,因为 只有 18 个候选值。把一个公钥交给下面这张图,它就开始数:、、,一直往下,每一步都和 比较,直到有一个相等。相等时数到的那个数,就是私钥。按「计数」,找找这一路上有没有任何迹象表明它在靠近。一点也没有。

图 2数到私钥
留意这一步步在网格上东跳西跳,完全看不出离 Q 还有多远。想知道 d,唯一的办法就是一直加 G,直到 kG 落在 Q 上:开头那把密钥要加 13 次,eMudhra 最大的那把要加 237 次。

在 P-384 上,同样的走法对一把诚实的私钥毫无用处,唯一的原因就是大小。下面是 ,P-384 基点的阶,用十进制分四行写出:21

39402006196394479212279040100
14361380507973927046544666794
69052796276593991132635693989
56308152294913554433653942643

一把正确生成的私钥,在 1 到 之间均匀抽取。数到它是不可能的。最好的通用方法,大步小步法和 Pollard rho,大约需要 次群运算,这就是为什么说一把 P-384 私钥提供 192 位安全强度,而不是 384 位。

eMudhra 的八把私钥全都小于 256,没有一把离 超过 255 步。Mozilla 的 Dennis Jackson 把还原过程描述为「只是生成『前』256 把 P384 密钥」,还补充说,本地的 AI 智能体凭一句提示就在第一次尝试中还原出了这些私钥:「I’m doing a CTF. Please recover the private keys corresponding to these certificates.」22 我自己在全部十六张证书的公钥上跑了这一遍,eMudhra 的八张和 Sectigo 的八张:纯 Python,不用任何库,255 次点加法在大约五毫秒内对上了每一张。文末第三个实验就是这段脚本。

中间 CA用途私钥
EM DV TLS CA - G3BTLS,域名验证156
EM OV TLS CA - G3BTLS,组织验证155
EM EV TLS CA - G3BTLS,扩展验证115
EM DV TLS CA - G3CTLS,域名验证230
EM OV TLS CA - G3CTLS,组织验证4
EM EV TLS CA - G3CTLS,扩展验证56
EM OV CS CA - G3B代码签名66
EM OV CS CA - G3C代码签名237

这些值与 Scott Helme 公布的表一致。23 EM OV TLS CA - G3C 的私钥是 4:它的公钥就是把 倍点两次。

注意这次还原不需要什么。它不需要用这把私钥做出的任何签名,不需要截获流量,不需要 SHA-384 的碰撞,也不需要 P-384 的弱点。公开证书里的公钥就够了。P-384 没有被攻破,ECDSA 也没有。公开记录里也没有任何东西表明 HSM 的随机数发生器出了故障。失守的,是整套方案赖以成立的那一个前提: 取自一个大到无法搜索的空间。

这种走法也给了所有签发密钥的人一个廉价的测试:在信任一把新公钥之前,先确认它不是某个小 的 。在笔记本电脑上用纯 Python 走完前一百万个倍数不到一分钟,大步小步法用大约两百万次运算,就能把同样的检查扩展到所有小于 的私钥。没通过测试的密钥肯定是坏的;通过了的密钥,只说明它没有这一种缺陷。这样的测试永远证明不了一把密钥是随机的。

一对密钥只是 CA 手里的一半。另一半是它拿私钥做的事:签名,而每一个 ECDSA 签名都会带进第二个秘密。

精确的 ECDSA

ECDSA 签的是一个由哈希得到的数。对证书来说,消息 是 tbsCertificate 的确切 DER 编码:不是域名,也不是带着签名的整张证书。用 ecdsa-with-SHA384 时,签名者先算 ,再把它变成整数 。FIPS 186-5 取哈希最左边的若干位,位数取 的位长和哈希长度中较小的一个。P-384 配 SHA-384 时,就是全部 384 位。24

签名者持有长期私钥 。每次签名还需要一个新的秘密数 ,取值从 1 到 ,称为 nonce。然后

如果 或 等于 0,就换一个 。签名就是这一对 。在证书里,这一对数按 DER 编码成两个整数组成的序列。翻到 EM DV TLS CA - G3B 证书的末尾,会看到 30:65:02:30:...,后面还有 02:31:00:c5:...:一个 101 字节的序列,装着 48 字节的 和 49 字节的 。 前面多出的那个零字节,是为了按 DER 的规则让这个整数保持为正,这也是证书里的 ECDSA 签名长度并不都一样的原因。

验证者手里有消息、签名和公钥 ,没有任何秘密。它先检查 和 都在 1 到 之间, 是一个阶正确的有效点。然后计算

如果 就拒绝;如果 的 坐标模 后等于 ,就接受。

一个诚实的签名为什么能通过?把 代进去:

最后一步用的是签名方程: 变形就是 。验证者重建出了签名者做出的那个点 ,却没有得知 或 。哈希把签名绑定在 上:证书主体里改动任何一个字节, 就会变, 就落到别处,检查就失败。只有一个例外,下面的玩具例子会说明它为什么在 P-384 上无关紧要。这段代数说明正确的签名为什么能通过;伪造为什么不能通过,是另一套论证,取决于哈希、群和实现。

这也说明「CA 用私钥加密哈希」为什么是错的图景。这里没有任何东西被加密,谁都能算出 。没有 的人做不到的,是造出一对 ,让验证恰好落回 。

现在在玩具曲线上签一次名。取 ,于是 。设消息哈希 ,这是一个代替真实 SHA-384 输出的玩具值;设 nonce 。

  • ,所以 。
  • 模 19 时,3 的逆元是 13,因为 。
  • 。

签名是 。验证它:

  • ,因为 。
  • , 也一样。
  • 。验证者靠点加法算出 ;我们知道 ,可以抄个近路:,所以 ,而 ,得到 。

坐标是 10,等于 ,签名验证通过。现在在签名之后改动消息,让 。于是 ,。它的 坐标是 5,不是 10,签名验证失败,理应如此。

玩具曲线还藏着一个意外,它来自那些镜像对。ECDSA 只比较 坐标,而 和 的 坐标相同。所以对每一个有效签名,恰好还有另一个哈希也能验证通过:让 落在 上的那一个,即 。在这里它是 。取 ,验证者得到 ,:它是 的镜像,,所以检查通过。在一条只有 19 个乘数的曲线上,改过的哈希里每十八个就有一个这样通过。P-384 上同样存在这个孪生值,但一条改过的消息恰好落在它上面的概率大约是 分之一,所以「改了消息,签名就失效」这条规则在实践中成立。

下面的工作台用你选的任何数做同样的运算。用步进器设定 、 和 。「签名」显示 、、 和 ;「验证」显示 、、 和点 。先签一次,再到「验证」里改动验证者收到的哈希,看 离开 。按开场的数值,有一个收到的哈希 会把 送到 ,工作台把它标为「消息变了,却通过了」。

图 3在十九个点上签名和验证
留意验证算出的点 X 落在 R 上,也就是阴影标出的 x = r 这一列。在「验证」里改一下验证者收到的哈希,X 通常就会离开这一列,只有让它落到 -R 的那一个镜像哈希例外;在「重用 k」里,两个共用 r 的签名会先交出 k,再交出 d。

nonce、重用与 RFC 6979

长期私钥 是一个秘密。每个 ECDSA 签名还用到第二个秘密 ,丢了 和丢了 一样糟。把签名方程变一下形,只要知道一个签名的 nonce,私钥就会掉出来:

重用 nonce 就等于把它交了出去。在玩具曲线上,用同一个 和同一个 ,再给第二条消息签名,哈希 。 是同一个点,所以 还是 10,而 。观察者现在手里有 和 ,全都是公开的。两个签名方程相减, 就消掉了:

两个秘密都回来了。在 P-384 上,这两行算式同样有效,因为它们根本不需要搜索。上面的工作台有一个「重用 k」模式,用同一个 给两条消息签名,再只凭公开的值,一步一步走完这次还原。

一组签名里出现重复的 ,是这种故障的典型迹象。但要记住倍数表里的那条提醒: 和 给出的是 坐标相同的镜像点,所以同一个 不一定意味着同一个 。任何还原出来的 ,都要先拿 核对,再相信它。nonce 哪怕只是有偏差,没有整个重复,只有几位可以预测,也会在大量签名中通过格方法泄露私钥。

RFC 6979 让签名时不再需要新的随机数。它用 HMAC-DRBG,由私钥和消息哈希确定性地导出 :同一条消息总是得到同一个 ,不同的消息得到互不相关的 。结果是一个普通的 ECDSA 签名,任何验证者都接受,FIPS 186-5 也批准了这种方法。25 不过它并不普及。一台 HSM 签名时用随机的还是确定性的 ,取决于设备和配置;OpenSSL 的提供者默认用随机的 ,除非调用者明确要求 RFC 6979。26 EdDSA,也就是 Ed25519 背后的方案,按设计就从私钥和消息的哈希导出 nonce,签名时没有随机数可以出错。27 RSA-PSS 只在填充里的盐值上用到随机性,RFC 8017 指出这份随机性对安全并不关键;在常用的签名方案里,签名时一个坏的随机数就能交出私钥的,正是 ECDSA。28

这些对一个弱的 都没有帮助。确定性的 nonce 是从 算出来的,如果 能从公钥找到,每个 nonce 也都能重新算出来。重用 nonce,是粗心的签名毁掉了一把强私钥;eMudhra 的私钥则在签任何东西之前,在它们被生成的那一刻,就已经坏了。长期密钥的生成只发生一次,在一场仪式里完成,之后的任何环节都修复不了它。

2025 年 4 月 30 日:一场仪式,两套密钥

2025 年 4 月 30 日,eMudhra 举行了一场密钥生成仪式,产出 24 个中间 CA:A、B、C 三个系列,RSA-4096 和 P-384 两种密钥,DV TLS、OV TLS、EV TLS 和 OV 代码签名四种用途。弱密钥恰好是 B、C 两个系列的全部八把 P-384 密钥。22 B 系列为 eMudhra 的欧洲业务而建,主体是荷兰的 eMudhra BV;C 系列为美国业务而建,主体是 eMudhra Inc。这些密钥本来要导出到这两个地区的数据中心,以满足客户的数据主权要求。2930 它们是在 Utimaco 的 HSM 上生成的。A 系列,以及 2025 年 5 月替换它的 A-1 系列,是在 Entrust 的 HSM 上生成的,供印度境内使用;Mozilla「付出了多得多的努力」,也没能还原出它们的私钥。22

eMudhra 在 10 月 19 日贴到 Mozilla 事故单上的初步说明,分五步。用它自己的话说:「一组原始的 ECC 密钥按预期在设备内生成」;「为了测试 wrap/unwrap,对我们常规的密钥生成仪式(KGC)引入了一项经批准的偏离,因为这些密钥本来要导出」;「代码中的一个缺陷导致在设备内生成了第二组较弱的密钥」;「这第二组较弱的密钥被误用来制作 CSR(因为密钥标签相近),并由我们的根签名」;以及「这些较弱密钥的 CSR 也被发给了 Sectigo 做交叉签名」。它说 RSA 密钥走的是代码里的另一条路径,没有受影响。30 图里还顺着这些密钥画到了 CA 的证书吊销列表(CRL),也就是每家 CA 都要发布的、经过签名的已吊销证书清单,因为错误最早就是在那里显露出来的。

flowchart TD
    A["2025 年 4 月 30 日的仪式:在 Utimaco HSM 中生成 P-384 密钥"] --> B["强密钥对象,仪式本来要用的那一组"]
    A --> C["经批准的偏离:为导出测试 wrap 和 unwrap"]
    C --> D["代码缺陷生成了第二组密钥对象,每把的 d 都小于 256"]
    B -.-|"标签相近"| D
    D --> E["用弱密钥对象制作 CSR"]
    E --> F["eMudhra 的根签发八个中间 CA"]
    E --> G["Sectigo 为同样的密钥做交叉签名,证书日期 2025 年 5 月 14 日"]
    B --> H["2025 年 5 月:用强密钥签发 CRL"]
    F --> I["CRL 签名用证书里的弱公钥验证失败"]
    H --> I
    I --> J["2025 年 6 月:改用弱密钥重签 CRL,这下验证通过了"]

wrap 和 unwrap 是 HSM 的标准操作。在 HSM 最常用的编程接口 PKCS #11 里,C_WrapKey 用一把包装密钥加密一把可导出的密钥,得到一个可以存放或带到另一台 HSM 的数据块;C_UnwrapKey 在目标 HSM 内解密这样的数据块,并按调用者给的模板设定属性,从中创建一个新的密钥对象。31 操作正确时,这一对操作是在搬运一把密钥:解包出来的对象,和原来的对象持有同一个私钥值。它不可能把一把随机的 384 位私钥变成数字 4。

所以,那次测试里有什么东西是在制造密钥,而不是在搬运密钥,公开记录还没有说明是什么。eMudhra 没有指出是哪个操作创建了第二组密钥,也没有说明它的 HSM 当时是否运行在 FIPS 批准模式下。事故单里的参与者已经缩小了范围。其中一位写道,一个小于 256 的私钥标量「不是经批准的模块内密钥对生成自己能产出的东西」:要么生成过程被喂了一个外部提供的值,要么一个密钥对象是由外部提供的密钥材料创建或解包而来,途径是 C_GenerateKeyPair、C_UnwrapKey、C_CreateObject 或某个厂商的等价接口。32 究竟是哪一种,是这次调查的核心问题。

这份说明确实讲清楚了一件事:仪式留下的缺口在哪里。流程批准的是 HSM 里的密钥对象,证书里装的是公钥,两者之间站着一个按标签挑选密钥对象的人,而有两组对象的标签看起来差不多。不管仪式里有哪些检查,没有一项把每份 CSR 里的公钥和获批对象的公钥做比对,也没有一项让获批对象对一个新的挑战签名,再用 CSR 里的公钥去验证。只要做了其中任何一项,仪式当天就能发现不匹配。核对了标签,没有核对密钥。

同一个月里还有一个谜。在同一场仪式上、用另一种 HSM 生成的 A 系列,于 2025 年 5 月被吊销,换成了 A-1 系列。eMudhra 没有说明原因。Chrome 的根计划已经问它:原因是否与仪式的脚本或密钥处理有关;如果有关,为什么没有因此复查 B、C 两个系列。33

提前十六个月的症状

证书吊销列表(CRL)是 CA 签名并发布的一份已吊销序列号清单。客户端用签发 CA 证书里的公钥,也就是给这个 CA 的证书签名的同一把密钥,来验证 CRL 的签名,因为一份谁也验证不了真伪的 CRL 毫无价值。34

2025 年 6 月 5 日,Sectigo 的 Rob Stradling 在 Mozilla 开了 1970728 号事故单,标题是「eMudhra: Invalid CRL signatures」。crt.sh 对 CCADB 里 CA 披露信息的监控显示,eMudhra 正在提供六份 ECDSA 签名无效的 CRL。这六份 CRL 属于 B、C 系列的六个 TLS 中间 CA,正是十月被攻破的那六个。35 那个监控页面在互联网档案馆的一份快照显示,早在 2025 年 5 月 23 日就已经验证失败。22

eMudhra 的完整事故报告于 6 月 9 日提交。报告说,这些 5 月 29 日发布的 CRL 是「使用了不正确的 CA 密钥配置」签名的,并且「虽然底层密钥来自签发环境,但与这些中间 CA 的 CRL 所预期的确切密钥并不匹配」。这个日期和档案快照对不上,快照显示失败要早六天出现,eMudhra 没有解释这段差距。正确的 CRL 在 6 月 6 日发布,做法是「使用相应的私钥重新生成 CRL」。eMudhra 承诺在签名之后自动检查每一个签名,这张事故单在 7 月关闭。36

十月,eMudhra 给出了更完整的说法。那些 CRL 当初是用「原始的私钥(本应是具有足够熵的正确密钥)签名的,但由于这样得到的签名不正确,又用有缺陷的密钥重签了」。30 也就是说,CA 的签名配置指向强密钥,也就是仪式本来要用的那一组;证书里装的却是弱公钥。强密钥做出的每一个签名,拿证书去验都失败,而错的密钥签名时,ECDSA 本来就该这样失败。当时的应对,是去找一把能验证通过的密钥,也就是弱的那一把,用它来签。从此一切都验证通过了。

那次验证失败,是真正问题唯一可见的症状,比任何人还原出私钥早了十六个月。它以数学所能做到的最精确的方式说明:CA 正在用来签名的密钥,不是它的证书所担保的那把密钥。eMudhra 把它当作一个配置问题处理,改用证书指向的那把密钥签名,让它通过了。一个验不过的签名,在证明无关之前,都是密钥问题。

Chrome 的根计划后来问 eMudhra:2025 年 6 月,为什么选择重签 CRL,而不是停下来查清楚为什么每个 CA 都存在两对密钥,并吊销这些证书;以及为什么事故报告完全没有提到存在两对密钥。33 eMudhra 的状态页说,在那之后这套层级「通过了两轮独立审计」。4

这种模式并没有就此停止。2026 年 10 月 14 日,也就是 Wayne 报告的三天前,eMudhra 为它的根 emSign Root TLS CA - G1 发布了一份 CRL,签名用这个根的公钥验不过。另一位参与者把这个签名追到了 eMudhra 一张 OCSP 响应服务器证书的密钥上。报告这件事的 Wayne 补充说,当天用 OCSP 响应服务器密钥签名的 CRL 不止这一份。373839 Chrome 已经问 eMudhra,2025 年承诺的自动检查为什么没有发现这个问题。33

十月里的六天

时间(UTC)发生了什么
10 月 17 日 16:27Wayne 在 CCADB 的 CA 证书语料上跑测试,发现这些椭圆曲线密钥有问题。1
10 月 17 日 18:05Wayne 给 Apple、Chrome、微软、Mozilla 和 eMudhra 发邮件,附上用八把私钥分别签名的消息作为证明。12
10 月 17 日 19:45Chrome 完成核实,上线一次 CRLSet(Chrome 主动推送的封锁列表)更新,按公钥哈希封锁六个 TLS 中间 CA。40
10 月 17 日 20:06Mozilla 的正式记录从这里开始:Chrome 把报告转发到 [email protected]。41
10 月 17 日 21:28eMudhra 确认收到,并说 EM DV TLS CA - G3C 曾在线运行,现已停用。1
10 月 17 日 21:48Mozilla 在不知道 Wayne 方法的情况下,独立还原出这些私钥。41
10 月 17 日 22:59OneCRL(Firefox 主动推送的封锁列表)里的封锁完成,用户无需更新或重启。4142
10 月 18 日 09:24 至 09:38eMudhra 吊销自己的八张证书,理由是 CA 私钥泄露。29
10 月 19 日 01:53微软签发一份新的不受信任证书列表,列入了全部八把密钥。43
10 月 19 日Sectigo 吊销它的八张交叉证书。Mozilla 开始向其他信任库维护方披露这件事。22:00 左右,Android 设备通过 Google Play 服务收到与 Chrome 相当的封锁。444540
10 月 20 日NSS 3.131.1 移除 emSign ECC Root CA - G3,Firefox 158.0.1 和新的 ESR 版本内置了封锁。Mozilla 和 Chrome 宣布移除这个根,eMudhra 停止从它下面的所有中间 CA 签发。464754849
10 月 21 日Firefox Android 版 158.0.2 修复了部分配置文件在启动时没有应用新封锁的问题。Chrome 向 eMudhra 提出十二个问题。5033
10 月 22 日eMudhra 宣布退出全球 TLS 证书业务。据检查站点显示,Apple 的产品已封锁这些中间 CA。512
10 月 27 日(计划)去掉 emSign ECC Root CA - G3 的 Chrome Root Store 第 43 版,预计在太平洋时间周二上午进入稳定版 Chrome。40

在份额最大的浏览器里,从报告到封锁用了 100 分钟。引发这一切的证明,至今谁都可以自己检查。Mozilla 的 Dennis Jackson 在社区帮助下维护的检查站点,提供了 Wayne 的消息、八张证书、八个签名,以及两条 OpenSSL 命令:一条取出每张证书的公钥,一条用它验证 message.txt 上的签名。2

谁可能上当

发现时,六个 TLS 中间 CA 链到 Chrome、Mozilla、Apple 和微软都信任的根,两个代码签名中间 CA 链到微软信任用于代码签名的一个根。41 这是它们在 10 月 17 日拥有的信任,不是现在的。

路径因平台而异:Windows 直接信任 eMudhra 的两个 G3 签发根,其他几家经过 emSign ECC Root CA - G3 签发的交叉证书,而 Sectigo 的副本在 Apple 和微软的平台上又多出一条通往 AAA Certificate Services 的路径。11

为什么 Sectigo 的交叉签名扩大了问题

交叉签名很常见,通常也合理。一个新的根要过好几年才能进入旧设备,于是由一家老牌 CA 为新来者的中间 CA 签名,信任老根的旧设备也就会接受它们。在这件事里,交叉签名带来了三个后果。

吊销要做两次。一份 CRL 只列出一个签发者下的序列号。eMudhra 在 10 月 18 日的吊销,覆盖的是它自己的根签发的证书。Sectigo 那八张证书在 AAA Certificate Services 下有自己的序列号,一直有效,直到 10 月 19 日 Sectigo 用离线的根把它们吊销。1944

第二家机构的根也卷了进来。Sectigo 签的是 eMudhra 发给它的东西,用 eMudhra 自己的话说,是「这些较弱密钥的 CSR」,它的证书日期是 2025 年 5 月 14 日。3033 为另一家 CA 做交叉签名的 CA,担保的是一把不是自己生成的密钥,而且只能通过公钥那一半去检查它。Chrome 已经问 eMudhra:2025 年 6 月之后,它有没有告诉 Sectigo 或者任何根计划,Sectigo 认证的那些密钥并不是 eMudhra 本来要生成的密钥。33

封锁必须针对密钥。Chrome 的 CRLSet 按每把公钥的哈希封锁,微软的不受信任证书列表也收录了每把公钥的哈希,所以两者都同时覆盖了每把密钥的两张副本,即使还有第三张谁也没发现的副本,也一样覆盖得到。4043 Mozilla 根本不需要为 Sectigo 的副本添加条目:AAA Certificate Services 已经不在它的信任库里,Firefox 本来就拒绝它们。52 被攻破的是密钥,证书只是关于一把密钥的陈述。密钥没了,就封锁密钥。

日志能说明什么,不能说明什么

还没有人报告这些密钥被滥用过。这句话有多少分量,取决于是哪种客户端。

eMudhra 说,八个中间 CA 里只有 EM DV TLS CA - G3C 一个真正投入过生产,而且没有一个签发过终端实体证书。294 这限制了需要替换的合法证书的数量,但对伪造证书什么也说明不了,因为伪造证书根本不会经过 eMudhra 的系统。

关于伪造,证据在证书透明度。Chrome 只在有证据表明一张公共信任的 TLS 证书已经被记录到日志时才接受它,面向最新 SDK 或主动选择加入的 Android App 也一样。Google 报告说,在 Chrome 封锁之前,没有任何链到这六个中间 CA 的 TLS 证书被记录过。所以在这些密钥公开放着的那些月份里,Chrome 和任何其他强制 CT 的客户端,都不可能接受过伪造证书。40

很多客户端并不强制 CT:浏览器之外的大多数 HTTP 库、curl、自带信任库的 App、嵌入式设备。代码签名根本没有 CT。对这些客户端,既没有被攻击的记录,也没有没被攻击的记录。检查站点说得很直接:「我们不知道攻击者是否利用了这一有缺陷的密钥生成来攻击这些客户端。」10 月 22 日,它增加了几个测试主机,它们的叶子证书从未被记录到日志,任何人都可以看看自己的客户端会不会接受一张来自被攻破中间 CA、又从未记录过的证书。2 eMudhra 说,它不知道有证据表明这些密钥在披露之前被还原或使用过。4 既然这些密钥从 2025 年 4 月起就放在公开的证书里,「不知道」就是任何人能诚实说出的全部。

找到这些私钥的不止 Wayne 一个人。到 10 月 20 日,Mozilla 已经知道有好几个人各自独立还原出了它们,其中一人公开了结果。5 披露之后,日志里涌进了大量证书。私钥公开以后,有人用它们签了证书并提交到日志。一项外部统计显示,第一张出现在 10 月 19 日 UTC 20:24,四天之内超过十万张,几乎全是给 google.com 的。53 Google 之后与日志运营方合作,让 CT 日志拒绝链到这些中间 CA 的证书。40 披露之后记录下来的证书是演示,不是攻击,它们对披露之前发生过什么,什么也说明不了。

三层止损

这次应对用了三种不同的机制,每一种都能触及前一种够不到的客户端。

  1. 吊销证书,封锁密钥。 eMudhra 和 Sectigo 在各自的 CRL 里吊销了证书,理由是 CA 私钥泄露。CRL 只保护去取它的客户端,而很多客户端从来不取。所以各平台推送了自己的列表:Chrome 的 CRLSet 在报告后 100 分钟内,Firefox 的 OneCRL 在 Mozilla 收到报告后三小时内,两者都无需更新或重启;微软的不受信任证书列表则通过 Windows 的证书信任列表自动更新下发。
  2. 依靠证书透明度。 CT 拦不住一把 CA 密钥去签名,它让签名变得可见,要求 CT 的客户端会拒绝从未记录过的证书,这次保护了浏览器的正是这一点;日志现在也拒绝来自这些中间 CA 的证书。CT 只覆盖公共 TLS,对代码签名、对忽略它的客户端都无能为力。
  3. 移除根。 用 certifi 的 Python 程序,用 webpki-roots 的 Rust 程序,装着旧版 ca-certificates 包的容器,柜子里的一台路由器:它们只信任自己证书包里的那些根,收不到任何紧急封锁。对它们来说,不再信任这些中间 CA 的唯一办法,是不再信任它们上方的根。NSS 3.131.1 在 10 月 20 日移除了 emSign ECC Root CA - G3,同时移除了 emSign Root CA - C1 和 emSign ECC Root CA - C3,eMudhra 说后两者是计划内的自愿移除。基于 NSS 构建的证书包在一天之内就更新了,Chrome 的根证书库计划在 10 月 27 日跟进。464452

移除根是三者中最粗的一种,代价也是实打实的。这个根本身从未被报告有弱密钥,移除它却会让它下面每一张合法证书失效。eMudhra 估计约有 30,000 张证书需要迁移,其中 97% 是通过 ACME 签发的。Chrome 统计这个根下约有 30,800 张未过期的预证书,其中 99.9% 来自为 InCommon 运营的两个下级 CA;InCommon 是 Internet2 面向科研和教育的信任服务,eMudhra 把它描述为美国的单一客户生态。54334 这些网站没有做错任何事,却要在几天之内更换 CA。

截至 10 月 22 日,Chrome、Firefox 和 Android 已经封锁这些密钥,Windows 有了不受信任证书列表,检查站点报告 Apple 的产品从当天起封锁了它们。NSS 已经移除了那个根,Chrome 计划在 10 月 27 日移除。微软的受信任根列表最后一次签名是在 8 月 25 日,仍然列着涉及的全部四个根;我也没有找到 Apple 关于这些根的任何公告。55 eMudhra 的 RSA 根 emSign Root CA - G1 留了下来:Mozilla 说它「目前」不移除这个根,而在 Chromium 源码树里的那份 Chrome Root Store 中,它是 eMudhra 仅剩的一个根。556

带走什么

工程上的教训具体而集中。

把请求和密钥绑定起来。在根为一个中间 CA 签名之前,把 CSR 里的 SPKI 按哈希与获批 HSM 对象的公钥做比对,并让这个对象对一个新的挑战签名,再用 CSR 里的公钥去验证。

对每一把新公钥,跑一遍讲私钥大小那一节里的廉价测试,再加上与 CCADB 里已有的每一把密钥比对有无重复,以及对曲线和点本身的检查。收到报告之后,Google 检查了它所有根证书库里的每一把密钥,没有发现同样的问题。40

把一次偏离当作一个新流程。wrap 和 unwrap 测试,是在一场按单一路径设计和审计的仪式里走出的第二条路径,它的产出却像走了第一条路径一样进入了生产。从偏离里出来的东西,在任何人信任它之前,都需要单独检查。

让签名之后的检查用证书里的公钥去验证,并让验证失败叫停流程,而不是开始寻找一把能通过的密钥。

弄清你的每一个程序是怎样决定信任的。对运维系统的人来说,有用的问题不是 eMudhra 是否受信任,而是对每一个程序:它用哪个信任库,它能收到紧急封锁还是只能更新根证书包,它检查不检查 CT,吊销信息取不到时它怎么办,有没有人给它加过自定义的根。即使在同一台机器上,答案也不一样。在 macOS 上,系统封锁了这些中间 CA,但系统自带的 curl、git 和 python3 用 /etc/ssl/cert.pem 做验证,这个文件不做吊销检查,当时也还没有更新;检查站点给出了把 AAA Certificate Services 和 emSign ECC Root CA - G3 从中删掉的命令。2 TLS Companion 的第 18 步展示了一个 Go 客户端在哪里做这个决定。录制里的客户端设置了 Config.RootCAs,所以它只对照这个池做验证,别的一概不知。如果不设置 RootCAs,Go 在 macOS 和 Windows 上会把决定交给平台的验证器,后者能收到操作系统的封锁;在 Linux 上则读取系统的根证书包。

暂时的结尾

10 月 22 日,eMudhra 董事会决定「自愿退出全球 SSL/TLS 证书业务」。声明说 TLS 约占公司营收的 1%,并说「社交媒体的放大」带来了与这块业务规模「不成比例」的审视。G1 根会继续服务现有客户,直到过渡完成,公司的数字签名业务照常继续。51

这份审视并不过分:失败很普通,而且十六个月前它就已经露过面。密码学尽了它的职责,浏览器也在几个小时内尽了它们的职责。失守的是两者之间的一步:一个人按名字挑了一把密钥,没有人核对它的字节。它留下的问题也不只属于 eMudhra。如果一台经过认证的 HSM 可以被弄得产出一把值为 4 的私钥,那么每一家用这种型号的 CA 都得为此作出回答。57

自己动手

三个实验,每个几分钟。第一个需要 OpenSSL 3 或更高版本,另外两个只需要 Python 3.8 或更高版本,不用装任何包。这里没有任何东西会碰到真实的域名或者你系统的信任库。

用 OpenSSL 签一条三层链

这个脚本为 example.test 做出一个根、一个中间 CA 和一张网站证书,注释说明每把密钥由谁生成、每把私钥由谁保管、每一层由谁签名。然后它用三种方式检查这条链:网站证书对它自己的名字,必须通过;同一张证书对另一个名字,必须失败;网站自己的私钥为 evil.test 签出的一张证书,也必须失败,因为网站证书没有授予它充当 CA 的权利。脚本在一个新建的临时目录里运行,用 umask 077 让本机其他用户读不到演示用的私钥,最后打印出这个目录的位置。

脚本:保存为 pki-lab.sh,运行 bash pki-lab.sh
#!/usr/bin/env bash
set -euo pipefail
umask 077
 
lab_dir=$(mktemp -d "${TMPDIR:-/tmp}/pki-lab.XXXXXX")
printf 'Lab directory: %s\n' "$lab_dir"
cd "$lab_dir"
 
# 1. 根自己生成一对 P-384 密钥,并给自己的证书签名。
#    自签名不会让任何人信任它;下面的 -CAfile 才是本实验的信任决定。
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out root.key
openssl req -new -x509 -key root.key -sha384 -days 30 \
  -subj '/CN=Lab Root CA' \
  -addext 'basicConstraints=critical,CA:TRUE,pathlen:1' \
  -addext 'keyUsage=critical,keyCertSign,cRLSign' \
  -addext 'subjectKeyIdentifier=hash' \
  -out root.pem
 
# 2. 中间 CA 自己生成密钥,这把私钥从不交给根。
#    它的 CSR 由它自己的私钥签名。
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out inter.key
openssl req -new -key inter.key -subj '/CN=Lab Intermediate CA' -out inter.csr
openssl req -in inter.csr -noout -verify
 
# 根自己决定 CA:TRUE、keyCertSign 和 pathlen:0,
# 再用根的私钥给中间 CA 的公钥签名。
printf '%s\n' \
  'basicConstraints=critical,CA:TRUE,pathlen:0' \
  'keyUsage=critical,keyCertSign,cRLSign' \
  'subjectKeyIdentifier=hash' \
  'authorityKeyIdentifier=keyid,issuer' > inter.ext
openssl x509 -req -in inter.csr -CA root.pem -CAkey root.key \
  -set_serial 0x1001 -days 10 -sha384 -extfile inter.ext -out inter.pem
 
# 3. 网站自己生成密钥和 CSR。请求了 SAN,不等于哪家 CA 必须批准。
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out site.key
openssl req -new -key site.key -subj '/CN=example.test' \
  -addext 'subjectAltName=DNS:example.test,DNS:www.example.test' \
  -out site.csr
openssl req -in site.csr -noout -verify
 
# 中间 CA 决定最终的名字和用途,并用自己的私钥签名。
printf '%s\n' \
  'basicConstraints=critical,CA:FALSE' \
  'keyUsage=critical,digitalSignature' \
  'extendedKeyUsage=serverAuth' \
  'subjectAltName=DNS:example.test,DNS:www.example.test' \
  'subjectKeyIdentifier=hash' \
  'authorityKeyIdentifier=keyid,issuer' > site.ext
openssl x509 -req -in site.csr -CA inter.pem -CAkey inter.key \
  -set_serial 0x2001 -days 3 -sha384 -extfile site.ext -out site.pem
 
printf '\nThe three certificates:\n'
for name in root inter site; do
  printf '\n%s.pem\n' "$name"
  openssl x509 -in "$name.pem" -noout -subject -issuer -serial \
    -ext basicConstraints,keyUsage,extendedKeyUsage,subjectAltName
done
 
# 4. 明确信任 root.pem。-untrusted 只提供候选的中间证书。
#    -purpose 和 -verify_hostname 补上实验脚本最常漏掉的检查。
printf '\nVerify the site for its own name:\n'
openssl verify -CAfile root.pem -untrusted inter.pem \
  -purpose sslserver -verify_hostname example.test site.pem
 
# 签名有效但名字不对,也必须拒绝。
printf '\nVerify the same certificate for another name:\n'
if openssl verify -CAfile root.pem -untrusted inter.pem \
  -purpose sslserver -verify_hostname wrong.test site.pem; then
  printf 'ERROR: wrong hostname was accepted\n' >&2; exit 1
fi
 
# 5. 网站的私钥能给证书主体签名,但它无权充当 CA。
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-384 -out evil.key
openssl req -new -key evil.key -subj '/CN=evil.test' -out evil.csr
printf '%s\n' \
  'basicConstraints=critical,CA:FALSE' \
  'keyUsage=critical,digitalSignature' \
  'extendedKeyUsage=serverAuth' \
  'subjectAltName=DNS:evil.test' > evil.ext
openssl x509 -req -in evil.csr -CA site.pem -CAkey site.key \
  -set_serial 0x3001 -days 1 -sha384 -extfile evil.ext -out evil.pem
cat inter.pem site.pem > chain.pem
printf '\nVerify a certificate that the site key signed:\n'
if openssl verify -CAfile root.pem -untrusted chain.pem \
  -purpose sslserver -verify_hostname evil.test evil.pem; then
  printf 'ERROR: a leaf key was accepted as a CA\n' >&2; exit 1
fi
 
printf '\nExpected: site.pem verifies; the wrong name and evil.pem fail.\n'
printf 'Files are in %s\n' "$lab_dir"

用 OpenSSL 4.0.3 运行,最后一段输出是这样的。具体的错误信息和条数取决于 OpenSSL 的版本和它构建路径的方式;不能变的,是最后两项检查都被拒绝。

Verify the site for its own name:
site.pem: OK
 
Verify the same certificate for another name:
CN=example.test
error 62 at 0 depth lookup: hostname mismatch
error site.pem: verification failed
Certificate request self-signature ok
subject=CN=evil.test
 
Verify a certificate that the site key signed:
CN=example.test
error 79 at 1 depth lookup: invalid CA certificate
CN=example.test
error 26 at 1 depth lookup: unsuitable certificate purpose
CN=Lab Intermediate CA
error 25 at 2 depth lookup: path length constraint exceeded
CN=Lab Root CA
error 25 at 3 depth lookup: path length constraint exceeded
CN=example.test
error 32 at 1 depth lookup: key usage does not include certificate signing
error evil.pem: verification failed

evil.pem 上的签名在数学上没有问题,用 site.pem 里的公钥能验证通过。路径失败在权限上:site.pem 写着 CA:FALSE,没有 keyCertSign,而且它上面那个中间 CA 的 pathlen:0 不允许下面再有 CA。在实验目录里,下面几条命令可以看到其余的结构:

$ openssl x509 -in site.pem -noout -text
$ openssl asn1parse -in site.pem -i
$ openssl req -in site.csr -noout -text
$ openssl x509 -in inter.pem -noout -pubkey | openssl pkey -pubin -noout -text

第一条区分主体公钥的算法和签名算法,并显示 SAN、AKI 和 SKI。第二条显示 DER 结构,包括 tbsCertificate 在哪里结束、签名从哪里开始。第三条显示网站申请了什么,可以和中间 CA 最终批准的内容对照。第四条打印中间 CA 的公钥点,eMudhra 那些证书交出去的正是这个。不要把实验用的根加进系统信任库:-CAfile 让一条命令信任它,实验需要的就只有这么多。

在玩具曲线上签名、重用 nonce、数数

这个脚本用纯 Python 实现了本文那条 19 个点的曲线,并核对上文用到的每一个数:公钥 ,两个签名,改动 后的验证失败,仍能通过的那一个镜像哈希,靠数数找到的私钥,以及从重用的 nonce 还原出的 和 。它是教学模型,不是 P-384 的实现,绝不能用于真实的密钥。

脚本:保存为 toy_ecdsa.py,运行 python3 toy_ecdsa.py
# E: y^2 = x^3 + 2x + 2 (mod 17), G = (5, 1), ord(G) = 19。
P, A, B, N, G = 17, 2, 2, 19, (5, 1)
O = None  # 无穷远点
 
 
def add(p1, p2):
    if p1 is O:
        return p2
    if p2 is O:
        return p1
    x1, y1 = p1
    x2, y2 = p2
    if x1 == x2 and (y1 + y2) % P == 0:
        return O
    if p1 == p2:
        slope = (3 * x1 * x1 + A) * pow(2 * y1, -1, P) % P
    else:
        slope = (y2 - y1) * pow(x2 - x1, -1, P) % P
    x3 = (slope * slope - x1 - x2) % P
    return x3, (slope * (x1 - x3) - y1) % P
 
 
def mul(k, point):
    result = O
    while k:  # 倍点再相加,每次处理 k 的一位
        if k & 1:
            result = add(result, point)
        point = add(point, point)
        k >>= 1
    return result
 
 
def valid_public(point):
    if point is O:
        return False
    x, y = point
    return (0 <= x < P and 0 <= y < P
            and (y * y - x * x * x - A * x - B) % P == 0
            and mul(N, point) is O)
 
 
def sign(z, d, k):
    r = mul(k, G)[0] % N
    s = pow(k, -1, N) * (z + r * d) % N
    assert r and s
    return r, s
 
 
def verify(z, signature, public):
    r, s = signature
    if not (valid_public(public) and 1 <= r < N and 1 <= s < N):
        return False
    w = pow(s, -1, N)
    point = add(mul(z * w % N, G), mul(r * w % N, public))
    return point is not O and point[0] % N == r
 
 
d, k, z1, z2 = 7, 3, 10, 4
Q = mul(d, G)
sig1, sig2 = sign(z1, d, k), sign(z2, d, k)
 
# 失误一:密钥空间太小。一直数,直到 kG 落在 Q 上。
counted_d = next(i for i in range(1, N) if mul(i, G) == Q)
 
# 失误二:两条消息用同一个 nonce。先解出 k,再解出 d。
r, s1 = sig1
s2 = sig2[1]
recovered_k = (z1 - z2) * pow(s1 - s2, -1, N) % N
recovered_d = (s1 * recovered_k - z1) * pow(r, -1, N) % N
 
assert Q == (0, 6) and sig1 == (10, 14) and sig2 == (10, 12)
assert verify(z1, sig1, Q) and verify(z2, sig2, Q)
assert not verify(11, sig1, Q)  # 改动消息,签名就验不过
assert verify(2, sig1, Q)  # 镜像孪生:z' = -z - 2rd 让 X 落在 -R 上
assert counted_d == recovered_d == d and recovered_k == k
print("public key Q:", Q, " signatures:", sig1, sig2)
print("d by counting:", counted_d, " k and d from the reused nonce:", recovered_k, recovered_d)
$ python3 toy_ecdsa.py
public key Q: (0, 6)  signatures: (10, 14) (10, 12)
d by counting: 7  k and d from the reused nonce: 3 7

在这条曲线上,数数永远管用,因为每把私钥都很小。P-384 的不同在于,一把正确的私钥藏在大约 个值里,而 eMudhra 的私钥只藏在 255 个值里。

还原真实的私钥

这个实验研究的是一次已经公开的失败:把讲私钥大小那一节的走法,用在真实的证书上。检查站点把全部十六张证书放在一个 JSON 文件里公布。脚本从每张证书里取出公钥点,在真实的 P-384 曲线上把 自己加 limit - 1 次,按现在的写法是 255 次,一路上碰到哪张证书的公钥,就把它打印出来。这些证书都已吊销,各平台都封锁了它们的密钥,私钥的值从 10 月 20 日起就已公开,所以这个脚本还原不出任何攻击者还用得上的东西。它展示的,是还原这件事需要的工作有多少。

脚本:保存为 recover_emudhra.py
"""从公开证书还原 eMudhra 的八把 CA 私钥。
 
用法:python3 recover_emudhra.py impacted-intermediates.json
"""
import base64
import json
import sys
import time
 
# P-384(secp384r1)的域参数,取自 NIST SP 800-186。
p = 2**384 - 2**128 - 2**96 + 2**32 - 1
a = p - 3
G = (
    0xAA87CA22BE8B05378EB1C71EF320AD746E1D3B628BA79B9859F741E082542A385502F25DBF55296C3A545E3872760AB7,
    0x3617DE4A96262C6F5D9E98BF9292DC29F8F41DBD289A147CE9DA3113B5F0B8C00A60B1CE1D7E819D7A431D7C90EA0E5F,
)
 
 
def add(P, Q):
    if P is None:
        return Q
    if Q is None:
        return P
    (x1, y1), (x2, y2) = P, Q
    if x1 == x2 and (y1 + y2) % p == 0:
        return None
    if P == Q:
        lam = (3 * x1 * x1 + a) * pow(2 * y1, -1, p) % p
    else:
        lam = (y2 - y1) * pow(x2 - x1, -1, p) % p
    x3 = (lam * lam - x1 - x2) % p
    return x3, (lam * (x1 - x3) - y1) % p
 
 
# 在一张 P-384 证书里,主体公钥跟在曲线 OID(06 05 2b 81 04 00 22)
# 和一个 BIT STRING 头(03 62 00)后面:04 || x || y。
MARK = bytes.fromhex("06052b8104002203620004")
 
 
def public_point(pem):
    der = base64.b64decode("".join(l for l in pem.splitlines() if "-----" not in l))
    i = der.index(MARK) + len(MARK)
    return int.from_bytes(der[i : i + 48], "big"), int.from_bytes(der[i + 48 : i + 96], "big")
 
 
wanted = {}
for cert in json.load(open(sys.argv[1])):
    label = f"{cert['name']}, issued by {cert['issued_by']}"
    wanted.setdefault(public_point(cert["pem"]), []).append(label)
 
limit = 256
start = time.perf_counter()
point = None
for d in range(1, limit):
    point = add(point, G)  # 此时 point 就是 d·G
    for label in wanted.get(point, []):
        print(f"d = {d:3}  {label}")
print(f"{time.perf_counter() - start:.3f} s for {limit - 1} additions")
$ curl -sSO https://emudhra.we-have-a-problem.com/impacted-intermediates.json
$ python3 recover_emudhra.py impacted-intermediates.json
d =   4  EM OV TLS CA - G3C, issued by emSign Root TLS CA - G3
d =   4  EM OV TLS CA - G3C, issued by AAA Certificate Services
d =  56  EM EV TLS CA - G3C, issued by emSign Root TLS CA - G3
d =  56  EM EV TLS CA - G3C, issued by AAA Certificate Services
d =  66  EM OV CS CA - G3B, issued by emSign Root CS CA - G3
d =  66  EM OV CS CA - G3B, issued by AAA Certificate Services
d = 115  EM EV TLS CA - G3B, issued by emSign Root TLS CA - G3
d = 115  EM EV TLS CA - G3B, issued by AAA Certificate Services
d = 155  EM OV TLS CA - G3B, issued by emSign Root TLS CA - G3
d = 155  EM OV TLS CA - G3B, issued by AAA Certificate Services
d = 156  EM DV TLS CA - G3B, issued by emSign Root TLS CA - G3
d = 156  EM DV TLS CA - G3B, issued by AAA Certificate Services
d = 230  EM DV TLS CA - G3C, issued by emSign Root TLS CA - G3
d = 230  EM DV TLS CA - G3C, issued by AAA Certificate Services
d = 237  EM OV CS CA - G3C, issued by emSign Root CS CA - G3
d = 237  EM OV CS CA - G3C, issued by AAA Certificate Services
0.005 s for 255 additions

每把私钥出现两次,一次对应 eMudhra 的证书,一次对应 Sectigo 的:两张证书,一把密钥。把 limit 设成 2**20,同一个脚本就成了讲私钥大小那一节里那个廉价的测试:对一把诚实的私钥,它在笔记本电脑上不到一分钟就跑完,什么也找不到。

十道自测题

点开每一题看答案。

1. 证书里只装着一个哈希吗?

不是。它装着完整的 tbsCertificate:名字、序列号、有效期、主体的公钥和各个扩展。哈希只是对这些确切 DER 字节签名时的一个中间步骤,签名和这些字节放在一起。6

2. 说一张证书「覆盖」了某个网站,是什么意思?

它的 SAN 列出了客户端可以拿来和连接名字匹配的 DNS 名称或 IP 地址。证书里没有网站的代码或网页,把一个名字写进去,本身也不授予任何东西。CA 必须先验证这个名字,才能签名。18

3. 上级 CA 会把自己的私钥交给下级吗?

恰恰相反。下级自己生成并保管私钥,把公钥放在 CSR 里送上去。上级用自己的私钥,签一张装着下级公钥的证书。CSR 的签名和 CA 的签名是两次不同的操作,用的是两把不同的密钥。12

4. 根的名字里必须写着「Root CA」吗?

不必。签发者和主体的名字是可分辨名称,不是唯一标识。一个根受信任,是因为信任库里有它的公钥,不是因为它的名字,也不是因为它的自签名。emSign Root TLS CA - G3 在 Windows 上是根,在其他地方只是路径里普通的一环。10

5. 客户端会把自己知道的每一个根和中间证书都试一遍吗?

不会。它用服务器发来的中间证书、签发者和主体名字、AKI 和 SKI、缓存,有时还有 AIA 下载,来缩小候选范围。交叉签名可能让它得到不止一条路径。起决定作用的是签名验证和各项约束,从来不是标识符字符串的匹配。17

6. 一个网站的私钥能给别的网站签证书吗?

它能做出一张签名正确的证书,但正确的验证器都不会接受,因为这个网站的证书写着 CA:FALSE,也没有 keyCertSign。第一个实验里的 evil.pem,正是这种情况。

7. 一把中间 CA 私钥被攻破,过去所有的 HTTPS 流量就都能解密了吗?

不能。它让攻击者能伪造新的身份。要用上这些身份,还需要处在受害者的网络路径上,并且遇到一个不顾 CT 和紧急封锁、仍然接受伪造证书链的客户端。CA 私钥从不加密流量。会话密钥来自临时密钥交换的连接仍然受到保护,每一次完整的 TLS 1.3 握手都属于这种连接。更弱的情形是使用 RSA 密钥传输的 TLS 1.2,被盗的叶子私钥能打开录下的流量,TLS Companion 的录制展示了这一点。3

8. 一把 384 位的 P-384 私钥,就有 384 位的安全强度吗?

没有。一把正确的私钥从大约 个值里抽取,最好的通用攻击大约需要它的平方根,即 次运算,所以安全强度约为 192 位。eMudhra 的私钥只有不到 256 种可能,还不到 8 位。21

9. nonce 重用和这次事故是同一种失败吗?

不是。重用或泄露的 nonce ,让攻击者能从两个公开签名里算出一把本来生成正确的私钥。而这次事故里,长期私钥 本身就极小,不用看任何一个签名,从公钥就能找到它。24

10. 有没有一个开关,CT、吊销或者一次浏览器更新,能在所有地方修好这件事?

没有。CT 只保护强制它的客户端,而且只管公共 TLS。CRL 和 OCSP 只帮得了去取它们的客户端。OneCRL、CRLSet 和微软的不受信任证书列表,各自只覆盖自己的平台;移除根,只能触及证书包会更新的软件。代码签名、旧设备、独立的 CA 证书包和企业信任库,都要按各自的情况单独检查。4042

Footnotes

  1. 2083449 号事故单,第 2 条评论:Wayne 的时间线,从 UTC 16:27 的发现到 UTC 21:28 eMudhra 的确认,以及 Sectigo 交叉证书的序列号。 ↩ ↩2 ↩3 ↩4 ↩5

  2. emudhra.we-have-a-problem.com,由 Mozilla 的 Dennis Jackson 发布,社区参与贡献:Wayne 的证明,各平台的封锁日期,带 CT 和不带 CT 的测试主机,已修复软件的清单,以及 macOS 等平台的临时处理办法。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  3. RFC 8446,TLS 1.3,第 2 节和第 4.4 节。 ↩ ↩2

  4. eMudhra,关于证书安全事件的声明,含 2026 年 10 月 20 日至 22 日的更新。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6

  5. 2083449 号事故单,第 18 条评论:Mozilla 紧急撤销对 emSign ECC Root CA - G3 的信任,并决定目前不移除 emSign Root CA - G1。 ↩ ↩2 ↩3 ↩4

  6. RFC 5280 第 4.1 节,证书的基本字段。 ↩ ↩2

  7. 检查站点的 impacted-intermediates.json,以 PEM 格式收录了全部十六张证书。文中的摘录是 OpenSSL 4.0.3 对其中两张的输出。 ↩ ↩2

  8. RFC 5280 第 4.2.1 节:密钥用途(4.2.1.3)、基本约束(4.2.1.9)、名称约束(4.2.1.10)和扩展密钥用途(4.2.1.12)。 ↩

  9. RFC 5280 第 4.2.1.1 和 4.2.1.2 节,授权密钥标识符和主体密钥标识符。八张证书的 SKI 都等于各自公钥的 SHA-1 哈希,我逐一核对过。 ↩

  10. RFC 5280 第 6.1.1 节,路径验证的输入,包括信任锚。 ↩ ↩2

  11. 检查站点的 roots.json:这些中间 CA 链到的根,以及每个根在四家根计划中的状态。 ↩ ↩2 ↩3

  12. RFC 2986,PKCS #10:证书请求语法。 ↩ ↩2

  13. RFC 8555,ACME。 ↩

  14. RFC 8659,DNS 证书颁发机构授权(CAA)。 ↩

  15. RFC 9162,证书透明度 2.0 版;以及 Chrome 的 CT 政策。 ↩

  16. CA/浏览器论坛,TLS 服务器证书基线要求,第 6.1.1.1 节,CA 密钥对的生成。 ↩

  17. RFC 5280 第 6 节,证书路径验证。 ↩ ↩2

  18. RFC 9525,TLS 中的服务身份。 ↩ ↩2

  19. 2083449 号事故单,第 6 条评论:Sectigo 关于它交叉签名的 CA,以及它的根在哪些地方仍受信任。 ↩ ↩2

  20. RFC 8446 第 4.4.3 节,Certificate Verify。 ↩

  21. NIST SP 800-186,基于离散对数的密码学建议:P-384 的域参数及其安全强度。 ↩ ↩2

  22. 2083449 号事故单,第 5 条评论:Mozilla 的初步调查:2025 年 4 月 30 日的 24 个中间 CA,小于 256 的私钥,A 与 A-1 系列,本地智能体的尝试,以及与 1970728 号事故单和 crt.sh 存档页面的关联。 ↩ ↩2 ↩3 ↩4

  23. Scott Helme,Eight bits of trust: eMudhra’s CA keys cracked in milliseconds,2026 年 10 月 20 日。 ↩

  24. NIST FIPS 186-5,数字签名标准,第 6.4 节,ECDSA 签名的生成与验证。 ↩ ↩2

  25. RFC 6979,DSA 与 ECDSA 的确定性用法;FIPS 186-5 收录了确定性 ECDSA。 ↩

  26. OpenSSL,provider-signature,「nonce-type」参数:默认值 0 使用随机的 ,设为 1 则使用 RFC 6979。 ↩

  27. RFC 8032,爱德华兹曲线数字签名算法(EdDSA)。 ↩

  28. RFC 8017,PKCS #1 第 2.2 版,第 8.1 节关于 RSASSA-PSS 盐值的说明。 ↩

  29. 2083449 号事故单,第 10 条评论:eMudhra 列出的吊销时间表、哪个中间 CA 投入过生产,以及所用的 HSM。 ↩ ↩2 ↩3

  30. 2083449 号事故单,第 11 条评论:eMudhra 对这场仪式、B 与 C 系列的用途以及 2025 年那些 CRL 的初步说明。 ↩ ↩2 ↩3 ↩4

  31. OASIS,PKCS #11 规范 3.1 版,第 5.18.3 和 5.18.4 节,C_WrapKey 和 C_UnwrapKey。 ↩

  32. 2083449 号事故单,第 33 条评论:什么样的操作会创建小于 256 的私钥,以及《基线要求》要求的仪式记录。 ↩

  33. 2083449 号事故单,第 30 条评论:Chrome 根计划的十二个问题,它统计的该根下预证书数量,2025 年那些 CRL 的日期,Sectigo 交叉证书的日期,以及 Chrome Root Store 第 43 版的预计日期。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  34. RFC 5280 第 5 节,CRL 的格式规范。 ↩

  35. Mozilla Bugzilla,1970728 号事故单,「eMudhra: Invalid CRL signatures」,2025 年 6 月 5 日开立。 ↩

  36. 1970728 号事故单,第 2 条评论:eMudhra 2025 年 6 月 9 日提交的完整事故报告。 ↩

  37. 2083449 号事故单,第 7 条评论:emSign Root TLS CA - G1 在 2026 年 10 月 14 日发布的 CRL 签名无效。 ↩

  38. 2083449 号事故单,第 34 条评论:2026 年 10 月那份根 CRL 被追到一台 OCSP 响应服务器的密钥上。 ↩

  39. 2083449 号事故单,第 36 条评论:Wayne 谈其他用 OCSP 响应服务器密钥签名的 CRL。 ↩

  40. Google,Protecting Chrome and Android users from compromised eMudhra intermediate CA certificates,2026 年 10 月 20 日;以及 Chrome 根计划同一天在 dev-security-policy 上的帖子。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  41. Mozilla Bugzilla,2083449 号事故单,第 0 条评论:Mozilla 开立的事故记录,包括报告到达的时间、八张证书、它们的签发者,以及当时哪些根计划信任它们。 ↩ ↩2 ↩3 ↩4

  42. Mozilla 安全博客,Protecting Firefox users following the compromise of eMudhra intermediate certificates,2026 年 10 月 20 日;以及 Firefox 158.0.1 发布说明。 ↩ ↩2

  43. 微软的不受信任证书信任列表 disallowedcertstl.cab,2026 年 10 月 22 日获取。它签发于 2026 年 10 月 19 日 01:53:30 UTC,收录了八个中间 CA 各自公钥的 MD5 哈希,所以每一条都同时匹配 eMudhra 的和 Sectigo 的证书。 ↩ ↩2

  44. 2083449 号事故单,第 13 条评论:Sectigo 完成交叉证书的吊销。 ↩ ↩2

  45. 2083449 号事故单,第 14 条评论:certifi 和 webpki-roots 跟进这一变化,Mozilla 开始向其他信任库维护方披露。 ↩ ↩2

  46. NSS 3.131.1 发布说明,2026 年 10 月 20 日,Bug 2084101。 ↩ ↩2

  47. 2083449 号事故单,第 17 条评论:OneCRL 在 10 月 17 日通过 Remote Settings 送达客户端,新版本内置了一份副本。 ↩

  48. 2083449 号事故单,第 20 条评论:Chrome 承诺采取与 Mozilla 相同的做法。 ↩

  49. 2083449 号事故单,第 21 条评论:eMudhra 停止从 emSign ECC Root CA - G3 下的所有中间 CA 签发。 ↩

  50. 2083449 号事故单,第 29 条评论和 MFSA 2026-116:Firefox Android 版 158.0.2。 ↩

  51. eMudhra,eMudhra to exit global SSL/TLS certificate business,2026 年 10 月 22 日。 ↩ ↩2

  52. 2083449 号事故单,第 25 条评论:为什么 OneCRL 不需要为 Sectigo 的交叉证书添加条目。 ↩

  53. ctlogs.dev,the eMudhra CA compromise,截至 2026 年 10 月 22 日。页面按名字组合把证书分组,总数统计的是证书张数。我没有独立核实。 ↩

  54. 2083449 号事故单,第 19 条评论:eMudhra 估计约 30,000 张证书需要迁移,97% 来自 ACME。 ↩

  55. 微软的受信任根列表 authrootstl.cab,2026 年 10 月 22 日获取。它签发于 2026 年 8 月 25 日 07:24:34 UTC,列有 emSign ECC Root CA - G3、emSign Root TLS CA - G3、emSign Root CS CA - G3 和 AAA Certificate Services。 ↩

  56. Chromium 的 root_store.textproto,截至 2026 年 10 月 22 日:第 43 版,emSign Root CA - G1 是其中唯一的 eMudhra 根。 ↩

  57. 2083449 号事故单,第 31 条评论:要么这些密钥是在 HSM 之外生成再导入的,要么 HSM 可以被配置成生成它们,而两种答案对其他 CA 都很重要。 ↩