Critical nginx vulnerability: how a regex map turns $1 into a weapon (CVE-2026-42533)
Critical nginx vulnerability: how a regex map turns $1 into a weapon (CVE-2026-42533)
Your nginx config has a location with a regex you’re pulling $1 from, and somewhere nearby a map with a regex pattern. Nothing unusual about that — half the installs doing path-based proxying, URL-based API versioning, or User-Agent whitelisting look exactly like this. The problem shows up when $1 is used in a string expression and, later in the same expression, a regex map variable gets evaluated: nginx sizes the buffer for one value of $1, then writes a completely different one into it — pulled from the request body or a header, both fully under the attacker’s control. The gap between what got measured and what got written is the heap overflow.
On July 15, 2026, F5 closed this hole in nginx 1.30.4 (stable) and 1.31.3 (mainline), plus NGINX Plus 37.0.3.1. CVE-2026-42533 landed CVSS 9.2 on the v4.0 scale (Critical) and 8.1 on v3.1 (High), CWE-122 — heap-based buffer overflow. Every nginx version from 0.9.6 through 1.30.3 on stable and through 1.31.2 on mainline is affected — a range that starts March 21, 2011, when the map directive got regex support. As of this writing, CVE-2026-42533 isn’t on CISA’s Known Exploited Vulnerabilities catalog, and the SSVC assessment from CISA-ADP marks exploitation status as “none.” No public exploit exists yet either — but that doesn’t make this a theoretical bug, and you’ll see why below.
HOW NGINX’S TWO-PASS ENGINE WORKS
To understand the bug, you need to understand how nginx evaluates string expressions like proxy_set_header X-Path "$1/$something" in the first place. The engine compiles the expression into a sequence of opcodes and then calls ngx_http_complex_value() in src/http/ngx_http_script.c — twice. The first pass, LEN, walks the opcodes and adds up how many bytes each piece will take. Based on that, a buffer of exactly that size gets allocated via ngx_pnalloc(r->pool, len). The second pass, VALUE, walks the same opcodes again and actually writes the bytes into that buffer.
The idea is sound on its own: measure first, then cut to fit. It only breaks in one case — if something changes the data the opcodes read in between the two passes. And that’s exactly what happens with regex capture groups.
HOW THE BUG WORKS
When a location match contains a regex, the result — capture group offsets and a pointer to the source string — gets stored in r->captures and r->captures_data directly on the request object. A reference to $1 compiles into opcodes that read these fields: on the LEN pass that’s ngx_http_script_copy_capture_len_code(), on the VALUE pass it’s ngx_http_script_copy_capture_code(). Both read the same array.
The problem is that r->captures isn’t a snapshot of the location match. It’s shared mutable state on the request, and any regex that runs later in the same request overwrites it. That’s exactly what a map with a regex pattern does: the call goes into ngx_http_map_find() in src/http/ngx_http_variables.c, which calls ngx_http_regex_exec() for each pattern in the map. That function runs a PCRE match against the map’s input variable and writes the result straight into the request’s r->captures and r->captures_data — right over whatever the location match had already put there. Neither ngx_http_complex_value() nor ngx_http_map_find() saves or restores this state anywhere.
Take an expression like "$1=$bodyvar=$1" in a location matching a short URI like “abc” (3 bytes), where $bodyvar is a map variable running a regex against a 200-byte $request_body. On the LEN pass, the first $1 reads the original capture — 3 bytes. Then $bodyvar gets evaluated, which triggers ngx_http_regex_exec() against the request body and overwrites r->captures with offsets inside the body. The second $1, still on the same LEN pass, now reads the overwritten capture — 200 bytes. Total length: 3 + 1 + 6 + 1 + 200 = 211 bytes, and a buffer gets allocated at exactly 211.
On the VALUE pass, the capture state is still clobbered — nobody restored it. The first $1 now also reads the 200-byte capture and writes 200 bytes, $bodyvar writes 6 bytes (“mapped”), the second $1 writes another 200 bytes. Total: 408 bytes into a 211-byte buffer — a 197-byte overflow with fully attacker-controlled content straight from the POST body. The LEN pass only saw the clobber for one $1 (the second one); the VALUE pass saw it for both. That asymmetry is the overflow.
The mechanism also runs in reverse. If the map’s input is shorter than the original capture, the buffer ends up larger than what actually gets written, and the uninitialized tail of the buffer gets sent to the client as-is — because ngx_http_complex_value() returns the length computed on the LEN pass, not the number of bytes actually written on the VALUE pass. For an 8160-byte URI path and a one-byte header that triggers the map regex, the buffer gets allocated at 8161 bytes but only 2 bytes get initialized. Researcher Stan Shaw, who reported the bug under the handle cyberstan, tested this on Ubuntu 24.04 with glibc 2.39: in the uninitialized tail, offset 0x08 reliably holds a libc pointer, offset 0x10 a heap pointer. That’s enough to compute libc’s base address and the buffer’s location in memory with a single unauthenticated GET request — in other words, defeat ASLR.
HOW THIS GETS EXPLOITED
F5’s advisory carefully hedges the RCE condition: code execution happens “if ASLR is disabled or can be bypassed.” On a default Ubuntu server with ASLR enabled, that reads like a narrow edge case. Shaw argues the opposite: the ASLR bypass is part of the same vulnerability, not a separate precondition. According to him, the full chain needs one GET request to leak pointers, roughly 40 spray connections, and one POST request to trigger the overflow. In his own testing on Ubuntu 24.04 with glibc 2.39 and full ASLR enabled, the chain worked 10 out of 10 times.
Shaw isn’t publishing exploitation details or a proof-of-concept yet — he says he’ll release them 21 days after the patch, which puts it around August 5. The reason is straightforward: May’s bug in the same nginx engine, Rift (CVE-2026-42945), needed ASLR disabled for reliable exploitation, and its exploit still went public within days of disclosure, with active exploitation following shortly after. This bug doesn’t need ASLR off at all, which is why the researcher would rather give administrators time to patch than repeat the Rift playbook.
WHAT HAPPENS NEXT — DEPENDS ON YOUR CONFIGURATION
This doesn’t affect every nginx deployment — it’s a specific pattern: a regex-based map whose output variable shows up in a string expression alongside a numbered capture ($1, $2) from an earlier regex — a location, server_name, rewrite, or if block. The capture and the map variable don’t even need to be in the same directive: Shaw shows this works across separate directives in the same location block too, because modules like proxy, fastcgi, scgi, uwsgi, and grpc each implement their own LEN/VALUE loop for the whole block with a single buffer allocation. An example from his report — an ordinary pairing of proxy_set_header X-Path "$1" and proxy_set_header X-Check "$map_var" in one location: the first directive feeds $1 to both passes, the second one evaluates the map on the LEN pass and clobbers the capture, and when the VALUE pass revisits the first directive, it reads the already-corrupted value.
Shaw’s list of affected directives is impressively long: proxy_set_header, proxy_pass, fastcgi_param, uwsgi_param, scgi_param, grpc_set_header, return, add_header, rewrite, set, root, alias, access_log, and more — he counted at least 13 independent call sites across 9 source files, spanning both the HTTP and stream modules. There’s also a second, independent exploitation path — through named captures ((?P<name>...)), which get read not from r->captures but from r->variables[]. If two map regexes define the same named group, the second overwrites the first in r->variables[] — the same overflow effect, just through a different data path.
This bears directly on the temporary workaround F5 recommends: switching regex maps to named captures closes the main path through r->captures, but doesn’t close the variant with identical named groups across different maps. So the workaround shrinks the attack surface without eliminating the vulnerability — only the upgrade does that.
A 15-YEAR-OLD BUG’S BACKSTORY
The most uncomfortable part of this story isn’t the bug itself — it’s that people knew about it long before the CVE. Back in 2014, a user named Pascal Jungblut filed nginx trac ticket #564 describing this exact behavior: a map with a regex overwrites capture groups in a rewrite directive, and $1 silently returns different data depending on whether the map’s regex matched. Maxim Dounin, an nginx maintainer, accepted it as a defect, writing that the current behavior was clearly bad and needed fixing. Over the following years, the ticket accumulated five cross-references to similar reports (#1044, #1142, #1285, #1498, #1934), and in 2020 someone made the case for bumping its priority from minor to critical.
In other words, the behavioral anomaly had been public knowledge for more than a decade — it just got treated as a logic bug (a variable returning an unexpected value), not a memory-safety issue. The two-pass expression model turns that same discrepancy into a heap overflow, which is an entirely different weight class. The gap between “found a behavioral quirk” and “realized this is RCE” comes down to needing to trace the connection specifically to the LEN/VALUE allocation model, not just to the fact that captures get clobbered.
WHY THIS IS BEING FOUND RIGHT NOW
This is already the third bug of this class in the nginx engine within a couple of months: in May, Rift (CVE-2026-42945), where the desync came from the is_args flag; shortly after that, an overflow with overlapping captures in the rewrite module (CVE-2026-9256). Shaw himself frames this as a pattern in his own report: all three have different specific causes, but the same structural vulnerability — a LEN/VALUE split that assumes evaluation is deterministic when in reality it can have side effects. Any mutable request state that an opcode reads to determine length, and that can change between passes, is — in the researcher’s own words — a candidate for the same category of bug, and regex captures, flags like is_args, and cacheable variables are just the candidates that have been found so far.
WHY THIS MATTERS
Nginx sits in front of a huge share of the world’s web infrastructure — reverse proxy, API gateway, Kubernetes ingress, terminating TLS in front of dozens of backends. The vulnerability doesn’t stop at the server itself and NGINX Plus; it extends to F5’s product line built on top of nginx: NGINX Ingress Controller, Gateway Fabric, App Protect WAF, and Instance Manager — though at the time the advisory was published, F5 hadn’t yet listed fixed builds for those four products. For anyone running a reverse proxy on nginx in front of WordPress, Joomla, or any self-hosted API, a regex-map setup for API version routing, header whitelisting, or geo-based rewrites is completely ordinary practice, not some exotic edge case. Which means the real attack surface goes well beyond large enterprise deployments.
It’s worth separately unpacking F5’s CVSS 9.2: attack complexity (AC) in the vector is marked High — but that’s the scoring engine’s estimate of exploitation complexity for the vulnerability itself, not an assessment of the real-world obstacles facing a specific attacker with the right skill set. RCE is RCE, regardless of how the vector happens to score it.
HOW TO KEEP THIS FROM HAPPENING AGAIN
From an engineering standpoint, the problem is that r->captures is shared mutable request state that many parts of the code read, but that never gets snapshotted before potentially dangerous operations. nginx’s patch doesn’t fix this by restoring capture state between passes — it takes a different approach: the engine got an end pointer marking the allocated buffer’s boundary, and a new ngx_http_script_check_length() check was inserted ahead of every write opcode (copy_code, copy_var_code, both branches of copy_capture_code including the escape path, and the raw separator writes in each upstream module). If the VALUE pass ever tries to write past that boundary, it sets NGX_HTTP_INTERNAL_SERVER_ERROR, diverts execution to ngx_http_script_exit, logs “no buffer space in script copy,” and the calling code bails out with HTTP 500 instead of corrupting the heap. The information-leak direction gets closed the same way: the returned string’s length is now computed from bytes actually written (e->pos - e->buf.data) rather than the inflated LEN-pass estimate, so an oversized buffer no longer hands back its uninitialized tail.
For anyone writing or reviewing nginx modules, or similar code with separate measure-then-write passes: any mutable request state that an opcode reads to determine length, and that can change between passes, is a candidate for this same category of bug. Explicit regex captures aren’t the only example — cacheable variables and flags like is_args from CVE-2026-42945 show the list is broader. The practical takeaway for your own projects is to snapshot or freeze any state that affects size before measurement starts, or, as nginx did here, add the bounds check right at the point of writing instead of trusting an earlier measurement to have been correct.
UPDATE
Checking your current nginx version is straightforward: the first command shows the binary version, the second shows which modules it was built with.
nginx -v
nginx -V
If the version is below 1.30.4 on the stable branch or below 1.31.3 on mainline, the server is vulnerable. One important catch: Ubuntu’s and Debian’s default repositories lag years behind current nginx releases — these can be versions old enough that the patch simply isn’t in them. That means apt-get install nginx without the official nginx.org repository configured will just reinstall the same outdated version. First, you need to add the repository: import nginx’s signing key and add the package source.
curl https://nginx.org/keys/nginx_signing.key | gpg --dearmor \
| sudo tee /usr/share/keyrings/nginx-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] \
https://nginx.org/packages/debian `lsb_release -cs` nginx" \
| sudo tee /etc/apt/sources.list.d/nginx.list
The first command fetches nginx.org’s official signing key and converts it into a format apt understands; the second adds the stable-branch repository for your distro (for Ubuntu it’s the same command, just with the URL path https://nginx.org/packages/ubuntu; for the mainline branch, both cases get mainline/ prepended to the distro name). Next, you’ll want to pin the nginx.org repository’s priority — otherwise your next scheduled apt upgrade risks rolling the package back to the outdated distro version:
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
| sudo tee /etc/apt/preferences.d/99nginx
This command creates a pinning config file that tells apt to prefer packages from the nginx.org repository over any other source of the same package name — without it, many distros will let a package with a higher version number but no patch win out from the distro’s own repo. After that, the update runs the usual way:
apt-get update
apt-get install nginx
Once it’s installed, check the version again with nginx -v — it should show 1.30.4 or newer (stable) or 1.31.3 or newer (mainline). For NGINX Plus, updating to 37.0.3.1 goes through the regular Plus package delivery channel; R33–R36 are fixed in R36 P7, and older R-releases won’t get a patch and require moving to a supported branch.
If an immediate upgrade isn’t possible, the temporary measure is switching every regex-based map whose output variables overlap with numbered captures in the same location to named captures instead of $1/$2. For example, a vulnerable pairing like this
location ~ "^/api/(.+)$" {
proxy_set_header X-Path "$1";
proxy_set_header X-Check "$map_var";
proxy_pass http://backend;
}
gets rewritten so the numbered capture doesn’t overlap with the map variable in the same directive or in the same buffer group of proxy/fastcgi/scgi/uwsgi/grpc directives:
location ~ "^/api/(?<path>.+)$" {
proxy_set_header X-Path "$path";
proxy_set_header X-Check "$map_var";
proxy_pass http://backend;
}
This closes the main exploitation path through r->captures, but not the variant with two maps defining the same named group — the scanner’s README specifically warns against reusing the same named group across different regex maps. Only upgrading to 1.30.4/1.31.3 closes the vulnerability completely, which the researcher himself says directly, and it’s worth factoring into your maintenance-window planning.
After any config change — whether it’s the upgrade or the temporary workaround — always check the syntax before applying it, or you risk restarting nginx with a broken config and taking the service down:
nginx -t && systemctl reload nginx
The first part, nginx -t, parses the main config and every included file, checks that paths to logs and other files are accessible, and returns “syntax is ok” and “test is successful” if everything checks out, or points to the exact error line if it doesn’t. The && operator guarantees that systemctl reload nginx only runs if the test passed — reload applies the new config by spinning up new worker processes and gracefully shutting down the old ones once their open connections finish, with no dropped client connections and no downtime, unlike a full restart.
To get a quick sense of whether your configs even contain a suspicious pattern, grep across all your configs for the map directive with regex patterns — it won’t give you a precise answer about any specific block’s exposure, but it’ll tell you where to look first:
grep -rn "map " /etc/nginx/ --include="*.conf"
For a precise answer, Shaw published a read-only static scanner that walks the config while following includes, detects cross-directive triggering, and outputs results including in JSON — it doesn’t exploit anything, it just tells you whether the preconditions are present. It’s installed and run like this:
git clone https://github.com/0xCyberstan/CVE-2026-42533-Config-Scanner
cd CVE-2026-42533-Config-Scanner
python3 nginx_capture_clobber_scan.py /etc/nginx/nginx.conf
The first two commands clone the repository and move into its directory; the third runs the scanner against your main nginx config — swap the path for wherever your nginx.conf actually lives if it’s different, and include directives get expanded automatically. The scanner exits with code 0 if nothing vulnerable was found and code 1 if at least one issue was found, which makes it easy to wire straight into CI:
python3 nginx_capture_clobber_scan.py /etc/nginx/nginx.conf || echo "review needed"
For machine-readable output there’s a --json flag, which returns the file, line, enclosing scope, and the specific directives and capture/map variables involved in each finding.
CONCLUSIONS
If you’re running nginx as a reverse proxy in front of WordPress, Joomla, or any self-hosted service, update to 1.30.4 (stable) or 1.31.3 (mainline) at your next maintenance window — don’t wait for the PoC to drop on August 5. If you’re on NGINX Plus, move to 37.0.3.1 or R36 P7 depending on your branch. If an upgrade isn’t possible right now, run your configs through Shaw’s scanner and switch the flagged regex maps to named captures as a stopgap, keeping in mind that this isn’t a complete fix.
For hosting providers running nginx across a fleet of client servers, this is a case worth scheduling into the next maintenance cycle without waiting for a client to ask — regex-map-plus-captures configurations are common enough that you can’t assume only “advanced” users have them. The same goes for admins running dedicated servers and VPS instances: if you’ve ever copied an nginx config with geo-routing, URL-based API versioning, or header whitelisting from an article or a forum post, it’s worth running it through the scanner right now, rather than after August 5 when the public exploit drops.
