Following Mutable Regex Captures from a Bounded Copy to a Caller-Side Heap Overflow

Publication 3 November 2026
Research status Independent rediscoveryConfirmed by F5 as a duplicate
First fixed OSS releases 1.31.7 and 1.30.6Released 14 October 2026

Executive Summary

While following the script-engine behavior discussed in NGINX Over-Under Copy, I found another way for a changing regex capture to cause a heap overflow. A regex changes $1 after its length has been measured, so the copy pass emits a longer result. This time, the copies stay inside the allocation: they use space that the caller reserved for a later append.

The path starts with a variable-bearing document root. NGINX measures the root expression, allocates room for both the root and the request URI, then evaluates the expression into that buffer. If the root grows between the two passes, it can consume part of the URI’s share. The evaluator still reports a length that includes the full reserve, and ngx_http_map_uri_to_path() trusts that length when appending the URI and its terminating NUL.

During script evaluation After script evaluation
The expression stays inside the allocation but consumes reserved bytes. The caller appends the URI as though its full reserve were still available.

An unauthenticated client can reach this write by controlling the URI under the non-default configuration shown below. In my Linux reproduction on an unmodified NGINX 1.31.6 build, glibc detected the corruption and aborted the worker.

Disclosure status: F5 confirmed the issue in my report, but classified it as a duplicate: the defect had already been reported and fixed before my submission arrived. It is covered by CVE-2026-93479, “Buffer overflow in script evaluation,” and F5 advisory K000162607. F5 also confirmed that my reproduction aborts a stock 1.31.6 worker and does not affect 1.31.7.

Affected Versions, Fixed Releases, and Source Lineage

The earlier fix series addressed both directions of the length/copy disagreement: bounds checks contained a longer copy, and an actual-length calculation excluded untouched trailing bytes after a shorter one. Commit a8289aa69 introduced that length correction. In script_run(), the calculation also re-added the caller’s reserve. The new failure appears when a copy stays within its bound but uses some of those reserved bytes before the final length is calculated.

This actual-length assignment is present in mainline 1.31.3–1.31.6 and stable 1.30.4–1.30.5. The preceding 1.31.2 and 1.30.3 source snapshots do not contain it. The stable equivalent of the introducing commit is 97e40e59b.

Scope Affected or reviewed range Fixed release or status
Original runtime reproduction NGINX 1.31.6 Worker abort recorded in the report; F5 confirmed the reproduction does not affect 1.31.7
Source-reviewed accounting path 1.31.3–1.31.6; 1.30.4–1.30.5 Reserved-area accounting corrected in 1.31.7 and 1.30.6
Official NGINX Open Source advisory 1.31.2–1.31.6; 1.30.0–1.30.5 1.31.7 and 1.30.6

The official NGINX security list gives the broader affected ranges in the last row. The narrower code path traced here does not establish that earlier releases in the advisory are safe. The NGINX changelog records the mainline fix on 14 October 2026; F5’s response identifies the same release date for the stable fix.

Background

Three details explain the path: $1 refers to mutable request state, a map can replace that state when first evaluated, and the root expression shares its allocation with a later URI append.

1. What can change between the two passes?

NGINX evaluates the root expression in two passes: one measures its components, and the other copies them. A positional capture such as $1 is represented by offsets into the current regex input. For the URI /ABC, the location regex in the configuration below captures just A:

Source string:                 /ABC
Character positions:           0123
Whole match:                   offsets 0 to 4
First capture ($1):            offsets 1 to 2

r->captures_data = "/ABC"
r->captures      = [0, 4, 1, 2]
                         └─┬─┘
                       $1 is here

r->captures_data points to the matched input, r->captures stores the offset pairs, and r->ncaptures bounds the valid entries. A later regex can replace those fields. Measuring $1 reads the current offsets; it does not save a separate string for the copy pass.

2. What does the regex map change?

Here is the relevant part of the reproduction’s configuration:

map $uri $m { "~^(.+)$" 1; }

location ~ "^/(.{1}).*$" {
    root /data/$1/$m;
}

The location regex initially makes $1 the first character after the leading slash. Asking for $m runs the map’s regex against $uri, replacing the first capture’s offsets with 0 and 4 in our example. $1 now selects /ABC. The map returns just 1, even though evaluating it has changed the meaning of $1.

This is a non-default configuration. The client controls the URI, not the root expression or the map. For this plain, cacheable map, $m must first be evaluated during the root’s length pass. An already-valid variable is returned from the request’s variable cache without running its getter again, as shown in ngx_http_get_indexed_variable(). The minimal configuration above leaves that first evaluation to the root expression.

3. Why does the caller reserve extra bytes?

NGINX can build a document root from variables. For a request handled by a variable-bearing root, it first evaluates the root expression, then appends the request URI and a terminating NUL byte. Both operations use the same allocation.

For /ABC, NGINX uses the initial one-byte capture to predict a 9-byte root, /data/A/1. Appending the URI and NUL needs another 5 bytes, giving a 14-byte allocation. The root must stay within its 9-byte share for the full append to fit.

We will use three quantities:

Quantity Meaning
P Predicted bytes for the evaluated expression
R Bytes reserved for the caller’s subsequent write
A Actual bytes emitted by the expression

The allocation contains P + R bytes. The contract requires A <= P, leaving at least R bytes available afterward.

4. What must the copy boundary protect?

The evaluator has to allocate all 14 bytes while limiting the root to the first 9. If it sets the copy boundary at the end of the allocation, a longer root can pass every check while using bytes needed for the URI. The caller will still append the same URI afterward.

Root Cause

With /data/$1/$m, the length pass has already counted $1 when it asks for $m. The map replaces the capture, so the copy pass reads a longer string. We can now trace why the existing bounds check accepts that copy and lets the caller proceed.

The root-path evaluation

ngx_http_map_uri_to_path() builds the filesystem path for the request. It calls ngx_http_script_run() to evaluate the root, then appends the URI to the result. The location regex, map regex, and capture opcodes all operate on the same request’s capture state.

The two functions share one result object: the path builder passes its path pointer into the evaluator, where the parameter is named value. Assigning value->len therefore changes the path->len that the caller will use next.

A concrete example before the C code

Start with the short URI /ABC. The location regex captures A, while the map regex captures /ABC. The root expression is still /data/$1/$m, and $m returns the one-byte string 1.

Moment What NGINX reads Result
Length pass reaches $1 The location capture A Counts 1 byte
Length pass reaches $m The map matches /ABC and returns 1 Replaces $1 with /ABC, then contributes 1 byte
Length pass finishes The earlier contribution from $1 is still 1 Predicts 9 root bytes
Copy pass builds the root $1 now selects /ABC Emits a 12-byte root

Replacing the one-byte A with /ABC adds three bytes to the root:

Measured root:  /data/A/1      9 bytes
Emitted root:   /data//ABC/1  12 bytes

The caller needs another 5 bytes for /ABC and its terminating NUL. NGINX therefore allocates 9 + 5 = 14 bytes. The 12-byte root fits, but leaves only 2 bytes for that 5-byte append.

These numbers come from tracing the code with a short URI. The recorded Linux run uses the same configuration with a longer URI.

1. The caller reserves room for the URI

For a variable-bearing root, ngx_http_map_uri_to_path() asks the script engine to leave room after the expression. In the demonstrated static-file path, the initial reserve is zero and alias is zero. The relevant branch adds the URI length and one byte for the terminator:

reserved += r->uri.len - alias + 1;

For /ABC, that gives reserved = 4 + 1 = 5. The caller passes this number into the evaluator:

if (ngx_http_script_run(r, path, clcf->root_lengths->elts, reserved,
                        clcf->root_values->elts)
    == NULL)
{
    return NULL;
}

Inside ngx_http_script_run(), this argument is named len and still holds the five-byte reserve. The root has not been measured yet.

2. The length pass counts the old capture

The evaluator’s length loop starts with that reserve, then adds each component’s measured size:

n = len;

while (*(uintptr_t *) e.ip) {
    lcode = *(ngx_http_script_len_code_pt *) e.ip;
    n += lcode(&e);
}

Here e.ip points into the length program, lcode is the next length operation, and n accumulates the total allocation size. It starts at 5, then counts the literal /data/, the capture $1, the separating slash, and $m.

When the loop reaches $1, ngx_http_script_copy_capture_len_code() reads the current capture offsets:

cap = r->captures;
len = cap[n + 1] - cap[n];

In this helper, n indexes the capture’s offsets and len is the capture’s byte count; they are local variables, separate from the accumulator and reserve in ngx_http_script_run(). The offsets still describe A, so the helper contributes 1 byte. That contribution stays in the total even if another opcode changes the capture afterward.

3. Resolving the map replaces the capture we just measured

The next regex runs when the length program asks for $m. In this evaluator, e.flushed is set to 1, so the variable-length opcode takes the indexed-variable branch:

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 plain map has not been evaluated earlier in this request. Its indexed lookup therefore invokes the registered getter. The map getter looks up $uri, and the regex branch of that lookup calls ngx_http_regex_exec().

That function supplies the request’s capture array to the regex engine:

rc = ngx_regex_exec(re->regex, s, r->captures, len);

After a successful match, it publishes the current capture count and source string:

r->ncaptures = rc * 2;
r->captures_data = s->data;

The capture offsets now select all four bytes of /ABC. $m itself still contributes only the one-byte result 1. The map has changed what the next read of $1 will return; it has not gone back and changed the one byte already counted for $1.

The length loop finishes with n = 5 + 6 + 1 + 1 + 1 = 14. The allocation will use that total, but the copy program will read the updated capture.

4. Allocation and the copy bound use that same total

The evaluator now allocates the measured total and prepares the copy pass:

value->len = n;
value->data = ngx_pnalloc(r->pool, n);
if (value->data == NULL) {
    return NULL;
}

e.ip = code_values;
e.pos = value->data;
e.end = value->data + n;

Read the last three assignments together: e.ip selects the value-copy program, e.pos starts at the beginning of the new buffer, and e.end marks the boundary checked by copy operations. Since n includes the reserve, that boundary is at offset 14.

Part of the allocation Intended use
First 9 bytes Evaluated root expression
Remaining 5 bytes URI and NUL written later by the caller

Although the caller needs the last 5 bytes for its append, e.end makes them available to the copy program too. The next opcode is checked against that larger region.

5. The capture copy passes the guard

The capture-copy opcode reads the request state again:

cap = r->captures;
len = cap[n + 1] - cap[n];
p = r->captures_data + cap[n];

cap[n] is the capture’s current start offset; cap[n + 1] is its end; and p points to the selected bytes. Those fields now describe /ABC, so the copy length is 4 rather than the 1 measured earlier.

The unescaped branch used by this expression checks the length before writing:

if (ngx_http_script_check_length(e, len) != NGX_OK) {
    return;
}

e->pos = ngx_copy(pos, p, len);

By this point, the literal /data/ has advanced the write pointer to offset 6. The boundary is at offset 14, leaving 8 bytes according to the guard. A four-byte capture fits. The remaining / and $m add two more bytes, and the copy pass finishes at offset 12.

Every copy passed its check. The root is now /data//ABC/1, occupying 12 bytes in the 14-byte allocation. Three of those bytes came from the caller’s five-byte reserve.

6. The returned length assumes the full reserve is still available

After running the copy program, ngx_http_script_run() checks its status and updates the result length:

if (e.status) {
    return NULL;
}

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

return e.pos;

No copy was rejected, so evaluation reaches the assignment. e.pos - value->data accounts for the 12 bytes actually emitted, and len still holds the original five-byte reserve. The result therefore claims 12 + 5 = 17 bytes, although the allocation contains only 14.

The actual root length is correct. The extra five bytes are the problem: three of them have already been used by the root, but the returned length promises all five to the caller again.

7. Path construction trusts the returned length

Back in ngx_http_map_uri_to_path(), the full-name helper leaves this allocation in place. The root starts with /data/, so ngx_get_full_name() recognizes an absolute path and returns without allocating a larger buffer.

The caller then recovers the root length and sets its append pointer:

*root_length = path->len - reserved;
last = path->data + *root_length;

The subtraction gives 17 - 5 = 12, which is the number of root bytes actually written. It does not tell the caller how much capacity remains. Starting at offset 12, the caller appends the four-byte URI and its NUL:

last = ngx_copy(last, r->uri.data + alias, r->uri.len - alias);
*last = '\0';

Only the first two URI bytes fit:

Offsets  0–11: /data//ABC/1   evaluated root
Offsets 12–13: /A            URI bytes inside the allocation
Offsets 14–15: BC            URI bytes outside the allocation
Offset     16: NUL           terminator outside the allocation

The write has crossed the allocation by three bytes. It happens while building the path, before any attempt to open the file, so the directory does not need to exist.

8. Where the invariant breaks

The guard checked the root against the entire allocation. The caller needs a stronger condition: the root and the subsequent append must fit together. Using the quantities from the background section:

The guard enforces:           A <= P + R
The caller still needs:      A + R <= P + R
Successful copying requires: A <= P

For /ABC, the guard accepts 12 <= 14, while the combined operation needs 17 bytes. The three bytes by which the root exceeds its prediction are also the three bytes missing from the caller’s reserve.

A successful script evaluation must leave the caller’s reserved bytes available after the actual result.

Exploitability Analysis

The write extends forward from the evaluated root, and its contents come from the URI. Whether it aborts a worker depends on where the buffer sits and what those bytes overwrite. In my Linux run, the longer URI made the buffer large enough to use a separate heap chunk; glibc detected the corruption when the request was cleaned up.

The URI-controlled write

In place of /ABC, the recorded request used a leading slash followed by 5,001 A characters. The location still captures one byte, so the predicted root stays at 9 bytes. The map now replaces that capture with the entire 5,002-byte URI:

URI bytes                     U = 5002
Caller reserve                R = U + 1 = 5003
Predicted root                P = 6 + 1 + 1 + 1 = 9
Allocated bytes               P + R = 5012
Actual root                   A = 6 + 5002 + 1 + 1 = 5010
Returned path length          A + R = 10013

The script emits 5,010 bytes into a 5,012-byte allocation, so its guard accepts the write. Only two bytes remain. The caller then writes a 5,002-byte URI followed by a NUL:

Byte offsets from the allocation start Contents Within the allocation?
0–5009 Evaluated root Yes
5010–5011 First two URI bytes Yes
5012–10011 Remaining 5,000 URI bytes No
10012 Terminating NUL No

The overflow consists of 5,000 URI bytes and the final NUL. Just as in /ABC, only two bytes of the append fit. The longer URI has increased the amount written past the buffer without causing the script’s own guard to reject the copy.

Why the large request aborts the tested worker

The allocation size matters as well as the overrun size. NGINX takes small allocations from its pool blocks, but obtains a separate heap allocation when the requested buffer exceeds the pool’s small-allocation limit. My test used an unmodified NGINX 1.31.6 release build on Amazon Linux 2023, x86-64, with GCC 11.5 and glibc 2.34. The 5,012-byte path buffer exceeded that limit, and glibc detected the damage when NGINX freed it during request cleanup.

The test session recorded 71 worker aborts for 71 trigger requests, sweeping 4,990 through 5,060 A characters. Those results apply to that build and allocator; the log excerpt below does not provide a complete length-by-length transcript. I did not measure service-wide availability, and a different allocator may handle the corruption differently.

Separating memory corruption from code execution

The official CVE record includes possible code execution when ASLR is disabled or bypassed. My reproduction stops at the heap write and worker abort. I did not carry this write into code execution; the RCE chain in the earlier article used a different path.

Upstream also corrected the analogous stream evaluator. The runtime evidence discussed here comes from the HTTP root-path configuration.

Local reproduction

Build and configuration

Below is a single-request version of the original reproduction, with paths relative to a lab directory. It binds to loopback and can terminate the worker, so use a disposable Linux environment. The logs come from the original test session; I have not rerun these adapted commands or made a fresh comparison with the fixed release for this article.

With a C compiler, make, Git, Python 3, curl, and the PCRE2 and zlib development dependencies installed, start in an empty lab directory:

git clone --depth 1 --branch release-1.31.6 \
    https://github.com/nginx/nginx.git nginx-1.31.6
cd nginx-1.31.6
./auto/configure --prefix="$PWD/../runtime"
make -j2
cd ..
mkdir -p runtime/conf runtime/logs

Save the following as runtime/conf/nginx.conf:

daemon off;
worker_processes 1;
error_log logs/error.log info;
pid nginx.pid;

events {
    worker_connections 128;
}

http {
    access_log off;
    map $uri $m { "~^(.+)$" 1; }

    server {
        listen 127.0.0.1:8461;

        location ~ "^/(.{1}).*$" {
            root /data/$1/$m;
        }
    }
}

Start the test instance from the lab directory:

./nginx-1.31.6/objs/nginx -p "$PWD/runtime/" -c conf/nginx.conf \
    > runtime/logs/stdout.log 2>&1 &

Once startup completes, send one request with the geometry analyzed above:

uri=$(python3 -c 'print("/" + "A" * 5001)')
curl --max-time 5 -sS -o /dev/null "http://127.0.0.1:8461$uri"

What the original report recorded

The original report includes this log excerpt, with the long path abbreviated:

2026/09/25 09:43:53 [error] 99346#0: *13 open() "/data//AAAA…" failed (36: File name too long), client: 127.0.0.1,
free(): invalid next size (normal)
2026/09/25 09:43:53 [alert] 99185#0: worker process 99346 exited on signal 6 (core dumped)

The order matters here. Path construction has already written out of bounds when open() reports File name too long. During cleanup, glibc reports invalid next size, and the master then records worker 99346 exiting on signal 6. The file-open failure alone would not show memory corruption; the allocator diagnostic and worker exit are the crash evidence.

Inspect runtime/logs/error.log, then stop the lab instance:

kill "$(cat runtime/nginx.pid)"

F5 independently confirmed the version comparison in its response: my reproduction aborts a stock 1.31.6 worker and does not affect 1.31.7.

Remediation

To stop this write, the evaluator must leave the caller’s reserve intact. The root can still change between measurement and copying, but a result that uses the URI’s space must be rejected before path construction continues.

Upgrade to a supported release containing the fix. The first corrected NGINX Open Source releases are 1.31.7 and 1.30.6; F5 K000162607 contains the product-specific guidance.

How upstream fixed it

In ngx_http_script_run() in 1.31.7, the length accumulator n starts at zero instead of len. It now counts only the predicted expression bytes. The reserve is added when allocating the buffer:

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

The copy boundary remains:

e.end = value->data + n;

This assignment has not changed, but n no longer includes the reserve. e.end therefore stops at the end of the expression’s budget. After a successful copy, the final length calculation can safely add the reserve because none of it was available to the copy program.

Return to /ABC. The allocation is still 14 bytes, but e.end is now at offset 9. After copying /data/, the engine has only 3 bytes left in the expression’s budget. The current capture needs 4, so the existing guard rejects it. ngx_http_script_run() returns NULL, and the caller exits before appending the URI. The same check rejects the enlarged capture in the long-URI reproduction.

The fixing commit applies the same change to ngx_stream_script_run(). NGINX 1.30.6 contains the stable correction.

Property NGINX 1.31.6 NGINX 1.31.7
Meaning of n Expression prediction plus reserve: P + R Expression prediction only: P
Allocation size P + R P + R
Script copy limit End of the whole allocation End of the expression’s predicted share
Successful returned length A + R, potentially larger than the allocation A + R, with the copy guard enforcing A <= P

The fix leaves capture evaluation unchanged. It contains the disagreement between the two passes by excluding the caller’s reserve from the copy region.

Regression tests worth keeping

Regression coverage should follow the result back to the caller and include:

  • the growing-capture input above, checking that rejection occurs before the URI append;
  • an ordinary expression that fits its prediction and leaves the full reserve available;
  • a shrinking result, preserving the earlier correction for untouched trailing bytes; and
  • a successful return whose reported length never exceeds the allocated buffer.

I have not run these as an additional regression suite for this article.

If You Cannot Upgrade Yet

F5’s published mitigation is to avoid unnamed captures and use named captures only within the block containing the regex match. In the configuration traced here, the relevant combination is the positional capture followed by a regex-backed map variable in root. I have not separately validated the vendor workaround; installing the released fix repairs the evaluator’s accounting directly.

Detection and Runtime Signals

The combination seen in the original run—free(): invalid next size (normal) followed by a worker exit on signal 6—is worth investigating, though other memory-corruption bugs can produce it too. A File name too long error without the allocator diagnostic is not enough to identify this failure.

The fixed evaluator’s length guard logs no buffer space in script copy when it rejects a copy that exceeds the expression’s budget. That is evidence of a detected size mismatch, not proof that an attacker successfully corrupted memory.

Conclusion

The bounds check accepted the longer root because the allocation included space for the URI. That left the script’s own copies inside the buffer while taking away room the caller still needed. The returned length hid that shortage by adding the full reserve again, and the URI append crossed the allocation. In my Linux reproduction, glibc detected the corruption during cleanup and aborted the worker.

Upstream repaired the contract by keeping the expression’s predicted size separate from the reserve. The allocation still holds both, while the copy limit protects only the expression’s share. NGINX 1.31.7 and 1.30.6 contain that correction. F5 confirmed my report as an independent duplicate of CVE-2026-93479.

Short Technical Summary

CVE-2026-93479 affects NGINX script evaluation with caller-reserved space. In the positional-capture path, a lazy regex map changes $1 after its length has been measured. The copy pass can consume reserved bytes while remaining inside the allocation, then report the actual expression length plus the full reserve. The document-root caller trusts that length and appends the request URI beyond the buffer. The original Linux reproduction records a heap out-of-bounds write and worker abort on NGINX 1.31.6. NGINX 1.31.7 and 1.30.6 separate the expression’s copy budget from the caller’s reserve.

Disclosure Timeline

  • 2026-09-25: Recorded the NGINX 1.31.6 worker abort analyzed here.
  • 2026-10-14: F5 published CVE-2026-93479 and the fixes shipped in NGINX 1.31.7 and 1.30.6.
  • 2026-11-03: Published this independent analysis and reproduction as a follow-up to Over-Under Copy.

F5 confirmed that the defect had already been reported and fixed before my submission arrived, so no new CVE was assigned. The September entry dates the experiment, not the submission.

Acknowledgements

Thanks to F5 SIRT for reviewing the report and clarifying its relationship to the existing fix, and to the NGINX engineering team for the correction.

The official CVE record credits Matt Suiche of Tolmo, Inc., Mateusz Gierblinski, LemonTea, and Toan Pham of Team Orca, Sea Security. The upstream fixing commit credits Matt Suiche.