漏洞
NGINX CVE-2026-90439:握手中途的数组越界
NGINX 的 HTTP/3 兼容处理曾让一次 TLS 握手先按旧配置分配扩展数组,再按 SNI 选出的新配置遍历,导致有限堆溢出;使用受影响构建并启用 QUIC 的部署应升级 NGINX,等待修复时停用 QUIC 监听并保留 TCP HTTPS。

文章导航
同一台 NGINX 上,有的站点开放 HTTP/3,有的只提供普通 HTTPS,这种配置很容易理解。麻烦在于,NGINX 会把这些站点的 TLS 设置交给同一个库处理,而一次握手又可能先后使用两份设置。CVE-2026-90439 就出在切换的中间:OpenSSL 已经按第一份设置分配好扩展数组,随后 NGINX 根据客户端要访问的域名换了配置,OpenSSL 再按第二份设置决定遍历多少项。后一份多了一项,原来的数组却没有变大。
NGINX 在 2026 年 9 月 15 日发布了 1.30.5 和 1.31.6 修复。F5 对这项漏洞的判断是有限、非确定性的堆溢出,可能使 worker 重启或造成有限数据损坏。对运营者来说,最有用的处置顺序是先核对实际构建和 TLS 配置,再安装所在分支的更新;来不及更新时,关闭 QUIC 监听,让原有 TCP HTTPS 继续工作。没有必要因为“堆溢出”三个字就宣布服务器已被接管,也不能因为分数是中危,就跳过面向公网的受影响实例。
下面的解释来自公开补丁和固定源码。我们核对了 NGINX 的引入版本、修复前后两个分支,以及 OpenSSL 3.5.0 的握手处理;没有运行完整 NGINX 漏洞复现。因此,文章能说明数组为什么会失去边界、修复改变了什么,不能给出真实部署的触发概率或攻击成功率。
1 先弄清一次连接会用到哪份配置
HTTPS 握手开始时,服务器还没收到 HTTP 的 Host 请求头。客户端通常在更早的 TLS ClientHello 中带上 SNI,也就是准备访问的服务器名称。NGINX 先使用监听地址的默认站点配置,读到 SNI 后,再选择相应虚拟主机的证书、协议和其他 TLS 设置。这个切换本身是正常功能,多域名共用一个地址就依靠它。
在 OpenSSL 里,SSL_CTX 保存一组可供连接使用的 TLS 配置。这里尤其重要的是它登记了哪些自定义扩展。HTTP/3 基于 QUIC,而 QUIC 的传输参数通过 TLS 扩展交换。NGINX 使用旧版 OpenSSL 的兼容层时,会为这个扩展登记处理函数;它的类型值是 0x39,登记位置在 1.31.5 的 ngx_quic_compat_init()。这是一项握手扩展,和 HTTP 请求头、URL 参数没有关系。
修复前,NGINX 只对能够通过 QUIC 到达的虚拟主机登记它。ngx_http_ssl_init() 的初始化分支明确受 addr[a].opt.quic 控制,再由兼容层初始化函数处理该地址上的站点。于是,同一个进程中的两份 TLS 配置,可能有不同的自定义扩展数量。
这也限定了本漏洞的范围。F5 的 CNA 记录指向 ngx_http_v3_module、OpenSSL 3.5.0 及更早版本和特定配置。HTTP/3 模块需要构建支持,QUIC 监听也需要显式配置;模块文档中的 http3 on 默认值,并不意味着每个 NGINX 安装都自动开放 HTTP/3。判断时要看运行中的二进制、实际 TLS 库,以及包含所有 include 后的有效配置。
OpenSSL 3.5.1 是这里一个容易漏掉的分界。NGINX 1.31.5 的 QUIC 头文件从这个版本起选择原生 OpenSSL QUIC API;旧版走本文讨论的兼容路径,BoringSSL、LibreSSL、QuicTLS 等另有分支。官方构建说明因此推荐 OpenSSL 3.5.1 或更新版本。这个数字说明 API 选择的下限,不是让所有人在今天统一安装 3.5.1;生产环境仍应采用其发行商当前维护的 TLS 包,并升级 NGINX 本身。
2 数组分配完了,计数却换了一份
先跟着 OpenSSL 收到的一份 ClientHello 往下走。OpenSSL 把“收集扩展”和“解释扩展”分成了两个阶段。收集时,它为认识的每一种扩展留一个 RAW_EXTENSION 槽位,保存相应原始数据和状态;并非客户端发来几个扩展,就只分配几个槽位。
OpenSSL 3.5.0 的 tls_collect_extensions()把内建扩展数加上当前配置的自定义扩展数,再分配内存。为方便说明,把内建部分记作 B。如果初始配置没有登记 QUIC 扩展,这时只分配 B 个槽位;登记了,就需要 B + 1 个。这里的 B 是解释用符号,实际值由 OpenSSL 的构建决定。
/* OpenSSL 3.5.0,收集阶段的代码节选 */
num_exts = OSSL_NELEM(ext_defs) + (exts != NULL ? exts->meths_count : 0);
raw_extensions = OPENSSL_zalloc(num_exts * sizeof(*raw_extensions));
这个数组存入 clienthello->pre_proc_exts 后,OpenSSL 才调用应用登记的 ClientHello 回调。两步可以在 statem_srvr.c 的 1680–1733 行对上。NGINX 正是在这个回调里读取 SNI:ngx_ssl_client_hello_callback()先检查名称编码,再交给 ngx_http_ssl_servername() 选择站点。
找到站点后,NGINX 在 983 行调用 SSL_set_SSL_CTX()。这不只是换一张证书。OpenSSL 会复制目标上下文的 CERT 配置,把连接上的 sc->cert 换成新对象;自定义扩展方法表也属于这份配置。该函数的完整关键分支没有重新分配刚才的 pre_proc_exts。
复制过程没有保住旧计数。ssl_cert_dup()会复制目标上下文的自定义扩展表和数量;紧接着的 custom_exts_copy_flags()只复制两边共有扩展的状态标志,没有把新表的项数改回去。连接里因此留下了来源不同的两件东西:数组来自默认配置,扩展方法表来自 SNI 选出的配置。
OpenSSL 随后继续握手,直到 1963 行开始解析全部扩展。tls_parse_all_extensions()没有沿用收集时保存的数组长度,而是重新读取当前 s->cert->custext.meths_count,算出循环次数。
/* OpenSSL 3.5.0,解析阶段的代码节选 */
numexts += s->cert->custext.meths_count;
for (i = 0; i < numexts; i++) {
if (!tls_parse_extension(s, i, context, exts, x, chainidx)) {
return 0;
}
}
如果切换前后是 0 → 1 个自定义扩展,循环就会尝试访问第 B + 1 项,而数组只有 B 项。反过来的 1 → 0 只会少遍历;两边都为 0,或者都登记了同一项,也不会由这个数量差产生越界。错误的方向因此很具体,并不是只要换过 SNI 就会发生。
真正读写越界位置的是 tls_parse_extension()。它先取得 &exts[idx],读 present 和 parsed,满足条件时再写入 parsed = 1。因此,不能把“多访问一个槽位”理解成“稳定多写一个由客户端任意指定的字节”。此时读到的状态已经位于数组之外,后续是否进入写入和解析取决于那片内存;F5 所说的非确定性和有限数据损坏,与这条源码路径相符。

左右滑动查看图中说明。
这条路径还有正常的退出条件。名称编码错误、虚拟主机查找发生内部错误、ssl_reject_handshake 拒绝、协议或密码组不匹配,都可能让握手在全扩展遍历之前结束;仅仅没有匹配到名称,则可能继续使用默认站点。它们没有修正数组长度与配置计数的关系,正常通过相关检查的连接仍须面对后面的错误假设。
从源码看,排查范围也不该只剩 UDP。一个只供 TCP 使用的默认站点,可以和同时供 QUIC 使用的站点共享 TLS 地址选择流程;后者的上下文已经登记了扩展,SNI 切换仍可能改变计数。HTTP/3 配置制造了这项差异,后续使用这些上下文的 TLS 握手同样需要检查。本文没有对这种配置做真实触发实验,因此不把它写成已测攻击案例;但它足以说明,单独在防火墙封住 UDP 并不能证明进程里的不一致已经消失。
3 补丁统一了配置,没有取消 SNI
为什么这个问题从 NGINX 1.29.2 开始?引入提交把基于 SNI 的站点选择移到了早期 ClientHello 回调。它原本要解决实际问题:先选定站点,再决定会话恢复和协议版本,让不同站点的客户端证书验证、ssl_protocols 设置按正确顺序生效。1.29.1 的 SSL 模块尚未登记这一早期回调,1.29.2 的同一位置已经增加它。这项时序改进,让原有的扩展登记差异进入了“收集之后、解析之前”的窗口。
9 月 15 日的主要修复保留了提前选站点的行为。它先看整个 HTTP 配置是否存在 QUIC 监听;只要存在,就为相关 TLS 上下文统一登记同一个 QUIC 传输参数扩展,包括不能从 QUIC 到达的站点。1.31.6 的初始化代码把登记动作移出了原来只处理 QUIC 地址的分支。这样,切换站点之前和之后都给这项扩展留好了槽位。
“登记处理方法”和“启用协议”仍然分开。新的兼容层初始化把扩展登记与 QUIC 使用的 keylog 回调拆开,后者仍只放在 QUIC 地址上。修复没有给普通 HTTPS 站点偷偷开一个 UDP 监听,也没有把 TCP 连接改成 QUIC。
同批次还有一处很小但必要的兼容性调整。当普通 TCP TLS 连接收到这个扩展时,解析回调现在直接返回成功并忽略它,避免新增登记让普通握手因为这一扩展被拒绝。发送回调仍在非数据报连接上返回 0,不发送 QUIC 参数。两个回调的返回值职责不同,不能只看见一个 0 改成 1,就概括为“关闭了校验”。
稳定分支也有同样修复。我们核对了 1.30.4 的旧分支与 1.30.5 的新初始化,以及 1.31.5、1.31.6 的对应实现。其余中间发行版的完整运行行为没有逐版测试;连续受影响范围采用 NGINX 公告,源码快照用于确认起点、代表性旧版和两个修复分支。
4 升级 NGINX,等待时保留 TCP HTTPS
截至 2026 年 9 月 22 日,NGINX 官方发布页的最新 stable 和 mainline 分别是 1.30.5、1.31.6,恰好也是本漏洞的首个修复版本。1.29.2 是受影响起点,公告覆盖至 1.31.5,并单列 1.30.5 已修复。不能把这几个版本压成一条不考虑分支的数字比较,否则连稳定分支的修复包也可能被误判。
| 产品与分支 | 公告列出的受影响版本 | 修复目标 |
|---|---|---|
| NGINX Open Source | 1.29.2–1.31.5;stable 单列 1.30.4,1.30.5 已修复 | stable 1.30.5;mainline 1.31.6 |
| NGINX Plus 37.0 / 37.1 | 37.0.0–37.0.6;37.1.0–37.1.1 | 37.0.6.2 LTS;37.1.1.2 CR |
| F5 NGINX Ingress Controller,2026-lts | 2026-lts-r1–2026-lts-r7 | 2026-lts-r8 |
| F5 NGINX Ingress Controller,5.x | 5.0.0–5.6.2 | 5.6.3 |
| F5 NGINX Ingress Controller,4.x / 3.x | 4.0.0–4.0.1;3.7.0–3.7.2 | 这两条分支无修复包;迁入有修复的分支 |
| NGINX Gateway Fabric,2.x | 2.2.2–2.7.1 | 2.7.2 |
| NGINX Instance Manager,2.x | 2.21.1–2.23.0 | 2.23.1 |
上表采用 F5 9 月 18 日更新的产品表。它比 9 月 16 日的 CNA 记录多出 Ingress Controller、Gateway Fabric 和 Instance Manager;只同步 CVE JSON 会漏掉这些交付形式。Plus 公告使用三段版本概括受影响范围,安装时要核对完整包号,当前发行页明确列出 37.0.6.2 LTS 和 37.1.1.2 CR。F5 只评估仍在技术支持期内的版本,表里没有的旧版本不能据此判为不受影响。
这里的 Ingress Controller 指 F5 的产品,不能与社区 ingress-nginx 混为一谈。Controller、Gateway 和 Instance Manager 使用各自的版本与升级路径,不适合自行替换内部一个 NGINX 二进制。安装包、容器镜像、动态模块和管理组件需要保持匹配;发行版回补则以对应包公告为准。版本相似,不能替代产品身份。
先在相应主机或容器里确认自己查的是哪一个安装。下面两条是本地检查命令,不会向网站发送漏洞请求:
nginx -V
nginx -T
-V提供所执行程序的版本与构建参数;官方 QUIC 排障说明也要求用它核对 TLS 库。-T 检查并展开配置,适合寻找 listen ... quic、默认站点和 SNI 名称。它可能输出内部地址或配置中的敏感值,结果留在本地。服务使用自定义 -c、-p、容器或其他二进制路径时,检查命令要对应服务的实际启动方式,不能拿终端里另一份 nginx 替运行进程签字。
来不及更新时,采用现行 F5 公告中的缓解:停用所有 QUIC 监听,保留原有 TCP HTTPS。较早的 CNA 记录还列过“给所有站点启用 QUIC”,现行公告已不再提供这条建议。它会改变原本不提供 HTTP/3 的站点和网络入口,不应继续当成今天的默认备选。
停用时需要看完整配置。假如一个站点已经分别有 listen 443 quic reuseport; 和 listen 443 ssl;,应移除前一条,保留后一条。直接从第一行删掉 quic,可能留下意外的 TCP 监听或与现有监听冲突。IPv4、IPv6、其他端口和被 include 引入的站点都要检查,不能只改眼前这一个 server。
# 在已有 TCP HTTPS 监听的站点中,移除这一条 QUIC 监听
# listen 443 quic reuseport;
# 保留原有的 TCP HTTPS 监听及证书配置
listen 443 ssl;
同时停止发布已不可用的 HTTP/3 Alt-Svc 通告,并确认客户端的 TCP 回退正常。通告调整能减少客户端继续尝试旧入口,但它本身不会关闭监听。只设置 http3 off 也不等于完成这项缓解:该指令控制协议协商,没有从有效配置里移除 QUIC 地址和它造成的上下文登记。临时停用的代价是失去 HTTP/3,部分客户端的连接体验会变化;原有 HTTP/1.1、HTTP/2 over TLS 能否继续使用,仍以现有配置和实际业务测试为准。
提交配置后先运行 nginx -t,再按该部署的服务管理方式重载,并检查结果。NGINX 在新配置无法应用时会继续使用旧配置,所以命令已执行、进程还活着,都不能证明 QUIC 已经停用。若升级失败,保持已经验证的临时配置;需要恢复旧镜像时,也保留这项缓解,不把旧程序和旧 QUIC 配置一起恢复到公网。就本 CVE 而言,能够重新开放原有 QUIC 配置的最低修复版本是 stable 1.30.5、mainline 1.31.6,Plus 则为上表中的对应版本;其他漏洞和模块兼容性仍会进一步限制回退选择。
5 验收要覆盖握手和实际运行的程序
这个补丁最值得测试的地方,是同一监听地址上的不同站点。只打开首页一次,通常只覆盖一个名称、一种协议和一次新连接;数组计数恰好一致时,旧版本也可能表现正常。修复验收应保留原有站点关系,用正常客户端覆盖默认站点、各 SNI 名称、无 SNI 或未知名称的既定处理,分别确认 TCP TLS 与需要恢复的 QUIC 行为。证书、协议版本、会话恢复及客户端证书验证有不同站点策略时,也应按原策略验证,避免修内存错误时破坏提前选择配置的原始目的。
可以把验收收束成三个实际结果:
- 运行对象正确。每个服务节点和容器实际加载修复后的程序、TLS 库及兼容模块;滚动更新结束后,旧 worker 和旧实例已经退出。配置重载与二进制升级是两件事,单独一次
reload不能证明进程换成了新程序。 - 业务路径正确。正常 TCP HTTPS、需要的 HTTP/3、不同域名的证书和认证策略均按预期工作。临时停用期间,QUIC 监听确实从生效配置消失,TCP 回退已经检查;永久更新通过后,才恢复原有 QUIC 范围和通告。
- 源码回归覆盖变化处。维护自编译版本或补丁分支的团队,在自有隔离环境检查不同上下文切换前后的扩展登记一致性,并检查普通 TLS 收到 QUIC 扩展时的忽略行为。比较修复前后时保持 TLS 库、配置和构建选项不变,才有助于判断是哪项变化消除了问题。
上述是针对这次补丁的验收建议,本文没有声称已经在完整 NGINX 上执行这些回归。源码能告诉我们需要保持什么关系,真实服务是否满足它,还要由实际运行版本、配置和测试共同确认。对于使用厂商成品包的团队,优先完成前两项,并采用厂商提供的补丁验证方法;不必为了证明认真,向生产服务发送可能使进程崩溃的输入。
调查已有异常时,关注 worker 退出、重启、内存错误和握手失败的时间关联。问题发生在 HTTP 请求之前,访问日志里没有对应 URL 并不奇怪;已有错误日志、服务管理记录和受保护的崩溃记录可能更有用。反过来,worker 重启也有很多原因,不能只凭一条退出记录认定遭到本漏洞利用。Core dump 可能包含连接数据和密钥材料,应按敏感文件保管,不直接上传到公开问题区。
截至本次 2026 年 9 月 22 日 UTC 检索,CISA 的 2026.09.21 版 KEV没有收录 CVE-2026-90439;这只说明该目录当时未收录。NVD仍处于 Awaiting Analysis,其中 6.5 的 CVSS 3.1 和 6.9 的 CVSS 4.0 均来自 F5,不是 NIST 另做的一次评分。F5 给出的后果局限于数据面上的拒绝服务或有限数据损坏;现有材料没有支持可靠远程代码执行,也没有支持因为这个漏洞统一轮换所有证书和密钥。
这次修复留下的工程经验很具体。把配置切换提前以后,需要一起检查那些已经按旧配置创建的对象。数组、缓存、会话和回调表都可能活得比原先的配置假设更久。NGINX 保留了有用的 SNI 处理顺序,同时让扩展登记在切换前后一致。对部署者,完成这件事也不复杂:确认是哪条 TLS 路径,运行正确的补丁,保留原本需要的 HTTPS 服务,再把临时改动有据可查地撤掉。
研究依据
研究依据静态核对 NGINX 1.29.1、1.29.2、1.30.4、1.30.5、1.31.5、1.31.6 的相关源码及引入、修复提交,追踪 OpenSSL 3.5.0 中 ClientHello、SSL_CTX 切换和扩展数组的关系。未运行完整 NGINX 漏洞复现,未测量部署普及率、崩溃概率或代码执行,未探测外部服务。官方状态检索截至 2026-09-22 UTC。
来源NGINX 与 F5 官方公告、固定发行源码及补丁;OpenSSL 3.5.0 源码;CVE CNA、NVD 与 CISA KEV
证据置信度 高
6证据与来源
6.1时间线
- SNI 选择提前
早期 ClientHello 回调进入 NGINX,1.29.2 发行源码包含该行为。
- 漏洞与修复公开
NGINX 发布 1.30.5、1.31.6;F5 公布 CVE-2026-90439,致谢报告者 Banny Liao。
- 产品范围与缓解更新
CNA 于 16 日更新;F5 18 日公告列出更多受影响产品,并以停用 QUIC 监听作为缓解。
6.2来源与材料
- NGINX 官方安全公告与修复版本https://nginx.org/en/security_advisories.html
- F5 公告 K000162604https://my.f5.com/manage/s/article/K000162604
- F5 提交的 CNA 原始记录https://cveawg.mitre.org/api/cve/CVE-2026-90439
- NGINX:在相关 SSL 上下文统一登记扩展https://github.com/nginx/nginx/commit/7c7363266d54bc3836c1d13d1ed1e1d93ee9ed98
- NGINX:普通 TLS 忽略 QUIC 扩展https://github.com/nginx/nginx/commit/22ba5662440d2865153ac1ff7de5f2b171414346
- OpenSSL 3.5.0 扩展收集与解析源码https://github.com/openssl/openssl/blob/636dfadc70ce26f2473870570bfd9ec352806b1d/ssl/statem/extensions.c
- OpenSSL 3.5.0 的 SSL_set_SSL_CTXhttps://github.com/openssl/openssl/blob/636dfadc70ce26f2473870570bfd9ec352806b1d/ssl/ssl_lib.c#L5453-L5504
- NGINX QUIC 构建、配置与排障https://nginx.org/en/docs/quic.html
- NGINX 配置重载与程序升级https://nginx.org/en/docs/control.html
- NVD 记录与变更入口https://nvd.nist.gov/vuln/detail/CVE-2026-90439