Vulnerability Research
NGINX Measured the String, Then the Value Changed — The Two-Pass Failure Behind CVE-2026-42533
NGINX sizes a complex string, allocates exactly that budget, then copies it; a specific regex map relationship or a qualifying non-cacheable rewrite path can change the bytes between the two passes, so CVE-2026-42533 lets an unauthenticated request overrun a worker heap buffer, trigger worker restarts and denial of service, and—when ASLR is disabled or bypassed—make code execution possible.

In this article
1 July 15 fixed two release lines; scope begins with the artifact, not a version comparator
On July 15, 2026, NGINX published stable 1.30.4 and mainline 1.31.3 together. The decisive event was not merely that two version numbers advanced: both release lines acquired a repair for a worker-process heap buffer overflow reached through a particular relationship between regex map, capture state, and request-time string evaluation. The official 1.31.3 record says that a map variable placed after a capture affected by that map in the same string expression can trigger the defect, and names a similar non-cacheable-variable case; the immutable release source records that wording at NGINX 1.31.3, docs/xml/nginx/changes.xml, release record, lines 8–28. That description supplies the public starting point. It does not by itself explain how NGINX can measure one value and later copy another, nor does it make every installation containing a map reachable.
The scope therefore has three coordinates that must remain separate throughout this investigation: the release artifact that is actually running, the configuration and request path that can activate the state transition, and the evidence level behind each claim. Version establishes whether vulnerable code may be present. Configuration establishes whether the two-pass engine can observe changing state. Traffic establishes whether an unauthenticated crafted HTTP request can drive the later value beyond the earlier allocation. Losing any one coordinate produces a misleading conclusion—either declaring an unreachable server exploitable because its banner is old, or declaring a reachable server safe because a lexically larger version belongs to the wrong branch.
1.1 The release event is frozen as tags, commits, trees, and branch-specific repairs
The public chronology is unusually compact. The nginx.org 2026 news page records the stable and mainline releases on July 15; the mainline CHANGES and stable CHANGES carry equivalent trigger wording; NGINX Plus published PLS.37.0.3.1 LTS and R36 P7; and the F5 CNA record was published at 2026-07-15T14:33:45.810Z. The security patch series was reviewed and merged as NGINX PR #1561 the same day. These are vendor and project facts. SOSEC's repository-derived analysis begins only after binding the public release names to immutable Git objects and reading those objects side by side.
The vulnerable mainline baseline is tag release-1.31.2, resolving to commit 2fd01ed47a1fd2965754c83f53b33a789d0e07f1 and tree a316153a0ab959d1f855ef504baebd1fd0c702b1. Its own version constants identify 1.31.2 at NGINX 1.31.2, src/core/nginx.h, nginx_version and NGINX_VERSION, lines 11–13. The fixed mainline tag release-1.31.3 resolves to commit 073ab5db06ec5f5079280a60a28f450b8f1ac504 and tree 6d5ca42a71655c0ba1cc17c0be336a43cd173515, with the new version constant at NGINX 1.31.3, src/core/nginx.h, nginx_version and NGINX_VERSION, lines 11–13. Stable is frozen independently: release-1.30.3 is commit 47c3628d23efaa1bfb1a32afbe9e3d013f860c2c, tree dbf9e8205f1cde585c2de018aedd69585025e30c; release-1.30.4 is commit 017cf98dcce217946572a896f0992370475e189f, tree 296dce3d163a3a0b4151f5c8ab092defb4c32daf. Commit pinning matters because a readable tag name is convenient for humans, while the commit and tree make a later line-level claim reproducible even if a web interface changes.
| Frozen object | Immutable identity | Role in this investigation | What it does not prove alone |
|---|---|---|---|
release-0.9.6, 2011-03-21 | 57f3455ad3d5e… | First release containing regex-map support; matches nginx.org's historical lower affected bound | It does not mean every configuration created since 2011 reaches the overflow |
| Regex-map introduction | 0519b43a7724… | Repository history describes the feature as allowing regex as a map parameter | Feature introduction is not the same thing as a contemporary exploit demonstration |
release-1.30.3 | 47c3628d23ef…; tree dbf9e820… | Vulnerable stable baseline | A downstream package carrying this visible version may still contain a separately proven backport |
release-1.30.4 | 017cf98dcce2…; tree 296dce3d… | First fixed stable release | It is not a universal lower bound across mainline or embedded products |
release-1.31.2 | 2fd01ed47a1f…; tree a316153a… | Vulnerable mainline baseline used for the execution trace | The tag proves code identity, not local reachability or exploitation |
release-1.31.3 | 073ab5db06ec…; tree 6d5ca42a… | First fixed mainline release and comparison target | It does not establish that an old worker or third-party direct runner has adopted the boundary |
The comparison uses 1.31.2 and 1.31.3 for the detailed HTTP trace because they are adjacent mainline tags, then verifies that stable 1.30.4 carries the corresponding repair family rather than assuming that similar release notes imply identical code. The release-history commits must also stay distinct from the shorter hashes shown in PR #1561 because the series was rewritten when integrated. For CVE-2026-42533, the release history contains the preparatory capture-copy simplification 28219209e0b4…, the principal standard-copy guard b767540492e8…, access-log propagation 4d32a2703c79…, direct-consumer propagation 25f920eca977…, and actual-result-length closure a8289aa69c74…. The stable counterparts have matching patch identities, which is stronger evidence of backport equivalence than matching prose alone.
That boundary prevents a common attribution error. Release 1.31.3 also contains 0cca8e055a2d… for stale regex captures associated with CVE-2026-60005 and 700dc9e0e750… for duplicate subrequest finalization associated with CVE-2026-56434. An earlier compiler bounds change, 42f8df65b694…, is unrelated as well. Their proximity in a tag or pull request does not turn them into parts of CVE-2026-42533. The line-by-line mechanism below therefore attributes only the two-pass sizing and copy-boundary changes to this report; it does not label the entire 1.31.2-to-1.31.3 diff as one vulnerability repair.
Adjacent tags make the mainline comparison controlled. Starting from 1.31.2 and changing to 1.31.3 avoids importing years of unrelated refactoring into the executor trace, while the independent 1.30.3-to-1.30.4 comparison tests whether stable received the same security invariant. This method also makes omissions visible. If a distributor reports only the standard complex-value guard but its build exposes the rewrite runner or another direct script consumer without the matching end/error path, the evidence does not yet close the finding. Conversely, a full logical backport can close it even when a package manager still prints 1.30.3. The question is never “does the string sort above 1.30.4?” It is “which lineage produced this executable, and does every reachable member of the repair contract exist in that build?”
1.2 Stable, mainline, Plus, and embedded products each need their own fixed target
The nginx.org security advisory index labels CVE-2026-42533 major, states a historical vulnerable range of 0.9.6 through 1.31.2, and identifies 1.30.4+ and 1.31.3+ as not vulnerable. The two plus signs describe two branches. Stable 1.30.4 contains the backport even though its number is lower than 1.31.0; mainline 1.31.0 and 1.31.1 do not become safe merely because an ordinary semantic-version comparison places them above 1.30.4. Current stable 1.30.0–1.30.3 moves to 1.30.4 or a later stable release, while mainline through 1.31.2 moves to 1.31.3 or later mainline. An older 1.28, 1.26, or other branch remains within the published historical range but has no named old-branch fixed point, so its defensible destination is a supported fixed line or a distributor build whose equivalent repair can be independently demonstrated.
The raw F5 CNA CVE record encodes Open Source as custom intervals 0.9.6 ≤ v < 1.30.4 and 1.31.2 ≤ v < 1.31.3. That machine-readable shape is not identical to nginx.org's human-readable historical statement. In particular, the second CNA interval must not be inverted into a claim that 1.31.0 and 1.31.1 are safe. The sources answer different representation needs; neither licenses throwing away branch identity. Inventory must first classify an artifact as upstream stable, upstream mainline, Plus, a distribution package, or an embedded downstream image, then apply the fixed target for that lineage.
| Product or release line | Public affected statement | Fixed candidate recorded by the vendor/project | Acceptance boundary |
|---|---|---|---|
| NGINX OSS stable | Current stable 1.30.0–1.30.3; historical range begins at 0.9.6 | 1.30.4 or later stable | Prove branch or equivalent backport; do not apply one lexical comparator |
| NGINX OSS mainline | Historical releases through 1.31.2 | 1.31.3 or later mainline | Do not infer safety for 1.31.0/1.31.1 from the CNA interval shape |
| NGINX Plus 37 LTS | 37.0.0.1–37.0.2.1 | PLS.37.0.3.1 LTS | Record the complete Plus artifact, not only “R37” |
| NGINX Plus legacy model | R33–R36 | R36 P7 or a supported fixed line | R33–R35 have no separate fixed candidate in the advisory |
| NGINX Instance Manager 2.x | 2.17.0–2.22.1 | None on that line | “None” means migrate; it does not mean unaffected |
| F5 WAF for NGINX 5.x | 5.9.0–5.13.3 | 5.13.4 | Update the product artifact, not only the host's nginx package |
| NGINX App Protect WAF 5.x / 4.x | 5.2.0–5.8.0 / 4.11.0–4.16.0 | None on those lines | Move to a supported product release containing the fix |
| NGINX Gateway Fabric 2.x | 2.0.0–2.6.6 | 2.6.7 | Verify the controller/image digest selected by the deployment |
| NGINX Gateway Fabric 1.x | 1.3.0–1.6.2 | None on that line | Migrate rather than treating an unsupported line as an exception |
| NGINX Ingress Controller 5.x LTS | 2026-lts-r1–r3 | 2026-lts-r4 | The LTS artifact name is the unit of acceptance |
| NGINX Ingress Controller 5.x | 5.0.0–5.5.2 | 5.5.3 | Confirm the running workload actually pulled the fixed image |
| NGINX Ingress Controller 4.x / 3.x | 4.0.0–4.0.1 / 3.5.0–3.7.2 | None on those lines | Use a supported fixed line; no candidate is not a waiver |
The downstream rows come from F5 advisory K000162097, updated July 17 and frozen for this review on July 20. They describe evaluated products, not a universal mapping from an embedded nginx -v string. A controller, WAF, or management product can package its own core, modules, base image, and upgrade workflow; replacing nginx.exe or a host package outside that artifact may change nothing. Conversely, a distribution can backport the logical repair while retaining an older visible version. Acceptance for either case needs package or image provenance plus patch/build evidence, not a response Server header and not a ticket containing only the CVE number.
F5's evaluated scope explicitly excludes versions already past End of Technical Support. Within supported scope, the advisory marks BIG-IP Next SPK, BIG-IP Next CNF, BIG-IP Next for Kubernetes, BIG-IP across modules, BIG-IQ Centralized Management, F5 Distributed Cloud services, F5 Silverline services, NGINX One Console, F5OS-A, F5OS-C, NGINX “all other products,” Traffix SDC, and F5 AI Gateway as not vulnerable. That list should be respected rather than expanding the issue to every product sharing a brand, but it cannot be projected backward onto EoTS versions F5 did not evaluate. “Not listed as affected,” “listed as not vulnerable within evaluated scope,” and “outside evaluated scope” are three different inventory outcomes.
There is a second scope boundary between public attack language and code maintenance. F5 and the CNA describe crafted HTTP requests and a data-plane-only issue; they do not claim control-plane exposure. The upstream repair also hardens the parallel stream script engine: vulnerable 1.31.2 has a stream engine with ip and pos but no destination end at NGINX 1.31.2, src/stream/ngx_stream_script.h, ngx_stream_script_engine_t, lines 17–29, while fixed 1.31.3 adds end and status at NGINX 1.31.3, src/stream/ngx_stream_script.h, ngx_stream_script_engine_t, lines 17–31. That symmetry is repository evidence that maintainers considered the same executor contract in both subsystems. It is not evidence to silently rewrite the vendor's HTTP attack statement as “all stream configurations are exploitable.”
1.3 Reachability is a conjunction of gates, and the evidence method stops short of exploitation claims
The narrow public trigger should be read literally before source analysis refines it. Fixed 1.31.3's release source at NGINX 1.31.3, docs/xml/nginx/changes.xml, CVE-2026-42533 release entry, lines 20–26 requires regex matching in map, places the map variable after a capture affected by that map in a string expression, and separately mentions a non-cacheable variable. The F5 record adds an unauthenticated crafted HTTP request, additional conditions outside the attacker's control, worker heap overflow and restart, and possible code execution only when ASLR is disabled or bypassed. F5 classifies the weakness as CWE-122 and the path as data plane, not control plane. These statements support urgency, but they also prohibit the shortcut “default NGINX is reliable unauthenticated RCE.”
The immutable 1.31.2 source supplies two related but distinct sets of mechanism-derived gates. A map variable is registered with ngx_http_map_variable() as its getter at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_block(), lines 220–236, so a declared but unread map does no request-time work. The handler uses a complex source and calls ngx_http_map_find() at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_variable(), lines 107–155; the finder returns an exact/hash result before it ever tries regexes at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_map_find(), lines 2529–2586. In the capture-before-map branch, a qualifying request must reach the affected binary, consume the map variable, miss any earlier decisive hash result, reach a winning regex that updates the relevant capture, enter an outer two-pass string sink with that capture before the map result, and make the later write longer than the earlier budget. The non-cacheable rewrite branch has different gates: a regex-free volatile map with a dynamic result can be reevaluated by the separate rewrite length and value engines, so later result growth can create the mismatch without regex execution or capture publication. Each branch is a conjunction; no individual gate establishes the overflow.
| Decision gate | Evidence that advances the asset | Condition that stops this reconstructed path | Evidence class |
|---|---|---|---|
| Artifact | Upstream affected tag or a downstream build lacking equivalent copy guards | Fixed upstream line or independently proven equivalent backport is actually running | Vendor fact plus artifact inspection |
| Subsystem | Crafted HTTP reaches the data-plane worker and relevant virtual server | Only unrelated management/control-plane service is exposed | F5/CNA fact plus deployment topology |
| Map demand | The map output variable is consumed on the request path | The map is declared but never read for that path | Documentation and source-derived dataflow |
| Capture-branch matcher | A regex entry wins and creates or changes relevant capture state | Only literal/hash entry wins, regex does not match, or no relevant capture exists; this stops the capture branch, not the separate non-cacheable rewrite branch | Source-derived execution trace |
| Consumer order | An outer script measures the affected capture before evaluating the map output | The values never meet in one vulnerable sink/order | Source-derived instruction order |
| Recomputation alternative | A non-cacheable value is evaluated separately by rewrite length and copy engines | The value is cached for the whole evaluation or produces an unchanged second result | Official trigger plus source-derived runner distinction |
| Length delta | Later actual bytes exceed the earlier predicted bytes | Later value is equal or shorter; that may expose a different correctness issue but not this overwrite direction | Source-derived arithmetic |
| Traffic influence | Request URI, header, argument, or another client-influenced field drives the qualifying match and growth | No attacker-influenced data reaches the state transition | Deployment/configuration evidence |
This table is a reachability model, not a claim that SOSEC executed an exploit. The review read the official advisories and documentation, resolved release tags to immutable commits and trees, compared vulnerable 1.31.2 with fixed 1.31.3 and stable 1.30.3 with 1.30.4, traced the compiler and runtime call paths, and checked the public repair commits. It did not run a proof of concept, crash a worker, validate a weaponized request, inspect a production deployment, or review production telemetry. Consequently, claims about the executor and necessary configuration relationship are repository-derived; claims about impact and products remain attributed to F5/nginx; and any assertion that a particular organization was exploited remains unverified until configuration, request, process, and memory evidence converge.
The same discipline applies to negative evidence. For the capture-before-map branch, a map resolved without a winning regex, a relevant expression that is never consumed, a match that does not alter the referenced capture, or a later value that is not longer stops that reconstructed path. A regex-free or literal-key map is not categorically safe, however: when it is volatile and its dynamic result can change, the separate rewrite length and value engines can still instantiate the non-cacheable branch. Disproving either branch does not make the whole binary safe or prove that another string sink or vulnerability is absent. Likewise, ASLR changes F5's condition for possible code execution but does not remove the heap overwrite or worker-restart path. Scope is closed only one proposition at a time: code identity, branch-specific dataflow, state transition or recomputation, length growth, and observed consequence.
2 A request enters the two-pass engine
By the time an HTTP request reaches this machinery, NGINX is no longer parsing configuration text. During configuration loading it has already translated every dynamic string into an executable plan. The request supplies the variable table, regex-capture state, pool allocator, and current input; the plan supplies the order in which those values will be measured and copied. CVE-2026-42533 does not begin with malformed syntax. The configuration is accepted, compiled, and executed in precisely the order the script engine assigned to it.
The central object is ngx_http_complex_value_t. In vulnerable 1.31.2, it holds the original value, an optional sentinel-terminated flush-index list, and two opaque instruction pointers named lengths and values at NGINX 1.31.2, src/http/ngx_http_script.h, ngx_http_complex_value_t/ngx_http_compile_complex_value_t, lines 66–90. A numeric-capture-only dynamic expression leaves flushes null and therefore has no sentinel. One instruction stream predicts output bytes and the parallel stream emits them. A string with no variable or capture takes a literal fast path; a dynamic reference becomes a two-pass program. Fixed 1.31.3 retains the representation at NGINX 1.31.3, src/http/ngx_http_script.h, ngx_http_complex_value_t/ngx_http_compile_complex_value_t, lines 67–91, indicating that the repair enforces execution bounds rather than abolishing the compiler design.
That program has several entry routes. ngx_http_complex_value() owns both passes and the allocation for a general complex value. ngx_http_script_run() exposes the same arrangement to callers that already have separate tapes and may reserve fixed extra bytes. The rewrite engine embeds another form: ngx_http_script_complex_value_code() runs a private length subprogram, allocates, then lets value instructions in the outer rewrite tape fill the buffer. All rely on the premise that their length and value programs describe the same eventual bytes. The premise looks natural because compilation preserves the same lexical order on both rails, but it is not backed by a snapshot. A length instruction may resolve a variable; the getter may run a map and regex; that work may alter state used elsewhere in the same expression.
Configuration lifetime and request lifetime meet only through this plan. The configuration pool owns instruction records and literal operands for as long as the loaded configuration remains active. An HTTP request tree shares its indexed-variable slots and request-pool backing; positional capture fields ordinarily live on each request object, except that a cloned subrequest can temporarily alias its parent's positional state. Each script evaluation has its own write cursor and, in fixed code, its own destination boundary, while the destination buffer itself is allocated from the shared request-tree pool rather than owned by the request object. The plan can therefore be immutable while its observations change. Reusing the same function pointer and operand on both passes proves that the same reader was selected, not that the reader returns the same bytes. This distinction rules out a tempting but incorrect diagnosis that a damaged or concurrently rewritten bytecode tape is required. The tape can be perfectly intact, and one request path can still make its prediction stale through ordinary getter side effects.
2.1 The compiler produces parallel lengths and values bytecode
ngx_http_compile_complex_value() is the configuration-time entry point. In vulnerable 1.31.2, NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_compile_complex_value(), lines 138–239 scans the source and counts ordinary variables separately from numeric captures, initializes the output object, and stops early if there is no dynamic reference. Every dynamic expression receives local configuration-pool arrays for flushes, lengths, and values, but only ordinary indexed references append to flushes; only a nonempty flush list is exposed on the completed complex value and terminated with (ngx_uint_t)-1. A numeric-capture-only expression therefore has dynamic tapes while its public flushes remains null. The compiler wires the arrays into ngx_http_script_compile_t, asks for complete terminators on both tapes, and calls ngx_http_script_compile(). On success the length/value backing storage becomes the runtime intermediate representation. Fixed 1.31.3 performs the same construction at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_compile_complex_value(), lines 144–247; the unrelated i + 1 < v->len parser hardening in that release must not be attributed to this CVE.
“Bytecode” here is threaded code, not a compact numeric opcode interpreted by a switch. Vulnerable 1.31.2 declares one function-pointer type for value operations and another for length operations, followed by records containing a function pointer and an operand such as byte count, variable index, or capture index at NGINX 1.31.2, src/http/ngx_http_script.h, script opcode types and records, lines 89–115. At runtime the engine treats the first word under e.ip as the next function, and each function advances e.ip past its own aligned record. A null word terminates a tape. The compiler walks left to right at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_compile(), lines 459–611: $1–$9 select capture emitters, an identifier selects ordinary-variable emitters, and other bytes form literal runs, except that with compile_args=1 the first question mark becomes paired argument-marker operations emitted by NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_add_args_code(), lines 990–1008. Dependencies are not reordered. A source shaped as capture, separator, then map remains that order on both rails.
For a literal run, vulnerable ngx_http_script_add_copy_code() appends ngx_http_script_copy_len_code and its constant count to lengths, then appends ngx_http_script_copy_code, the count, and the aligned literal bytes to values at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_add_copy_code(), lines 808–849. For an ordinary variable, ngx_http_script_add_var_code() resolves a request-variable index, adds it to flushes, and emits length/copy records carrying that same index at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_add_var_code(), lines 888–930. A named regex capture and a map output both compile as indexed-variable reads, but their producers are different: the map slot is filled by its getter, whereas NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_compile(), lines 2631–2667 registers a named capture and NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_exec(), lines 2712–2737 writes that slot directly as a regex side effect.
A positional capture uses a dedicated record. Vulnerable ngx_http_script_add_capture_code() stores 2 * n because PCRE represents each group with a start/end offset pair, then emits ngx_http_script_copy_capture_len_code and ngx_http_script_copy_capture_code at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_add_capture_code(), lines 1307–1339. Unlike a variable record, it owns no string pointer; each runtime opcode consults the request's current capture offsets and base pointer. Finally, ngx_http_script_done() writes null pointers to the completed length and value tapes at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_done(), lines 725–767. Those nulls are instruction boundaries, not destination boundaries: they say when to stop fetching operations, not how many bytes those operations may write.
| Source segment | Length-rail instruction | Value-rail instruction | Runtime state consulted | Stability property |
|---|---|---|---|---|
| Literal bytes | copy_len returns embedded count | copy emits embedded bytes | Instruction record only | Immutable by itself |
| Ordinary variable, map output, or named capture | copy_var_len resolves the index and returns current len | copy_var resolves the same index and copies current data/len | r->variables[index]; a getter runs only on an applicable cache miss, while regex execution publishes named captures directly | Stable only if no relevant side effect or permitted recomputation intervenes |
Positional $1–$9 | copy_capture_len reads current offset pair | copy_capture rereads offsets and capture-data base | r->captures, r->ncaptures, r->captures_data | Stable only if no later regex replaces request capture state |
| Escaped positional capture | Counts raw bytes plus escape expansion | Escapes the then-current capture slice | Capture vector, base pointer, URI flags, actual bytes | Both slice and encoded length must remain equal |
| Terminator | Null function pointer | Null function pointer | Instruction pointer | Stops dispatch; does not constrain output cursor |
The rewrite compiler shows why the two rails need not occupy parallel standalone arrays. Vulnerable 1.31.2's ngx_http_rewrite_value() creates a ngx_http_script_complex_value_code_t with a private length array, directs sc.values into the location's larger rewrite-code array, and requests only a length terminator at NGINX 1.31.2, src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_value(), lines 965–1019. Fixed 1.31.3 retains that split: NGINX 1.31.3, src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_value(), lines 1007–1014 initializes the compiler and sets complete_lengths; NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_done(), lines 765–771 emits the private null terminator; and NGINX 1.31.3, src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_value(), lines 1020–1027 separately appends the outer complex-value end record. Copy operations still fall through to that record and later rewrite instructions. This is why a precise explanation must name the runner, not casually say that every complex value shares identical cache behavior.
Consider the source order $1|$route, where $route is a map output whose winning regex can replace capture 1. The length program is conceptually capture_len(2), copy_len(1), var_len(route_index), then null. The value program is capture_copy(2), copy("|"), var_copy(route_index), then null. The doubled operand addresses the start/end pair for group 1; the map index addresses its request-variable slot. On the length pass, the first operation measures the old pair. The final operation may run the lazy getter and install a new pair. On the value pass, the first operation rereads that new pair. The compiler has preserved source order exactly and paired every segment with the correct operation family; the unsafe result arises because pairing is temporal, not transactional.
Alignment matters to decoding but not to the capacity claim. Each operation advances e.ip by the aligned size of its record and embedded data, so a reviewer must follow the record layout rather than search a byte array for numeric opcode constants. A malformed advance could dispatch the wrong function, but that is not the path reconstructed here. Every advance can be correct, every null terminator can be present, and the destination can still be too small. Instruction integrity and output-capacity integrity are separate invariants.
The compile-time fast path sharpens the candidate boundary. If counting finds no variables or captures, the original value stays a literal and the two request-time rails are unnecessary. If it finds dynamics, the emitted records retain their lexical dependencies; the compiler neither topologically sorts getters nor snapshots the sources they will read. Static review can therefore reason from the ordered segments of each complex value, but it must resolve what every indexed variable means. A plain variable, named capture, and map output all use the same variable record shape even though only the latter two may hide regex-produced state. Matching the text $name is insufficient without joining the index to its registered getter and producer.
The fixed compiler's new rewrite end operation is not the same as the existing null terminator. The null ends a private length tape or a standalone value tape by stopping instruction dispatch. The rewrite end operation executes inside the larger outer tape after the final complex-value copy and, at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_complex_value_end_code(), lines 1873–1884, closes the produced string to the actual cursor before the outer interpreter continues. Its placement at the expression boundary gives the nested allocation an explicit scope even though value instructions remain interleaved with later rewrite instructions. That detail is why a backport audit must inspect the compiler that initializes the split, the generic compiler that emits the private null, the rewrite compiler that emits the outer end, and the runtime that consumes it.
Configuration-pool capacity is not the request-time output budget. Ordinary complex-value compilation preinitializes its arrays at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_compile_complex_value(), lines 182–205; NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_init_arrays(), lines 682–721 estimates capacity only for compiler callers arriving with null arrays. Appended code can still grow or relocate through NGINX 1.31.2, src/core/ngx_array.c, ngx_array_push_n(), lines 95–140. These configuration allocations protect threaded instructions, not request output. Unused aligned bytes there provide no slack after a request destination cursor. A reviewer should record both allocation domains rather than compare their byte counts.
The flush program is a third compiled artifact with different membership rules. NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_compile_complex_value(), lines 138–239 creates a local flush array for a dynamic expression but attaches the runtime list only after an ordinary-variable reference appends an index; the completed list then receives (ngx_uint_t)-1. A numeric-capture-only value leaves the published flushes pointer null because capture opcodes read request fields directly. Named captures enter when referenced because they compile as indexed variables. The runtime list describes eligible slot invalidation, not every mutable dependency observed by the tapes.
2.2 Runtime follows flush → length → exact allocation → copy
The compact runtime is vulnerable 1.31.2's ngx_http_complex_value() at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_complex_value(), lines 57–104. A literal returns immediately. A dynamic value first calls ngx_http_script_flush_complex_value(), zeroes an engine, points e.ip to lengths, attaches the request, and sets e.flushed = 1. It invokes each length function and accumulates one size_t len; assigns that prediction to the result; calls ngx_pnalloc(r->pool, len) for exactly that many bytes; resets e.ip to values; sets e.pos to the allocation; and dispatches copy functions from the expression's beginning. There is no growth factor, retry, or private value snapshot between the rails.
The initial flush is neither a snapshot nor a second-pass boundary. Vulnerable ngx_http_script_flush_complex_value() at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_flush_complex_value(), lines 35–54 walks compiled indexes and clears valid/not_found only for slots already marked no_cacheable. It does not copy variable strings or capture offsets. With e.flushed = 1, the ordinary length and value opcodes use ngx_http_get_indexed_variable(), which returns a cached slot when either valid or not_found is set and invokes its getter only otherwise. The alternative ngx_http_get_flushed_variable() first examines both cached flags; for a non-cacheable slot it clears both before invoking the indexed path. Both implementations are at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_get_indexed_variable()/ngx_http_get_flushed_variable(), lines 618–685. Thus ordinary ngx_http_complex_value() normally computes a volatile map once after its entry flush. Its first vulnerability class is instead that a later map getter on the length rail changes the capture already measured earlier.
A getter can do extensive work during “length calculation.” The map output getter evaluates its source, runs lookup, and may evaluate a dynamic result; a regex match also changes request capture state. The length pass therefore moves through a sequence of states rather than observing one frozen state. Let S0 be state at entry. Early length opcodes observe S0. A later map opcode invokes a getter and advances the request to S1. The allocation sums measurements taken across that evolving path. The value tape then restarts its first instruction in S1, not S0. Syntactically parallel tapes have become semantically unequal without any concurrency.
Vulnerable ngx_http_script_run() exposes the same accounting to direct callers at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_run(), lines 614–660. It invalidates all currently non-cacheable variables, sets e.flushed = 1, adds the length-opcode prediction to a caller-supplied reserved suffix, allocates the exact total, and returns the dynamic cursor after the value tape. Fixed 1.31.3 keeps that order, sets e.end = value->data + n, and propagates copy status at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_run(), lines 623–678. Its final value->len = e.pos + len - value->data is the actual dynamic cursor distance plus that reserved suffix, not simply the number of bytes already emitted by the dynamic tape. The diff shows that exact allocation remains a valid optimization; unchecked trust in a mutable prediction is what changes.
The non-cacheable class belongs to the rewrite runner and must not be generalized to every call above. Vulnerable ngx_http_rewrite_handler() allocates a zeroed outer engine and leaves flushed false at NGINX 1.31.2, src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_handler(), lines 137–183. When it reaches ngx_http_script_complex_value_code(), that opcode creates another zeroed engine le, runs the private length tape, allocates exactly the sum, and assigns the buffer/cursor to the outer engine at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_complex_value_code(), lines 1759–1799. Both length and later outer copy operations therefore select ngx_http_get_flushed_variable(). A map marked non-cacheable can run while its length is calculated and run again when its bytes are copied. Fixed 1.31.3 adds the boundary and an end opcode at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_complex_value_code()/ngx_http_script_complex_value_end_code(), lines 1834–1884.
| Runner | Who owns the length pass | Value-pass context | Variable getter selection in the relevant path | Distinct drift class |
|---|---|---|---|---|
ngx_http_complex_value() | The function itself after one compiled flush | The same engine is reused with flushed=1 | Indexed getter on both rails | A later length opcode can mutate capture state already measured earlier |
ngx_http_script_run() | The function itself, plus caller-supplied fixed bytes | The same initialized engine restarts on values | Indexed getter after its entry invalidation | Same evolving-state class; direct callers must receive and propagate bounds |
| Rewrite complex value | Zeroed private engine le | Zeroed outer rewrite engine continues through embedded value records | Flushed getter in both contexts because both flushed flags remain false | A non-cacheable value may be recomputed and grow between measurement and copy |
The ordinary sequence can be audited instruction by instruction. Entry invalidates eligible compiled slots once. The engine starts at the first length record. A capture-length record reads the current pair. Literal length adds a constant. A later variable-length record reaches the uncached map getter, whose regex changes the request capture state and whose returned map value becomes valid. The loop terminates and the allocator reserves the exact sum. The same engine then restarts at the first value record with flushed=1. Capture copy reads the new pair. Literal copy advances the cursor. Map copy obtains the cached result. No operation is individually out of order, and the map need not run twice. The mismatch exists because the first capture read precedes the single state-changing map evaluation while the second follows it.
The rewrite sequence differs at two exact points. Its private length engine was zero-initialized rather than configured with flushed=1, and the outer rewrite engine that executes copy records is independently zero-initialized. A variable opcode therefore calls the flushed getter on each rail. Once the first result has been marked non-cacheable, the second getter invalidates and invokes its handler again. If that second evaluation selects a longer dynamic result, the map variable itself supplies the positive delta even without an earlier capture segment. If it also changes a capture consumed elsewhere, both terms can contribute. Describing this as “volatile always runs twice” would still be wrong: the claim is confined to this runner shape and an actually consumed non-cacheable variable.
Exact allocation is not itself a programming error. It becomes unsafe when prediction is treated as authorization. Before each copy of n bytes, the necessary predicate is either that the writer owns an immutable proof that at least n bytes remain or that it evaluates n <= end-pos against the allocation it will update. Vulnerable 1.31.2 has neither for these mutable readers. The fixed design chooses the second predicate, allowing values to remain lazy and mutable while turning an underprediction into a controlled error. This preserves the performance and semantics of the two-pass design in the normal case and moves trust from “the passes must agree” to “the writer must fit.”

A rewrite value with no dollar reference never enters the nested two-pass path. Vulnerable NGINX 1.31.2, src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_value(), lines 965–1019 emits a single ngx_http_script_value_code record when ngx_http_script_variables_count() returns zero; only a dynamic value receives ngx_http_script_complex_value_code, a private length array, and embedded value operations. A constant rewrite string is therefore a poor negative control for the volatile class: it removes the runner under test. A valid control retains a dynamic reference and changes only cacheability or the state transition, preserving the execution contract being compared.
Getter failure follows another path that can masquerade as an empty stable value. When a registered handler does not return NGX_OK, vulnerable NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_get_indexed_variable(), lines 647–664 marks the slot invalid and not found, then returns null. Variable length code contributes zero and variable copy code emits nothing for a not-found slot at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_copy_var_len_code()/ngx_http_script_copy_var_code(), lines 933–987. A zero-length observation is useful negative evidence only after logs and sentinels prove successful getter completion. Otherwise error suppression is being mistaken for stability.
2.3 Literal, variable, and capture opcodes inherited no destination end
The missing enforcement is visible in vulnerable 1.31.2's ngx_http_script_engine_t: NGINX 1.31.2, src/http/ngx_http_script.h, ngx_http_script_engine_t, lines 17–36 contains instruction pointer ip, write cursor pos, stack pointer, working strings, flags, status, and request pointer, but no allocation end. e.buf is not a universal substitute—some runners do not initialize it, and standard copy opcodes never calculate remaining capacity from it. Fixed 1.31.3 inserts u_char *end between pos and sp at NGINX 1.31.3, src/http/ngx_http_script.h, ngx_http_script_engine_t, lines 17–37. That one structural contrast falsifies the idea that old copies had an implicit universal bound.
The literal pair demonstrates the sink contract without creating drift. Vulnerable ngx_http_script_copy_len_code() returns an embedded constant; ngx_http_script_copy_code() later copies that many embedded bytes with ngx_copy() and advances e.pos at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_copy_len_code()/ngx_http_script_copy_code(), lines 852–885. The count cannot change, but the copier still assumes the overall earlier total made room. Fixed 1.31.3 retains the pair and adds a check before the copy at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_copy_len_code()/ngx_http_script_copy_code(), lines 889–927.
The ordinary-variable pair reads live state twice. Vulnerable ngx_http_script_copy_var_len_code() resolves the indexed slot according to e.flushed and returns its current len; ngx_http_script_copy_var_code() resolves the same index again and gives current data/len to ngx_copy() at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_copy_var_len_code()/ngx_http_script_copy_var_code(), lines 933–987. The record stores neither the first length nor the first pointer. “Same index” stands in for “same bytes.” Fixed 1.31.3 measures in the same manner but checks the current length before copying at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_copy_var_len_code()/ngx_http_script_copy_var_code(), lines 975–1034.
Positional captures bypass the variable table but repeat the assumption. Vulnerable ngx_http_script_copy_capture_len_code() subtracts the current start/end offsets and, when necessary, adds escape expansion at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_copy_capture_len_code(), lines 1342–1377. The later ngx_http_script_copy_capture_code() rereads both offsets and r->captures_data, then copies or escapes the then-current slice at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_copy_capture_code(), lines 1380–1417. Its n < r->ncaptures condition proves only that the offset pair lies within the current capture vector/count; it does not prove that an optional subgroup participated, whose converted offsets can remain unset at -1, or that the current pair is the one previously measured. Escaping does not restore stability: if a participating slice changes, raw length and the number of bytes requiring percent expansion can both change.
A named capture travels through the ordinary-variable pair because regex compilation registers its group against a variable index. That representation difference does not create a snapshot: successful regex execution directly overwrites every registered named slot, and a nonparticipating optional group becomes a valid zero-length value derived from unset offsets rather than a matched slice. The slot's cacheable flag is not an immutability guarantee; a later regex side effect can replace it before the value opcode revisits it. Conversely, a literal, cached variable, or capture that remains byte-for-byte stable cannot create drift merely because it uses this engine. The defect requires mutable observation plus growth. The old terminator cannot prevent it, because it limits instruction count rather than output bytes; the old status field cannot prevent it, because none of these copy operations had a reason to set an error before writing.
Fixed 1.31.3 makes the missing predicate explicit in ngx_http_script_check_length() at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_check_length(), lines 827–842. A null end preserves the optional source-level caller convention; otherwise the helper compares remaining capacity with the pending write. On shortage it emits the generic alert, redirects the instruction pointer to the exit opcode, sets internal-error status, and returns NGX_ERROR. The capture writer at NGINX 1.31.3, src/http/ngx_http_script.c, ngx_http_script_copy_capture_code(), lines 1439–1489 invokes that check but does not establish the boundary. Standard wrappers establish and propagate the bounded result through ngx_http_complex_value(), lines 58–110 and ngx_http_script_run(), lines 623–678. The nested rewrite runtime and end opcode at lines 1834–1884 establish that nested value's boundary and close its actual length; copy failure instead reaches the enclosing rewrite control flow because the guard sets the exit pointer and error status, and ngx_http_rewrite_handler(), lines 137–183 returns the engine status after the tape stops. The vulnerable conclusion remains: two rails consult evolving request state, exact allocation has no slack, and 1.31.2 writers receive a cursor without an end.
3 A lazy map rewrites the capture after the length rail has measured it
The public trigger identifies an ordering relationship, not the mere coexistence of regex and map. Fixed 1.31.3's release record says that the affected string places a capture changed by the map before the map result and names a separate non-cacheable-variable class at NGINX 1.31.3, docs/xml/nginx/changes.xml, CVE-2026-42533 release entry, lines 20–26. The official ngx_http_map_module documentation supplies the intended behaviors: map variables are evaluated only when used, regex entries can publish named or positional captures, results can be complex strings, and volatile marks the result non-cacheable. None is independently a defect. The repository trace below shows how those features interact with the vulnerable engine's exact allocation and unchecked copies.
The transition occurs inside one request on one worker. The length rail reads a capture, a later map getter runs a regex and publishes another match, and the value rail rereads the capture. No second thread, timing race, or invalid PCRE boundary is needed. The shared object is the current request's regex state, which is visible to multiple instructions. Fixed 1.31.3 deliberately retains lazy maps and their capture side effects: the map handler and lookup chain remain materially unchanged, while copying gains a destination boundary. That comparison locates the broken contract at prediction-to-copy enforcement rather than at legitimate map semantics.
3.1 The state-changing call path is map variable → map find → regex exec
At configuration time, vulnerable 1.31.2 registers the map output as a changeable variable and installs ngx_http_map_variable() as its getter at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_block(), lines 175–236. The output is getter-produced: ngx_http_get_indexed_variable() returns an already cached request slot or calls that registered handler with the slot to fill at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_get_indexed_variable(), lines 618–665. A regex may run inside the handler's lookup, but the regex executor does not write the map-output slot. Registration therefore creates laziness without executing a match, and first demand can occur exactly where the map variable appears on the length tape, after an earlier capture instruction returned its old size.
Vulnerable ngx_http_map_variable() first evaluates the map source as a complex value, normalizes a trailing dot for hostname mode, and calls ngx_http_map_find(); it then selects the default or evaluates a complex result if the chosen value is dynamic at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_variable(), lines 107–155. A constant result does not make a regex branch inert: selection still runs the regex and can change captures before returning the constant. Conversely, a dynamic result adds nested evaluation but is not a mandatory gate for the ordinary capture-before-map class.
The lookup order is explicit in vulnerable ngx_http_map_find() at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_map_find(), lines 2529–2586. For a nonempty source it allocates and lowercases a copy for the combined-hash key; an empty source keeps that copy null but still calls the combined lookup. NGINX 1.31.2, src/core/ngx_hash.c, ngx_hash_find_combined(), lines 211–245 attempts the exact hash first and, for an empty key, returns before wildcard lookup. A successful exact, wildcard, or hostname result returns without running a regex; only a nonempty complete miss reaches configured regexes in order. The parser separately recognizes ~/~* at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map(), lines 527–562. A scanner must retain lookup priority and reachable branch, not simply flag every map containing a tilde.
Named captures use a different producer. Vulnerable ngx_http_regex_compile() records capture metadata, registers each name as a changeable indexed variable, pairs its index with 2 * group_number, and initially assigns the generic not-found getter at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_compile(), lines 2600–2670. On a successful match, ngx_http_regex_exec() itself indexes r->variables and directly writes every named slot at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_exec(), lines 2712–2737. State ownership is layered rather than simply request-local: a top-level request allocates its indexed array in NGINX 1.31.2, src/http/ngx_http_request.c, ngx_http_alloc_request(), lines 630–637, while every subrequest points to the parent's array at NGINX 1.31.2, src/http/ngx_http_core_module.c, ngx_http_subrequest(), lines 2502–2509. Positional fields belong to each request object: an ordinary subrequest begins zeroed because ngx_http_subrequest(), lines 2423–2426 allocates the request structure with ngx_pcalloc(), whereas a clone temporarily aliases the parent's count, vector, and data pointer until a capture-bearing regex reallocates under ngx_http_subrequest(), lines 2556–2572 and ngx_http_regex_exec(), lines 2683–2694.
The executor's vector setup is conditional, not universal. At NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_exec(), lines 2673–2739, a regex with captures allocates or reuses the request vector, whereas a capture-free regex invokes PCRE with zero vector length. A no-match returns NGX_DECLINED before success publication. After a successful match, however, the code updates every registered named slot and then publishes the positional count and source pointer. Backend handling differs: in the PCRE2 build, the NGINX wrapper obtains the library ovector and copies its offsets into NGINX's integer capture array at NGINX 1.31.2, src/core/ngx_regex.c, PCRE2 ngx_regex_exec(), lines 390–451; in the PCRE1 build, the PCRE1 wrapper, lines 453–460 passes that integer array directly to pcre_exec() for the library to fill. In either backend, an optional named subgroup that did not participate has an unset pair observed as -1, -1, so subtracting it yields a valid zero-length named value, not evidence of a matched slice. Capture bytes are not copied into an expression-owned snapshot.
The ordinary cacheable sequence now has eight deterministic steps: an earlier operation leaves the capture absent, empty, or short; the outer length tape reads it; the later map output is not yet cached; its getter evaluates the source; hash lookup misses and a regex wins; regex execution publishes a longer capture; the map result length completes the budget and becomes cached; the value tape restarts and reads the new capture before copying the cached map result. Prior map evaluation can suppress that late side effect on one path, because the later read may return a cached result. It is not a general mitigation: another phase, location, internal redirect, error path, or request shape can reach the expression before that earlier consumer. Static analysis identifies the potential order; path-aware testing determines whether first evaluation is reachable.
Fixed 1.31.3 preserves ngx_http_map_variable() at NGINX 1.31.3, src/http/modules/ngx_http_map_module.c, ngx_http_map_variable(), lines 107–155 and ngx_http_map_find() at NGINX 1.31.3, src/http/ngx_http_variables.c, ngx_http_map_find(), lines 2529–2586. The analogous stream chain also exists in vulnerable source—NGINX 1.31.2, src/stream/ngx_stream_map_module.c, ngx_stream_map_variable(), lines 105–152, NGINX 1.31.2, src/stream/ngx_stream_variables.c, ngx_stream_map_find(), lines 977–1030, and NGINX 1.31.2, src/stream/ngx_stream_variables.c, ngx_stream_regex_exec(), lines 1122–1186. This parallelism justifies a maintainer audit of stream scripts; the published CVE attack claim remains crafted HTTP and must keep that evidentiary boundary.
Lookup priority creates four distinct outcomes. A successful exact, wildcard, or hostname entry in the combined hash returns before any regex runs. After a hash miss, an empty source skips the regex loop and reaches the default. A nonempty hash miss walks regexes in configuration order: the first success selects its result and publishes match state, while a sequence in which every regex declines returns no value from ngx_http_map_find() and causes ngx_http_map_variable() to select the default. That last default path is not a regex-free hash short circuit. Its defensible capture statement is only that this lookup made no successful-match publication; it does not prove that earlier capture state is absent or immutable. Tests and scanners must preserve these branches rather than treating every default or every map containing a tilde as equivalent.
A successful regex has two outputs that must not be conflated. It selects the map result returned to the output variable, and it publishes match state into the request. The selected result may be a constant, so result evaluation itself can be simple while capture mutation is still significant. The map output may then be cached as valid; that prevents the same getter from running again through the ordinary indexed path, but it does not restore the earlier capture. The value rail can therefore copy a cached five-byte map result and simultaneously reread an eleven-byte capture created while that result was selected. Caching stabilizes the returned map value after first evaluation; it does not roll back side effects of the evaluation.
The readers preserve that distinction. The positional length and copy opcodes at NGINX 1.31.2, src/http/ngx_http_script.c, capture copy opcodes, lines 1343–1413 test only whether n < r->ncaptures; that proves the pair index lies within the published count, not that an optional subgroup participated. An unset -1, -1 pair has zero length rather than a matched slice. State must be traced from the last relevant transition: a map-regex decline makes no successful-match publication, while the rewrite regex wrapper explicitly clears r->ncaptures on NGX_DECLINED at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_regex_start_code(), lines 1065–1075; named indexed slots are not cleared there. Cacheability governs whether a getter runs again. It does not make named or positional capture state immutable.
The call chain also identifies where negative evidence belongs. A declared map with no consumer stops before ngx_http_map_variable(). A consumed map whose source cannot take the candidate value stops before the relevant lookup branch. A hash hit stops before ngx_http_regex_exec(). A regex no-match returns without publishing a successful capture. A successful regex whose captured bytes do not feed an earlier segment stops the documented dependency. Recording the earliest stopped edge produces a review that another operator can reproduce; recording only “map present” or “request returned 200” does not.
For a nonempty normalized key, the combined hash has priority over the map regex loop. Vulnerable NGINX 1.31.2, src/core/ngx_hash.c, ngx_hash_find_combined(), lines 211–245 tries the exact hash, head wildcard, then tail wildcard, returning the first value. Only a complete miss reaches regex iteration in NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_map_find(), lines 2530–2585. For an empty normalized key, ngx_http_map_find() still calls the combined hash: that helper makes the exact-hash attempt and then returns before either wildcard stage; the regex loop is skipped because its len guard is false. “Hash miss” therefore means all three combined-hash structures only for a nonempty key. Priority evidence must preserve the normalized runtime key.
Hostname normalization and regex case handling operate on different representations. NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_variable(), lines 107–154 removes one trailing dot in hostname mode before lookup. For nonempty input, the finder allocates and lowercases a copy for the combined-hash key; for empty input it sets low to null. When regex evaluation is reached, it receives the hostname-normalized but otherwise original, non-lowercased bytes at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_map_find(), lines 2537–2568. The parser chooses case-sensitive ~ or case-insensitive ~* compilation at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map(), lines 527–562. Controls that vary capitalization or a terminal dot must account for these transformations before claiming independent branches.
Map-block handler wiring sends entries to the row parser at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_block(), lines 271–277, where include dispatch occurs at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map(), lines 417–419. NGINX 1.31.2, src/core/ngx_conf_file.c, ngx_conf_include(), lines 821–880 expands platform patterns and parses files inline; regex rows append on encounter at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map(), lines 534–559. Reproduce the platform's glob expansion, preserve inline order, and never globally resort files or rows.
Result-expression deduplication happens before matcher handling. Vulnerable NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map(), lines 421–510 hashes, compares, reuses, or compiles the result object; ngx_http_map(), lines 512–572 then keeps matcher paths distinct, assigning var to a regex row or passing it to ngx_hash_add_key(). The hash builder stores that value pointer at NGINX 1.31.2, src/core/ngx_hash.c, ngx_hash_add_key(), lines 837–851 and lines 1000–1010. At runtime ngx_http_map_find(), lines 2563–2571 executes regex rows in order and returns the winner's stored value, while ngx_http_regex_exec(), lines 2699–2737 publishes that row's captures. Equal result pointers do not merge matcher identity or capture effects.
An omitted default is represented by the shared null variable value. After parsing, vulnerable NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_block(), lines 286–293 selects ngx_http_variable_null_value when no explicit default exists. NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_map_find(), lines 2539–2585 returns null for lowercase-buffer allocation failure, regex execution error, and exhausted matching; the getter substitutes the default for every null at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_variable(), lines 128–139. A default sentinel therefore identifies fallback bytes, not why lookup returned null. Exclude allocation/regex errors and confirm getter completion before recording “no regex matched.”
The map getter writes http map started before source evaluation and writes source/result only on successful completion at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_variable(), lines 107–154, so one start proves one getter attempt, not success. Active debug branches and their HTTP bitmask gates appear at NGINX 1.31.3, src/core/ngx_log.h, debug branches and macros, lines 84–145 and no-variadic debug0/debug2 gates, lines 171–183; under !NGX_DEBUG, the used debug0/debug2 macros expand to nothing at non-debug macros, lines 216–220. Count these records only after build provenance proves debug support and the request's effective log mask enables NGX_LOG_DEBUG_HTTP; silence otherwise is no evidence about evaluation count. With both preconditions established, source/result closes the success distinction and repeated starts can support repeated attempts if routing is constant.
3.2 Named and positional state use different readers, but the same match event makes L and W diverge
Positional syntax takes the dedicated capture route. Vulnerable ngx_http_script_compile() recognizes $1–$9 at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_compile(), lines 480–498, and the emitter stores a doubled pair index. The vulnerable length opcode calculates from current r->captures[n]/[n+1]; the value opcode later reloads those offsets and current r->captures_data. A named capture instead follows the ordinary-variable opcodes after ngx_http_regex_exec() overwrites its indexed slot. Different representation, same hazard: neither reader is bound to what its partner saw earlier.
This is why the vendor's temporary preference for named captures must not be shortened to “named captures cannot participate.” The public buffer-protection commit b7675404… illustrates its side-effect case with a named group, an explicitly empty capture, and a later expression containing capture before map. Names support safer locality—operators can keep a capture close to the regex that defines it—but they do not create lexical ownership or immutability. Referencing a named capture across the defining relationship still reads mutable request state.
Let an outer expression be C + S + M, where C is the capture, S is literal text, and M is the map result. All lengths are encoded bytes, not characters. Let a be the capture length before lazy evaluation, b its length after the map regex, s = |S|, m1 the map-result length seen on the length rail, and m2 the length copied on the value rail. Then L = a + s + m1, W = b + s + m2, and W-L = (b-a) + (m2-m1). For an ordinary cacheable map, its first result is cached, so m1 = m2 = m; the formula reduces to L = a+s+m, W = b+s+m, and the excess is exactly b-a.
A concrete byte example fixes the direction without pretending to be a crash reproduction. If the old capture is 2 bytes, the separator is 1, and the cached map result is 5, vulnerable 1.31.2 allocates L=8. If the map regex changes the capture to 11 bytes, the value rail attempts W=17, an excess of 9. If b=a, no overflow follows from this formula even if the bytes differ. If b<a, the overwrite direction is absent, but the reported result length can encompass unwritten tail bytes. Fixed ngx_http_complex_value(), lines 58–110 closes its result with e.pos - e.buf.data, whereas fixed ngx_http_script_run(), lines 623–678 preserves its caller-reserved suffix with e.pos + len - value->data. Those are wrapper-specific actual-length contracts, not one interchangeable formula. URI escaping likewise requires comparing encoded bytes because a different slice can change both raw count and expansion.
The lifecycle is therefore: configuration registers group metadata; a request arrives with no capture, a previous regex capture, or an explicitly assigned named value; the length opcode reads that state; first demand for the later map runs its winning regex and publishes a new match; the value tape restarts and reads the new state; a still later regex may replace it again. The state is valid at each instant. The error is treating validity at one instant as a durable capacity proof for another.

Putting the map before the affected capture changes this ordinary sequence: the length rail executes the map first and then measures the newly published capture, while the copy rail normally reuses the cached map and same capture. That removes the specific capture-before-map path, but it is not a universal safety theorem; another side effect or non-cacheable reevaluation can still intervene. Fixed 1.31.3 instead enforces the general rule through bounded callers. ngx_http_complex_value(), lines 58–110 and ngx_http_script_run(), lines 623–678 establish the destination boundary and propagate failed status to their callers. The nested rewrite path at lines 1834–1884 establishes the nested boundary and closes actual length; when a writer detects shortage, ngx_http_script_check_length(), lines 827–842 redirects execution to the exit opcode and sets error status, which ngx_http_rewrite_handler(), lines 137–183 returns after the tape stops. The capture writer itself only checks its current pending size before copying at NGINX 1.31.3, ngx_http_script_copy_capture_code(), lines 1439–1489. Mutable capture state no longer authorizes an excess copy.
The three-segment formula extends directly to a real expression with multiple dynamic segments. Let the length rail observe segment lengths x1…xn and the value rail emit y1…yn; then L=Σxi, W=Σyi, and overflow requires Σ(yi-xi)>0. A growing capture can be partially or completely offset by a shrinking later variable, while two individually modest increases can combine. The writer cannot rely on one segment's local equality unless all remaining segments are also stable. This is why testing only the map result or only the capture length can miss the actual total, and why the fixed code checks each pending write against one shared destination end.
Byte representation matters at both observations. NGINX length operations account for the representation their paired copy operation intends to emit, including URI escaping where that operation is selected. A capture that remains five Unicode characters can occupy a different number of bytes after a different match, and an equally long raw slice can require more emitted bytes if additional bytes need escaping. The relevant comparison is never screen width or character count; it is the encoded count produced by the length operation versus the bytes the copy operation will advance over. Test cases should record raw input, selected branch, capture offsets, encoding mode, predicted total where observable, actual guarded result, and the final cursor length.
| Measured state | State after lazy map | Predicted L | Attempted W | Interpretation |
|---|---|---|---|---|
a=2, s=1, m=5 | b=11, m=5 | 8 | 17 | Cacheable capture growth gives a nine-byte excess |
a=11, s=1, m=5 | b=2, m=5 | 17 | 8 | No overwrite direction from this expression; actual output is shorter |
a=2, s=1, m1=9 | b=7, m2=4 | 12 | 12 | State changed but positive and negative deltas cancel |
a=2, s=1, m1=4 | b=7, m2=10 | 7 | 18 | Capture growth and non-cacheable-result growth combine; runner reachability must be proven |
The cancellation rows are important controls. Seeing a different value on the second observation proves mutability, not overflow. Seeing a longer capture proves one positive term, not necessarily a positive total if another segment shrinks. Conversely, a one-variable volatile rewrite expression can omit capture arithmetic entirely and still underpredict when m2>m1. A scanner should therefore report ordered producers and consumers; a guarded fixed-build test should evaluate total encoded output. Neither should label any mismatch as memory corruption on its own.
Named-capture variables are intentionally changeable and can be shared by more than one regex definition. NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_add_variable(), lines 424–499 performs case-insensitive name comparison, rejects reuse of a non-changeable variable, and otherwise returns the existing changeable definition. Regex compilation requests NGX_HTTP_VAR_CHANGEABLE and resolves the resulting index at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_compile(), lines 2631–2667. Two regexes publishing the same capture name can therefore address the same request slot. Names do not namespace state to one regex block; symbol tables must key captures by index as well as spelling.
Named capture and map output use indexed-variable readers, but their producers differ. Fixed NGINX 1.31.3, src/http/modules/ngx_http_map_module.c, ngx_http_map_block(), lines 219–236 registers a changeable map getter; regex setup obtains a named slot at NGINX 1.31.3, src/http/ngx_http_variables.c, ngx_http_regex_compile(), lines 2648–2664. When names are distinct under case-insensitive comparison, result and capture have separate indexes and cache rules. Names equal under that comparison—including spelling case variants—reuse one changeable definition at NGINX 1.31.3, src/http/ngx_http_variables.c, ngx_http_add_variable(), lines 424–466; NGINX 1.31.3, src/http/ngx_http_variables.c, ngx_http_get_variable_index(), lines 558–614 returns that index. Registration order can replace the shared getter, so preserve declaration order, flags, and final handler ownership.
The capture vector has its own capacity calculation, unrelated to the output destination. Configuration records maximum groups at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_compile(), lines 2600–2630, then converts them to PCRE ovector size (ncaptures + 1) * 3 at NGINX 1.31.2, src/http/ngx_http_core_module.c, ngx_http_core_init_main_conf(), lines 3511–3512. At runtime, only a regex with captures enters the allocation/reuse branch at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_exec(), lines 2683–2694; it allocates when absent or marked for reallocation, otherwise reuses the vector. A capture-free regex has zero vector length. This workspace remains separate from script-destination capacity.
A trace must follow the last capture-state transition, not merely the last successful regex. On success, NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_regex_exec(), lines 2699–2739 publishes count, vector, and data base. Capture opcodes at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_copy_capture_len_code()/ngx_http_script_copy_capture_code(), lines 1343–1413 require the doubled index below the current published count and read the current pair/base; vector availability does not prove that an optional subgroup participated, whose offsets may remain unset. The PCRE wrapper's transfer into NGINX's integer capture array is visible at NGINX 1.31.2, src/core/ngx_regex.c, ngx_regex_exec(), lines 390–460. Separately, a declining rewrite regex clears r->ncaptures at NGINX 1.31.2, src/http/ngx_http_script.c, ngx_http_script_regex_start_code(), lines 1065–1075. A replay ledger must order every clear, publish, and read.
Request state has layered ownership. Fixed NGINX 1.31.3, src/http/ngx_http_core_module.c, ngx_http_subrequest(), lines 2426–2438 zero-allocates the object, then every subrequest shares the parent's indexed-variable table at NGINX 1.31.3, src/http/ngx_http_core_module.c, ngx_http_subrequest(), lines 2505–2513. An ordinary subrequest starts with empty, independent positional fields; only a clone aliases the parent's count, vector, and data at NGINX 1.31.3, src/http/ngx_http_core_module.c, ngx_http_subrequest(), lines 2559–2575. Only that clone alias detaches when a later capture-bearing regex allocates under NGINX 1.31.3, src/http/ngx_http_variables.c, ngx_http_regex_exec(), lines 2683–2694. This fixed-release state evidence is not attributed to this CVE's repair.
3.3 volatile enables repeated evaluation, and the trigger matrix separates candidates from reachable paths
The second public class uses a different observation route. In vulnerable 1.31.2, volatile sets the map context's no_cacheable flag at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map(), lines 380–409, and completion of the block transfers NGX_HTTP_VAR_NOCACHEABLE to the map variable at NGINX 1.31.2, src/http/modules/ngx_http_map_module.c, ngx_http_map_block(), lines 266–288. After the getter runs, ngx_http_get_indexed_variable() transfers the flag to the request slot; ngx_http_get_flushed_variable() sees it, clears cached validity, and recomputes at NGINX 1.31.2, src/http/ngx_http_variables.c, ngx_http_get_indexed_variable()/ngx_http_get_flushed_variable(), lines 647–685.
The runner distinction is decisive. Ordinary ngx_http_complex_value() flushes once at entry and gives both rails flushed=1; it must not be described as necessarily recomputing a volatile map between its passes. The rewrite set path is the public non-cacheable example. For a dynamic right-hand side, vulnerable ngx_http_rewrite_value() emits the nested complex-value record and embedded value operations at NGINX 1.31.2, src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_value(), lines 997–1017; ngx_http_rewrite_set(), lines 934–959 separately appends the assignment record that consumes the completed stack value. The zeroed nested length engine and zeroed outer engine both leave flushed=0, so the variable length/copy branches at NGINX 1.31.2, src/http/ngx_http_script.c, variable compilation and copy opcodes, lines 888–987 use flushed lookup. A consumed non-cacheable map can therefore yield m1 on the length rail and m2 on the value rail; in a one-variable expression, the excess is m2-m1.
| Case | Static scan signature | Runtime fact still required | Length/copy conclusion | Safe fixed-build confirmation |
|---|---|---|---|---|
| Lazy capture-before-map | Regex map can set a named/positional capture; one expression consumes that capture before the map result | Map is not already cached, regex branch wins, client-influenced input makes post-map capture longer | m2=m1; hazardous when b>a | Exercise exact virtual host, location, phase, and branch on 1.31.3 and record bounded failure/output |
| Map-before-capture control | Same components, reverse order | No later side effect changes the capture again | This ordinary ordering measures post-map state and removes the documented sequence | Compare with capture-first case using equivalent input |
| Regex without a relevant participating subgroup | ~/~* wins but supplies no longer subgroup consumed earlier | Publishing the new match can make an older positional capture unavailable; another segment must supply the positive net delta | No positive subgroup-growth term from this map, though negative positional drift remains possible | Trace final r->ncaptures and total W-L rather than assuming the vector is unchanged |
| Exact/wildcard/hostname hash hit | The combined hash resolves the normalized source before regex traversal | The tested source must select that exact hash branch | The regex executor is not called, so this lookup makes no successful capture publication | Pair it with a nonempty hash-miss input that reaches the intended regex |
| Empty source after hash miss | The normalized source length is zero and the combined hash supplies no value | The production path must preserve the empty value through source evaluation and hostname normalization | The regex loop is skipped and the handler selects the default; this lookup makes no successful capture publication | Compare empty, nonempty hash-hit, and nonempty regex-hit controls |
| Nonempty hash miss; all regexes decline | The source reaches the ordered regex list, but no entry succeeds | Every earlier regex must be shown to decline before the default is selected | No successful-match publication occurs in this lookup; that does not establish absent prior capture state | Record each attempted row and compare with a first-success input |
| Already-cached ordinary map | An earlier consumer anywhere in the same request tree may populate the shared indexed slot | That evaluation must precede and dominate every route to the sink, including error_page, named-location, ordinary-subrequest, and cloned-subrequest alternatives | The proven route may reuse a cached result; another route can still perform first evaluation inside the candidate expression | Vary routing, request-tree order, and first-consumer placement; another top-level request does not warm this cache |
| Volatile rewrite recomputation | volatile map enters rewrite complex-value path whose two engines use flushed getter | First evaluation changes input/state and second encoded result is longer | Hazardous when m2>m1, or combined W>L | Drive both evaluations on fixed code and inspect guard plus application behavior |
| State changes without growth | Ordering/recomputation exists but tested second representation is equal or shorter | Other attacker-influenced inputs may still grow | No overflow for that sample; not a universal negative | Test boundary classes and encoded lengths, not one nominal request |
| No untrusted HTTP route | Candidate relationship exists but source/sink is not fed by untrusted request data | A data path to regex and sink is needed for the published remote scenario | Configuration candidate, not established unauthenticated reachability | Document variable provenance and trust boundary |
| Stream analogue | Parallel stream map/session script shows same state relationship | Protocol-specific reachability and impact need separate evidence | Source contract is analogous; public crafted-HTTP claim is not enlarged | Audit under a separately labeled stream-hardening track |
Feed that matrix with the post-include effective configuration, not a grep of the primary file. Preserve nginx -T under restricted custody because it can expose credentials and internal routing. Enumerate HTTP maps and separately stream maps; source expressions; exact/string/mask/default/regex priority; all ~/~* entries; named and positional groups; volatile; and every consumer of map outputs and affected captures. Preserve component order inside each complex value, then join it to virtual host, location, rewrite phase, upstream-building directive, error path, and the client-controlled field. Static outputs should be “not a candidate,” “candidate requiring path analysis,” or “reachable drift confirmed safely,” never “exploited.”
The fixed-build test should be designed to falsify the hypothesis as well as exercise it. Keep the artifact, configuration, virtual host, location, and sink constant while changing one causal dimension at a time. A hash-hit control tests whether regex execution is required. Reversing component order tests the ordinary capture-before-map dependency. Equal encoded lengths test whether mutation without growth stays within capacity. Removing volatile tests recomputation only when the chosen runner otherwise matches. If the candidate and controls behave identically, inspect branch selection and first-consumer history before declaring the source model wrong; if only the candidate reaches the guard, the observation supports a prediction/copy mismatch but still does not establish malicious intent or prior exploitation.
4 The repair turns a sizing prediction into an enforceable write boundary
The July 15 repair does not try to make map eager, freeze every capture, or prohibit non-cacheable variables. Each of those choices would change an established configuration language and would still leave other stateful variables capable of surprising a two-pass caller. Instead, NGINX changes the receiving contract: a caller that allocates from the first pass also supplies the exclusive one-past-the-end address to the second pass. Every standard copy operation checks that boundary, and a mismatch becomes a controlled evaluation failure rather than a write past the allocation. The final externally visible result is selected by the caller: rewrite and direct request builders commonly take an internal-error path, standard wrappers return NGX_ERROR or NULL, and when a guarded access-log format operation fails, its handler abandons that invocation without rewriting a completed response. This is why the patch begins in the shared script engine and then travels outward to each consumer.
There are two complementary correctness directions. If the second pass is longer than predicted, the new end check stops it. If it is shorter, a companion change reports only the bytes actually produced instead of returning the original prediction; without that closure, the reported result length can encompass unwritten tail bytes. The first direction is the memory-safety repair at the center of CVE-2026-42533; the second makes the same two-pass interface honest when drift has the opposite sign. Reading both prevents a misleading conclusion that allocating a slightly larger buffer, truncating the copy, or zero-filling spare capacity would restore the full contract.
4.1 e.end makes the destination extent part of the script engine state
In fixed mainline 1.31.3, src/http/ngx_http_script.h, ngx_http_script_engine_t, lines 17–37 inserts u_char *end immediately after pos. The vulnerable 1.31.2 structure at the same path had a current instruction pointer and write cursor but no destination extent. That small structural difference matters because copy opcodes are intentionally generic: the same bytecode can assemble a rewrite result, an upstream parameter, a file candidate, or another complex value. Passing the limit through the engine lets the opcode enforce memory safety without knowing which directive requested the string.
The fixed release-1.31.3/src/http/ngx_http_script.c, ngx_http_complex_value(), lines 58–110 preserves the old length loop and exact ngx_pnalloc(), then sets e.end = value->data + len before executing the value bytecode. It checks e.status after that loop and returns NGX_ERROR on failure. The analogous general runner at lines 623–678, ngx_http_script_run() calculates its total in n, sets the same end pointer, and returns NULL if a copy operation records an error. These are the call sites that turn an optional field into protection for standard evaluation.
The central guard is explicit in ngx_http_script_check_length(), lines 827–842. If end is NULL, the function returns success for source compatibility. Otherwise it subtracts the current cursor from the end and compares the remaining signed extent with the proposed size_t write. A shortage produces an alert containing no buffer space in script copy, redirects the instruction pointer to ngx_http_script_exit, sets NGX_HTTP_INTERNAL_SERVER_ERROR in the engine, and returns NGX_ERROR. The caller must therefore do two things: set the limit and stop when status is nonzero.
That optional-when-NULL behavior is an API- and source-compatibility choice, not a claim that every possible caller becomes safe merely by recompiling and not an ABI guarantee for an already built dynamic module. Standard calls through ngx_http_complex_value() and ngx_http_script_run() now set end; a third-party module that constructs ngx_http_script_engine_t itself and leaves the zeroed field unset receives the old unchecked behavior. A downstream acceptance review should search for direct engine construction, establish the real allocation extent, set end, propagate status, and make every custom copy opcode check its own actual write. The optional field avoids forcing callers that do not use a bounded destination to set a meaningless pointer; it does not turn an unchecked out-of-tree runner into an acceptable security boundary or establish any named third-party module's safety.
The failure is intentionally not silent truncation. A partially copied upstream address, authorization-related variable, cache key, filesystem name, or protocol parameter can have a different meaning from the intended string. Continuing with that prefix could exchange heap corruption for request smuggling, route confusion, or an access-control error. The fixed engine terminates evaluation, and ordinary request-building callers convert the result to their error path. Depending on the consumer and phase, an affected request commonly surfaces as an internal error, but the important invariant is narrower and stronger: no byte at or beyond end is written, and the caller is told that the value was not constructed.
The stable 1.30.4 backport implements the same design rather than a branch-specific workaround. Its principal release-history commit is 7ac67898bdfed2140a1bedcda252074cca11f494, while mainline uses b767540492e8c79a58bc26034d3bab2f708b7bd1; their shared patch ID is a077d24f756826e1c55badd59c0590d29de6beea. Patch identity is useful when reviewing vendor backports because commit hashes necessarily differ across histories. The acceptance question is whether the engine carries an extent, every relevant caller sets it, every copy checks it, and failure propagates—not whether a package happens to contain the mainline object name.
4.2 Literal, variable, capture, escape, and nested-value copies all meet the same guard
The simplest fixed operation appears in ngx_http_script_copy_code(), lines 903–927. Before copying a compiled literal of code->len bytes, it calls ngx_http_script_check_length() and returns immediately on failure. Literals do not normally change between passes, yet guarding them keeps the sink rule complete and protects callers whose surrounding cursor or budget has already diverged. It also avoids a fragile policy in which safety depends on remembering which opcode is believed to be immutable.
Dynamic variables need the guard at the moment their current length is known. In ngx_http_script_copy_var_len_code() and ngx_http_script_copy_var_code(), lines 975–1034, the length opcode returns the variable value seen during sizing, while the value opcode obtains the slot again and checks its current value->len before ngx_copy(). This placement covers the published non-cacheable case: it does not assume that a flushed variable will reproduce the earlier size, and it does not need a map-specific blacklist.
Captures require two calculations because argument escaping can expand them. The fixed ngx_http_script_copy_capture_len_code() and ngx_http_script_copy_capture_code(), lines 1401–1489 recompute len = end_offset - start_offset from the current request capture array and set the source to captures_data + start_offset. For a raw copy, the guard uses len. For an escaped argument, it first asks ngx_escape_uri() how many extra bytes are required and checks len + escape. A capture that grows, or whose escaped representation grows, can no longer cross the allocation.
Nested script contexts also receive an extent. Regex rewrite startup points end at the allocated rewrite buffer, and nested complex-value execution sets it to buf.data + len. When ngx_get_full_name() replaces the buffer with a newly allocated full path, the fixed code moves both pos and end to the end of that new value. These details matter because a correct boundary belongs to the current object, not to the object that happened to exist when the engine structure was first zeroed. Carrying a stale end pointer after buffer replacement would instead create an object/extent mismatch.
Regex replacement and appended-argument construction add another caller-owned region. The fixed regex runtime, lines 1192–1325 establishes the rewrite result's end and checks space for bytes that are appended outside ordinary variable or capture operations. The full-name path, lines 1538–1561 realigns both cursor and extent when ownership changes. These locations show why adding a single guard to capture copy was not enough: bytecode operations, manual separators, and buffer replacement all have to agree on the same current object.
Commit a8289aa69c74f7e664ad63b91c17aa2a554f190f closes the shrink direction, but its formulas differ by caller. At ngx_http_complex_value() lines 106–107, 1.31.3 returns e.pos - e.buf.data rather than the predicted e.buf.len. The general ngx_http_script_run(), lines 623–678 deliberately preserves the caller's reserved suffix: it closes with e.pos + len - value->data, where len is the non-script space supplied by that caller. For rewrite set and other nested complex values, fixed NGINX 1.31.3 src/http/modules/ngx_http_rewrite_module.c, ngx_http_rewrite_value(), lines 1020–1027 appends ngx_http_script_complex_value_end_code; src/http/ngx_http_script.c, the nested runtime and end opcode, lines 1834–1884 then sets the stack value's length from the actual cursor. These changes do not cure an overrun by themselves. They close the separate shrink case in which the reported result length can encompass unwritten tail bytes when later state makes the result shorter.
The two directions produce a useful review truth table. Prediction equals output: the cursor reaches the expected point and behavior is unchanged. Output is shorter: the returned length follows the cursor. Output is longer: the next operation that would exceed end records failure and stops. Allocation failure: the caller returns its existing error before value execution. This table is stronger than testing only one crafted map; it describes the interface contract every future variable implementation must satisfy.

end; later patches carry the same limit into logs and modules that run script bytecode without the standard wrapper.4.3 HTTP and stream share the hardening, while the published attack statement remains HTTP-specific
The stream engine receives the same structural repair. In fixed 1.31.3, src/stream/ngx_stream_script.h, ngx_stream_script_engine_t, lines 17–31 adds end and an engine status field; ngx_stream_complex_value(), lines 59–110 sets the limit and propagates failure, and ngx_stream_script_check_length(), lines 690–705 enforces it. Literal, variable, and capture copies then call the stream-specific guard.
The stream patch can be checked operation by operation. Fixed literal copy is at src/stream/ngx_stream_script.c, lines 766–790, variable length/copy at lines 840–899, and capture length/copy at lines 940–1001. When full-name resolution changes the buffer, lines 1049–1074 propagate failure and realign cursor and end. These ranges matter to stable or distributor review because seeing an end field in the structure proves neither that every copy calls the helper nor that a replaced buffer keeps a correct extent.
That symmetry is source evidence about a shared engine contract, not permission to rewrite the CNA. F5 and the CVE record describe crafted HTTP requests and a data-plane NGINX worker. This report therefore treats HTTP as the publicly established attack path. Stream maintainers should still accept the upstream hardening and review their configurations, because the code owners repaired the same assumption there, but a source diff alone does not establish that every stream deployment meets the CVE's published reachability and impact conditions.
Out-of-tree modules deserve an explicit inventory because end == NULL remains accepted. Search source and build artifacts for direct instances of ngx_http_script_engine_t and ngx_stream_script_engine_t, not merely calls to ngx_http_complex_value(). For each instance, identify the prediction loop, destination allocation, non-script bytes written around opcodes, current buffer replacements, and error return. A module using only the standard complex-value API inherits its guard; a module running values directly has to prove the same propagation as the upstream consumers in the next chapter.
Failure propagation must also be stated by interface. The shared HTTP helper sets its internal engine status to NGX_HTTP_INTERNAL_SERVER_ERROR, but ngx_http_complex_value() exposes NGX_ERROR and ngx_http_script_run() exposes NULL; their callers choose the final request path. The rewrite handler at ngx_http_rewrite_module.c, lines 136–184 returns the engine status directly. Direct protocol and filesystem consumers return their documented error paths. Stream has its own internal-error status. In access logging, Chapter 5 distinguishes a guarded format-operation failure, an if= filter result, and a dynamic-filename result; none retroactively rewrites a completed response. “The guard always returns HTTP 500” would erase these caller boundaries.
A binary upgrade can also change application-visible behavior without indicating a failed fix. A configuration whose two passes had drifted silently may begin returning an internal error while emitting a guard alert. If that drift reaches a guarded access-log format operation, the log-specific alert accompanies abandonment of the current handler invocation, including its later configured targets; Chapter 5 preserves the distinct filter and dynamic-filename branches. That is the repaired engine exposing a latent state dependency. Operators should map the message to the expression, remove the capture ordering or unnecessary recomputation, and retest. Suppressing the alert or reverting to the vulnerable binary restores neither a safe string nor a valid business result.
The acceptance boundary for this chapter is therefore code and behavior together: fixed 1.31.3 or stable 1.30.4 carries the guarded engine; every in-tree and relevant out-of-tree direct runner supplies a correct extent and observes status; shorter results report actual length; representative traffic produces intended strings without guard alerts. Only then can the generic script layer be considered closed before reviewing its specialized consumers.
Stable acceptance can use immutable source as a second baseline. Vulnerable stable commit 47c3628…/src/http/ngx_http_script.c, lines 58–103 performs exact allocation and the unchecked second pass, while fixed stable commit 017cf98…/src/http/ngx_http_script.c, lines 58–109 supplies end, propagates failure, and closes to actual output; its optional check is at lines 824–840. That tag-to-tag comparison prevents a package assessor from treating one mainline commit hash as the only admissible proof while still demanding the same shipped invariant.
5 The boundary has to follow every path that executes copy bytecode
The generic wrappers are central but not universal. Access logs compile their own operations, calculate a line length, and then run those operations into a log buffer. Several upstream protocol and filesystem modules also execute script values directly because they need to interleave generated strings with length fields, separators, frame encoding, or a trailing NUL. Before the repair, those paths inherited the same two-pass assumption without passing through ngx_http_complex_value(). A complete backport must therefore follow the bytecode to every direct sink rather than stop after seeing e.end in the shared structure.
This propagation is also why a short “map module fix” description is technically misleading. The map remains the state-changing producer, but the vulnerable write occurs wherever a first-pass budget is trusted by a second-pass copier. Upstream repairs the receiving graph: standard scripts, HTTP and stream logs, and eight direct HTTP consumers. That graph gives defenders a concrete equivalence checklist for distribution packages and private modules, and it explains why four substantive commits—not one conditional in ngx_http_map_module.c—define the public repair.
5.1 Access logging owns a separate get-length/run loop and a separate failure message
The fixed HTTP access-log operation type changes its calling convention. In release-1.31.3/src/http/modules/ngx_http_log_module.c, lines 17–31, ngx_http_log_op_run_pt now receives both buf and end. This is not the generic script engine structure; it is the log module's own contract. Constant fragments, built-in time and status fields, escaped variables, JSON variables, and unescaped variables all share the explicit extent through their operation signature.
The handler shows why the end excludes the line terminator. At ngx_http_log_handler(), lines 302–387, fixed 1.31.3 adds NGX_LINEFEED_SIZE to the allocation, defines end as the line end before that reserved terminator, and passes it to every operation. Each loop continues only while the cursor is non-NULL. If an operation detects insufficient space, the handler returns NGX_ERROR instead of appending a line feed after a failed copy.
Buffered and allocated/syslog destinations establish the same logical line end through different storage. In the buffered branch at lines 341–354, the available region ends before the reserved line feed; in the allocation branch at lines 375–388, a newly allocated record receives the same restriction. This prevents a superficially complete backport from securing file-buffer output while leaving syslog formatting on the old operation signature. Tests should include every configured destination type, especially when one request is written to several targets.
Variable formats each calculate their actual pending write. The ordinary path at lines 1017–1072 checks one byte for a missing-value dash, the raw length for an unescaped value, or value->len + escape_count * 3 for the default hexadecimal escaping. The JSON path at lines 1136–1185 checks the JSON expansion. The unescaped operation and ngx_http_log_check_length() at lines 1206–1237 emit the distinct alert no buffer space in log script copy.
Those branches cannot share one predicted number because escaping is data dependent. A raw value needs its current byte count; default log escaping can replace one source byte with a three-character hexadecimal sequence; JSON escaping follows a different count and representation; a not-found variable writes a single dash. The fixed operation computes the size that it is about to emit, calls the log-specific helper, and returns NULL before writing when the region is short. This is the same check-current-write principle as the core engine, expressed in the log operation ABI rather than ngx_http_script_engine_t.
The stream log module carries the same design at src/stream/ngx_stream_log_module.c, ngx_stream_log_handler(), lines 249–335, with guarded ordinary, JSON, and unescaped operations at lines 749–970. This source symmetry supports backport completeness. It still does not broaden the public HTTP exploit claim; it shows that upstream recognized the access-log two-pass contract in both subsystems.
The first of three access-log script contexts is the guarded log-format operation. In HTTP, the target loop and both operation-run branches at ngx_http_log_handler(), lines 278–387 consume an operation's NULL by returning NGX_ERROR immediately. The log-specific helper at ngx_http_log_check_length(), lines 1206–1237 emits exactly no buffer space in log script copy before the operation returns NULL. The current target is abandoned and every later configured target in that handler invocation is skipped. Stream has the same log-format-operation result in its target loop and operation branches, lines 225–335, and its log-specific helper, lines 940–970. That source symmetry is defensive stream coverage; the published crafted-request reachability claim remains HTTP-specific.
The second context is an access_log if= filter, evaluated before that target's format operations. The fixed HTTP branch at lines 278–289 calls standard ngx_http_complex_value(). If that API returns an error, the handler returns NGX_ERROR and skips the current and later targets; when the error is a generic copy-bound shortage, the core helper at lines 827–842 emits no buffer space in script copy, without the word log. By contrast, a filter that evaluates successfully to an empty value or 0 takes continue: only that target is skipped, and later configured targets still run. The stream branch uses ngx_stream_complex_value() with the same API-error versus false-predicate split at lines 225–236; its generic helper and alert are at lines 690–705.
The third context is a dynamic log filename, evaluated only after the formatted record exists. HTTP ngx_http_log_write(), lines 430–452 calls ngx_http_log_script_write(), lines 484–552; that function runs ngx_http_script_run() and converts NULL into len as “simulate successful logging.” The target therefore writes no record, but ngx_http_log_write() treats the byte count as success, returns to the target loop, and later configured targets continue. A generic copy-bound shortage on this filename runner emits no buffer space in script copy, not the log-format-operation alert. Stream implements the analogous simulated-success path in ngx_stream_log_write(), lines 366–401 and ngx_stream_log_script_write(), lines 432–447.
The outer dispatchers add one more boundary. The HTTP log-phase dispatcher in src/http/ngx_http_request.c, lines 4015–4030 and the stream dispatcher in src/stream/ngx_stream_handler.c, lines 314–329 call phase handlers without consuming their return values. A format-operation NULL or filter API error therefore stops the remaining targets inside this access-log handler but does not travel backward and turn an already completed HTTP response into a 500 or otherwise rewrite a completed stream session; a false filter predicate or simulated-success filename failure skips only its current target. Other log-phase handlers may still run after the access-log handler returns. These distinctions keep memory bounded without conflating three different missing-record patterns.
5.2 Eight direct consumers protect script bytes and the protocol bytes around them
Commit 25f920eca977139dc6fc7638b419169a602a0064 handles direct execution in FastCGI, SCGI, uWSGI, HTTP/1 proxy, HTTP/2 proxy, gRPC, index, and try_files. These modules cannot merely set end around variable opcodes. Their first pass also budgets bytes written outside the script: FastCGI and uWSGI length fields, SCGI NUL delimiters, HTTP header colons and CRLF, HTTP/2 integer encodings, and filesystem terminators. The fixed code separates each script-owned sub-budget, checks surrounding writes, recomputes actual key or value length when necessary, observes e.status, and rejects length mismatches.
| Fixed 1.31.3 consumer and source location | Boundary established | Additional bytes or completion rule | Failure result |
|---|---|---|---|
FastCGI, ngx_http_fastcgi_module.c, budget lines 877–922 and execution lines 1072–1150 | e.end = b->last + params_len | Checks one- or four-byte encoded lengths and requires e.pos == e.end | NGX_ERROR; a short result logs a request-length mismatch |
SCGI, ngx_http_scgi_module.c, budget lines 683–727 and execution lines 819–914 | Dedicated parameter extent inside the netstring allocation | Checks the NUL after each value and the exact final cursor; outer comma is separately reserved | NGX_ERROR |
uWSGI, ngx_http_uwsgi_module.c, budget lines 880–937 and execution lines 1041–1147 | Parameter extent after the four-byte uWSGI header | Checks two-byte key and value length fields and exact final cursor | NGX_ERROR |
HTTP/1 proxy, ngx_http_proxy_module.c, budgets lines 1240–1324, headers lines 1413–1477, and body lines 1518–1536 | Separate header and configured-body extents | Checks ": ", CRLF, script status, and actual output cursor | NGX_ERROR |
HTTP/2 upstream proxy (proxy_v2), ngx_http_proxy_v2_module.c, budget lines 522–534, headers lines 730–817, and body lines 965–998 | Temporary key/value regions plus header-block and payload ends | Uses actual temporary lengths before integer and HPACK-style encoding | NGX_ERROR on script or frame-space failure |
gRPC, ngx_http_grpc_module.c, budget lines 760–873 and execution lines 1063–1207 | Temporary key/value regions plus complete HTTP/2 header-block and frame payload limits | Uses actual lengths before HPACK-style encoding and verifies payload space | NGX_ERROR |
Index, ngx_http_index_module.c, lines 123–208 | Allocated mapped candidate path | Checks status and trailing NUL, then takes URI/path length from the actual cursor | NGX_HTTP_INTERNAL_SERVER_ERROR |
try_files, ngx_http_try_files_module.c, lines 116–198 | Allocated mapped candidate path | Checks status and trailing NUL; path.len uses actual bytes, while the alias-branch memmove still uses predicted len | NGX_HTTP_INTERNAL_SERVER_ERROR |
FastCGI illustrates the dual check. Fixed ngx_http_fastcgi_create_request() calculates params_len separately from the complete request allocation, points e.end at that parameter region, and checks the one- or four-byte encoded value length before writing it. Each key and value script must finish without status, and the final cursor must equal the predicted end. Growth is stopped by the bound; shrinkage is stopped from creating a malformed outer record by the exact-cursor comparison.
A backport that adds only e.end to the FastCGI engine but omits the surrounding-length checks can still mishandle the bytes generated directly by ngx_http_fastcgi_create_request(). Conversely, increasing the outer allocation without propagating status can allow a failed script to be followed by header finalization. Focused validation should therefore decode the emitted FastCGI parameter stream, compare each declared key/value length with its payload, confirm the aggregate record boundaries, and exercise equal, shorter, and longer second-pass results. The goal is not merely “worker survived”; it is that a valid request is byte-for-byte correct and an inconsistent one is rejected before a malformed upstream record leaves NGINX.
SCGI and uWSGI apply the same logic to their wire formats. SCGI needs one NUL after a generated value. uWSGI writes two little-endian bytes for each key length and two for each value length. A guard only inside ngx_http_script_copy_var_code() would not protect those manual writes if the cursor had already consumed the region. The direct-consumer patch calls ngx_http_script_check_length() before separators or length words and stops when script status is set.
The HTTP/1 proxy maintains separate headers_len and body_len budgets. It bounds header scripts, checks the colon-space pair and CRLF pair surrounding them, then bounds a separately generated configured body. This separation prevents one section's drift from silently consuming another section's allowance. It also demonstrates why a downstream patch that sets end to the entire allocation can be weaker than upstream: the relevant object is the current logical region, not merely the physical buffer's farthest address.
gRPC and proxy_v2 need a second layer because generated keys and values are first written into temporary buffers and then encoded into an HTTP/2 header block. Fixed code bounds each temporary script at tmp_len, derives key_len or val_len from the resulting cursor, and checks the remaining header-block capacity including integer-encoding overhead before writing. A false first-pass key length therefore cannot escape through the encoding function after the temporary copy has been secured.
Index and try_files are filesystem-name consumers rather than upstream protocols. Their fixed handlers bound bytecode to the allocated candidate region, observe engine status, and check one remaining byte for the terminator. Index derives the final URI and path from e.pos; try_files sets path.len from the actual cursor. One detail must remain visible in backport prose: the alias branch at ngx_http_try_files_module.c, lines 164–198 still calls ngx_memmove() with the predicted len - alias count. The repair does not replace every internal count with an actual-length count, so an accurate claim is that candidate execution is bounded and the published path length becomes actual—not that every alias copy was rewritten. Their presence also warns reviewers not to equate “proxy parameters” with the whole sink set.
The table consolidates families, but release acceptance should retain exact source anchors for each module. A vendor may omit a module at build time, carry a renamed fork, or add a private direct runner. Search the delivered source or symbols for engine construction and script value loops; then prove regional bounds, non-script overhead checks, status propagation, and actual cursor use. The upstream list is a verified baseline, not a closed universe for every commercial distribution.

end, checks each actual copy, and records actual output; the guarded log-format-operation press stops its handler invocation rather than turning the completed response into a 500; four conduits summarize four protocol and filesystem framing shapes, not a one-for-one map of the eight HTTP modules; and a custom direct runner remains disconnected until its owner supplies a real end, checks manual bytes, and propagates status.5.3 Commit identity separates the CVE repair from neighboring July security changes
The release-history series begins with preparatory capture-copy simplification 28219209e0b4f9e155fd8bd91ab81b8ac30628f2. It introduces local len and source-pointer calculations but does not add a bound, so it must not be accepted as the fix by itself. The principal guard is b767540492e8c79a58bc26034d3bab2f708b7bd1; access-log propagation is 4d32a2703c79f92b7a99ce3547759cc599d6f83e; direct consumers are 25f920eca977139dc6fc7638b419169a602a0064; and actual-length closure is a8289aa69c74f7e664ad63b91c17aa2a554f190f. These release-history hashes differ from the shorter PR #1561 objects because the public release branch rewrote the history.
Stable 1.30.4 carries rewritten commits because its history has different parents. A local git patch-id --stable comparison of the public diffs produced the five identities below. That repository-derived calculation is reproducible evidence that each stable transformation matches its mainline counterpart; it is not a vendor-published checksum and should not be confused with the annotated release-tag object.
| Repair layer | Mainline release-history commit | Stable rewrite | Common stable patch ID |
|---|---|---|---|
| Preparatory capture-copy simplification; not a guard by itself | 28219209e0b4… | ea47fabf55e2… | aa9b9769583442a32218cc76449c73bd618bcfe9 |
| Generic HTTP and stream engine end/check/status contract | b767540492e8… | 7ac67898bdfe… | a077d24f756826e1c55badd59c0590d29de6beea |
| HTTP and stream access-log operation bounds | 4d32a2703c79… | 78950bdcd443… | 30e9af247f43380f449daef421c4f05367eea137 |
| Eight direct HTTP consumers | 25f920eca977… | 326b17b00383… | ffa90aeb9fd36b2cfad35877ea0d3354070a9cbf |
| Actual-output-length closure | a8289aa69c74… | 97e40e59b7b4… | d722d619875fcd022c47a76d6e1fd8d417b507ea |
The table supports a precise downstream review model. First establish which upstream baseline the vendor used. Then compare each logical transformation, including files omitted because a module was not built and files added because a private module runs the engine directly. A matching package changelog without these transformations is insufficient; a semantically complete vendor diff can be acceptable even though none of the upstream commit hashes exists in its ancestry.
The same 1.31.3 release and PR contain other security work. Release-history commit 0cca8e055a2d909f1a00c2071665b502ec2fe94c, corresponding to PR object 96628b7, repairs stale regex-capture state reached through slice or background-cache subrequests and belongs to CVE-2026-60005. Duplicate SSI subrequest finalization, represented by PR object 1f03529 and its release-history rewrite, belongs to CVE-2026-56434. Neither is evidence about the two-pass allocation drift.
Separating those changes is operationally important. A downstream vendor can correctly backport CVE-2026-42533 without copying an unrelated SSI patch into the same commit, and can copy the SSI patch without fixing script-copy bounds. Treating the entire 1.31.2-to-1.31.3 tree difference as one CVE would make equivalence impossible to review and could create false confidence. The ledger should name the field invariant, affected paths, exact patch or patch ID, and tests expected for each issue.
The public commit messages contribute evidence beyond code shape. The principal repair explicitly gives two configuration classes: a capture changed by a later map lookup and a non-cacheable variable whose length changes between length and copy opcodes. The direct-consumer message explicitly lists proxy, FastCGI, SCGI, uWSGI, gRPC, index, and try_files, while noting the added proxy_v2 hardening. Those messages support intent and coverage; fixed release source proves the final lines that shipped.
A defensible backport receipt can therefore remain compact: vulnerable baseline or vendor parent; the engine-end/check transformation; access-log operation-end transformation; every compiled direct consumer present in that product; actual-length closure; patch identities or an independently reviewed equivalent diff; and focused regression results. A package note saying only “CVE-2026-42533 fixed” lacks enough detail to distinguish the complete graph from a symptom patch.
6 Impact claims have to stop where the public and source evidence stops
The mechanism establishes a worker heap write beyond an allocation when the later value grows, and the vendor establishes remotely supplied HTTP input under the required configuration. Those facts support urgent remediation without collapsing every possible consequence into one headline. The direct published outcome is worker restart and loss of service; F5 adds that code execution may be possible when ASLR is disabled or can be bypassed. The qualifying condition belongs beside the claim every time. Neither an enabled ASLR setting nor a self-healing master removes the overwrite.
Evidence also has a time dimension. The CNA's scores, CISA ADP assessment, frozen KEV absence, and NVD enrichment state describe different questions at named retrieval points. A later exploit report would change the exploitation-status paragraph without changing the source mechanism; a later distribution backport would change an asset's remediation answer without changing the upstream affected history. Keeping those layers separate makes the article updateable and lets incident responders decide from local reachability rather than from a stale label.
6.1 One deterministic request path can corrupt a worker and repeatedly drain availability
The state transition is deterministic within one request. No second thread has to race the worker, and no use-after-free window has to align. The length program observes one valid capture state, a later map evaluation updates the request's capture state, and the value program restarts from the beginning. If the new capture or non-cacheable result is longer than the earlier prediction, old copy code advances beyond the pool allocation. Scheduling can affect the surrounding heap layout and visible crash, but not the logical mismatch W > L.
The bytes beyond the allocation are drawn from a request-influenced variable or capture. That is more serious than an assertion at the boundary because the worker may corrupt a neighboring pool object before it faults, logs, or returns. Source review can establish the destination extent, copied length, and missing check. It cannot, without a controlled run on a named build, establish which neighboring object is overwritten, how its fields are used, or whether attacker-selected bytes can survive long enough to control an instruction pointer.
F5 describes a restart, which is consistent with NGINX's master/worker model. A worker handles data-plane connections; the master owns configuration control and process supervision. An abnormal worker exit does not by itself prove control-plane access, configuration change, or master compromise. The CNA explicitly identifies a data-plane issue. The practical outage begins because all connections on that worker are interrupted and a replacement loads the same binary and hazardous configuration.
Automatic replacement changes recovery time, not the vulnerability. If an equivalent request reaches the same virtual server after a new worker starts, the state relation is recreated. Repeated exits can concentrate traffic on remaining workers, increase upstream retries, exhaust connection queues, and trigger health-check removal. A fleet with one healthy spare may therefore lose much more capacity than the nominal fraction represented by one worker.
The local availability calculation should name connections and dependencies. Record workers per instance, connections per worker, graceful-retry behavior at clients and upstreams, load-balancer ejection thresholds, spare replicas, and the identity or application services hit by reconnects. A worker that serves long-lived streams or expensive upstream requests can create a longer recovery tail than a short stateless request workload. CVSS conveys a general severity; this capacity model schedules the change.
ASLR changes one exploitation prerequisite. F5's wording allows possible code execution when ASLR is disabled or can be bypassed, so inventories should record hardening and exceptions. But ASLR does not prevent the out-of-bounds write, make the worker's heap object larger, or stop repeated termination. Treating “ASLR enabled” as remediation would leave the published denial-of-service path and memory corruption intact.
The converse overstatement is equally unhelpful. The public record does not provide a reliable arbitrary-code-execution chain against every default contemporary deployment. A defensible higher-impact claim would need a specific, pinned vulnerable build, compiler and allocator details, deterministic neighboring object, controllable overwrite, ASLR defeat or disabled state, and a viable execution window before process death. Until such evidence is reproduced and coordinated, this report preserves F5's conditional language rather than promoting possibility to demonstrated RCE.
Confidentiality and integrity scores in the CNA express potential vulnerable-system impact under the scoring model; they are not a public incident report of stolen data or modified configuration. For a specific event, data disclosure, command execution, or persistence needs independent evidence: unexpected child processes, file modifications, executable mappings, network destinations, credential use, or memory artifacts. A signal 11 and a matching map configuration establish a strong memory-safety hypothesis, not every downstream consequence.
This calibration does not reduce urgency. A remotely influenced heap overwrite in an internet gateway, fixed upstream and capable of restarting workers, is a high-value maintenance item even when attack complexity is high. Precise qualifications direct the response toward the actual gates—binary, configuration dataflow, traffic control, process churn, and hardening—instead of a generic emergency message that cannot distinguish exposed and non-exposed instances.
6.2 major, 9.2, KEV absence, and NVD enrichment answer different questions
| Source and frozen time | Published state | What it supports | What it does not support |
|---|---|---|---|
| nginx.org security advisory | major | The project's own severity vocabulary and affected/fixed branch statement | A numerical CVSS score or an exploitation report |
| F5 CNA, published July 15 and updated July 16, 2026 | CVSS 4.0 9.2 Critical; CVSS 3.1 8.1 High; CWE-122 | Vendor scoring, network/data-plane description, conditions, affected products, and ASLR qualification | A NIST-authored score or proof of reliable default RCE |
| CISA ADP timestamped assessment | Exploitation none; Automatable no; Technical Impact total | The enrichment provider's state at that timestamp | A permanent forecast about exploit tooling or later activity |
| CISA KEV catalog v2026.07.16, checked July 20 | CVE absent from the 1,647-entry frozen catalog | That release did not list it as known exploited | Evidence that exploitation was impossible or had never occurred |
| NVD on July 20, 2026 | Awaiting Enrichment | NVD had received the record but had not published a NIST assessment | Permission to call the displayed CNA values “NVD scores” |
The nginx.org label and F5 scores can coexist because they belong to different systems. Replacing major with “Critical” in a quotation would misstate the project page; replacing the CNA's 9.2 with major would discard the quantitative vector. The article attributes each and lets the reader compare them. Asset policy can then map either source into its own service criticality without pretending the labels are synonymous.
The CVSS 4.0 vector records network reachability, high attack complexity, no privileges or user action, and high confidentiality, integrity, and availability impact on the vulnerable system, with no subsequent-system impact. The 3.1 vector expresses a related result under the older framework. The configuration and environmental prerequisites explain the high complexity. They do not turn the input into an authenticated administration action; the request reaches the data plane without application credentials once the deployment's network and TLS prerequisites are satisfied.
CISA ADP's Exploitation: none is a timestamped classification embedded in the CVE record. It should be written in past tense and with its provider. Automatable: no similarly reflects an assessment of repeatability and prerequisites at that time, not a guarantee that a future scanner, configuration fingerprint, or exploit harness cannot reduce operator effort. Local automation is still possible where an organization already knows its own configuration and client prerequisites.
KEV is a positive catalog of vulnerabilities CISA has added under its criteria, not an exhaustive database of every malicious request. Absence from v2026.07.16 supports only a narrow historical statement. It cannot close a hunt, reduce the required fixed version, or prove that a private incident did not occur. If the identifier appears in a later KEV release, the frozen statement remains historically correct and the current status paragraph must be updated.
NVD's “Awaiting Enrichment” state prevents attribution drift. A page can display CNA-provided CVSS values before NIST completes its own analysis. The source register records that the 9.2 and 8.1 were F5 values. This matters in audit systems that distinguish vendor assessment from NIST enrichment and in later revisions where NVD may add a different vector.
Publication time, discovery time, and remediation time are also different. The CVE was reserved on May 5, the releases and CNA record became public on July 15, the record and ADP material changed on July 16, F5 updated its advisory on July 17, and this source review froze status on July 20. An incident predating disclosure is not excluded by the absence of a public identifier then; it simply must be investigated from process, configuration, and request evidence rather than matched to a contemporaneous alert label.
Priority should combine this public layer with local fields: fixed or vulnerable binary, hazardous map relationship, internet or partner reachability, client control over the source string, worker crash history, ASLR state, service concentration, and upgrade availability. A non-KEV vulnerability can be the highest-priority item on an exposed gateway. Conversely, a vulnerable package in an offline image with no qualifying configuration can be scheduled differently while still being upgraded before activation.
The status sentence for the frozen report is deliberately bounded: at the checked disclosure window CISA ADP recorded no exploitation, and CVE-2026-42533 was absent from KEV v2026.07.16; the condition-dependent heap corruption still warrants remediation based on local reachability and impact. This phrasing can survive later evidence because it names its date and does not convert absence into assurance.
6.3 Investigation has to reconnect a build, expression, state change, request, and process exit
The fixed alert no buffer space in script copy proves that a guarded generic script attempted to exceed its prediction. The guarded log-format alert adds the word log. Neither proves hostile intent or prior exploitation. A production request, health check, or rare route can first exercise a configuration dependency that has existed for years. Treat the alert as a high-fidelity pointer to a mismatched expression, preserve the request context, and correct the relationship.
A vulnerable build cannot emit the new guard because it has no end check. Its evidence can be sparse: an abnormal worker exit, signal, core, allocator diagnostic, rapid PID replacement, connection reset, gateway 5xx, upstream retry, or load-balancer health transition. NGINX's error log may identify the signal and worker PID but not the generated string. Access logging can be incomplete because it is one affected sink and because process death can precede flush.
Start collection by freezing identity. Record the executable path and hash mapped by the worker, package or image digest, nginx -V output, loaded dynamic modules, master and worker PIDs, process start times, and the effective configuration revision. A package database updated after the process started is not the same as the mapped object. Container orchestration history should retain the previous instance and rollout revision before automatic cleanup removes them.
Preserve the effective configuration with nginx -T under restricted access. It can contain upstream addresses, paths, credentials, and certificate locations. Enumerate regex maps, named and positional captures, volatile, and every consumer expression. For the candidate virtual server, write the exact evaluation order and determine whether client-controlled URI, headers, arguments, cookies, or derived variables reach the map source.
The state ledger should contain at least predicted segments, state-changing lookup, post-lookup capture offsets or variable length, actual write segments, sink, and guard or crash outcome. It need not store full sensitive values. Lengths, variable indexes, expression identifiers, a request correlation ID, and a safely hashed source can show that b > a while avoiding a new credential or personal-data repository.
Time alignment needs multiple clocks. Normalize edge or load-balancer request time, NGINX error and access logs, kernel or service-manager events, container restarts, upstream retries, and external availability probes. Record timezone and clock skew. A worker may exit between accepting a request and final logging, so the strongest correlation can be an edge request followed by one worker PID disappearance and matching resets across upstream telemetry.
A single signal 11 is not unique to this CVE. Dynamic modules, allocator corruption from another request, hardware faults, and other July security issues can terminate workers. The CVE hypothesis strengthens when the binary is affected, the effective expression has the published ordering or non-cacheable behavior, input can control the source, observed lengths drift, and the crash repeats on that path. It weakens when any required gate is absent or a fixed build stops safely at the guard.
Code-execution attribution has a higher bar. Search for unexpected process ancestry, executable memory or modules, file writes, new accounts, persistence, anomalous outbound connections, credential use, and control-plane changes. A heap-overflow crash alone does not establish those. Conversely, evidence of a child process or persistence should not be dismissed merely because the public direct outcome emphasizes restart; it must be investigated as a potentially separate or escalated event.
Cores and sanitizer reports can contain full requests, headers, secrets, upstream data, or memory from other connections. Apply restricted collection, encryption, retention, and disclosure handling. Record compiler, optimization, allocator, sanitizer, architecture, and exact commit beside every stack. A source line in a distribution backport can shift; function, instruction, object extent, and build identity make the finding reproducible.
A useful evidence matrix has rows for each suspected event and columns for runtime identity, configuration dataflow, input correlation, first abnormal operation, process outcome, service effect, and higher-impact indicators. Mark unknown rather than forcing a binary conclusion. This makes the distinction between “vulnerable and reachable,” “guarded mismatch,” “crash consistent with CVE,” and “confirmed exploitation consequence” visible to reviewers.
No local exploit, worker crash reproduction, proof-of-concept validation, or production telemetry review was performed for this article. Its mechanism conclusions come from immutable upstream source and public repair commits; its impact and product claims come from the cited vendor, CNA, and government records. Any new run that appears to establish a stronger effect should be retained privately and coordinated with the maintainer before publication rather than inserted as an unverified headline.
7 Closure replaces the vulnerable code, the hazardous state relationship, and every old process generation
A successful change has three independent proofs. The running artifact contains the complete repair for its branch or product. The effective configuration has been reviewed for the published ordering and non-cacheable paths, and any guard alert found during testing has been resolved rather than hidden. Finally, no worker or old master capable of accepting production traffic remains mapped to the vulnerable executable. A package installation, syntax check, or one green probe establishes only part of that result.
The work begins from runtime identity and ends with traffic evidence. Between them lie configuration provenance, include expansion, downstream product mapping, canary behavior, protocol regression, process-control semantics, rollback, and incident review. Keeping those fields in one change record makes an exception visible: an appliance waiting for firmware, an out-of-tree module without an end pointer, a long-lived old worker, or a rare map route that now returns a guarded error cannot disappear behind a fleet percentage.
7.1 Inventory the mapped executable and trace the effective configuration as a dataflow
Start with every listener and workload that embeds NGINX: upstream OSS packages, NGINX Plus, ingress or gateway controllers, WAF appliances, sidecars, containers, virtual-machine images, disaster-recovery nodes, and autoscaling templates. Record service owner, network scope, role, release line, package or image provenance, digest, current master and worker PIDs, start time, dynamic modules, traffic share, and rollback candidate. Dormant templates matter because an emergency scale-out can activate them after the live fleet is fixed.
Collect nginx -v and nginx -V from the actual executable path and associate them with a file hash or image layer. The Server response header can be hidden, rewritten, or emitted by a different proxy, so it is not runtime proof. A package-manager version can also disagree with a process that started before the update. On Linux, process executable mappings or the /proc executable link can tie a PID to an inode; containers need image digest and workload revision as well.
Distribution backports require a chain rather than a string comparison. The vendor advisory maps a package build to the CVE; source or a patch shows the guarded generic engine, log operations, direct consumers present in that build, and actual-length handling; the delivered repository or image contains that package; the deployment selects it; and the running process maps it. Each missing link creates a common false closure: fixed repository but old image, fixed image but old rollout, installed package but old PID, or a partial patch labelled with the CVE.
Use nginx -T to obtain the post-include effective configuration and protect the output as sensitive. Capture the exact binary and module set first, because an older parser or private module can change accepted directives. Save a checksum, collection time, host or workload identity, and configuration revision so a reviewer can distinguish what was tested from what was later deployed.
Enumerate every HTTP map block, not merely files whose names suggest routing. For each one, record its source complex value, result variable, string keys, entries beginning with ~ or ~*, named and positional captures, complex results, volatile, include provenance, and consuming virtual servers. A declared map is lazy; it becomes relevant only when some directive reads its result.
Then build a use graph. Search set, rewrite and return expressions, proxy URLs and headers, configured request bodies, FastCGI/SCGI/uWSGI parameters, gRPC headers, index, try_files, and log formats. Preserve expression order. The first public class requires a capture to appear before the map result that changes it within the same generated string; reversing that order or separating values can alter whether the first measurement is stale.
Named and positional captures need separate inventory entries even when they originate from the same regex. A named capture occupies a variable slot whose data and length are updated by regex execution. Positional captures use request-level offset pairs and captures_data. Other regex directives can also update those request fields. Trace which match establishes the pre-map capture and which map match replaces it; do not infer ownership from the variable spelling alone.
The volatile class requires the exact rewrite execution context, not a global assumption about every complex value. Identify set values that compile through ngx_http_script_complex_value_code(), the non-cacheable map variable they contain, and any capture or input changed between its length and outer copy evaluation. Ordinary ngx_http_complex_value() preflushes once and uses indexed getters for both passes; marking a map volatile is therefore a candidate, not proof that every consumer reevaluates it twice.
For each candidate, identify the remote data origin. URI, arguments, headers, cookies, host, client address, TLS metadata, upstream-derived values, and internally generated variables have different control and normalization. Record front-proxy transformations, request-size limits, authentication or certificate gates, and whether a caller can choose a value that makes the later capture longer. This converts “affected binary plus regex map” into a reproducible reachability statement.
A practical worksheet can use one row per sink and columns for map source, regex, capture created, initial capture producer, expression sequence, no-cacheable context, client-controlled field, first-pass length, possible second-pass length, fixed-build behavior, and owner. Rows that cannot satisfy growth remain documented non-exposure findings. Rows with uncertain include or module behavior remain open rather than being rounded down.
Do not respond by disabling every map. Most maps are ordinary string lookups or safe regex uses, and global removal can break routing, tenant isolation, caching, and authentication. The objective is to upgrade the binary and eliminate hazardous state relationships found during the review. A configuration rewrite should make evaluation dependencies explicit and preserve the intended result for a representative input corpus.
Out-of-tree module inventory belongs in the same worksheet. Record module source revision, build flags, engine construction sites, allocation formulas, end-pointer setup, manual framing writes, status handling, and vendor support. A module that calls the standard complex-value API may inherit the fix; a direct bytecode runner must demonstrate its own guarded region. Rebuilding against the new header without setting end does not create protection because NULL remains compatible.
Finish inventory with capacity and ownership. For each instance, record connections, requests per second, redundancy, drain time, maintenance window, and the person authorized to change configuration and binary. These fields decide rollout batch size and prevent a technically correct fix from causing an avoidable availability incident. They also give incident responders a fast way to find which process generation served a suspicious request.
7.2 Each downstream product follows its own fixed artifact, and “None” means migrate
For upstream OSS, current stable 1.30.0 through 1.30.3 moves to 1.30.4 or a later stable release; current mainline moves to 1.31.3 or later mainline. Older 1.28, 1.26, and other historical branches remain inside nginx.org's vulnerable history beginning at 0.9.6 but have no repair point named on those branches. They should move to a supported fixed line or a distributor package whose equivalent backport can be independently demonstrated.
NGINX Plus uses full product identifiers. F5 lists Plus 37 LTS 37.0.0.1 through 37.0.2.1 as affected and PLS.37.0.3.1 LTS as fixed. Under the prior release model, R33 through R36 are affected and R36 P7 is the fixed candidate; older releases without their own candidate need migration. Recording only “R37” or “Plus updated” loses the patch level required for acceptance.
| F5/NGINX product | Affected versions published by F5 | Fixed candidate or required action | Acceptance evidence |
|---|---|---|---|
| F5 WAF for NGINX 5.x | 5.9.0–5.13.3 | 5.13.4 | Product package/image and running embedded core/module identity |
| NGINX Gateway Fabric 2.x | 2.0.0–2.6.6 | 2.6.7 | Controller and data-plane image digests plus rollout revision |
| NGINX Ingress Controller 5.x | 5.0.0–5.5.2 | 5.5.3 | Controller image, managed NGINX process identity, generated configuration |
| NGINX Ingress Controller 5.x LTS | 2026-lts-r1–r3 | 2026-lts-r4 | Complete LTS revision and pod replacement |
| Instance Manager 2.17.0–2.22.1; App Protect WAF 4.11.0–4.16.0 and 5.2.0–5.8.0; Gateway Fabric 1.3.0–1.6.2; Ingress Controller 3.5.0–3.7.2 and 4.0.0–4.0.1 | Listed as affected | No candidate on the original line; migrate to a supported release containing the fix | New product-line mapping, not a permanent exception |
A downstream controller or WAF can package its own NGINX binary, modules, generated configuration, and update mechanism. Updating an unrelated host nginx package does not modify that embedded process. Inventory “product version” and “NGINX/module identity actually running” in separate columns. Close against the vendor's product target and process replacement procedure rather than mechanically applying 1.31.3 to every branded artifact.
In F5's table, None under fixed candidate is not an unaffected verdict. It means that the affected line has no update candidate listed, and the general action is migration to a version containing the repair. An exception that remains on such a line should have a vendor case, isolation and capacity controls, owner, migration plan, and expiration date; it should not be marked remediated because the table cell lacks a number.
F5's “not vulnerable” list also has a boundary: it evaluates software versions that have not reached End of Technical Support. Within that evaluated scope, the advisory lists BIG-IP Next SPK, BIG-IP Next CNF, BIG-IP Next for Kubernetes, BIG-IP all modules, BIG-IQ Centralized Management, F5 Distributed Cloud all services, F5 Silverline all services, NGINX One Console, F5OS-A, F5OS-C, NGINX all other products, Traffix SDC, and F5 AI Gateway as not vulnerable. Do not extend that conclusion to omitted EoTS releases.
Shared branding is not a dataflow. A product on the not-vulnerable list should not be added to the affected surface merely because it contains an NGINX name, and an EoTS product should not be cleared merely because a supported sibling was evaluated. The evidence unit is a named product, version, embedded component, configuration, and vendor statement. This avoids both unnecessary emergency change and unsupported reassurance.
F5's temporary configuration guidance is to avoid unnamed captures, use named captures, and reference a named capture only within the block containing the associated regex match. Preserve that wording as vendor mitigation. Source-derived review adds that hazardous capture-before-map dependencies should be removed and unnecessary non-cacheable reevaluation eliminated. Those changes can alter routing and variable lifetime, so compare outputs on an isolated copy before rollout.
A temporary rewrite is not equivalent to the fixed sink. New includes, a future map consumer, a third-party module, or another state-changing variable can reintroduce the two-pass mismatch. Keep the configuration change as defense in depth and a way to reduce immediate reachability, while retaining the binary upgrade as the completion criterion. ASLR and worker supervision similarly reduce one impact condition or outage duration without enforcing the destination extent.
For a vendor backport, request more than a CVE label. The evidence should show the optional engine end field, generic guard and failure status, literal/variable/capture and escape checks, nested and regex buffer extents, access-log operation ends, direct consumers compiled in that product, and actual-result lengths. Matching upstream patch IDs is strong evidence where history permits; a clean independently reviewed equivalent diff is acceptable when a vendor has reorganized the code.
Third-party module compatibility needs a build and behavior plan. Rebuild against the intended NGINX headers, review every direct runner, run focused tests, and retain module source revision and artifact hash. If a module cannot set the correct region end or propagate failure, remove or isolate it rather than assuming the upstream binary surrounds every write. The optional field was chosen to avoid a hard source break, which makes explicit module review more important.
Rollback artifacts must already contain the security invariant. Returning to 1.31.2 or 1.30.3 because an unrelated deployment check fails reopens the known vulnerability. Prepare a previous known-good build that includes the backport, or separate the application/configuration rollback from the binary security update. Document which elements can safely roll back and which cannot.
Sequence product upgrades by failure domain and capacity. Start with a low-traffic or canary member, keep enough fixed or isolated capacity for retry load, observe long-lived connections and upstream behavior, and then widen. For controllers, confirm that reconciliation does not recreate an old image or configuration. For autoscaling groups, update the launch template before counting the live node replacement as complete.
7.3 Canary tests and process-generation evidence prove that the repair is serving traffic
Begin canary validation with nginx -t, but treat it as syntax and static configuration loading only. It does not execute every lazy map, reproduce both script passes, or prove which worker binary will handle traffic. Follow it with representative requests that cover every candidate map source, regex alternative, capture length, consumer sink, and upstream protocol used in production.
A focused mechanism corpus needs safe length transitions rather than destructive payloads. For each candidate expression, choose inputs that produce no match, equal-length capture replacement, shorter replacement, and longer replacement. Run them only on a fixed isolated or canary build. Observe response status, selected route, upstream headers or parameters, file candidate, access logs, both guard strings, worker PID, and error rate.
The expected outcome is not simply “all requests return 200.” A previously inconsistent expression can return an internal error and no buffer space in script copy on the fixed build. A mismatch in a guarded access-log format operation can preserve the completed response while emitting no buffer space in log script copy and abandoning that access-log handler invocation, including later configured log targets in the same invocation. Both are safe stops that require configuration repair before broad rollout. The if= filter and dynamic-filename branches have the different target-skipping behavior described in Chapter 5.
After rewriting a hazardous expression, replay the same input set and compare business output. Confirm URI or route, cache key, proxy destination, every configured upstream header and parameter, response body, index or try_files result, and access-log fields. A change that merely silences the alert by dropping a variable can alter authorization or tenant routing. The regression oracle must be the intended application behavior, not absence of a message.
Exercise every direct consumer present in the build. FastCGI, SCGI, and uWSGI tests should inspect decoded parameter records and lengths. Proxy tests should inspect header delimiters and configured bodies. gRPC or HTTP/2 proxy tests should decode generated headers and frame payload length. Index and try_files tests should verify real path selection and termination. This catches a partial backport whose generic engine is safe but specialized construction remains unchecked.
Test shorter results as well as growth. The actual-length companion prevents the reported length of a returned or nested value from encompassing unwritten tail bytes. Compare output lengths byte-for-byte, including argument escaping and rewrite results. A package that blocks overrun but returns the first-pass length can still report unwritten bytes as part of the value or generate malformed protocol fields, so survival alone is not complete evidence.
Keep positive controls. Ordinary string maps, regex maps whose captures remain stable, valid volatile rewrite behavior, normal logs, upstream parameter generation, index resolution, and try_files fallback should continue working. A security patch that disables all maps, external modules, or upstream integrations may avoid the sample while failing the product. Positive controls distinguish the intended guard from an operational workaround.
Process replacement depends on which control path is used. A HUP reload is configuration reconfiguration, not a binary upgrade. The official reconfiguration procedure and fixed release-1.31.3/src/os/unix/ngx_process_cycle.c, master reconfiguration branch, lines 211–244 have the current master re-read configuration, start new workers, and ask old workers to shut down gracefully. Those new workers still come from the current master executable; installing a new file and sending HUP does not execute it.
A service-manager restart is the straightforward binary-replacement path where the availability model permits it. Stop or restart the service through its managed unit, confirm the old master and workers exit, then verify that the new master maps the fixed inode or image. Preserve configuration test, restart timestamps, listener recovery, client error rate, and rollback controls. A restart can interrupt connections, so batch and redundancy planning remain necessary.
NGINX's documented hot executable upgrade is different. Sending USR2 causes the old master to rename its PID file and execute the new binary, creating a new master and worker generation while the old generation initially remains able to serve. The fixed source handles the change-binary and no-accept controls at ngx_process_cycle.c, lines 262–273. Operators then send WINCH to the old master to stop its workers gracefully, wait and validate the new generation, and finally QUIT the old master. That sequence preserves a rollback window while both masters exist; it also makes explicit why “sent USR2” is not closure until the old generation has drained and exited.
Graceful worker exit is observable, not instantaneous. In ngx_worker_process_cycle() and its graceful-shutdown path, lines 698–749, a quitting worker closes listening sockets and idle connections, then waits for outstanding work and timers. Long-lived or stuck connections can therefore keep a vulnerable worker mapped after a change appears healthy. Record old worker PIDs and wait for their actual exit.
Do not mix these three procedures in the acceptance note. HUP proves a configuration generation changed but not the executable. A managed restart replaces the process with expected interruption semantics. USR2 hot upgrade temporarily creates two masters and needs explicit WINCH, observation, and QUIT. The evidence and rollback path differ, so a generic checkbox labelled “reloaded” cannot prove the fixed binary serves all traffic.
For any procedure, capture master and worker PID, parent PID, start time, executable mapping or image digest, configuration checksum, listener ownership, and exit time before and after. Confirm no old master or worker remains capable of accepting production connections. External probes should run throughout, and local metrics should show worker exits, restarts, 5xx, resets, latency, and upstream retry rates returning to baseline.
In a hot upgrade, validate both generations deliberately. Confirm the new master uses the fixed binary and intended configuration before draining the old generation. If validation fails, follow the documented rollback while the old master is still available; do not delete evidence or leave both generations accepting indefinitely. After success, QUIT the old master and verify its PID file and processes are gone.
Container and orchestrator rollouts have analogous generations even when signals are hidden. Confirm the workload template references the fixed immutable image, every old pod or task has terminated, readiness reflects a meaningful request path, and load balancers no longer route to old endpoints. A deployment marked complete while a terminating pod serves long-lived connections can retain exposure just like a graceful old worker.
Maintain an observation window long enough to cover rare routes, cache refresh, scheduled health checks, and normal traffic variation. Alert separately on both guard messages, worker abnormal exit, PID churn, 5xx, resets, and latency. One quiet minute after restart cannot exercise a map used only by a monthly tenant route. The owner should define the window from actual usage and risk.
| Acceptance layer | Required evidence | Failure that keeps the item open |
|---|---|---|
| Artifact | Fixed upstream version or complete equivalent backport, package/image digest, module inventory | Version banner only, partial patch, unknown embedded core |
| Configuration | Effective nginx -T graph, capture/map order, volatile rewrite context, remote source | Keyword count without consumers or includes |
| Behavior | Growth/equal/shrink corpus plus positive controls across compiled sinks | Guard alert unexplained, output changed, only syntax tested |
| Process | No accepting process maps old binary; procedure-specific HUP, restart, or USR2 evidence | Package installed or HUP sent while old executable remains |
| Service | External health, resets, 5xx, latency, retries, and capacity at baseline | Port open but tunnels, routes, or upstream work still failing |
| Incident | Historical crashes correlated or explicitly unresolved; higher-impact evidence assessed | KEV absence, ASLR, or one missing access log used as closure |

The engineering lesson is narrower than “never use lazy variables” and more durable than one map workaround. A prediction can guide allocation, but only the current destination extent authorizes a write. NGINX 1.31.3 and 1.30.4 encode that rule in standard copies and propagate it through their direct consumers. Production closure uses the same discipline: an intended version or configuration is only a prediction until runtime identity, actual process generation, representative output, and observed service state all remain inside the verified boundary.
Research record
8Evidence, objects, and sources
The material below preserves the identifiers and references used in this report.
8.1Research objects
Products, actors, techniques, affected objects, and control points discussed in the report.
Configuration-dependent NGINX regex-map heap buffer overflow
Historical upstream range; remediation must still be selected by stable or mainline branch
First fixed stable and mainline releases, respectively
Fixed Plus artifacts named by F5; older lines without a candidate must migrate
Fixed-build guard signal for output exceeding the predicted script buffer; investigate the expression, but do not treat the message alone as proof of exploitation
Fixed-build access-log guard signal; the current log-handler invocation stops and may skip later configured targets, while an already completed response is not rewritten as a 500
8.2Event chronology
- regex map enters 0.9.6
NGINX 0.9.6 releases regex matching for map; nginx.org later uses this version as the historical lower bound for CVE-2026-42533.
- CVE reserved
CVE-2026-42533 is reserved.
- Fixes and advisory become public
NGINX 1.30.4, 1.31.3, Plus PLS.37.0.3.1 LTS, and R36 P7 are released; the official security PR is merged the same day, and the F5 CNA publishes its record.
- CVE record incorporates CISA ADP
The CVE record is updated with CISA ADP's timestamped assessment: exploitation none, automatable no, and technical impact total.
- F5 advisory updated
F5 updates the security advisory; this report uses the affected and fixed-candidate matrix in that dated version.
- SOSEC evidence baseline frozen
SOSEC freezes official release tags, repair commits, product scope, and public exploitation state as a reproducible evidence baseline.
8.3Sources and material
- F5 security advisory K000162097https://my.f5.com/manage/s/article/K000162097
- F5 CNA raw CVE-2026-42533 JSONhttps://raw.githubusercontent.com/CVEProject/cvelistV5/main/cves/2026/42xxx/CVE-2026-42533.json
- nginx.org security advisory indexhttps://nginx.org/en/security_advisories.html
- nginx 2026 newshttps://nginx.org/2026.html
- nginx 1.31.3 CHANGEShttps://nginx.org/en/CHANGES
- nginx 1.30.4 CHANGEShttps://nginx.org/en/CHANGES-1.30
- Official ngx_http_map_module documentationhttps://nginx.org/en/docs/http/ngx_http_map_module.html
- Official NGINX PR #1561https://github.com/nginx/nginx/pull/1561
- Immutable vulnerable mainline release commit 2fd01ed4 (1.31.2)https://github.com/nginx/nginx/commit/2fd01ed47a1fd2965754c83f53b33a789d0e07f1
- Immutable fixed mainline release commit 073ab5db (1.31.3)https://github.com/nginx/nginx/commit/073ab5db06ec5f5079280a60a28f450b8f1ac504
- Immutable vulnerable stable release commit 47c3628d (1.30.3)https://github.com/nginx/nginx/commit/47c3628d23efaa1bfb1a32afbe9e3d013f860c2c
- Immutable fixed stable release commit 017cf98d (1.30.4)https://github.com/nginx/nginx/commit/017cf98dcce217946572a896f0992370475e189f
- Preparatory capture-copy simplification 28219209https://github.com/nginx/nginx/commit/28219209e0b4f9e155fd8bd91ab81b8ac30628f2
- General script-copy boundary repair b7675404https://github.com/nginx/nginx/commit/b767540492e8c79a58bc26034d3bab2f708b7bd1
- Access-log boundary repair 4d32a270https://github.com/nginx/nginx/commit/4d32a2703c79f92b7a99ce3547759cc599d6f83e
- Direct script-consumer repair 25f920echttps://github.com/nginx/nginx/commit/25f920eca977139dc6fc7638b419169a602a0064
- Actual-result-length repair a8289aa6https://github.com/nginx/nginx/commit/a8289aa69c74f7e664ad63b91c17aa2a554f190f
- Stable preparatory rewrite ea47fabfhttps://github.com/nginx/nginx/commit/ea47fabf55e2bc9192ddc27f52486b4e58d5e007
- Stable general script-copy boundary repair 7ac67898https://github.com/nginx/nginx/commit/7ac67898bdfed2140a1bedcda252074cca11f494
- Stable access-log boundary repair 78950bdchttps://github.com/nginx/nginx/commit/78950bdcd443830bde00f6844dba3033c18862b1
- Stable direct-consumer repair 326b17b0https://github.com/nginx/nginx/commit/326b17b0038321c29252e79bf4c53d4773fcb8b5
- Stable actual-result-length repair 97e40e59https://github.com/nginx/nginx/commit/97e40e59b7b4ad6aa36f54ce4f1e64d1e98f24cb
- Official NGINX Plus release recordhttps://docs.nginx.com/nginx/releases/
- NGINX command-line parameter documentationhttps://nginx.org/en/docs/switches.html
- NGINX process control and upgrade documentationhttps://nginx.org/en/docs/control.html
- Official CISA KEV data mirrorhttps://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json
- NVD CVE-2026-42533 recordhttps://nvd.nist.gov/vuln/detail/CVE-2026-42533
- Ubuntu CVE-2026-42533 repair mappinghttps://ubuntu.com/security/CVE-2026-42533