深度解析塑造未来的技术文章。

eMudhra的弱CA密钥:破除PKI五大误区

一家受信任的CA发布的私钥,毫秒级即可被破解。我们以eMudhra事件为例,检验关于PKI、密钥生成与审计的五个常见认知。

一扇巨大的开放保险库门,却只用一把小玩具挂锁锁着
十七个月的CA安全,竟然只靠8比特的熵撑着。

eMudhra的某个证书颁发机构(CA)私钥是数字 4。不是以4开头的384位数字,就是4,后面补上47个零字节,看起来才像样。这是一个CA密钥生成故障,我以前还会告诉你这种事不可能发生。它躲过了审计、Sectigo的交叉签名,在接近生产环境的基础设施里待了十七个月,直到有人试了最显而易见的办法:暴力破解单个字节。下面是这起事件彻底推翻的五个关于PKI的认知。

误区一:“结构有效的证书就是安全的证书”

流水线里的每项自动检查都说这些证书没问题。从一个狭窄且没什么用的角度看,它们确实没问题。对应 d = 4 的公钥是P-384曲线上一个完全合法的点。ASN.1编码能正常解析,签名能验证,证书链也能构建。每个lint工具、每个结构校验器、每个检查证书格式是否规范的合规清单,都会放行它们,因为它们的格式确实是规范的。

问题就在这里。结构校验回答的是“这是一张格式正确的证书吗?”,它并不回答“里面的密钥生成得对吗?”。对椭圆曲线密钥来说,这两个性质几乎完全独立。私钥只是一个标量 d,公钥是 d × G,其中 G 是曲线的生成点。整个安全论证的前提是:从公钥点反推 d 需要解离散对数问题,但前提是 d 确实是从完整的384位空间中选出的。如果 d 只从8位空间中选,那么原本“不可能”的问题就变成了一个最多循环255次的循环。

证书本身看不出任何异常。由 d = 4 推导出的公钥,看上去和正常生成的密钥推导出的公钥一样随机。你肉眼看不出来,lint也查不出来。唯一的办法是试小的数值和已知的弱模式,而这正是发生的事:报告人在开箱即用的情况下,用Google的 paranoid_crypto 测试套件跑了Common CA Database语料库,立刻命中。这项检查在笔记本上只需几毫秒,而它检查的对象,恰恰是真实浏览器信任链的锚点。

所以第一课:有效性是必要条件,远远不是充分条件。如果你的PKI测试只停留在“证书能解析、能成链”,那你测试的只是信封,没有看信的内容。

误区二:“基线要求和审计能发现密钥生成的缺陷”

CA要遵守CA/Browser Forum基线要求,并依据WebTrust或ETSI标准接受审计。流程有仪式文档、HSM要求、密钥长度要求。eMudhra的密钥长度是对的,纸面上是384位。审计的漏洞在于,合规检查验证的是密钥的表示形式,而不是产生它的过程熵。一个补了47个零字节的密钥,在磁盘上依然占48个字节。

根据eMudhra在Mozilla事件工单中的自述,根本原因读起来像一出黑色喜剧:仪式脚本的一个bug在HSM上生成了第二对弱密钥;两对密钥名字相近;有人给CSR选错了。三个独立的失误,脚本bug、命名冲突、选择错误,而在任何一个环节,都没有控制手段去问一句:“这个密钥真的具备我们为HSM所付出代价的那种熵吗?”

这部分应该让每个运营内部PKI的工程师都感到不安。如果一家公开信任的CA,身边有根证书程序盯着、审计日程排得满满,仍然能发布一个值为4的私钥,那你的Vault PKI后端或Active Directory证书服务模板里又藏着什么?修复成本很低,没有理由不做。生成任何密钥之后、用它签任何东西之前,先用 paranoid_crypto 跑一遍,或者至少做一个简单的合理性检查:

from cryptography.hazmat.primitives.asymmetric import ec
CURVE_ORDER_BITS = 384
def sanity_check_private_key(key: ec.EllipticCurvePrivateKey) -> None:
d = key.private_numbers().private_value
# Reject keys whose effective entropy is far below the curve size.
# A healthy P-384 key should have a bit length close to 384.
if d.bit_length() < CURVE_ORDER_BITS - 32:
raise ValueError(
f"Suspiciously small private scalar: {d.bit_length()} bits"
)
# Also reject known bad values outright.
if d < 2**16:
raise ValueError("Private scalar is trivially brute-forceable")

只需半分钟的工作量。它本可以抓出eMudhra全部八个密钥。审计验证的是流程是否存在,并不验证某个四月的周二,流程是否真的跑对了。只有对输出做测试才能做到这一点。

误区三:“被攻破的CA密钥可以被控制住”

通常意义上的CA密钥“泄露”,是有一个窃贼的:某人在某个时刻窃取了密钥,你可以推断冲击范围,也就是窃取之后由持有被盗密钥的人签发的证书。虽然糟糕,但范围是有界的。我们之前写过中间CA被攻破会导致信任链断裂的情况,标准应对流程(吊销、取消信任、重新签发)默认存在“之前”和“之后”的分界。

这次事件没有“之前”和“之后”。密钥从证书被创建的那一刻起就实际上是公开的,因为任何看过证书的人都能在几毫秒内还原它。没有可以划定边界的窃取事件。风险窗口就是这些证书的整个生命周期,从2025年4月起,而且没有办法知道究竟有没有人、有多少人,从这扇敞开的门走了进去。eMudhra的声明称中间CA没有签发任何终端实体证书,证书透明度日志也证实了这一点,这对于那些CA实际做了什么确实令人安心。但它对于任何持有 d=4 的人可能在网络路径上机会性地做了什么,只字未提。

更糟的是,应对措施本身也无法完全落地。Chrome和Firefox通过CRLSet和OneCRL在数小时内封禁了这些中间CA,而且两者都要求证书透明度日志,所以伪造的TLS证书在这些浏览器里本来就无法通过验证。很好。但这层保护止步于浏览器的边界,这就引出了下一个误区。

误区四:“浏览器安全,生态就安全”

一个发光的装甲浏览器窗口,周围是黑暗中数百台暴露在外的联网设备
浏览器几小时内就打上了补丁,而TLS生态中的其他部分仍然暴露在风险中。

浏览器是TLS生态里的最佳情形,而且从运维角度看,它们只占你真正关心的TLS客户端中的一小部分。想想你的基础设施里到底是什么在建立HTTPS连接:curl、Python的 requests、Node、Go二进制文件、Java服务、git、包管理器、云SDK。这些客户端有三个特点,叠加在一起就很糟糕:它们信任磁盘上的CA证书包,默认不检查吊销状态,也不检查证书透明度。当初保护Chrome用户的三重防线,在这里全都不存在。

Scott Helme具体演示了这一点:用一张链到某个弱eMudhra中间CA的伪造 api.stripe.com 证书,默认的 curl 报告 SSL certificate verify ok,Python完成了握手,Node报告连接已授权,Windows CryptoAPI也接受了这条证书链。在测试过的每一台Linux和macOS机器上都是如此。任意主机名的伪造证书,都会被你的部署脚本和CI运行器所依赖的默认工具接受。这不是浏览器的问题,而是你自己基础设施的问题。

暴露面比大多数人想象的要更广:

  • curl | sh 安装脚本和自更新程序。rustup、nvm、Homebrew安装脚本,以及无数“install.sh”页面。这里如果出现伪造证书,就等于在运行攻击者的代码,而且往往是以root身份运行。
  • CI/CD流水线。运行器在持有云凭证和签名密钥的同时拉取依赖、推送构件,而且往往基于陈旧的基础镜像。
  • 服务器之间的流量。支付回调、OAuth令牌交换、在请求头中携带密钥的云SDK调用。
  • 物联网和嵌入式设备。固件已经冻结,CA证书包在证书过期之前都不会更新,而这一天是2039年7月。
  • Windows代码签名。八个弱密钥中有两个属于代码签名CA;用它们签名的恶意软件,看起来就像来自正规发布者的软件。

可行的建议平淡但正确:更新所有附带CA证书包的东西,包括操作系统的 ca-certificates 包、certifi、Node、Homebrew。一旦基础镜像获得了不信任的更新,就重建你的容器镜像,因为镜像层会“记住”旧的内容。无法操作的部分,是那些你没法更新的设备。那台智能电视还会在接下来的十五年里信任一个值为4的密钥。如果你还需要一个理由去把物联网设备放进独立网段,这就是了。

误区五:“肯定已经有人在检查这个了”

事件中最令人暗自痛心的一句话,来自报告人:这个发现,是他在转向用户证书之前,把 paranoid_crypto 拿去跑CCADB语料库,作为一次快速的语料测试时发现的。这是唾手可得的低垂果实。报告人自己的原话是:“我的大部分时间都花在了检查那些大家都以为别人早就做过的基础事项上,这在安全领域是绝对不能犯的错误。”

再读一遍这句话。八个CA私钥,是一家公开信任的权威机构的核心资产,交叉签名到了Sectigo的AAA Certificate Services根证书下,在十七个月里一直可以被暴力破解。而它们不是被审计员发现的,不是被根证书程序发现的,也不是被CA自己发现的,而是被一个人用开源工具开箱即用地跑出来的。整个生态系统都默认基础事项已经有人负责了,结果没有人真正负责。

这是安全基础设施中一个更普遍的模式,也是我一直在反复强调防御式工程,即能够抵御灾难的系统的原因,而不是假设一切都会正常运作的系统。能够发现这个问题的检查,便宜得几乎让人尴尬:

  1. 在密钥生成时,以及在签名前的每一份CSR上,运行 paranoid_crypto 或同类的弱密钥检查。
  2. 以经验方式验证熵:检查私有标量的位长,拒绝有效熵低到不合理的密钥,做法见上面的代码示例。
  3. 测试仪式本身,而不仅仅是配置。对密钥仪式进行演练,并检查实际产出的制品,包括最终写入CSR的是哪个命名的HSM对象。
  4. 持续扫描已签发的证书,而不只是模板。证书才是事实的依据;上游的一切都只是意图。

证书才是事实依据。上游的一切(脚本、仪式、审计)都只是意图。

八个比特就够了

一场因为愚蠢原因而酿成的灾难,有种近乎令人安心的意味:一个脚本bug,HSM上两个名字相似的密钥,一个人选错了其中一个。没有国家级黑客,没有什么新型密码分析,只是一出带着十七个月导火索的闹剧。但这份愚蠢本身就是警告。如果一家公开信任的CA最薄弱的环节可以只是一个字节,那么你PKI的强度并不取决于你选的曲线或你的HSM预算,而取决于流水线中是否有任何人、任何地方,真正测试过输出结果。

浏览器厂商在数小时内就完成了他们的部分。剩下的就靠我们自己了:审计你自己的密钥生成,运行那些便宜的检查,更新你能触达的每一个CA证书包,并把你无法更新的设备隔离出来。别再假设别人已经检查过基础事项了。在安全领域,这是代价最高昂的假设。