15-Year-Old Pre-Auth nginx RCE Across 13 Call Sites: Two-Pass Capture Clobbering CVE-2026-42533

CVE-2026-42533 · Fixed in nginx 1.30.4 / 1.31.3 · Affected: nginx 0.9.6 through 1.30.3 (stable) and 1.31.2 (mainline) · NGINX Plus R33–R36 (fixed in R36 P7) and 37.0.0.1–37.0.2.1 (fixed in 37.0.3.1)· F5 Advisory · Reported by Stan Shaw

A note for defenders

If you run nginx with map directives that use regex patterns and any regex capture source — location, server_name, rewrite, or if blocks – produces captures ($1, $2, named groups) that appear before a regex map variable in the same evaluated buffer, you are potentially affected. The order matters: the capture reference must be evaluated before the regex map variable for the clobber to produce a mismatch between passes. The list includes proxy_set_header, proxy_method, proxy_pass, fastcgi_param, uwsgi_param, scgi_param, grpc_set_header, return, add_header, rewrite, set, root, alias, access_log, and others. The capture reference and the map variable do not need to be in the same directive; separate directives in the same location block are sufficient. Both the HTTP and stream modules are affected.

For any deployment matching the pattern above, this is a pre-authentication remote code execution vulnerability. The attacker controls the overflow content and length through normal HTTP request fields (URI, headers, request body). No credentials, no client certificates, no special configuration beyond the pattern above. If you are running any version of nginx from 0.9.6 through 1.30.3 (stable) or 1.31.2 (mainline), you are running vulnerable code. The recent security releases for CVE-2026-42945, CVE-2026-9256, CVE-2026-42055, and CVE-2026-48142 do not fix this bug. Upgrade to 1.30.4 (stable), 1.31.3 (mainline), or the corresponding NGINX Plus release as soon as possible. Until then, audit your configs for any block that combines a regex capture (from a location, server_name, rewrite, or if regex) with a regex map variable appearing after it in evaluation order, and consider whether those can be restructured to avoid the combination.

A static scanner that flags vulnerable configs automatically is at github.com/0xCyberstan/CVE-2026-42533-Scanner. It follows includes, handles cross-directive triggering, distinguishes exploitable ordering from safe ordering, and supports JSON output. It does not exploit anything; it tells you whether the preconditions are present.

I will not be releasing the proof of concept or the full exploitation details with this post. Both will be published once users have had adequate time to patch. CVE-2026-42945 (nginx rift), a weaker bug in the same engine that required ASLR to be disabled for reliable exploitation, saw active exploitation shortly after its PoC dropped. This bug does not require ASLR to be off. The info leak primitive defeats it in a single GET request. I would rather give people time to patch.

Summary

A missing save/restore of PCRE capture state in nginx’s script engine lets a remote unauthenticated attacker trigger a heap buffer overflow with fully controlled content and length, and separately an information leak of heap pointers sufficient to defeat ASLR. The two primitives chain into reliable pre-auth remote code execution.

nginx evaluates compiled expressions in two passes: a LEN pass that measures how large the result will be, and a VALUE pass that writes the result into a buffer allocated to the LEN pass’s measurement. Both passes resolve capture references like $1 by reading from r->captures, a shared mutable array on the request. When a map variable with a regex pattern is evaluated between two capture references in the same expression, the map’s regex match overwrites r->captures with new offsets pointing at a different input string. The two passes then disagree on how large $1 is: one measures the original capture, the other writes the clobbered one. The buffer is sized for one and filled with the other.

The result is either a heap overflow (when the clobbered capture is larger than the original, so the VALUE pass writes more than the LEN pass measured) or an uninitialized heap read (when it is smaller, so the buffer is oversized and the response returns uninitialised bytes containing heap pointers). Both directions are attacker-controlled through the size of the input that triggers the map regex.

The bug affects multiple call sites across the HTTP and stream modules wherever the two-pass pattern is used, spanning a large number of directives. The overflow length is unbounded and the content comes directly from attacker-supplied request data. The vulnerable code path has been reachable since nginx 0.9.6, when the map directive gained regex support (21 March 2011), and remains present in nginx 1.30.3 (stable) and 1.31.2 (mainline). NGINX Plus releases built on affected open-source versions are also impacted; see the F5 advisory for the full product matrix.

The two-pass evaluation model

nginx’s script engine compiles expressions containing variable references and captures into a sequence of opcodes. When a directive needs the value of such an expression at runtime, ngx_http_complex_value() in src/http/ngx_http_script.c (lines 58-104) evaluates it in two passes.

The LEN pass walks the opcode stream at lines 81-84 and sums up the length each piece will contribute:

/* LEN pass: lines 81-84 */
while (*(uintptr_t *) e.ip) {
    lcode = *(ngx_http_script_len_code_pt *) e.ip;
    len += lcode(&e);
}

Then a buffer is allocated at line 87 to exactly that size:

value->data = ngx_pnalloc(r->pool, len);

Then the VALUE pass walks the same opcode stream at lines 96-99 and writes into the buffer:

/* VALUE pass: lines 96-99 */
while (*(uintptr_t *) e.ip) {
    code = *(ngx_http_script_code_pt *) e.ip;
    code((ngx_http_script_engine_t *) &e);
}

This design assumes every opcode produces the same length in both passes. If an opcode returns 3 bytes during LEN but writes 200 bytes during VALUE, the buffer is 197 bytes too small and the VALUE pass writes past its end. There is no bounds check on the VALUE pass write position against the allocated buffer size. The engine trusts that the LEN pass already measured correctly.

The stream module has an independent implementation of the same pattern in ngx_stream_complex_value() at src/stream/ngx_stream_script.c (lines 58-105), with the LEN pass at lines 82-85, allocation at line 88, and VALUE pass at lines 97-100.

The bug: shared mutable capture state

When a location block uses a regex, the match results (capture group offsets and a pointer to the matched string) are stored in r->captures and r->captures_data on the request object. A $1 reference in an expression is compiled into opcodes that read these fields to find the captured substring’s offset and length.

The LEN pass reads captures via ngx_http_script_copy_capture_len_code() at line 1336, with the length computation at lines 1354/1365:

/* ngx_http_script_copy_capture_len_code -- line 1336 */
cap = r->captures;
return cap[n + 1] - cap[n];

The VALUE pass writes them via ngx_http_script_copy_capture_code() at line 1374, with the copy at line 1404:

/* ngx_http_script_copy_capture_code -- line 1374 */
cap = r->captures;
p = r->captures_data;
e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);

Both functions read the same r->captures array and r->captures_data pointer. The problem is that these are shared mutable state on the request. They are not a snapshot of the location match. They are a live pointer that any subsequent regex match on the same request will overwrite.

When a map directive uses a regex pattern, evaluating the map variable enters ngx_http_map_find() in src/http/ngx_http_variables.c (line 2526), which iterates the map’s regex patterns at line 2563:

n = ngx_http_regex_exec(r, reg[i].regex, match);

ngx_http_regex_exec() at line 2695 runs the PCRE match against the map’s input variable and writes the result directly into the request’s captures:

/* ngx_http_regex_exec -- line 2695 */
rc = ngx_regex_exec(re->regex, s, r->captures, len);

/* lines 2732-2733 */
r->ncaptures = rc * 2;
r->captures_data = s->data;

After this returns, r->captures contains offsets within the map’s input string (a request header, query parameter, or request body) instead of the original location match. r->captures_data points at the map input’s data instead of the URI. Neither ngx_http_complex_value() nor ngx_http_map_find() saves or restores the capture state at any point.

Shared mutable state: r->captures across three files No save/restore anywhere in the chain. ① Location regex match ngx_http_core_find_location() ngx_regex_exec(re, uri, r->captures, r->ncaptures) r->captures = [0, 3] ② Two-pass evaluator ngx_http_complex_value() LEN: copy_capture_len_code VAL: copy_capture_code reads cap[n+1]-cap[n] map var eval mid-pass ③ Map regex (THE CLOBBER) ngx_http_regex_exec() r->captures = body offsets r->captures_data = s->data captures clobbered, returned to evaluator No save before LEN pass. No restore before VALUE pass.
Three files, no recorded dependency. The evaluator assumes captures are stable; the map code writes them freely.

So when an expression contains $1, then a map variable with a regex, then $1 again, the first $1 reads the original capture and the second reads the clobbered one. If the clobber happens between the LEN and VALUE passes for the same $1 reference, and it does since both passes evaluate the full expression sequentially, the LEN pass and VALUE pass disagree on the size of that capture.

The overflow primitive

Consider an expression like "$1=$bodyvar=$1" in a location block matching a short URI path, where $bodyvar is a map variable that evaluates a regex against $request_body.

In the LEN pass, the first $1 reads the original capture via copy_capture_len_code: 3 bytes (the URI path “abc”). Then $bodyvar is evaluated, which triggers ngx_http_map_find() into ngx_http_regex_exec() against the 200-byte request body. This clobbers r->captures to point at offsets within the body. The second $1 in the LEN pass reads the clobbered capture: 200 bytes. LEN total: 3 + 1 + 6 + 1 + 200 = 211 bytes. A 211-byte buffer is allocated via ngx_pnalloc(r->pool, 211).

In the VALUE pass, the capture state is still clobbered. Nothing restored it. The first $1 reads the clobbered capture via copy_capture_code and writes 200 bytes. $bodyvar writes “mapped” (6 bytes). The second $1 writes another 200 bytes. VALUE total: 200 + 1 + 6 + 1 + 200 = 408 bytes into a 211-byte buffer. That is a 197-byte heap overflow with fully attacker-controlled content from the POST body.

r->captures clobbered between LEN and VALUE passes LEN PASS (lines 81-84) $1 -> r->captures -> 3 B (“abc” from URI, original) $bodyvar -> ngx_http_regex_exec() CLOBBERS r->captures, captures_data $1 -> r->captures -> 200 B (from request body, clobbered) LEN = 3 + 1 + 6 + 1 + 200 = 211 -> ngx_pnalloc(r->pool, 211) -> 211-byte buffer VALUE PASS (lines 96-99) — captures still clobbered $1 -> CLOBBERED -> writes 200 B $bodyvar -> writes “mapped” (6 B) $1 -> CLOBBERED -> writes 200 B VALUE = 200 + 1 + 6 + 1 + 200 = 408 -> 408 into 211-byte buffer -> 197-byte OVERFLOW Buffer layout at VALUE pass completion allocated buffer (211 bytes) sized by LEN pass OVERFLOW (197 bytes) attacker-controlled content from POST body byte 211 The LEN pass sees the clobber for only one $1 (the second); the VALUE pass sees it for both. That asymmetry is the overflow.
Nothing saves r->captures before the LEN pass or restores it before the VALUE pass.

The overflow occurs at ngx_http_script_copy_capture_code (line 1404), inside the ngx_copy() call that writes the capture data into the buffer. With an ASan-enabled build, a single trigger request produces:

==PID==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x521000015100
WRITE of size 200 at 0x521000015100 thread T0
    #0 memcpy
    #1 memcpy
    #2 ngx_http_script_copy_capture_code src/http/ngx_http_script.c:1404
    #3 ngx_http_complex_value src/http/ngx_http_script.c:98

0x521000015100 is located 0 bytes after 4096-byte region [0x521000014100,0x521000015100)
SUMMARY: AddressSanitizer: heap-buffer-overflow in memcpy

The crash trace confirms the VALUE pass write at line 98 dispatching to copy_capture_code at line 1404, where the ngx_copy() overflows the buffer. Without ASan, the write silently corrupts adjacent allocations on the nginx pool heap.

The information leak primitive

The same mechanism works in reverse. If the map’s input is shorter than the original capture, the clobber makes $1 smaller during the VALUE pass than during the LEN pass. The buffer is oversized: the LEN pass measured a large capture, allocated a large buffer, but the VALUE pass writes only a few bytes. The response returns the full allocated buffer, and the uninitialised tail contains residual heap data from prior allocations.

With a URI path of 8160 bytes and a 1-byte header triggering the map regex, the allocated buffer is 8161 bytes but only 2 bytes are initialised. The remaining 8159 bytes are uninitialised heap residue from the nginx pool allocator. On Ubuntu 24.04 with glibc 2.39, the uninitialised region reliably contains allocator metadata from freed pool blocks. At offset 0x08 sits a libc pointer and at offset 0x10 sits a heap pointer. These two values are sufficient to compute the base address of libc and the location of the overflow buffer in memory, defeating ASLR in a single unauthenticated GET request.

Two primitives, one root cause Clobbered capture size vs original capture size determines the direction. OVERFLOW: clobbered capture LARGER than original Short URI capture clobbered by long request body. VALUE writes more than LEN measured. LEN: 211 bytes VALUE: 211 +197 overflow Heap buffer overflow Attacker-controlled POST body content Overflow = clobbered_len – original_len INFO LEAK: clobbered capture SMALLER than original Long URI capture clobbered by short header. Buffer oversized, uninitialised bytes returned. LEN: 8161 bytes (includes 8160-byte URI capture) VALUE: 2 B 8159 bytes uninitialised heap residue Information leak body[0x08:0x10] -> libc pointer body[0x10:0x18] -> heap pointer The attacker chooses the direction by controlling the relative sizes of the location capture vs the map input.
Both primitives from the same missing save/restore. Overflow for corruption, leak for ASLR bypass.

Exploitation

Both primitives chain into a complete pre-auth RCE. The info leak defeats ASLR in a single GET request. The heap overflow, triggered by a POST, hijacks control flow through nginx’s pool cleanup mechanism. Total: 1 leak request, roughly 40 spray connections, and 1 overflow trigger. Tested at 10 out of 10 reliability on Ubuntu 24.04 with glibc 2.39 and full ASLR enabled.

For comparison: CVE-2026-42945 (nginx rift) required ASLR to be disabled for its public RCE chain. This bug does not. The info leak primitive provides the addresses needed to construct the payload at runtime. That is the difference between a crash-or-maybe-RCE and a reliable RCE.

The full exploitation write-up and proof of concept will be published 21 days after the patch is available.

Variant analysis

The capture clobbering is not specific to ngx_http_complex_value(). The two-pass LEN/VALUE pattern appears in at least 13 independent call sites across 9 source files. Each site has its own LEN loop, its own allocation, and its own VALUE loop. Each reads captures through the same opcodes (copy_capture_len_code and copy_capture_code), and none of them save or restore r->captures between passes. All 13 are confirmed vulnerable via AddressSanitizer builds of nginx 1.30.1.

Every site is exploitable in both directions. When the clobbered capture is larger than the original (INNER > OUTER), the LEN pass undersizes the buffer and the VALUE pass overflows it. When it is smaller (OUTER > INNER), the LEN pass oversizes the buffer and the VALUE pass underflows, leaving uninitialised heap data in the response. Both directions are attacker-controlled through the relative sizes of the location capture and the map input.

Core script engine (6 sites, 2 files)

#1 ngx_http_complex_value at src/http/ngx_http_script.c lines 58-104. LEN at line 81, alloc at line 87, VALUE at line 96. This is the primary evaluation function used by return, add_header, add_trailer, error_page, sub_filter, proxy_redirect, proxy_cookie_path, proxy_cookie_domain, proxy_method, auth_basic_user_file, proxy_cache_key, limit_req_zone key, limit_conn_zone key, expires, and others.

#2 ngx_stream_complex_value at src/stream/ngx_stream_script.c lines 58-105. LEN at line 82, alloc at line 88, VALUE at line 97. Independent implementation for the stream module. Used by stream return, set, proxy_pass, proxy_bind, proxy_ssl_name, proxy_ssl_certificate, proxy_ssl_certificate_key, proxy_upload_rate, proxy_download_rate, ssl_certificate, ssl_certificate_key, limit_conn_zone key, pass, hash, split_clients, and stream access_log.

#3 ngx_http_script_run at src/http/ngx_http_script.c lines 615-660. LEN at line 639, alloc at line 646, VALUE at line 654. Used by proxy_pass, fastcgi_pass, scgi_pass, uwsgi_pass, grpc_pass, root/alias, access_log, and store when their arguments contain variables.

#4 ngx_stream_script_run at src/stream/ngx_stream_script.c lines 491-536. LEN at line 515, alloc at line 522, VALUE at line 530. Caller: stream access_log.

#5 ngx_http_script_regex_start_code at src/http/ngx_http_script.c lines 1038-1191. Variable replacement branch only; the static branch (lines 1143-1155) is not vulnerable. Trigger: rewrite ^/(.*)$ "/new/$1/$map_regex_var" last;

#6 ngx_http_script_complex_value_code at src/http/ngx_http_script.c lines 1753-1791. LEN at line 1774, alloc at line 1779. Trigger: set $var "$1$map_regex_var"

Module-level two-pass (7 sites, 7 files)

Each of these modules has its own LEN and VALUE loop independent of the core script engine. Capture opcodes are compiled via ngx_http_script_compile (lines 480-498) and dispatched through the same copy_capture_len_code/copy_capture_code functions, so they read the same clobbered r->captures.

#7 proxy_set_header / proxy_set_body at src/http/modules/ngx_http_proxy_module.c. ngx_http_proxy_create_request line 1174, alloc at line 1336.

#8 fastcgi_param at src/http/modules/ngx_http_fastcgi_module.c. ngx_http_fastcgi_create_request line 844, alloc at line 1023.

#9 scgi_param at src/http/modules/ngx_http_scgi_module.c. ngx_http_scgi_create_request line 642, alloc at line 804.

#10 uwsgi_param at src/http/modules/ngx_http_uwsgi_module.c. ngx_http_uwsgi_create_request line 866, alloc at line 1030.

#11 grpc_set_header at src/http/modules/ngx_http_grpc_module.c. ngx_http_grpc_create_request line 718. Dual overflow: both the temporary value buffer and the output buffer.

#12 proxy_v2 body path at src/http/modules/ngx_http_proxy_v2_module.c. ngx_http_proxy_v2_create_request line 341. Body path only; header path is not vulnerable (it re-measures concurrently).

#13 index at src/http/modules/ngx_http_index_module.c. Line 152. Has 16 bytes of slack but the OOB is unbounded past that.

Sites #7-#13 need an overflow larger than 4096 bytes to cross pool block boundaries for ASan to report them. Write sizes are attacker-controlled, so this is just a matter of sending a larger request body or header. All 13 sites confirmed via ASan on nginx 1.30.1, Ubuntu 24.04, glibc 2.39, PCRE2 10.42.

Cross-directive triggering

The capture reference and the map variable do not need to appear in the same directive. Modules like proxy, fastcgi, scgi, uwsgi, and grpc each implement their own two-pass loop to build upstream requests, and each loop runs across all directives in the location block with a single buffer allocation.

In ngx_http_proxy_create_request() (src/http/modules/ngx_http_proxy_module.c), the LEN pass runs at lines 1286-1305 across every proxy_set_header and proxy_set_body directive in the block. A single buffer is allocated at line 1336 via ngx_create_temp_buf(). The VALUE pass then runs at line 1396 onward, filling the same buffer from all directives. So this common pattern is vulnerable:

location ~ "^/api/(.+)$" {
    proxy_set_header X-Path  "$1";
    proxy_set_header X-Check "$map_var";
    proxy_pass http://backend;
}

The X-Path directive contributes $1 to both passes. The X-Check directive evaluates the map variable during the LEN pass, clobbering captures. When the VALUE pass revisits X-Path, it reads the clobbered capture and writes a different, larger value than LEN measured. This is a far more common configuration pattern than having both references in a single string. The same structure exists independently in ngx_http_fastcgi_create_request, ngx_http_scgi_create_request, ngx_http_uwsgi_create_request, and ngx_http_grpc_create_request.

Named capture variable clobbering

The numbered capture path ($1 via r->captures[]) is not the only vulnerable code path. There is a second, independent path through named captures ((?P<name>...)) that works through r->variables[] instead of r->captures[]. A fix that saves and restores r->captures will not fix this variant.

ngx_http_regex_exec() at src/http/ngx_http_variables.c lines 2708-2718 writes named captures directly into r->variables[index] with no_cacheable=0 (cached). When two map regexes define the same (?P<ver>...) group, the second overwrites the first in r->variables[]. The LEN pass measures the short value, the VALUE pass writes the long one. Same overflow.

The read path for this variant is different from the numbered capture path. Numbered captures are read by copy_capture_len_code / copy_capture_code which index into r->captures[]. Named captures are read by copy_var_len_code / copy_var_code which index into r->variables[]. Both sets of opcodes are dispatched by the same script engine loop in the same two-pass sites, so the same 13 sites, both directions, and the same ~50 directives are affected.

ASan confirms both code paths independently:

Site 1 (single-expression via add_header):
WRITE of size 1000
  #1 ngx_http_script_copy_var_code src/http/ngx_http_script.c:980
  #2 ngx_http_complex_value src/http/ngx_http_script.c:98

Site 2 (cross-directive via separate proxy_set_header):
WRITE of size 8000
  #1 ngx_http_script_copy_var_code src/http/ngx_http_script.c:980
  #2 ngx_http_proxy_create_request src/http/modules/ngx_http_proxy_module.c:1434

The root cause is at src/http/ngx_http_variables.c line 2716, where no_cacheable=0 on named captures makes the overwrite permanent for the lifetime of the request. The same 13 two-pass consumer sites are affected because both copy_capture_*_code and copy_var_*_code are dispatched by the same script engine loop. A single snapshot/restore at each site would fix both bugs, but a fix targeting only r->captures[] will leave the r->variables[] path open.

Affected directives

Via sites #1-#6 (HTTP): return, rewrite, set, add_header, add_trailer, error_page, sub_filter, proxy_redirect, proxy_cookie_path, proxy_cookie_domain, proxy_method, proxy_pass, fastcgi_pass, scgi_pass, uwsgi_pass, grpc_pass, root, alias, access_log, store, auth_basic_user_file, proxy_cache_key, limit_req_zone key, limit_conn_zone key, expires.

Via sites #1-#6 (stream): return, set, proxy_pass, proxy_bind, proxy_ssl_name, proxy_ssl_certificate, proxy_ssl_certificate_key, proxy_upload_rate, proxy_download_rate, ssl_certificate, ssl_certificate_key, limit_conn_zone key, pass, hash, split_clients, access_log.

Via sites #7-#13: proxy_set_header, proxy_set_body, fastcgi_param, scgi_param, uwsgi_param, grpc_set_header, index.

Not affected: the mail module, the SSI module (uses its own ctx->captures/ctx->captures_data, a separate path from r->captures), the FastCGI regex branch (local stack captures), and the static branch of regex_start_code.

The fix

Rather than snapshotting r->captures around the two passes, the patch makes the VALUE pass itself incapable of overrunning its buffer. The script engine struct gains an end pointer set to the allocated-buffer boundary, and a new ngx_http_script_check_length() guard is inserted ahead of every write opcode: copy_code, copy_var_code, copy_capture_code (both its plain and ngx_escape_uri branches), and the raw separator writes in each upstream module. If the VALUE pass ever tries to write past end, it sets e->status = NGX_HTTP_INTERNAL_SERVER_ERROR, diverts e->ip to ngx_http_script_exit, and logs “no buffer space in script copy”; callers (ngx_http_complex_value, ngx_http_script_run, and each module’s create_request) abort with HTTP 500 instead of corrupting the pool heap. The information-leak direction is closed by computing the returned string length from bytes actually written (e->pos - e->buf.data, via the new ngx_http_script_complex_value_end_code) rather than the inflated LEN-pass measurement, so an oversized buffer no longer emits its uninitialised tail.

Because the guard lives at the write sites and every two-pass consumer — roughly 14 sites across the HTTP core, the proxy, fastcgi, scgi, uwsgi, grpc, proxy_v2, index, and try_files modules, and the parallel stream engine — was updated to set e.end after allocating and check e.status after the loop, the fix is source-agnostic: it covers numbered captures, the named-capture r->variables[] path, and is_args-style flag divergences alike. Two smaller hardening changes round it out: r->ncaptures is reset to 0 on capture reallocation in ngx_http_variables.c, and a bounds fix in the compiler prevents a trailing $ from reading one byte past the expression string.

Disclosure timeline

I sent the initial report to F5 SIRT on 17 May 2026 with full analysis, a working PoC covering crash, info leak, and RCE modes, and a minimal trigger configuration. Follow-up emails over the next three days included the stream module variant, a comprehensive variant analysis of all affected call sites confirmed via ASan, and two additional findings (named capture variable clobbering and cross-directive triggering). F5 SIRT acknowledged receipt on 18 May and confirmed they were working with NGINX engineering on internal analysis. The fix is scheduled for the F5 Security Advisory on 15 July 2026 with a CVE assignment.

A broader pattern

The shape of this bug is a compound evaluation that assumes its own inputs are stable across passes. The two-pass pattern, measure first then fill, is a common and reasonable design when the measurement and the fill see the same data. It becomes unsound the moment any side effect of the evaluation itself can change what a later opcode will produce, because the measurement is no longer a prediction of the fill.

The specific side effect here is a regex match that writes into shared mutable state. r->captures is the request’s capture array, not the expression’s. Any regex evaluated during the expression, whether a map lookup or a variable handler or anything that calls ngx_http_regex_exec(), overwrites it. Nothing in the two-pass loop saves or restores it. The evaluator trusts that the world it is measuring will hold still while it measures, and the world does not.

Three independent two-pass mismatch bugs have been found in this engine within the span of a few months: the is_args flag divergence (CVE-2026-42945), overlapping captures in the rewrite module (CVE-2026-9256), and this capture clobbering. They have different root causes and require different fixes, but they share the same structural vulnerability: a LEN/VALUE split that assumes deterministic evaluation in a system where evaluation has side effects. Any mutable state on the request that an opcode reads to determine its output length, and that can change between passes, is a candidate. Captures, flags, and variable caching are three such states. There may be others.

The broader lesson is that a measurement which is correct only because of an assumption about external state is load-bearing on that assumption, and nothing records the dependency. When map directives used only static string matching, the missing capture save/restore was invisible. When regex support arrived, the same code became an unbounded heap overflow. The thing to grep for is not the overflow but the unrecorded assumption: an evaluator that re-reads shared state and trusts it has not changed since the last read, in a system where any number of things can change it between reads.

While researching this bug, I found that the capture clobbering behaviour had been observed before. In 2014, Pascal Jungblut filed nginx trac ticket #564 reporting that a map directive with a regex overwrites the capture groups in a rewrite directive. He noticed that $1 silently returned different data depending on whether the map regex matched, built a minimal reproducer, and reported it. Maxim Dounin accepted it as a defect: “this case clearly shows that the current behaviour is bad, and should be fixed.” The ticket accumulated five cross-references to related reports over the following years (#1044, #1142, #1285, #1498, #1934). In 2020, another user argued it should be reclassified from minor to critical. The connection to the two-pass allocation model, and the memory safety consequences that follow from it, is what this report adds.


Thank you to the F5 SIRT team for the coordination and responsiveness throughout the disclosure process, and to the NGINX engineering team for the fix.