漏洞

Erlang CVE-2026-89422:证书验证为何被跳过

Erlang/OTP 的 TLS 1.3 客户端曾把对端未经请求的 PSK 选择当成恢复会话的依据,让开启 verify_peer 的连接跳过服务器证书检查;应升级应用实际使用的 OTP,暂时无法更新时将受影响客户端限制为 TLS 1.2,并验证身份检查恢复正常。

暖纸手绘桌面,一条连接两份文件夹的纸带绕过了旁边的证书卡,借此引出被跳过的身份检查。
文章导航

给 TLS 客户端打开 verify_peer,配好可信 CA,原本是希望它在连接成功前确认服务器身份。Erlang/OTP 在 9 月 22 日披露的 CVE-2026-89422,却能让这套配置接受一个从未发送证书的对端。应用拿到的仍然是成功的 {ok, Socket},后面的数据也照常加密。问题是,应用已经把数据交给了有能力回答这次连接的人,而没有确认那个人是谁。

原因出在 TLS 1.3 的两条握手路径被错误地拼到了一起。客户端根据服务器发来的扩展进入“恢复会话”状态,省掉了证书检查;真正选择预共享密钥时,又因为本地没有那把密钥,退回普通完整握手的默认值。对端于是可以完成密钥协商,却不用提供原本必须验证的身份凭据。

我们的处置建议是先找出使用 OTP ssl 发起 TLS 1.3 连接的运行节点,更新其实际使用的 OTP 包;等待更新时,把这些客户端临时限制为 TLS 1.2。只检查服务器的入站 HTTPS、重装 CA 或关闭会话票据,都会漏掉这次问题。要理解原因,得先看 TLS 原本在什么条件下可以省略证书检查,再看源码怎样漏掉了这个前提。

1 TLS 什么时候可以不再发送证书

TLS 1.3 的完整握手通常同时完成两件事:协商本次连接的密钥,验证服务器身份。临时密钥交换让双方算出共同秘密;服务器证书及 CertificateVerify 签名,则把这次握手关联到可信身份。后面的 Finished 用握手密钥核对已经交换的消息。知道本次密钥的人能计算这个核对值,身份是否可信还依赖此前正确完成的认证步骤。

再次连接时,客户端可以使用上次连接留下的会话票据及关联 PSK,也就是预共享密钥。这个握手可以省去新的证书交换,因为认证依据来自双方已经持有的秘密。客户端先列出愿意使用的 PSK 身份,服务器再用 selected_identity 指明选中了哪一项。它是客户端候选列表中的索引,不是一把由服务器随意指定的新密钥。

RFC 8446 第 4.2.11 节因此要求客户端检查这个索引是否落在自己提供的范围内。客户端一项都没提供,服务器就没有任何合法项可以选。收到不符合条件的选择时,握手应以 illegal_parameter 终止。这个检查把“服务器说要恢复会话”与“客户端确实有对应的认证材料”接在了一起。

被省略的证书步骤有一个前提:已经协商了可用 PSK。修复前的 OTP 把扩展的出现当成这个前提已经成立,随后又允许取密钥失败的路径继续运行。恢复会话的标志和实际使用的密钥失去了对应关系,握手于是同时缺少证书认证和真实 PSK 认证。

2 一处决定跳过证书,另一处返回默认值

下面以修复前提交 751f87b703fe5948607d08e82599ce644b772e76 为固定观察点。ssl_handshake.erl 的扩展解码分支先把 ServerHello 中的选择解析成 #pre_shared_key_server_hello{selected_identity = Identity}。这里检查数据形状和重复扩展,尚未判断客户端是否提供过相应身份。这个判断需要结合客户端保存的状态。

随后,handle_server_hello/2 的 794–805 行取出扩展,在版本与握手重试检查之后交给 handle_resumption/2。后者只有两个分支:扩展不存在时保持原状态;存在时把 handshake_env.resumption 设为 true这五行代码没有读取客户端的候选列表。

%% 修复前:扩展存在就设置恢复会话标志
handle_resumption(State, undefined) ->
    State;
handle_resumption(#state{handshake_env = HSEnv0} = State, _) ->
    HSEnv = HSEnv0#handshake_env{resumption = true},
    State#state{handshake_env = HSEnv}.

程序接着检查密码套件与密钥交换参数,直到 handle_server_hello/2 的 831 行才调用 get_pre_shared_key/4 取得 PSK。麻烦出在这个函数把两种不同情况都返回成了零值:服务器没有选择 PSK,以及服务器选择了 PSK、客户端却没有可用票据。1147–1153 行可以直接看到这个合并;manualauto 的无票据分支也有相同的回退。

%% 修复前的两个分支节选
%% 第四个参数 undefined:服务器没有选择 PSK
get_pre_shared_key(_, _, HKDFAlgo, undefined) ->
    {ok, binary:copy(<<0>>, ssl_cipher:hash_size(HKDFAlgo))};
%% 第二个参数 undefined:客户端没有可用票据
get_pre_shared_key(_, undefined, HKDFAlgo, _) ->
    {ok, binary:copy(<<0>>, ssl_cipher:hash_size(HKDFAlgo))};

这个零值是密钥派生中“没有 PSK”时的输入,长度由哈希算法决定,符合完整握手的正常计算方式。最终流量密钥还包含其他材料:OTP 的 calculate_handshake_secrets/5 及下游函数会加入本次临时密钥交换的结果。一个自己参与密钥交换的对端,可以得到同样的握手秘密,因此仍能完成 Finished 校验。密码计算正常进行,服务器身份检查却已经被省略。

与此同时,先前写入的 resumption = true 仍留在状态中。客户端处理完 EncryptedExtensions 后,handle_encrypted_extensions/2调用 maybe_resumption/1。标志为真时,下一状态直接变成 wait_finished,正常的 wait_cert_crwait_certwait_cv 证书处理路径因此被绕开。

这里的返回值写成 {error, {State, wait_finished}},很容易被误读为握手已经失败。实际是内部控制流:do_maybe/0把它抛出,外层 catch 再取出下一状态继续执行。这个 error 元组不是发给应用的 TLS 错误。证书链与签名验证分别位于被跳过的 process_certificate/2verify_certificate_verify/2;配置中的 verify_peer 没有机会在这些步骤发挥作用。

完整握手经过证书和签名验证;漏洞路径提前设置恢复标志并使用零 PSK 输入,跳过证书状态后继续校验 Finished;修复后先检查 PSK 选择,没有对应候选就终止。

左右滑动查看图中说明。

图 1:关键状态路径示意,省略普通握手消息与无关检查。图中的零值仅指 PSK 输入,流量密钥仍由临时密钥交换等材料派生。合法的 PSK 恢复会话另有认证依据,不属于中间这条错误路径。

最后,wait_finished/3照常检查 Finished、派生应用流量密钥并进入连接状态。应用看到成功返回,接着发送令牌或请求正文,就已经晚于应当拒绝对端的位置。这也解释了为什么“抓包里有加密流量”“握手没有报错”都不能完成这次验收。

3 补丁先否决错误选择,再改变状态

修复提交 afec515在 2026 年 9 月 18 日完成,随后进入 OTP 29.1.1。它保留“服务器没有选择 PSK”的正常零值分支,把其余本地无候选却收到选择的情况改成致命 illegal_parameter修复后的完整选择逻辑同时覆盖未配置票据、缺少票据及手动、自动模式的无匹配分支;自动模式在退出前仍释放票据锁。

%% 修复后:收到 PSK 选择,但客户端没有可用票据
get_pre_shared_key(_, undefined, _, ServerPSK) ->
    {error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER,
                     {unsolicited_pre_shared_key, ServerPSK})};

这项拒绝已经足以阻断错误路径。补丁还把 handle_resumption/2 移到 get_pre_shared_key/4 成功之后,见 827–846 行。现在程序先确认对端选择可以被接受,再允许它改变后续认证方式。遇到未请求的 PSK,客户端会在 wait_sh 中终止,还没走到握手秘密派生和证书状态选择。

这条路径可以追溯到 2019 年。提交 21b8a1b为客户端加入 PSK 恢复会话,并增加从 wait_ee 直达 wait_finished 的转换;官方把受影响起点定为 OTP 22.2。今天的代码经历过拆分和重构,早期提交用于确认功能起点,本文的具体行号则固定在修复前后两个提交,不能拿其中一份行号套所有历史版本。

上游还加入了 tls13_reject_unsolicited_psk 回归测试,覆盖默认关闭票据和自动模式空票据库。断言要求客户端返回 illegal_parameter;成功连接、超时或其他错误都不能算通过。维护者在提交说明中记录了修复前成功连接、修复后按预期拒绝的对照。本文核对了这些断言及相关源码,没有在本地运行完整 Erlang TLS 复现,也没有把维护者的结果写成自己的实验。

4 优先找出站客户端,再更新实际运行的 OTP

官方公告列出的入口是协商 TLS 1.3 的 ssl:connect 消费者,包括 HTTPS 的 httpc、数据库和消息客户端,以及 TLS 分布式连接的发起端。排查要落实到进程、实际 OTP 运行时和 TLS 连接目标。Elixir 应用也要看具体库最终是否调用 OTP ssl;仅凭语言或框架名无法判定每条连接。

受影响客户端不需要开启票据缓存。默认的 session_tickets = disabled 就在范围内,manualauto 在没有票据可提供时同样受影响,例如首次连接。对端需要能够回答这次连接:可以是应用连接的恶意主机,也可以是能截获并回应连接的在途攻击者。一个只接受入站 TLS、从不通过受影响路径发起连接的角色,不会仅因使用 OTP 就触发本文的客户端错误;同一服务是否还有外部 API、目录、消息或其他出站访问,需要另查。

截至 2026 年 9 月 23 日,官方最新三条修复发行线如下。本次首修版本恰好也是各分支当前发布的目标,安装入口与对应 ssl 应用版本放在一起,便于核对打包结果。

现有 OTP 分支当前修复目标所含 ssl 版本与补丁
2929.1.111.7.7;afec515
2828.5.0.711.6.0.6;98c66c8
2727.3.4.1811.2.12.13;fd1d9d0

受影响起点是 OTP 22.2、ssl 9.5;22–26 的旧部署不能因为表里只有 27–29 就被排除。它们需要迁入适用的修复分支,或取得发行商明确覆盖该 CVE 的回补包。版本比较也要按分支进行:OTP 的版本规则把维护分支组织成树,不能用“28 大于 27.3.4.18”证明 OTP 28.0 已修复。操作系统包还可能保留上游旧版本号并回移补丁,应同时查看包发行商的安全记录。

建议优先更新完整、匹配的运行时或厂商镜像,不要只把一个 ssl 目录复制进去。29.1.1 发布说明要求独立应用 ssl 更新时满足 public_key 依赖;28 分支还列出了 cryptopublic_key 条件,27 分支也有自己的下限。同批发布还改变了 SSH 默认连接与通道数量限制,使用 OTP SSH 的部署应把这项兼容变化纳入升级测试,提前评估现有连接规模是否需要调整配置。

在应用实际运行的节点控制台里,可以先读取这些信息。它们是本地诊断调用,不会向外发送 TLS 请求;不要在另一套临时启动的 erl 中执行后,就认为生产节点也相同。

application:get_key(ssl, vsn).
ssl:versions().
code:which(tls_handshake_1_3).
code:module_status(tls_handshake_1_3).
code:module_status(tls_client_connection_1_3).

get_key/2读取应用元信息;返回 undefined 时,应先确认应用是否加载,不能当成安全结果。ssl:versions/0同时列出 ssl_app 和运行环境支持的协议,但具体连接选项仍可覆盖协议列表。module_status/1能发现加载代码与磁盘文件不同的 modified 状态;即使显示 loaded,也只是两者一致,仍要确认它们对应的是修复包。容器、嵌入式 release 和热更新节点尤其需要把包身份、加载路径与滚动更新结果合起来看。

无法立即升级时,官方给出的临时措施是让受影响客户端只使用 TLS 1.2。下面是传入客户端 TLS 选项的那一项,不是服务器监听配置,也不是一条自动修改所有库的全局指令:

{versions, ['tlsv1.2']}

修改时保留原有 verify_peer、可信 CA、服务器名称和应用认证设置,再让连接池建立新连接。代价是暂时失去 TLS 1.3;只接受 TLS 1.3 的对端会无法连接,需要提前找出并安排替代或暂停访问。官方没有提供既保留 TLS 1.3 又消除此漏洞的配置。关闭票据、更换 CA,或在失败后改成 verify_none,都不能替代修复。

更新后按应用的 release 流程重启或滚动替换节点,确认旧实例和需要重建的连接已经退出,再恢复原有 TLS 1.3 策略。如果升级失败,维持经过业务验证的 TLS 1.2 临时设置;旧运行时只能连同这项限制一起回退。能够恢复原 TLS 1.3 策略的本 CVE 修复下限是表中对应版本或有明确回补证据的包,其他安全更新和产品兼容要求仍可能进一步限制回退选择。

5 验收要确认不该成功的连接会失败

本漏洞最容易让测试产生错觉的地方,是旧版本也能顺利连接正常服务器。单测一条成功请求,覆盖不到那个“对方选择了本地没有的身份”的分支。验收应保留正常连接作为对照,再检查错误身份和不成立的恢复条件;对于自编译或回补版本,可以在自有隔离测试环境采用上游回归用例,生产服务不需要接收构造的异常握手。

检查条件应当看到的结果它回答的问题
正常服务器、可信证书、正确名称,新建 TLS 1.3 连接成功,应用请求与响应正常修复后完整握手和业务仍可使用
测试环境中不可信证书或错误服务器名称按原认证策略拒绝更新没有顺手关闭原有身份检查
上游未请求 PSK 回归用例,关闭票据与自动模式空库illegal_parameter,未进入应用连接态错误选择确实在 ServerHello 阶段被拒绝;超时不算通过
从正常认证连接取得有效票据,再测试合法恢复会话符合业务策略的恢复仍成功没有把所有省略证书的握手一律判错
临时 TLS 1.2 设置与更新后的 TLS 1.3 恢复新连接协商预期协议,证书和应用认证仍生效限制作用于真实客户端,撤除也覆盖连接池和所有节点

上表是验收建议,不是本文执行过的测试记录。上游新增用例重点覆盖默认模式和自动空库,并固定了密码套件与密钥交换组;它不能替代每个应用的代理设置、主机名规则、连接池和合法会话恢复测试。对成品包用户,包发行商的修复确认、真实运行身份与正常认证的正反对照,通常比自行改造握手测试更适合上线验收。

调查历史连接时,官方指出两个值得保留的观测:明明要求验证对端,ssl:peercert/1 却返回 {error, no_peercert};客户端此前没有票据,连接信息却报告 {session_resumption, true}。应把它们与当时的客户端配置、票据状态、目标地址和应用日志一起判断。单独看到“恢复会话”并不异常,合法恢复本来就会省略证书交换;没有保存这些信息,也不能据此排除过去发生过问题。

若已有证据表明敏感请求被交给异常对端,后续处置应检查在该连接上发送的令牌、凭据与业务操作,撤销相关凭据并核对返回数据带来的影响。漏洞修复阻止新的错误认证,不会收回已经发出的秘密。处置范围由实际连接和数据决定,不必把它扩大成没有依据的全站证书重签。

本次研究截至 2026 年 9 月 23 日 02:00 UTC。EEF 给出的 CVSS 4.0 是 9.3;NVD 当前记录状态为 Deferred,展示的是 EEF 的分数与 CWE-322,没有另一份 NIST 独立评分。已读取的 2026.09.22 版 CISA KEV未收录本 CVE,CISA 在 CNA 中的 9 月 22 日 SSVC 记录为 Exploitation: none。这些是明确时间和来源下的观察,文章不据此宣称现实中绝无利用,也不把“严重”评分换算成受害规模。

这次补丁值得留下的检查方法,是把“允许省略认证的状态”追溯到它的依据。一个对端字段可以提出恢复会话,客户端必须先确认自己持有相应的 PSK,才能省去证书验证。升级也应验收到这一层:修复包已在实际节点运行,错误身份被拒绝,正常连接与合法恢复仍然可用。三项都成立,这次更新才恢复了应用原本依赖的认证保证。

研究依据

研究依据静态追踪修复前提交 751f87b 的 TLS 1.3 客户端扩展解析、PSK 选择、会话恢复与证书状态,比较 afec515、98c66c8、fd1d9d0 三分支补丁及上游回归断言,核对 OTP 27、28、29 当前发行记录。未运行 Erlang 漏洞复现,未探测外部服务。官方状态核对截至 2026-09-23 02:00 UTC。

来源Erlang/OTP 官方公告、固定源码与三分支补丁;RFC 8446;EEF CNA、NVD 与 CISA KEV

证据置信度

6证据与来源

6.1时间线

  1. 客户端 PSK 恢复加入源码

    21b8a1b 加入相应状态转换;官方受影响起点为 OTP 22.2。

  2. 三分支修复提交

    afec515、98c66c8、fd1d9d0 拒绝未请求的 PSK 选择,并调整恢复状态设置顺序。

  3. 公告与修复版本发布

    OTP 29.1.1、28.5.0.7、27.3.4.18 发布;EEF 公布 CVE-2026-89422。

6.2来源与材料

  1. Erlang/OTP 官方安全公告 GHSA-rgxr-4g4w-j875https://github.com/erlang/otp/security/advisories/GHSA-rgxr-4g4w-j875
  2. EEF CNA 原始记录与 CISA 补充信息https://cveawg.mitre.org/api/cve/CVE-2026-89422
  3. OTP 29 修复与回归测试https://github.com/erlang/otp/commit/afec5156361bb50d3607c7c1a453c19b9149b324
  4. OTP 28 修复https://github.com/erlang/otp/commit/98c66c858113949c4262d26cd7d426c4b09d2b35
  5. OTP 27 修复https://github.com/erlang/otp/commit/fd1d9d07fc92ec0d59f96dfb66182195882bb7dd
  6. RFC 8446:PSK 选择规则https://www.rfc-editor.org/rfc/rfc8446.html#section-4.2.11
  7. RFC 8446:密钥派生https://www.rfc-editor.org/rfc/rfc8446.html#section-7.1
  8. OTP 版本比较规则https://www.erlang.org/doc/system/versions.html#order-of-versions
  9. OTP ssl 接口与客户端选项https://www.erlang.org/doc/apps/ssl/ssl.html
  10. NVD 记录与变更历史入口https://nvd.nist.gov/vuln/detail/CVE-2026-89422