Tracing a 15-Year Script-Engine Lineage from Mutable Regex Captures to Remote Code Execution

Publication 15 July 2026
CVSS 4.0 9.2 CriticalCVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 8.1 HighCVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H

Executive Summary

CVE-2026-42533 is a heap-buffer-overflow vulnerability in NGINX’s HTTP script engine. It can restart a worker and, when ASLR is disabled or bypassed, lead to remote code execution.

NGINX builds values from configuration variables in two passes: first it measures how many bytes the result needs, then it copies those bytes into a buffer of that size. Over-Under Copy breaks the assumption holding those passes together. A component can be measured from one version of mutable state and copied from another, so NGINX allocates for one byte count and emits another.

That makes this a state-consistency bug, not simply a bad length calculation. The vulnerable engine neither bounds the copy to the predicted allocation nor corrects the final published length. This walkthrough follows the unnamed positional-capture path, $1 through $9; the upstream patch and changelog identify named-capture and non-cacheable-variable variants of the same failure.

In the primary path covered here, $1 is a view over mutable request capture state. A later regex, evaluated on demand, can replace that state after $1 has been measured but before the copy opcode reads it again.

First pass: measure Second pass: copy
$1 means capture A $1 now means capture B

NGINX reserves len(A) bytes but may copy len(B) bytes. If B is longer, the copy leaves the reserved region. If B is shorter, part of the recorded result remains untouched but can still be included in later output.

I verified this mismatch in NGINX release-1.31.1, commit d44205284fa41662da803b796d6056fc1e59b1f3. I traced it from the two-pass builder through lazy map evaluation and capture-state replacement to request-pool allocation and response handling. I found the issue before the vendor advisory and, after coordinated disclosure, reviewed the upstream changelog and fixing commits.

In my Linux lab, I carried the regex-capture overcopy through request-pool corruption into a live request-completion callback and demonstrated remote code execution. The Exploitability Analysis walks through the full chain.

Disclosure status: F5 published K000162097 on 15 July 2026 and assigned CVE-2026-42533. The advisory describes the same capture-before-map ordering, classifies it as CWE-122: Heap-based Buffer Overflow, and states that crafted requests can restart a worker. Code execution is possible when ASLR is disabled or bypassed. F5 describes the exposure as data-plane only.

The fixed NGINX Open Source releases are 1.30.4 and 1.31.3. F5 also lists NGINX Plus 37.0.3.1 and R36 P7.

Both published vectors set Attack Complexity to High (AC:H), although the metric differs between CVSS versions. In CVSS 3.1 it reflects conditions beyond the attacker’s control; in CVSS 4.0 it covers evading built-in defenses such as ASLR, while AT:N records no separate deployment or execution requirement. The trigger itself remains network-reachable and unauthenticated.

Affected Versions, Fixed Releases, and 15-Year Source Lineage

NGINX commit 0519b43a772476a7537e02a34acb9b265e814815 added regex matching to map on 16 March 2011, and the feature shipped five days later in release-0.9.6 at commit 57f3455ad3d5e0711457fc726ecba81a02f750be. That release already combined regex-backed map evaluation, captures, and the script engine’s separate length and value programs. This gives the bug class a 15-year source lineage; the vendor ranges below define the evaluated product scope.

Scope Affected or reviewed range Fixed release or status
Research-verified baseline NGINX 1.31.1 Directly verified as vulnerable; listed separately from the CNA-evaluated ranges
CNA: NGINX Open Source 0.9.6 <= version < 1.30.4; 1.31.2 <= version < 1.31.3 1.30.4 and 1.31.3
CNA: current NGINX Plus 37.0.0.1 <= version < 37.0.3.1 37.0.3.1
F5: NGINX Plus Rx release tracks R33–R36 R36 P7; consult K000162097 for product-specific support status

The first row is the build I verified directly; the others are the ranges evaluated by the CNA. The official CNA record notes that software which had reached End of Technical Support was not evaluated, so omitted historical builds should not be assumed safe. F5’s K000162097 defines supported-product status and fixed versions.

Background

Three NGINX behaviors line up to make this possible: regex captures are mutable request state, map variables are lazy, and complex values are built in two passes. Subrequests and request pools matter later, when we follow where that state lives and where an overcopy lands.

1. How does NGINX represent $1?

NGINX represents a regex capture as offsets into the matched input. For ^user/(.)$ matched against user/A, the request state looks roughly like this:

Source string:                 user/A
Character positions:           012345
Whole match:                   offsets 0 to 6
First capture ($1):            offsets 5 to 6

r->captures_data  = "user/A"
r->captures       = [0, 6, 5, 6]
                          └─┬─┘
                       $1 is here

r->captures_data points to the current regex input, r->captures stores the offsets, and r->ncaptures bounds the valid entries. Reading $1 copies the bytes between offsets 5 and 6—A in this example.

2. What does map do?

map derives one variable from another. For the vulnerability, the relevant form is a regex entry whose visible result is empty:

map $arg_q $m {
    ~^(.+)$  "";
    default  "";
}

A regex map match replaces the current request’s capture state. When the regex contains a capture group, it can change what $1 means even though $m returns an empty string. Because map variables are lazy, that mutation occurs only when some code first asks for $m. The map can therefore contribute no visible bytes and still change a capture used elsewhere in the expression.

The NGINX map documentation describes both behaviors: map variables are evaluated only when used, and regex entries can create captures for later directives. Historical ticket #564 also records one regex replacing capture state created by another.

3. What is a complex value?

A complex value is a configuration expression that mixes literals, variables, and captures. NGINX compiles it while loading the configuration and evaluates it later for each request. With the map above, the relevant shape is:

set $label "$1$m";

The important detail is that NGINX does not resolve $1 and $m into one immutable set of strings before sizing the result. NGINX compiles distinct length instructions and value-copy opcodes. In the set path, ngx_http_rewrite_value() stores the length program in the complex-value opcode and appends the copy opcodes to the outer rewrite program. Sizing and copying therefore remain separate operations, and resolving $m can change the capture state that the $1 copy opcode will read again.

4. Why measure first and copy second?

Before building the result, NGINX needs to know how much memory to allocate from the request pool. It therefore walks the compiled expression twice:

  1. Length pass: run each length instruction, add the answers, and reserve one buffer for the whole complex value.
  2. Copy pass: read the components again and write their bytes into that buffer.

The length pass is not side-effect-free. A length instruction for a variable can invoke that variable’s getter, and a getter can perform work such as regex matching. The primary safety rule is that the copy pass must not write more bytes than NGINX allocated. There is a second requirement too: the published result length must not include bytes the copy pass never wrote. A longer value causes an overcopy; a shorter value leaves part of the recorded result untouched.

5. What are main requests and subrequests?

NGINX represents the client request and each internal subrequest with a separate ngx_http_request_t. A subrequest performs internal work; it is not another HTTP request read from the connection. The main request points r->main to itself, while a subrequest receives sr->main = r->main and sr->parent = r.

Only two boundaries matter for the primary path:

  • positional captures $1 through $9 come from the current request object’s captures, ncaptures, and captures_data; and
  • subrequests allocate from the same pool as their main request through sr->pool = r->pool.

The first boundary keeps capture mutation and capture reads on the same request object. The second means a subrequest’s complex-value buffer and later request-group state can still occupy the same allocation arena. ngx_http_regex_exec(r, ...) updates the captures on the r it receives, while ngx_http_subrequest() creates a distinct sr but assigns it the main request group’s pool. The same length/copy mismatch also works on the main request; subrequests matter to where the resulting allocation can interact with later state, not to the existence of the bug.

6. What is a request pool?

A request pool is a lifetime arena for a main HTTP request and its subrequests, not a separate operating-system heap. ngx_http_alloc_request() creates it, and subrequests reuse it. The complex-value result is allocated from this pool with ngx_pnalloc().

On the normal small-allocation path, allocations no larger than pool->max pass through ngx_pnalloc() and ngx_palloc_small(), which use a forward-moving pointer. We can picture one block as follows:

start of pool block                                      end of pool block
       |                                                         |
       v                                                         v
       [pool header / used allocations][result buffer][unused tail]
                                        ^             ^           ^
                                        m     d.last after alloc  d.end

Before the allocation, d.last is m. ngx_pnalloc() returns m and advances d.last by the requested size. It does not clear the returned bytes or create a per-allocation guard boundary.

This layout matters because the complex-value result starts at the current pool tail. While an overcopy remains below d.end, it first enters the block’s unused tail, not an already allocated live object.

If the overrun stays below d.end, it pre-writes space that a later pool allocation may reuse. If it crosses d.end, it leaves the block, and the source alone does not identify the physical allocation that follows. In the opposite direction, an undercopy leaves prior contents untouched while a response path may still trust the measured length. Those bytes depend on allocator history; they are not necessarily data from an earlier HTTP request.

When a pool block is full, NGINX can add another block; allocation requests larger than pool->max instead take the separately tracked ngx_palloc_large() path. The default request_pool_size is 4 KiB, but it sets block geometry rather than a total request-memory limit. Changing it can move an exploit target without repairing the length/copy mismatch.

Root Cause

With the state model in place, we can follow the disagreement through the evaluator. A length opcode records one capture’s byte count from the current offsets and source data, then the matching copy opcode re-reads r->captures, r->ncaptures, and r->captures_data. A regex between those reads can change both what $1 means and how many bytes it contributes after that component’s allocation contribution is fixed.

The capture-state path

This is the path I used for the Linux RCE chain. First, NGINX measures a numbered capture. It then evaluates a later regex-backed variable on demand, replacing the request’s capture state before the copy opcode reads that capture again.

The first positional capture is created during regex location selection, which calls ngx_http_regex_exec() with the current request object:

/* r is the request or subrequest currently undergoing location lookup. */
n = ngx_http_regex_exec(r, (*clcfp)->regex, &r->uri);

Every operation stays on the request object passed as r. If r is a subrequest, the location regex, map regex, and both capture opcodes use that subrequest; the same sequence also works when r is the main request. Because subrequests allocate from the group’s shared pool, their out-of-bounds copies still land in memory used by that request group.

A concrete example before the C code

Before reading the C code, we can reduce the state change to a small example. Call the later regex-backed variable $m, assume every other component is empty, and start with these values:

  • an earlier regex has established $1 = "A", one byte long;
  • a later regex input contains BBBBBBBBBBBBBBBB, 16 bytes;
  • the later variable returns an empty visible value after matching that input.
Moment What NGINX does Meaning of $1
Length pass reads $1 Adds 1 byte to the allocation total A
Length pass reads $m The map runs; its regex captures 16 B bytes, while $m contributes 0 bytes The request’s positional capture now contains 16 B bytes
NGINX reserves the result buffer Uses the total already calculated: 1 byte The earlier $1 length is not recalculated
Copy pass reads $1 again Copies the current capture: 16 bytes BBBBBBBBBBBBBBBB

The map cannot go back and revise the 1 byte already added to the total. What it changes is the state that $1 will read next. NGINX therefore reserves one byte for the simplified result, then copies 16 bytes into it.

1. The two-pass builder

With that example in mind, we can trace the builder in four steps:

  1. Walk through the expression and add up the expected length of every part.
  2. Reserve exactly that many bytes from the request pool.
  3. Return control to the outer script loop.
  4. Let subsequent value opcodes copy each component into the reserved region.

In the excerpt below, le is the measuring engine, len is the total size, and e->buf belongs to the whole complex value—not just $1.

The outer rewrite loop provides the sequencing. ngx_http_script_complex_value_code() performs the measurement and allocation:

/* The outer loop executes each opcode in order. */
while (*(uintptr_t *) e->ip) {
    code = *(ngx_http_script_code_pt *) e->ip;
    code(e);
}

/* The complex-value opcode creates a separate measuring engine. */
ngx_memzero(&le, sizeof(ngx_http_script_engine_t));

le.ip = code->lengths->elts;
le.line = e->line;
le.request = e->request;  /* Both passes share the same request state. */
le.is_args = e->is_args;
le.quote = e->quote;

/* Pass 1 asks each length opcode for a byte count. */
for (len = 0; *(uintptr_t *) le.ip; len += lcode(&le)) {
    lcode = *(ngx_http_script_len_code_pt *) le.ip;
}

/* Allocate exactly the number of bytes measured above. */
e->buf.len = len;
e->buf.data = ngx_pnalloc(e->request->pool, len);

/* Subsequent value opcodes write into this allocation.
 * e->buf.len remains the length calculated above. */
e->pos = e->buf.data;
e->sp->len = e->buf.len;
e->sp->data = e->buf.data;
C expression How to read it
le.request = e->request The measuring engine and later copy opcodes observe the same mutable request.
le.is_args = e->is_args The measuring pass inherits argument-escaping state; it does not snapshot capture bytes or offsets.
len += lcode(&le) Run each length operation and add its answer to the total.
ngx_pnalloc(..., len) Reserve exactly len bytes from the request pool.
e->pos = e->buf.data Place the write pointer at the start of the whole-value buffer for subsequent copy opcodes.

The excerpt is abridged, and the comments are added for clarity. This is the path used for set expressions, not just the generic complex-value helper.

At this point, the vulnerable implementation relies on every copy opcode writing exactly the number of bytes reported by its matching length opcode.

2. The length opcode reads the old capture

Numbered captures such as $1 are views into the request’s current regex state; they are not immutable strings attached to the expression. During the length pass, the capture-length opcode reads the current offset pair and returns the difference.

ngx_http_script_copy_capture_len_code() performs that calculation:

/* Pass 1 reads the current offsets and returns their length. */
cap = r->captures;
return cap[n + 1] - cap[n];

In our example, this returns 1 for capture A and adds that value to the total.

3. The later variable is resolved on demand

The later variable cannot revise the length already returned for $1: opcodes run in expression order, so the capture reports its short value before a later opcode asks for the regex-backed map variable.

Because le was zero-initialized, the variable-length opcode enters the flushed-variable lookup. With no usable cached value, the indexed-variable getter invokes the map’s registered getter. The getter evaluates the map input, calls ngx_http_map_find(), and reaches ngx_http_regex_exec() through the regex branch.

/* The length opcode asks for $m while pass 1 is running. */
if (e->flushed) {
    value = ngx_http_get_indexed_variable(e->request, code->index);
} else {
    value = ngx_http_get_flushed_variable(e->request, code->index);
}

/* The flushed lookup reaches the indexed lookup when no
 * usable cached value is available. */
return ngx_http_get_indexed_variable(r, index);

/* If the value is not already valid/cached, NGINX invokes
 * the getter registered for that variable. */
if (v[index].get_handler(r, &r->variables[index], v[index].data)
    == NGX_OK)
{
    return &r->variables[index];
}

/* The map getter evaluates its input and performs map matching.
 * A regex map entry reaches ngx_http_regex_exec() through ngx_http_map_find(). */
if (ngx_http_complex_value(r, &map->value, &val) != NGX_OK) {
    return NGX_ERROR;
}

value = ngx_http_map_find(r, &map->map, &val);

/* After exact-key lookup misses, map tries regex entries.
 * This call reaches the function that replaces request capture state. */
n = ngx_http_regex_exec(r, reg[i].regex, match);

This is why $m can run during the length pass rather than before it. If the map value is already cached, the getter does not run here and capture state remains unchanged; caching is part of the trigger boundary.

4. A successful regex replaces capture state

When the regex matches, ngx_http_regex_exec() writes new offsets into the request’s capture array, updates the number of valid offsets, and points captures_data at the new regex input. Here the map regex replaces the one-byte location capture with a longer attacker-controlled capture, even though the map’s visible result is empty.

/* The later regex writes into the request capture array. */
rc = ngx_regex_exec(re->regex, s, r->captures, len);

/* A successful match publishes the new capture state. */
r->ncaptures = rc * 2;
r->captures_data = s->data;

The array can be overwritten in place, so saving only the r->captures pointer would not preserve the offsets seen during the length pass.

5. Allocation keeps the old measured length

When the map returns, the length loop continues with the old contribution from $1 still in its accumulator. NGINX records that total and allocates exactly that many bytes.

ngx_http_script_complex_value_code() still allocates from that accumulator:

/* len still includes the old capture length returned above. */
for (len = 0; *(uintptr_t *) le.ip; len += lcode(&le)) {
    lcode = *(ngx_http_script_len_code_pt *) le.ip;
}

e->buf.len = len;
e->buf.data = ngx_pnalloc(e->request->pool, len);
e->pos = e->buf.data;

6. The copy opcode reads the current capture

When the copy pass begins, it has no snapshot of the state we measured. The opcode reads r->captures and r->captures_data again, which now describe different bytes and a different length.

ngx_http_script_copy_capture_code() now reads the current state:

/* Pass 2 re-reads the current request capture state. */
cap = r->captures;
p = r->captures_data;
e->pos = ngx_copy(pos, &p[cap[n]], cap[n + 1] - cap[n]);

Reading the final line from the inside out:

  • cap[n] is the current capture’s start offset;
  • cap[n + 1] is its current end offset;
  • cap[n + 1] - cap[n] is the current byte count;
  • &p[cap[n]] points to the first current captured byte;
  • ngx_copy() writes that slice at the output position.

In our example, the allocation still reflects the one-byte A capture, while this code copies the 16-byte B capture. Reversing the lengths produces the undercopy case: fewer bytes are written than the result claims to contain.

The copy shown here is the unescaped branch; the escaped branch re-reads the same mutable request state.

7. Where the invariant breaks

Changing capture state is not a memory-safety bug by itself. The vulnerable builder turns it into one by assuming that prediction and emission will agree, while neither bounding the copy nor correcting the published length. Full semantic consistency can be expressed as follows:

For every segment of one complex-value evaluation, the length pass and copy pass must use the same byte source and the same byte count.

Memory safety does not require the two passes to become semantically identical. It requires every copy to remain within the allocated result and the published length to cover only bytes the copy pass emitted. Overcopy violates the first property; undercopy violates the second. A capture update between the two passes can produce either outcome.

Patch-derived sibling paths

Upstream’s Script: buffer overrun protection commit and changelog reveal two sibling ways to produce the same prediction/emission disagreement:

  • Named-capture state. A named capture is exposed through the indexed variable array, r->variables[index]. A regex with a named group can update that entry after ngx_http_script_copy_var_len_code() has obtained its old length but before ngx_http_script_copy_var_code() resolves and copies the variable again. The state carrier and opcodes differ from $1, but the disagreement is the same: prediction and emission observe different values.
  • Non-cacheable or volatile variables. A non-cacheable variable can be reevaluated between its length and copy opcodes. If reevaluation returns a different length, the same allocation-versus-emission mismatch occurs without relying on positional-capture opcodes. A volatile map is the concrete upstream example.

Together, these patch-derived siblings confirm the broader root-cause boundary: mutable state observed independently across two passes. The source and exploit walkthrough below stays with the unnamed-capture path.

Exploitability Analysis

The path to code execution has two complementary halves. If the capture becomes longer between the passes, NGINX writes too far. If it becomes shorter, NGINX can return bytes the second pass never wrote. One side supplies corruption; the other can supply the information needed to use it.

One desynchronization, two useful primitives

Start with the overcopy case. The length pass sees a one-byte capture, so NGINX reserves one byte. Before the copy opcode runs, the map regex replaces the capture state with a 16-byte value. The first byte fits and the remaining 15 bytes continue past the result buffer:

Reserved buffer:     [ 1 byte ]
Bytes copied:        [ B ][ B B B B B B B B B B B B B B B ]
                            └────── 15 bytes out of bounds ──────┘

The request can influence the bytes and the difference len(B) - len(A), but not an arbitrary destination address. This is a forward, sequential copy from the end of a pool allocation. Its usefulness depends on what NGINX allocates or consumes around that region.

Now reverse the lengths. The length pass reserves 16 bytes, but the copy pass emits only one. In the vulnerable implementation, the result can still retain the original 16-byte length:

Recorded length:     16 bytes
Bytes freshly copied:[ B ][ ? ? ? ? ? ? ? ? ? ? ? ? ? ? ? ]
                            └─── 15 bytes not freshly written ───┘

Those question marks are whatever already occupied that part of the allocation. They may be uninteresting, and they are not read from an attacker-chosen address. The important point is that they can become observable when a response path trusts the measured length.

The short copy can expose address material

The source explains why the untouched tail can escape. ngx_pnalloc() sends small, unaligned requests to ngx_palloc_small(), which advances the pool pointer without clearing the returned memory. Separately, ngx_http_complex_value() records the size predicted by the first pass, allocates that amount, and runs the copy opcodes. The advancing copy pointer never replaces the predicted result length.

/* The first pass determines the public length. */
value->len = len;
value->data = ngx_pnalloc(r->pool, len);

e.pos = value->data;
e.buf = *value;

while (*(uintptr_t *) e.ip) {
    code = *(ngx_http_script_code_pt *) e.ip;
    code((ngx_http_script_engine_t *) &e);
}

/* e.pos moved, but e.buf.len still contains the first-pass length. */
*value = e.buf;

A redirect provides a straightforward route to the network. ngx_http_send_response() places the evaluated value in Location. The header filter copies its recorded length through either the absolute-redirect path or the generic header serializer, then sends the buffer through the normal output chain. add_header follows the same pattern: it evaluates the complex value, and ngx_http_add_header() passes the data and recorded length to the response header.

In a separate lab profile, this undercopy returned stale pool bytes through ordinary HTTP response fields. Some samples contained live heap addresses and pointers into the NGINX main image. When the target binary is known and a leaked code pointer can be matched to its static offset, subtracting that offset yields the process’s PIE slide. A recognizable leaked code pointer can therefore weaken ASLR by revealing that slide.

The result is a stale-tail disclosure rather than an arbitrary read. The attacker controls where the short result is used, but not which previous bytes happen to occupy its tail. Reliability depends on allocator history and on obtaining a recognizable pointer.

The official Script: avoid garbage at the end of the result string commit closes this side of the bug by publishing only the bytes the copy pass actually emitted:

/* Fixed code: exclude the untouched tail from the result. */
value->data = e.buf.data;
value->len = e.pos - e.buf.data;

The long copy still needs a useful target

Knowing addresses does not turn the overcopy into an arbitrary write. The bytes still move forward from the complex-value buffer. On the small-allocation path, a useful chain has to shape the request pool so that later NGINX state is placed in, or made to reference, the region already crossed by the copy. Module selection, request phases, pool space, compiler options, and ordinary allocation history all influence that geometry.

That was the second half of the lab work. I used the overcopy to corrupt request-pool state, arranged for a later allocation to overlap a live request-completion callback, and let NGINX reach that callback through its normal completion path. The overwritten callback and data then led into a process-execution path. The execution therefore came from normal NGINX control flow consuming corrupted state, not from the copy instruction jumping directly to code.

The two halves fit together conceptually like this:

undercopy -> stale response bytes -> heap pointer / PIE slide ---+
                                                               |
overcopy  -> pool geometry -> live callback corruption --------+-> code execution

Separating address discovery from code execution

I kept the leak and RCE runs separate. The undercopy profile showed that stale pool bytes can disclose randomized heap and main-image addresses. The Linux RCE profile used ASLR-off addresses supplied by an observe-only debugger, which detached before the attack request, so the run could focus on corruption, callback control, and process execution.

Together, the two runs establish RCE with ASLR disabled and a separate leak that can reveal the main executable’s PIE slide when it returns a recognizable code pointer. They are separate results, not a single end-to-end ASLR-on chain. This matches F5’s boundary: unauthenticated requests can restart a worker, while code execution is possible when ASLR is disabled or bypassed. Successful code runs with the privileges of the affected NGINX worker; F5 does not list memory disclosure as a separate CVE impact.

Remediation

Snapshotting capture bytes and offsets would stabilize the unnamed-capture path, but it would not cover every mutable sibling. NGINX instead made the evaluator safe even when prediction and emission disagree: copies are checked against the measured allocation, and the final string length covers only the bytes actually emitted.

F5 recommends upgrading. The advisory lists NGINX Open Source 1.31.3 and 1.30.4, and NGINX Plus 37.0.3.1 and R36 P7, as fixed. K000162097 contains the product-specific status for the other affected F5 NGINX products.

The upstream changelog ties CVE-2026-42533 to the regex-map capture ordering shown here. The code below shows how the fix contains both directions of the mismatch.

How upstream fixed it

Upstream split the HTTP fix across three commits:

Commit Role in the fix
b767540 — Script: buffer overrun protection Adds the generic script-engine boundary and checks standard copy operations.
25f920e — Script: buffer overrun protection in direct script usage Carries that boundary into direct script evaluation in proxy, proxy_v2, FastCGI, SCGI, uWSGI, gRPC, index, and try_files.
a8289aa — Script: avoid garbage at the end of the result string Publishes the number of bytes actually emitted, excluding an untouched tail when the copy is shorter than predicted.

The nearby 4d32a27 access-log hardening applies the same class of copy bounds to HTTP and stream access-log script operations. The public CVE record does not attribute that commit to this fix.

The generic b767540 commit adds an optional end pointer to the script engine. Once an evaluator allocates the predicted result size, end points one byte past that allocation. Copy opcodes call ngx_http_script_check_length() before writing:

/* Evaluator: set the boundary after allocating the measured length. */
e.end = value->data + len;

/* Shared guard: reject a copy that would cross that boundary. */
if (e->end == NULL) {
    return NGX_OK;
}

if (e->end - e->pos < (ssize_t) len) {
    ngx_log_error(NGX_LOG_ALERT, e->request->connection->log, 0,
                  "no buffer space in script copy");
    e->ip = ngx_http_script_exit;
    e->status = NGX_HTTP_INTERNAL_SERVER_ERROR;
    return NGX_ERROR;
}

If the later capture is too long, the check fails before the write crosses e.end. The script exits with an internal-error status instead of overrunning the result. On a patched build, the guard emits the alert-level message no buffer space in script copy, giving operators a direct signal that prediction and emission disagreed.

The 25f920e follow-up covers modules that evaluate script bytecode directly instead of entering through the generic wrappers updated by b767540. It records the measured subregion, sets e.end, checks the engine status, and fails the enclosing request builder if a copy exceeds its prediction. Backporting only the generic commit would leave these direct consumers outside the intended protection.

A bounds check does not solve the reverse case, where the copy pass writes fewer bytes than predicted. The Script: avoid garbage at the end of the result string commit instead sets the public length from the distance actually advanced by the copy pointer:

value->data = e.buf.data;
value->len = e.pos - e.buf.data;

Across the three-commit fix, NGINX enforces two properties:

  1. the copy pointer must not move beyond the end of the allocated result; and
  2. the published result length must not include bytes the copy pass did not emit.

This is different from snapshotting one stable value. The two passes may still disagree, but the disagreement is contained: the generic and direct-use bounds stop a longer result at the relevant allocation boundary, and the actual-length change prevents a shorter result from exposing its untouched tail. The patch series closes both memory-safety directions without forcing the passes to become semantically identical.

Regression tests worth keeping

  • a one-byte initial capture followed by a long regex-map capture, verifying that the fixed evaluator fails before writing beyond e.end;
  • a long initial capture followed by a shorter response-visible capture, verifying that the result length equals the bytes actually emitted;
  • capture references before and after regex-backed variables in one value;
  • a named capture used outside the regex block that defines or most recently updates it, including a value mutation between ngx_http_script_copy_var_len_code() and ngx_http_script_copy_var_code();
  • volatile maps and other non-cacheable variables whose getters return different lengths across the length and copy passes;
  • escaped and unescaped copy paths;
  • the same capture-state ordering in main-request and subrequest evaluation;
  • response-visible shorter results in redirects and headers;
  • direct script consumers in proxy, proxy_v2, FastCGI, SCGI, uWSGI, gRPC, index, and try_files, so a regression cannot bypass the generic wrapper boundary;
  • assertions that the evaluator sets an allocation boundary, checks failure status, and publishes the actual emitted length, with ASAN retained as supplementary coverage.

If You Cannot Upgrade Yet

The primary mitigation is to upgrade. If that is not immediately possible, F5 advises avoiding unnamed captures and using named captures only in the same block as the regex match. This directly addresses the capture ordering shown here, but it is not a substitute for the fixed code.

Start with expressions that place $1 through $9 before a variable that may run another regex, especially a request-controlled regex map. Then review named captures used outside the block containing their defining regex, along with volatile maps and other non-cacheable variables inside complex strings. Finally, audit script expressions consumed by proxy, proxy_v2, FastCGI, SCGI, uWSGI, gRPC, index, and try_files. Replacing positional captures with named captures in the same block follows F5’s mitigation; splitting an affected expression can also reduce exposure in a specific configuration.

Generic WAF signatures are a poor fit because the vulnerable condition lives in server configuration, capture state, and evaluation order. The demonstrated unnamed-capture request needs no shell metacharacters or obvious exploit marker; ordinary-looking headers, query data, or body bytes can be enough. On a server without the capture-before-map ordering, the same request bytes are benign because the trigger lives in configuration semantics. Application-specific rules can still protect known affected routes, but no reliable content-only signature covers the bug as a whole.

Emergency controls should therefore be specific to known affected routes and configurations. Restrict or authenticate input to those locations, and impose application-specific size limits where large values are unnecessary. Body limits help only when the relevant input or allocation geometry comes from the body; they do not cover header-driven capture changes. These measures reduce exposure but do not repair the code.

Detection and Runtime Signals

Runtime signals worth investigating:

  • the patched alert-level message no buffer space in script copy, which shows a detected length/copy disagreement but is not proof of exploitation;
  • unexpected worker crashes or worker exits with SIGSEGV;
  • response headers or redirects containing unexpected binary-looking or stale bytes.

Unusually long headers or query parameters are only low-confidence signals, not direct signatures of exploitation.

Conclusion

The bug begins when NGINX sizes a component from mutable state and later copies bytes from a different version without containing the disagreement. In the unnamed-capture path, a lazy regex-backed map replaces the request’s captures between those reads, leaving the copy opcode with a buffer sized for old data. In the Linux lab, I carried that mismatch through request-pool corruption to remote code execution.

Upstream preserved the two-pass design while restoring memory safety: generic and direct-consumer guards bound copies in the affected evaluators, and the final string length includes only bytes actually written. The patch also covers named-capture and non-cacheable-variable siblings of the primary path. F5 assigned CVE-2026-42533, confirmed denial-of-service and conditional code-execution impact, and published the fixed releases in K000162097.

Short Technical Summary

CVE-2026-42533 is a heap-buffer-overflow vulnerability in NGINX’s two-pass HTTP script evaluator. In the unnamed positional-capture path, a lazy regex-backed map can replace capture state after a capture has been sized, so the copy opcode reads a different byte source and length from the ones used for allocation. A longer capture overruns the result; a shorter capture leaves an untouched tail under the recorded length. Upstream also identified named-capture and non-cacheable-variable siblings of the same prediction/emission failure. In my Linux lab, I demonstrated remote code execution against NGINX 1.31.1 through the unnamed-capture path. Upstream fixed the class by bounding generic and direct script-copy consumers and publishing only the bytes actually emitted. Operators should install the releases listed in F5 K000162097.

Disclosure Timeline

  • 2026-05-22: Identified the root cause in NGINX 1.31.0.
  • 2026-05-23: Validated remote code execution locally.
  • 2026-05-24: Moved to NGINX 1.31.1 and revalidated the finding.
  • 2026-05-25: Validated remote code execution on Linux amd64 and submitted the report to F5 SIRT.
  • 2026-05-26: F5 SIRT acknowledged the report and confirmed that review was in progress.
  • 2026-06-16: F5 SIRT confirmed the issue and said a fix was in progress.
  • 2026-07-15: F5 published Security Advisory K000162097, assigned CVE-2026-42533, and listed affected and fixed product versions.
  • 2026-07-16: Reviewed the official patch series and updated this write-up.

Acknowledgements

Thanks to F5 SIRT for coordinating the disclosure and to the NGINX engineering team for confirming and fixing the issue.