利用一个18年前的漏洞实现NGINX远程代码执行
摘要
研究人员利用自动化系统发现NGINX重写模块中一个自2008年以来存在的严重堆缓冲区溢出漏洞(CVE-2026-42945),可实现远程代码执行。多个CVE已获NGINX确认。
<p><a href="https://lobste.rs/s/xnoqe8/achieving_nginx_remote_code_execution">评论</a></p>
查看缓存全文
缓存时间: 2026/05/13 22:20
# NGINX Rift:通过一个 18 年历史漏洞实现 NGINX 远程代码执行 | depthfirst
来源:https://depthfirst.com/research/nginx-rift-achieving-nginx-rce-via-an-18-year-old-vulnerability
**TLDR:我们利用 depthfirst 的系统对 NGINX 源码进行了分析,系统自主发现了 4 个远程内存破坏问题,其中包括一个从 2008 年引入的严重堆缓冲区溢出漏洞。我们进一步研究了这些漏洞的可利用性,并开发了一个在 ASLR 关闭状态下演示 RCE 的工作概念验证。如果你的 NGINX 配置中使用了 `rewrite` 和 `set` 指令,你正面临风险。**
四月中旬,我和一位同事聊到了我们基础设施中最脆弱的环节。由于我们的大多数服务完全运行在内部网络中,我们的应用平台是唯一暴露在外的表面。他开玩笑说,如果能在我们的 web 服务上实现远程代码执行,那就等于完全攻破了 depthfirst。攻破 web 服务本身并不是我通常关注的重点。然而,攻破底层 web 服务器的想法引起了我的兴趣,于是我把目光投向了 NGINX。
NGINX 是目前最流行的 Web 服务器,为全球近三分之一的网站提供支持 (https://w3techs.com/technologies/overview/web_server)。其高性能架构使其在处理海量网络流量方面成为无可争议的领导者。从提供静态内容到充当关键的反向代理,它位于现代互联网的关键边缘。因此,这个核心基础设施中的单个漏洞可能会使无数的后端系统面临严重风险。
我们内部有一个专门分析底层软件的自���化系统。分析 NGINX 只需要单击一下即可接入仓库并触发分析。经过六小时的扫描,系统识别出了 5 个安全问题,其中包括一个高严重性发现——这是在处理 NGINX 的 `rewrite` 指令时出现的堆溢出问题。
**depthfirst 系统在 NGINX 中识别出了 4 个远程内存破坏问题**
**图 1:depthfirst 系统在 NGINX 中识别出了 4 个远程内存破坏问题。**
在简要审查了这些发现后,我们通过 GitHub 安全公告向 NGINX 报告了这些问题。对于每个发现,我们提供了详细的漏洞描述、根本原因分析以及由系统直接生成的概念验证。其中 4 个发现得到了 NGINX 的确认:
- **CVE‑2026‑42945**(严重,CVSS 9.2):`ngx_http_rewrite_module` 中的堆缓冲区溢出问题,在 `rewrite` 和 `set` 序列期间未传播的 `is_args` 标志导致缓冲区分配尺寸不足。随后的复制阶段将攻击者控制的转义 URI 数据写入堆边界之外,从而导致 RCE。
- **CVE‑2026‑42946**(高危,CVSS 8.3):`ngx_http_scgi_module` 和 `ngx_http_uwsgi_module` 中的过度内存分配问题,不完整上游状态行读取后的状态不匹配导致跨缓冲区指针相减。这产生了大约 1 TB 的密钥长度,从而导致工作进程崩溃。
- **CVE‑2026‑40701**(中危,CVSS 6.3):`ngx_http_ssl_module` 中的释放后使用问题,如果在异步 OCSP DNS 解析完成之前 TLS 连接关闭,上下文池会被销毁但不会取消解析器请求。DNS 计时器随后解引用已释放的指针。
- **CVE‑2026‑42934**(中危,CVSS 6.3):`ngx_http_charset_module` 中的越界读取问题,在代理缓冲区边界处处理不完整 UTF‑8 序列时的差一错误会破坏长度状态。这计算出负的源偏移量,从而读取上游缓冲区之前的 2 个字节。
在 4 个已确认的问题中,**CVE‑2026‑42945** 是最关键的。这是一个在 **2008 年**引入的堆缓冲区溢出漏洞,影响 **NGINX 0.6.27 至 1.30.0 版本**。鉴于其严重性且已存在 18 年,我们决定对其进行深入调查。
## CVE‑2026‑42945
该漏洞需要 `rewrite` 和 `set` 指令才能触发,但这些指令是什么呢?
想象一下,你正在将传统 API 迁移到新系统。你需要无缝地将传入请求路由到新端点。`rewrite` 指令允许你动态修改请求路径。但是,你的后端应用程序可能仍然需要知道原始请求路径。这正是 `set` 指令至关重要的地方。它允许你在重写发生之前将原始路径捕获并存储到自定义变量中。这两个指令共同构成了 API 网关配置中的常见构建块。
`rewrite` 指令根据正则表达式更改请求 URI。当请求匹配指定模式时,NGINX 会用新字符串替换 URI。例如,`rewrite ^/api/(.*)$ /v2/api/$1` 会获取括号中匹配的部分(捕获组)并使用 `$1` 变量将其附加到新路径。如果替换字符串包含问号,NGINX 会将字符串的其余部分视为查询字符串,并将原始请求参数附加到其后。
`set` 指令用于为自定义变量赋值。这在实践中非常有用,可用于临时存储原始请求的部分内容、动态路由端点或在后续重写改变 URI 之前在整个请求生命周期中维护状态。与 `rewrite` 指令类似,它也可以引用最近一次正则表达式的捕获组。例如,配置可能使用 `set $original_path $1` 将第一个捕获组的值保存到名为 `original_path` 的变量中。这确保了即使在 URI 被完全重写之后,后端应用程序或访问日志仍能访问原始请求的端点。
在底层,NGINX 使用其脚本引擎优化这些操作。解析配置时,脚本引擎将这些指令编译为一系列操作。在运行时,它通过两遍过程执行它们。第一遍计算最终字符串的总长度,以便从其内存池中分配精确所需的内存。第二遍则执行复制操作,将实际数据写入新分配的缓冲区。这种设计避免了多次小内存分配,但要求第一遍计算出的长度与第二遍写入的数据量完全匹配。如果引擎状态在这两遍之间发生变化,就可能导致内存损坏漏洞。
## 根本原因
如上所述,脚本引擎使用两遍过程。首先,它计算所需内存长度。然后,复制实际数据。堆缓冲区溢出的发生是因为引擎内部状态在这两遍之间发生了变化。
具体来说,漏洞位于 `src/http/ngx_http_script.c` 中。当 `rewrite` 指令的替换字符串包含问号时,`ngx_http_script_start_args_code` 函数会永久地将 `e->is_args = 1` 标志设置在脚本引擎上:
``
void
ngx_http_script_start_args_code(ngx_http_script_engine_t *e)
{
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, e->request->connection->log, 0,
"http script args");
e->is_args = 1;
e->args = e->pos;
e->ip += sizeof(uintptr_t);
}
``
该标志在脚本代码评估之间从未被重置。当后续的 `set` 指令引用正则表达式捕获组时,会触发 `ngx_http_script_complex_value_code` 函数。这就是两遍设计失效的地方。在长度计算阶段,该函数使用一个全新的、完全清零的子引擎,称为 `le`。
``
void
ngx_http_script_complex_value_code(ngx_http_script_engine_t *e)
{
size_t len;
ngx_http_script_engine_t le;
ngx_http_script_len_code_pt lcode;
ngx_http_script_complex_value_code_t *code;
code = (ngx_http_script_complex_value_code_t *) e->ip;
e->ip += sizeof(ngx_http_script_complex_value_code_t);
ngx_log_debug0(NGX_LOG_DEBUG_HTTP, e->request->connection->log, 0,
"http script complex value");
ngx_memzero(&le, sizeof(ngx_http_script_engine_t)); // 完全清零的子引擎
le.ip = code->lengths->elts;
``
因为它被初始化为零,`le.is_args` 为零。长度计算函数 `ngx_http_script_copy_capture_len_code` 检查以下条件以决定是否需要转义:
``
size_t
ngx_http_script_copy_capture_len_code(ngx_http_script_engine_t *e)
{
...
if ((e->is_args || e->quote)
&& (e->request->quoted_uri || e->request->plus_in_uri))
{
p = r->captures_data;
return cap[n + 1] - cap[n]
+ 2 * ngx_escape_uri(NULL, &p[cap[n]], cap[n + 1] - cap[n],
NGX_ESCAPE_ARGS);
} else {
return cap[n + 1] - cap[n];
}
...
``
由于 `le.is_args` 为零,该条件评估为假,进入 `else` 分支,只返回原始未转义的捕获长度。然而,在第二遍复制阶段,复制函数 `ngx_http_script_copy_capture_code` 运行在主引擎上,此时 `e->is_args` 仍然为 `1`。相同的条件现在评估为真,进入不同的逻辑分支:
``
void
ngx_http_script_copy_capture_code(ngx_http_script_engine_t *e)
{
...
if ((e->is_args || e->quote)
&& (e->request->quoted_uri || e->request->plus_in_uri))
{
...
// 溢出发生在这里
// 目标缓冲区 `pos` 是以 `raw_size` 分配的,
// 但 `ngx_escape_uri` 会展开字符并写入
// 更大的 `raw_size + 2 * N` 字节!
e->pos = (u_char *) ngx_escape_uri(pos, &p[cap[n]],
cap[n + 1] - cap[n],
NGX_ESCAPE_ARGS);
} else {
e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);
}
``
它调用带有 `NGX_ESCAPE_ARGS` 标志的 `ngx_escape_uri`。该函数将每个可转义字符(如加号或与号)从 1 字节扩展为 3 字节。
考虑以下触发配置:
``
location ~ ^/api/(.*)$ {
rewrite ^/api/(.*)$ /internal?migrated=true;
set $original_endpoint $1;
}
``
由于意外的状态变化,复制函数将 `raw_size + 2 * N` 字节写入仅为 `raw_size` 字节分配的缓冲区,其中 `N` 是可转义字符的数量。第二遍写入的转义输出变得显著更大。这种不匹配导致数据完全溢出分配的池边界。
## 漏洞利用
幸运的是,NGINX 使用多进程架构,工作进程从单个主进程 fork 出来。由于这种设计,每个子工作进程的内存空间是完全相同的副本。这意味着堆布局在不同工作进程间保持完全确定性。如果我们的漏洞利用失败并导致工作进程崩溃,主进程会以完全相同的内存布局生成一个新的工作进程。这允许我们安全地多次尝试,直到成功,而不用担心工作进程崩溃会改变内存布局。从理论上讲,我们可以利用这种设计逐步覆盖指针,逐字节泄露 ASLR。在本文中,我们假设 ASLR 已被绕过,然后讨论利用技术。
该漏洞为我们提供了一个高度可控的堆缓冲区溢出。通过用加号填充请求 URI,我们可以强制转义函数将每个字节扩展为三个字节,从而溢出已分配的块。溢出的大小完全由我们控制,取决于我们提供的可转义字符数量。然而,我们面临一个主要限制:用于覆盖相邻内存的字节经过了 URI 解析器和转义函数的处理。这意味着我们不能简单地注入任意字节。我们的payload严格限制为 URI 安全字符。那么,在没有空字节的情况下,我们如何构造指针呢?
### 覆盖 ngx_pool
为了将溢出转化为代码执行,我们需要一个可靠的目标。NGINX 使用内存池来管理每个连接和每个请求的分配。内存池由 `ngx_pool_t` 结构体定义,其中包含管理分配器状态的关键元数据。
``
struct ngx_pool_s {
ngx_pool_data_t d;
size_t max;
ngx_pool_t *current;
ngx_chain_t *chain;
ngx_pool_large_t *large;
ngx_pool_cleanup_t *cleanup;
ngx_log_t *log;
};
``
我们的最终目标是覆盖偏移 64 处的 `cleanup` 指针。该字段指向一个 `ngx_pool_cleanup_t` 结构的链表,这些结构持有在池销毁时执行的回调函数指针(`handler`)及其参数(`data`):
``
typedef void (*ngx_pool_cleanup_pt)(void *data);
struct ngx_pool_cleanup_s {
ngx_pool_cleanup_pt handler;
void *data;
ngx_pool_cleanup_t *next;
};
``
然而,由于我们的堆溢出是连续的,我们面临一个重大挑战。要到达 `cleanup` 指针,我们必须首先覆盖池结构中的所有前面字段:`d`、`max`、`current`、`chain` 和 `large`。
用 URI 安全填充字节(如 `%2B`)填充这些元数据字段会完全破坏池分配器的内部状态。如果受害连接尝试分配更多内存、从网络读取或进一步处理数据,NGINX 将不可避免地解引用其中一个损坏的指针并过早崩溃。这种过早的崩溃会完全阻止我们的利用成功。
为了绕过这一点,我们必须确保在损坏发生后立即销毁池,且在此之前不会使用任何损坏的分配字段。我们通过一种跨请求的堆风水技术实现了这种精确时机。攻击者通过连接顺序控制堆布局和池的精确生命周期:
1. 打开初始连接并发送部分头部。NGINX 为此连接分配一个请求池。
2. 打开第二个受害连接,该连接分配一个恰好与第一个池相邻的受害池。
3. 完成初始头部,触发 rewrite 溢出,从第一个池直接溢出到相邻的受害池头部。
4. 立即关闭受害连接,这将调用 `ngx_destroy_pool` 销毁受害池。
这种精度允许我们可靠地损坏受害池头部,而不会导致工作进程崩溃。这是因为销毁池时,NGINX 会遍历 `cleanup` 链表,但不会触及池结构中任何被损坏的字段。在此之后,我们获得了解引用任意 `ngx_pool_cleanup_s` 的原语。
### 喷射伪 ngx_pool_cleanup_s
由于我们只能写入 URI 安全字符,我们需要一种方法将任意二进制指针(通常包含空字节)注入到受害池头部。我们通过使用 POST 请求体喷射堆来实现这一点。与 HTTP 头部或请求 URI(会被严格解析)不同,POST 体被视为原始数据流,可以包含任意二进制 payload,包括空字节。我们构造一个喷射 payload,其中包含一个指向 libc `system` 函数的伪清理结构体,后跟用户提供的命令字符串。
``
for (c = pool->cleanup; c; c = c->next) {
if (c->handler) {
c->handler(c->data);
}
}
``
由于堆布局在工作进程间高度可预测,通过 POST 喷射的伪结构体会落在固定偏移处。我们可以暴力破解这些伪结构体的落地地址,并显式地
相似文章
新的 Nginx 漏洞利用
Nginx 重写模块中的一个关键堆缓冲区溢出漏洞(CVE-2026-42945)允许未经身份验证的远程代码执行,并且已经发布了概念验证漏洞利用代码。该漏洞影响 Nginx 0.6.27 到 1.30.0 版本以及多个 Nginx Plus 版本。
为Google kernelCTF报告一个隐藏19年以上的Linux内核零日漏洞:CVE-2026-43456
由Yuki Koike和Kota Toda发现的一个根源于2007年代码的Linux内核零日漏洞(CVE-2026-43456),通过Google的kernelCTF获得超过8万美元奖励。该漏洞是net/bonding子系统中的类型混淆问题,可在1秒内可靠地实现权限提升。
CVE-2026-42530: nginx HTTP/3 QUIC模块中的释放后使用漏洞
CVE-2026-42530 披露了 nginx 的 HTTP/3 QUIC 模块中的一个释放后使用漏洞。
OpenAI 的 Codex 将十年前的 DoS 技术链结成 HTTP/2 炸弹
研究人员利用 OpenAI 的 Codex 代理,将两种十年前的 DoS 技术链结成一个 HTTP/2 炸弹,可在数秒内使易受攻击的 Web 服务器崩溃,影响包括 nginx、Apache、IIS、Envoy 和 Pingora 在内的主要服务器。
CVE-2026-46529: 存在10年之久的Linux PDF查看器远程代码执行漏洞(XReader/Evince/Atril)
某安全研究人员发现了CVE-2026-46529,这是Linux PDF查看器XReader、Evince和Atril中一个存在10年之久的远程代码执行漏洞,原因是在生成子进程以打开远程文档链接时,参数引用不充分。