//nefariousplan

CVE-2026-42945: The Other Half of the 2012 Patch

pattern

cve

proof of concept

On 22 April 2026, Roman Arutyunyan committed one line to ngx_http_script.c. The line was e->is_args = 0;. It went directly above an existing line, e->quote = 0;, that had been sitting in the same function since May 2005. The commit message reads, in part: "A similar issue was fixed in 74d939974d43." That referenced commit is from May 2012. Its title is Rewrite: fixed escaping and possible segfault. The 2026 title is Rewrite: fixed escaping and possible buffer overrun. The flags reset on the same line of a two-line block. Half of the block was added in 2012. The other half waited fourteen years for a researcher named Leo Lin to notice.

The patch is one line. The line sits directly above a 2012 line.

The diff for CVE-2026-42945 is small enough to read in full:

@@ -1202,6 +1202,7 @@ ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)
     r = e->request;

+    e->is_args = 0;
     e->quote = 0;

That is the entire fix. One line. Above it, untouched, sits a line that has been in nginx since release 0.1.29, dated 12 May 2005. The 2026 commit, authored by Roman Arutyunyan on 22 April 2026, is titled Rewrite: fixed escaping and possible buffer overrun. The commit body credits Zhenpeng "Leo" Lin and points to a prior fix:

A similar issue was fixed in 74d939974d43.

Commit 74d939974d43 is from 11 May 2012. Maxim Dounin. Title: Rewrite: fixed escaping and possible segfault. The bug report it closed was ticket #162. The two commit messages open with the same clause: If there were arguments in a rewrite's replacement string. The same clause, fourteen years apart, against the same flag in the same function. The 2012 commit removed a line that copied is_args from a sub-engine. The 2026 commit zeros is_args at the end of the regex code. They are halves of one reset.

The engine has two flags and one predicate that reads both

NGINX rewrites run in two passes through a compiled script engine. First a length pass computes how big the output buffer needs to be. Then a copy pass writes into a buffer of exactly that size. The engine carries state in ngx_http_script_engine_t. Two fields of that struct matter here:

unsigned     is_args:1;
unsigned     quote:1;

is_args means "the engine has crossed the ? and is now writing query-string bytes." quote means "we are inside a quoted context." Each is set, used, and reset across the lifetime of a rewrite. Each is read by the same predicate. From ngx_http_script_copy_capture_len_code and ngx_http_script_copy_capture_code:

if ((e->is_args || e->quote) && (r->quoted_uri || r->plus_in_uri)) {
    len += 2 * ngx_escape_uri(NULL, &r->captures_data[code->n],
                              code->m, NGX_ESCAPE_ARGS);
}

If either flag is set, and the incoming request had quoted characters or + signs, the length pass adds 2 * ngx_escape_uri(...) bytes to its estimate. Three output bytes per escapable input byte: a %, two hex digits. That 2 * reflects the expansion. The copy pass uses the same predicate and either escapes or memcpy's accordingly.

Both flags can leak. Both flags can produce a length pass that disagrees with the copy pass. Both flags will, when leaked, produce a buffer with the wrong size and a write that runs past its end. The fix in 2012 patched the is_args leak path through the sub-engine. The fix in 2026 patches the is_args leak path through the main engine. The fix for quote in regex_end_code, the one written by Igor Sysoev twenty-one years ago, has held since the day it was written. The fact that it sat there alone for fourteen years is the bug.

How the 2026 leak path works

Look at ngx_http_script_start_args_code. The compiled rewrite invokes it when the engine reaches a ? in the replacement string. The whole body is one line:

e->is_args = 1;

That flag now points at every subsequent capture and copy operation. The replacement string keeps writing into the URI and arguments. Eventually the regex completes. Control passes to ngx_http_script_regex_end_code. Before the patch, that function did this:

e->quote = 0;

It did not touch is_args. The flag stayed set for as long as the request lived. The engine moved on to whatever script code came next, including operations that have nothing to do with query strings. From ngx_http_script_complex_value_code:

ngx_memzero(&le, sizeof(ngx_http_script_engine_t));
le.ip = code->lengths->elts;
le.line = e->line;
le.request = r;
le.quote = e->quote;

A new sub-engine is zeroed for the length pass of a complex value, then its quote field is loaded from the parent engine. is_args on the sub-engine is left at zero, because the memzero just ran. The length pass of set $var ... therefore computes a size that assumes is_args=0. No escape budget gets added.

Then the copy pass runs on the parent engine. The parent's is_args is still 1. The capture-copy predicate fires. ngx_escape_uri runs with NGX_ESCAPE_ARGS. Every reserved byte in the captured group expands from one byte to three. The destination buffer was sized for one byte. The difference writes off the end.

The 2012 commit explains the same shape from the other direction:

if there were arguments in a rewrite's replacement string, and length was actually calculated... the is_args flag was set and incorrectly copied after length calculation. This resulted in escaping applied... buffer was allocated without escaping expected, thus this also resulted in buffer overrun and possible segfault.

Same flag. Same function. Same buffer. Different path in.

What an exploit body looks like

Public PoCs for CVE-2026-42945 share a structure. From jelasin/poc.py, the trigger payload:

payload = "A" * 349 + "+" * 969 + target_bytes.decode("latin-1")

349 ASCII alphas, 969 + signs, 6 bytes of attacker-chosen address. The vulnerable server config is six lines:

location ~ ^/api/(.*)$ {
    rewrite ^/api/(.*)$ /internal?migrated=true;
    set $original_endpoint $1;
}

The rewrite directive contains a ?. The start-args opcode fires. e->is_args is now 1. The regex captures (.*) which is the attacker payload. Then regex_end_code runs and does not reset is_args. Then set $original_endpoint $1 runs.

The 969 plus-signs each expand from one byte to three under NGX_ESCAPE_ARGS. 969 times 2 extra bytes is 1938 bytes of overrun. The target address is the cleanup-handler pointer in a sprayed ngx_pool_cleanup_s. From make_body:

buf = struct.pack('<QQQ', system_addr, data_addr, 0)
buf += b'\x00' * 8
buf += cmd.encode('utf-8') + b'\x00'

system_addr is libc_base + 0x50d70. The PoC reads /proc/$WP/maps to learn the libc base, then sprays 400 POST bodies of 4000 bytes each into the worker's heap. Each body is the layout of a real cleanup struct: handler, data, next. When the worker pool is destroyed, ngx_destroy_pool walks the cleanup chain and calls c->handler(c->data). That is system(cmd).

The cleanup chain is interesting because it is the canonical post-heap-corruption pivot in this codebase. Anything that gets a single 8-byte write into a pool-attached structure can reach RCE through this primitive. The 2012 patch did not have to confront it directly, because the bug class was a segfault. The 2026 patch is rated the same way in the official advisory, "worker process crash and possible code execution," but the public PoCs land in the second clause within seventy-two hours of disclosure.

The 2012 commit names the flag. The 2026 commit names it again.

Side by side:

2012, commit 74d939974d43, by Maxim Dounin: If there were arguments in a rewrite's replacement string, and length was actually calculated (i.e. there were complex values to substitute), the is_args flag was set and incorrectly copied after length calculation. This resulted in escaping applied to original args (if any) on the second iteration, while buffer was allocated without escaping expected, thus this also resulted in buffer overrun and possible segfault.

2026, commit referenced as CVE-2026-42945, by Roman Arutyunyan: If there were arguments in a rewrite's replacement string, the is_args flag was set and incorrectly never cleared. This resulted in escaping applied... A similar issue was fixed in 74d939974d43. Reported by Leo Lin.

The two messages reuse the same first clause. Both name is_args. Both end at buffer overrun. The 2012 fix removed one line:

-    e->is_args = le.is_args;

The sub-engine was writing the flag back to the main engine after length calculation. Dounin deleted the write. The flag could no longer leak from sub-engine to main.

The 2026 fix adds one line. Above the existing e->quote = 0; reset, after fourteen years of pretending one flag did not need symmetric treatment, the engine now zeros both:

e->is_args = 0;
e->quote = 0;

The eighteen-year-old line

The e->quote = 0; line that sat alone is itself a renamed survivor. In release 0.6.27, dated 12 March 2008, Igor Sysoev rewrote the script engine's argument handling. That release renamed e->args to e->is_args and added the ngx_http_script_mark_args_code op. Both copy_capture_len_code and copy_capture_code were rewritten to read both flags. The predicate

if ((e->is_args || e->quote) && (r->quoted_uri || r->plus_in_uri))

was introduced in that same release. From the day the predicate was born, the two flags it reads have been governed by different reset policies. quote was reset in regex_end_code before the rename. is_args, freshly created in 2008 and given the same role as quote in the predicate, was not.

Eighteen years and one month later, on 22 April 2026, the asymmetry was finally noticed. One commit. One line. Directly above the line that should have had a sibling all along.

The PoC field is wider than the disclosure

Within seventy-two hours of the advisory, at least sixteen public repositories ship a working PoC. The samples are not minor variations. jelasin/poc.py ships a fork-deterministic spray that targets Ubuntu 22.04 with ASLR off. cipherspy/nginx_rift_htb.py ships a portable variant with libc offsets for Ubuntu 20.04, 22.04, and 24.04, Debian 11 and 12, and Alpine. rheodev/vuln_analysis.c is a complete Chinese-language root-cause writeup with a diagrammed exploit chain. The PoCs converge on the same trigger and the same primitive because there is only one trigger that works and only one primitive worth pivoting through. That convergence is itself a measurement: the bug is small enough to fit on one screen, and once you have read the patch you know how to reach it.

The same researcher, Zhenpeng "Leo" Lin, is credited on at least three of the five NGINX CVEs published in the 1.31.0 cycle. The other two in the cycle are escape-related as well. The script engine, the request line parser, and the SMTP state machine all surface defects on a cadence. A vendor that ships five CVEs in one cycle in the components that handle untrusted strings is telling you, with full reproducibility, where the architectural debt lives.

This is a design-debt-driver fingerprint

The design-debt-driver pattern describes a component whose internal structure produces the same class of bug on a predictable schedule. The script engine's shape is the driver here. Two parallel flags, governed by one predicate, with asymmetric reset hooks. The 2012 fix patched one leak path through the sub-engine. The 2026 fix patches the other leak path through regex_end_code. Both fixes are one line of code. Both close the same buffer overrun. Both name the same flag in their commit titles. Fourteen years separate them.

The driver is not malicious. It is geometric. When a predicate fans in over multiple flags, and those flags have different reset policies, the predicate's reliability is the minimum of the individual flag reliabilities. The script engine has been near that minimum twice. It will be near it again the next time the predicate gains a third input or the reset block fails to grow.

The defender's read

If you run NGINX with rewrite rules that contain a ? in the replacement string, and the captured groups can include + or percent-encoded bytes from an untrusted source, you were exposed. The advisory's "worker process crash" framing is a lower bound. The "possible code execution" framing is what the public PoCs achieve.

Upgrade to 1.31.0. The patch is one line and ports cleanly. If you cannot upgrade, audit your rewrites for replacement strings that contain ?. The vulnerable shape is any rewrite whose target URI introduces query arguments while the captured group flows into a subsequent set directive. The minimal config is six lines and one of them is the trigger.

For everyone else: the takeaway is that NGINX's script engine has now produced the same buffer-overrun in the same flag in the same function twice. The first time, in 2012, the fix removed a write. The second time, in 2026, the fix adds a reset. The reset is now symmetric. Until the predicate gains a third flag.

Two parallel flags. One reset point. Half a fix in 2012. The other half in 2026.