DEV Community

Cover image for nginx is not the bug. Two lines of your config are. CVE-2026-42945 on a live stand
Oleg Usoltsev
Oleg Usoltsev

Posted on • Originally published at habr.com

nginx is not the bug. Two lines of your config are. CVE-2026-42945 on a live stand

A critical nginx vulnerability, 9.2 on CVSS, sat in the code for eighteen years. It was found not by a human but by an AI agent, and six hours were enough. The news comes with a number attached: 5.7 million servers on the internet.

Then two independent researchers run scanners over real nginx configurations from GitHub. The first looks at 1465 configs from 528 popular repositories and finds not a single vulnerable one in production. The second looks at 35633 configs and finds one, in an abandoned project from 2011.

Between "5.7 million" and "one out of thirty five thousand" the gap is tens of thousands of times. I built a test stand to work out which of them is right, and to check whether my own server is lying there open.

Short answer: both are right, because they count different things. Long answer below, with commands, logs, and a script that checks your config in a second.

What happened

On 13 May 2026 nginx 1.30.1 and 1.31.0 shipped, closing CVE-2026-42945. A heap hole, in the URL rewriting module. The first version with it is 0.6.27, the last is 1.30.0, so almost the entire history of the product is caught in it. For commercial NGINX Plus, per NVD, it is releases R32 through R36, fixed in R37.

The vulnerability was found by depthfirst, who ran their own AI agent for low-level code audit over the nginx sources. In six hours the agent found five memory problems, four of which nginx confirmed. This one is the most serious.

One small detail worth flagging right away, because it pays off at the end: in the fix commit itself, the "reported by" field carries the name of a live human being, Leo Lin. The agent found it, but an engineer made it into the commit history.

One more thing, since we are on the accuracy of numbers. Most sources put the age of the bug at eighteen years, and that adds up: version 0.6.27 came out in 2008. But at least one major outlet wrote "sixteen years", and that number spread further through retellings. The correct one is eighteen.

The scores diverged immediately. NVD and F5 give 9.2 on CVSS v4.0 and 8.1 on v3.1. nginx itself, in its own security advisories list, marks it as medium. I will unpack the reason for that gap at the end, once it is visible what it grows out of.

The mechanics: a flag nobody cleared

The fix is one line. Here it is in full, file src/http/ngx_http_script.c, function ngx_http_script_regex_end_code:

@@ -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;
Enter fullscreen mode Exit fullscreen mode

To see why one line is worth 9.2, you need to look at how nginx substitutes regular expression captures.

Substitution runs in two passes. First the engine counts how many bytes the result will take and allocates a buffer. Then the second pass copies the data in. As long as both passes count the same, everything is fine.

Copying an unnamed capture, that is $1 through $9, works like this: if the data goes into the query string rather than the path, it has to be escaped. A space becomes %20, a plus becomes %2B, so one byte becomes three. The decision is made by the is_args flag inside the engine.

A rewrite directive with a question mark in the replacement string arms that flag: everything after the question mark is arguments now. Reasonable. There is a caveat here about the case where nothing follows the question mark, but I will come back to it in the section on the boundary. Then rewrite processing ends, and this is where the flag should have been cleared, and it was not. It stayed armed until the end of location processing.

Now look at the set directive. It computes the length of the value through a separate, freshly zeroed engine where is_args is zero. So it counts the length with no room for escaping. But the data is copied by the main engine, the one where the flag stayed armed from the previous rewrite. Copied with escaping.

And in that zeroed engine one flag of the pair does get carried over from the main one, this line is right there in the code: le.quote = e->quote;. is_args was forgotten next to it. Half the state carried over, half not, and each half on its own looks completely correct.

Result: the buffer is allocated for the raw string, and an escaped string is written into it, which can be three times longer. Everything past the end goes outside the allocated memory.

That is also why the descriptions of the vulnerability talk about unnamed captures. Ordinary variables like $myvar are copied by different code, which has no escaping check at all, they are simply carried across as is. Although, further down on the stand it will turn out that this rule is stated imprecisely, and the wording is a dangerous one, but that is in the section on the boundary.

There is one more detail in the commit text that I like better than the bug itself. The author of the fix writes: "A similar issue was fixed in 74d939974d43". That is a commit from 2012, trac ticket number 162, the same class of error: the counting pass and the copying pass out of sync on the same flag. What is amusing is that back then it was fixed the other way around. In 2012 they removed the line that carried is_args into the local engine, that is, they stopped passing the flag. In 2026 they added the line that clears it. Fourteen years between two halves of the same mistake.

The stand

I build it on a VPS, everything stays on the loopback: a deliberately vulnerable nginx inside, and no reason for it to face outward.

I take the config not from my own setup but verbatim from the text of the commit that fixed this. That is more honest, and nobody gets to ask whether I tuned the configuration to fit the result.

worker_processes 1;
error_log /var/log/nginx/error.log info;

events {
    worker_connections 1024;
}

http {
    access_log off;

    server {
        listen 80;

        location / {
            rewrite ^(.*) /new?c=1;
            set $myvar $1;
            return 200 $myvar;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Five containers: the vulnerable nginx 1.30.0 with this config, three controls on the same version, and the patched 1.30.1 with the exact same config. One worker, so a crash is unambiguous.

services:
  vuln:
    image: nginx:1.30.0-alpine
    container_name: rift-vuln
    ports:
      - "127.0.0.1:8099:80"
    volumes:
      - ./conf/vuln.conf:/etc/nginx/nginx.conf:ro
Enter fullscreen mode Exit fullscreen mode

The other four services differ only in image, port and mounted config, so I show one.

The request I hit it with: a long run of pluses in the path.

curl "http://127.0.0.1:8099/++++++++++++++++++++++ ... ++++"
Enter fullscreen mode Exit fullscreen mode

The plus works on two fronts here. It arms the internal marker that there is something in the URI to escape, and it is itself subject to escaping, turning into three bytes instead of one. So it delivers the length growth needed.

Result of the very first run:

CONTAINER              PORT   HTTP   CRASHES
rift-vuln              8099   000    1
rift-ctl-noq           8098   200    0
rift-ctl-noconsumer    8097   200    0
rift-ctl-named         8096   200    0
rift-fixed             8095   200    0
Enter fullscreen mode Exit fullscreen mode

The vulnerable stand did not answer at all, the connection was dropped. In the log:

2026/08/12 23:29:03 [notice] 1#1: signal 17 (SIGCHLD) received from 30
2026/08/12 23:29:03 [alert] 1#1: worker process 30 exited on signal 11
2026/08/12 23:29:03 [notice] 1#1: start worker process 31
Enter fullscreen mode Exit fullscreen mode

Signal 11 is a segfault. The master immediately brings up a new worker, and the server keeps running. Remember that log line, we come back to it in the detection section.

Three conditions, not one

Here is where the interesting part starts, the reason the stand was built at all. I check what exactly in the configuration is responsible for the crash. I remove exactly one element at a time.

Removed the question mark from the replacement string, kept everything else: rewrite ^(.*) /new;. No crash. There is nothing to arm the flag.

Kept the question mark, removed the consumer: rewrite stays, the line set $myvar $1; is gone, replaced with return 200 "ok";. No crash. The flag is armed, but there is nobody to inherit it.

That second control matters more than it looks. A day after this fix another one shipped, for a different bug in the same module, with overlapping captures. And the shape of its attacking request is exactly the same, a long run of pluses. If my stand had crashed without a consumer too, I would have been dissecting the wrong vulnerability and the whole mechanics above would be wrong. It does not crash, so this really is the flag leaking.

Replaced the unnamed capture with a named one: rewrite ^(?<tail>.*) /new?c=1; and set $myvar $tail;. No crash, exactly as the code predicts.

The patched version with the same dangerous config stands and answers 200.

So a crash needs three things at once: a question mark in the rewrite replacement string, an unnamed capture used after that rewrite in the same location, and suitable request content. The third one gets its own section. Both of the first two I will refine below: stated this way they are only approximately true.

Where the boundary actually runs

I was going to stop here, but then I decided to check four more cases that looked obvious. Three turned out not to be what they seemed, and one of those three also refuted what I wrote above.

A named group does not save you. Above, (?<tail>.*) with the consumer $tail did not crash, and the code explains it: nginx turns a named capture into an ordinary variable, and that gets copied with no escaping at all. The analysis was right. The conclusion it invites is not. I keep the group named, but refer to it by number:

rewrite ^(?<tail>.*) /new?c=1;
set $myvar $1;
Enter fullscreen mode Exit fullscreen mode

Crash. In PCRE a named group keeps its number as well, so $1 here is a working reference, and it goes down that same vulnerable path. Which means what decides it is not how the group is declared but how you refer to it. The formulation "named captures are not vulnerable" that is going around in the descriptions misleads exactly the people who try to defend themselves with it.

One question mark is not enough, it needs arguments after it. This line does not crash:

rewrite ^/old/(.*)$ /new/$1?;
set $myvar $1;
Enter fullscreen mode Exit fullscreen mode

A trailing question mark in the replacement is the standard way to say "do not drag the original query string along". There are no arguments after it, and the pair does not form. Why exactly that is, I did not check in the code, this is a stand observation. Add anything at all, /new?x=1, and the crash comes back.

This matters in practice: that exact trailing question mark sits in an enormous number of migration configs, where old addresses are glued onto new ones. If you treat any ? as dangerous, an enormous number of perfectly healthy configs will come out falsely vulnerable.

An intermediate rewrite disarms the flag.

rewrite ^/old/(.*)$ /mid?c=1;
rewrite ^/mid(.*)$ /new;
set $myvar $1;
Enter fullscreen mode Exit fullscreen mode

No crash. The second rewrite, this time without the dangerous question mark, clears the armed state, and the pair falls apart.

The break flag cuts the chain. With it, set simply does not run: rewrite module processing stops, and there is nothing left to crash.

Here is what I managed to measure, in a table:

Config inside one location Crash
rewrite ^(.*) /new?c=1; + set $x $1; yes
rewrite ^/old/(.*)$ /new?x=1; + set $x $1; yes
rewrite ^/old/(.*)$ /new/$1?; + set $x $1; no
rewrite ... /mid?c=1; then rewrite ... /new; then set $x $1; no
rewrite ^(.*) /new?c=1 break; + set $x $1; no
rewrite ^(?<tail>.*) /new?c=1; + set $x $1; yes
rewrite ^(?<tail>.*) /new?c=1; + set $x $tail; no

What the request is stuffed with matters too

I assumed percent-encoding was enough: since it arms the marker "there are encoded characters in the URI", escaping should kick in. I checked, and it is not so.

What the path is stuffed with Result
+ repeated 2000 times crash
A repeated 2000 times 200 response, no crash
%41 repeated 700 times 200 response, no crash
%20 repeated 700 times crash

The difference between %41 and %20 explains everything. %41 is the letter A. It decodes into an ordinary character that does not need escaping back, the length does not grow, the buffer is enough. %20 is a space, it decodes, and on copying it gets escaped back into three bytes, and that is where the lengths diverge.

So it is not enough for the request to merely contain percent-encoding. You need a character that after decoding is again subject to escaping. This detail is not in the advisory, it only surfaces on the stand.

The threshold that is not there

The logical expectation: the longer the string, the more certain the crash. I test from eight characters up to four thousand.

Length of the + run Worker crash
8, 16, 32 no
64, 128, 256 yes
384 no
512, 640 yes
768, 896 no
1024, 1280, 1536 yes
2048, 3072, 4096 yes, and the client no longer gets a response

The threshold is ragged. Crashes at 64, does not crash at 384, crashes again at 512, does not at 768. For a heap overflow that is normal: whether the process dies or not depends on what was sitting past the end of the allocated memory and whether somebody else's structure survives it.

An important caveat about the stand here: I ran nginx from an alpine image, and libc there is musl. Its allocator is its own, so on glibc builds, which is most of what the reader is running, the specific threshold values could well land differently. The effect itself does not go anywhere, but the numbers in the table above should be treated as an illustration, not a constant.

The practical conclusion is nastier than it looks. The absence of a crash does not mean nothing happened. By the mechanics, at those same 384 characters the write past the end of the buffer happened there too, it just landed in something that did not lead to an immediate crash. I did not confirm this with tools like ASAN, but quiet memory corruption is worse than an honest segfault precisely because nobody notices it.

One more detail: at lengths from 64 to 1536 the client manages to get a 200 response, and only then does the worker crash. The response goes out, the damage surfaces later, when nginx works with the memory pool. In the access logs an attack like this looks like ordinary successful requests.

How hard this takes a site down

Now let us measure what this crash means in practice. I define failure strictly: in parallel with the attack I send sixty ordinary harmless requests to the same server and count what share of them got no answer.

Load Worker crashes Probes failed Failure rate
one curl thread, 20 seconds 263 2 of 60 3.3%
eight curl threads, 20 seconds 365 4 of 60 6.7%
ab, concurrency 20, 30 seconds 594 3 of 60 5.0%

Look at the last row closely. Ab reported exactly 594 completed requests, and the worker crashed exactly 594 times. One request kills one worker, one to one, no misses.

And the site still stays available: across three measurements, between 3.3 and 6.7 percent of probes failed.

The reason is that the attack runs into its own result. Having killed a worker, the attacker has to wait for the master to bring up a new one, and there is simply nobody to accept the next connection. The final rate topped out at 19.8 requests per second, and that is a ceiling of the attack, not of the server. The master process restores a worker faster than it can be killed.

So I am not going to call this a reliable way to take a site down. It is service degradation and miles of alert lines in the log. The real danger is not here.

Who is actually vulnerable

Back to the discrepancy from the start of the article. The 5.7 million is a VulnCheck estimate, counted by the version the server reports about itself. A correct answer to the question "how many nginx of a vulnerable version are on the internet". In the same original publication there is a caveat the news almost never carried across: the actually exploitable share, in their own words, is noticeably smaller.

Except that exploitation needs not a version but a configuration. And a specific one: a rewrite with a question mark in the replacement, and then use of an unnamed capture in the same location. Scans of real configurations gave zero live ones out of 1465, and one out of 35633.

An ordinary WordPress or Laravel config, with something like try_files $uri $uri/ /index.php?$query_string;, does not match the condition: try_files is not rewrite, and there are no captures there.

Where the pair does show up: where the config is not written by hand but generated from a template. Ingress controllers in clusters, hosting control panels, WAFs with their own rewriting rules, multi-tenant platforms handing out redirect rules to customers. A template with somebody else's input substituted into it can perfectly well assemble the required combination, and nobody will ever see it with their eyes.

Hence the medium from the nginx developers themselves against the 9.2 from NVD. The CVSS v4.0 vector has AC:H, high attack complexity. That is not about the attacker needing rare skills, the attack is trivial. It is about a specific configuration having to be on the server. NVD scores the worst case given the conditions are met, nginx scores the probability of meeting those conditions. I did not find a public explanation from nginx itself, so this is my reconstruction of the logic, not their official position.

How to check yourself

A naive grep for the word rewrite gives a pile of false positives, because rewrite is almost everywhere, and what is dangerous is the pair. A question mark inside a regular expression, where it is merely a quantifier, is also safe, and a plain grep will catch it.

I wrote a script that parses the output of nginx -T with all included files, tracks location boundaries and looks for the pair specifically, and by the rules I measured on the stand or derived from the mechanics, not by the description from the advisory. That is: a question mark only in the second argument of rewrite and only if there are arguments after it, disarm on an intermediate rewrite, break does not count as a finding, and a reference by number counts regardless of whether the group is named. It lives here: github.com/CynepMyx/nginx-rift-check. Python 3, no dependencies.

nginx -T | python3 check_rewrite.py
Enter fullscreen mode Exit fullscreen mode

Here is what it says on the stand config. The tool speaks Russian, so for anyone who does not: the four labels are location, rewrite, the directive that reads the capture, and why it counts.

[HIGH] находка #1
  location:  location /  (/etc/nginx/nginx.conf:14)
  rewrite:   rewrite ^(.*) /new?c=1  (/etc/nginx/nginx.conf:15)
  захват $N: set -> set $myvar $1  (/etc/nginx/nginx.conf:16)
  почему:    обработка продолжается в этом же location без редиректа

Итого находок: 1
Enter fullscreen mode Exit fullscreen mode

A [LOW] mark instead of [HIGH] happens on the last flag: processing moves to a different location, the pair rarely forms, and I did not measure that case separately.

Three exit codes, not two: zero if clean, one if a pair was found, two if the config could not be read or parsed. The last one matters more than it looks: a silent "nothing found" on a config the tool could not parse to the end is the worst thing a security checker can do. So on an unclosed quote or unbalanced braces it honestly says the result cannot be trusted. There is a --json flag for machine parsing.

I tested it on more than the stand. First run on my own server: an ordinary nginx -T, 182 lines, the standard mime types block of a hundred lines and a multiline log_format with quotes. Zero findings, zero parsing complaints.

Second on a client's production frontend: 356 lines, nine included files, a map with a regular expression that has both a semicolon and a question mark living inside it, a CSP header with a dozen semicolons inside quotes, comments in Russian inside blocks. I ran it on a copy where I replaced domains and addresses and cut out the mime types block: 250 lines, zero findings, zero parsing complaints. Then I planted a real vulnerable pair into that same copy and got exactly one finding, with both lines pointed at precisely. So on real noise it stays quiet, on a real problem it speaks.

What the script deliberately does not catch, so you do not treat it as a guarantee:

  • a second pair in the same location: only the first is reported;
  • rewrite and set sitting not inside a location but directly in server;
  • chains through an intermediate variable, of the form set $tmp $1; and then use of $tmp: by the mechanics of the bug what is dangerous is the raw capture, but the tool does not try to check transitive passing;
  • transitions via try_files, error_page and named locations;
  • branch reachability: if the pair sits inside an obviously false if, it will still be shown.

This list is printed at the end of every report, so the reader sees it in the same place as the result, not only here.

Upgrading is of course more reliable than any config check. And what you should install is not 1.30.1 but the current stable: that second bug with overlapping captures, mentioned above, was closed after it. At the time of writing, 1.30.4 in the stable branch and 1.31.3 in the mainline are current, but check the number on nginx.org before installing, it changes. For NGINX Plus the minimum required release is R37.

How to spot attempts

If you cannot upgrade right now, what is left is watching the logs. There is exactly one sign and it is unambiguous:

grep "exited on signal" /var/log/nginx/error.log
Enter fullscreen mode Exit fullscreen mode

The line worker process NNN exited on signal 11 in error.log is a worker segfault. A healthy nginx does not have it at all, not one. Even one such line appearing is a reason to dig in, independently of this specific vulnerability.

Worth hanging an alert on that, if you have log collection. Separately, a thing that saves time during analysis. On my stand access_log was off on purpose, so it would not interfere with counting crashes, so here I lean not on the access log but on response codes. But the conclusion from them is unambiguous: at moderate lengths the client gets a 200, which means in access.log an attack like this will land as ordinary successful requests. What you need to look at is error.log.

What I did not show

I did not demonstrate RCE. The company that found it describes a path from the overflow to code execution through heap layout and substitution of a pointer to a pool cleanup function. That is plausible and published, but it is their result, not mine, and it requires bypassing address space randomization. In my case the overflow led to a worker crash, and I did not go further.

Treat this article as showing denial of service and behavior that matches a write past the end of a buffer on three signs at once: the segfault, the dependence on string length, and the dependence on which exact bytes are subject to escaping back. Code execution is out of scope.

Active exploitation: attempts have been recorded, VulnCheck reported them from 16 May, three days after disclosure, on their own honeypot network. These are attempts and scanning specifically, and I did not find a single publicly named production victim. The vulnerability has not been added to the official CISA KEV catalog: I looked at their feed from 11 August, there is no entry. Meanwhile a third-party commercial tracker gave it confirmed-exploitation status back on 19 May, and these two lists get regularly confused, with the second passed off as the first.

What to do today

  • Check the version: nginx -v. Everything up to and including 1.30.0 is vulnerable, cured by upgrading to the current stable or mainline branch, see the section above.
  • Run your config through the check from the section above. Empty means you can breathe out.
  • Confirm there have been no crashes yet: grep -c "exited on signal" /var/log/nginx/error.log. The answer should be zero.
  • If the configuration is generated from a template, what you check is not the template but the result: nginx -T on the live server.
  • Upgrade, without putting it off until "we will look at it later".

Takeaways

Eighteen years of live code, one forgotten flag reset line, and the same mistake was already fixed in this file fourteen years ago. This is not about nginx being bad code, this is about the fact that a mismatch between "count the length" and "copy the data" remains one of the most durable classes of bugs, and it survives any review, because each half on its own looks correct.

As for the panic: if you have an ordinary site with an ordinary config, this most likely does not concern you at all, even on a vulnerable version. If you generate your nginx configuration from a template and substitute something user-controlled into it, check your templates for the pair today.

And separately, for those following the AI-in-security topic. The agent found in six hours what eighteen years of review, fuzzing and reading with human eyes did not. But to understand what that means in practice, it still took a stand, five containers and an hour and a half of fiddling with what exactly to stuff the request with. Finding and understanding are still different jobs.

The config check script is in nginx-rift-check, take it. I am not publishing a ready-made stand for reproducing the crash: the config from the commit text and the commands from the article are enough to assemble it yourself in a couple of minutes, and I see no point in handing out a ready build for crashing other people's servers. If you found the pair in production, write to me, I am curious what class of systems it actually shows up in.

Originally published in Russian on Habr.

Top comments (0)