{"slug": "nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand", "title": "nginx is not the bug. Two lines of your config are. CVE-2026-42945 on a live stand", "summary": "A critical nginx vulnerability, CVE-2026-42945, with a CVSS score of 9.2, was discovered by an AI agent in six hours, affecting nearly all nginx versions since 2008. The bug, a heap overflow in the URL rewriting module, was fixed in nginx 1.30.1 and 1.31.0. An engineer's test stand found that while 5.7 million servers may be exposed, actual vulnerable configurations are rare, with only one out of 35,633 configs found vulnerable.", "body_md": "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.\n\nThen 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.\n\nBetween \"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.\n\nShort 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.\n\nOn 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.\n\nThe 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.\n\nOne 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.\n\nOne 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.\n\nThe 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.\n\nThe fix is one line. Here it is in full, file src/http/ngx_http_script.c, function ngx_http_script_regex_end_code:\n\n```\n@@ -1202,6 +1202,7 @@ ngx_http_script_regex_end_code(ngx_http_script_engine_t *e)\n\n     r = e->request;\n\n+    e->is_args = 0;\n     e->quote = 0;\n```\n\nTo see why one line is worth 9.2, you need to look at how nginx substitutes regular expression captures.\n\nSubstitution 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.\n\nCopying 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.\n\nA 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.\n\nNow 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.\n\nAnd 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;`\n\n. is_args was forgotten next to it. Half the state carried over, half not, and each half on its own looks completely correct.\n\nResult: 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.\n\nThat is also why the descriptions of the vulnerability talk about unnamed captures. Ordinary variables like `$myvar`\n\nare 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.\n\nThere 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.\n\nI build it on a VPS, everything stays on the loopback: a deliberately vulnerable nginx inside, and no reason for it to face outward.\n\nI 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.\n\n```\nworker_processes 1;\nerror_log /var/log/nginx/error.log info;\n\nevents {\n    worker_connections 1024;\n}\n\nhttp {\n    access_log off;\n\n    server {\n        listen 80;\n\n        location / {\n            rewrite ^(.*) /new?c=1;\n            set $myvar $1;\n            return 200 $myvar;\n        }\n    }\n}\n```\n\nFive 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.\n\n```\nservices:\n  vuln:\n    image: nginx:1.30.0-alpine\n    container_name: rift-vuln\n    ports:\n      - \"127.0.0.1:8099:80\"\n    volumes:\n      - ./conf/vuln.conf:/etc/nginx/nginx.conf:ro\n```\n\nThe other four services differ only in image, port and mounted config, so I show one.\n\nThe request I hit it with: a long run of pluses in the path.\n\n```\ncurl \"http://127.0.0.1:8099/++++++++++++++++++++++ ... ++++\"\n```\n\nThe 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.\n\nResult of the very first run:\n\n```\nCONTAINER              PORT   HTTP   CRASHES\nrift-vuln              8099   000    1\nrift-ctl-noq           8098   200    0\nrift-ctl-noconsumer    8097   200    0\nrift-ctl-named         8096   200    0\nrift-fixed             8095   200    0\n```\n\nThe vulnerable stand did not answer at all, the connection was dropped. In the log:\n\n```\n2026/08/12 23:29:03 [notice] 1#1: signal 17 (SIGCHLD) received from 30\n2026/08/12 23:29:03 [alert] 1#1: worker process 30 exited on signal 11\n2026/08/12 23:29:03 [notice] 1#1: start worker process 31\n```\n\nSignal 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.\n\nHere 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.\n\n**Removed the question mark** from the replacement string, kept everything else: `rewrite ^(.*) /new;`\n\n. No crash. There is nothing to arm the flag.\n\n**Kept the question mark, removed the consumer**: rewrite stays, the line `set $myvar $1;`\n\nis gone, replaced with `return 200 \"ok\";`\n\n. No crash. The flag is armed, but there is nobody to inherit it.\n\nThat 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.\n\n**Replaced the unnamed capture with a named one**: `rewrite ^(?<tail>.*) /new?c=1;`\n\nand `set $myvar $tail;`\n\n. No crash, exactly as the code predicts.\n\n**The patched version** with the same dangerous config stands and answers 200.\n\nSo 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.\n\nI 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.\n\n**A named group does not save you.** Above, `(?<tail>.*)`\n\nwith the consumer `$tail`\n\ndid 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:\n\n```\nrewrite ^(?<tail>.*) /new?c=1;\nset $myvar $1;\n```\n\nCrash. In PCRE a named group keeps its number as well, so `$1`\n\nhere 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.\n\n**One question mark is not enough, it needs arguments after it.** This line does not crash:\n\n```\nrewrite ^/old/(.*)$ /new/$1?;\nset $myvar $1;\n```\n\nA 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`\n\n, and the crash comes back.\n\nThis 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 `?`\n\nas dangerous, an enormous number of perfectly healthy configs will come out falsely vulnerable.\n\n**An intermediate rewrite disarms the flag.**\n\n```\nrewrite ^/old/(.*)$ /mid?c=1;\nrewrite ^/mid(.*)$ /new;\nset $myvar $1;\n```\n\nNo crash. The second rewrite, this time without the dangerous question mark, clears the armed state, and the pair falls apart.\n\n**The break flag cuts the chain.** With it, `set`\n\nsimply does not run: rewrite module processing stops, and there is nothing left to crash.\n\nHere is what I managed to measure, in a table:\n\n| Config inside one location | Crash |\n|---|---|\n`rewrite ^(.*) /new?c=1;` + `set $x $1;`\n|\nyes |\n`rewrite ^/old/(.*)$ /new?x=1;` + `set $x $1;`\n|\nyes |\n`rewrite ^/old/(.*)$ /new/$1?;` + `set $x $1;`\n|\nno |\n`rewrite ... /mid?c=1;` then `rewrite ... /new;` then `set $x $1;`\n|\nno |\n`rewrite ^(.*) /new?c=1 break;` + `set $x $1;`\n|\nno |\n`rewrite ^(?<tail>.*) /new?c=1;` + `set $x $1;`\n|\nyes |\n`rewrite ^(?<tail>.*) /new?c=1;` + `set $x $tail;`\n|\nno |\n\nI 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.\n\n| What the path is stuffed with | Result |\n|---|---|\n`+` repeated 2000 times |\ncrash |\n`A` repeated 2000 times |\n200 response, no crash |\n`%41` repeated 700 times |\n200 response, no crash |\n`%20` repeated 700 times |\ncrash |\n\nThe difference between `%41`\n\nand `%20`\n\nexplains everything. `%41`\n\nis 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`\n\nis a space, it decodes, and on copying it gets escaped back into three bytes, and that is where the lengths diverge.\n\nSo 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.\n\nThe logical expectation: the longer the string, the more certain the crash. I test from eight characters up to four thousand.\n\nLength of the `+` run |\nWorker crash |\n|---|---|\n| 8, 16, 32 | no |\n| 64, 128, 256 | yes |\n| 384 | no |\n| 512, 640 | yes |\n| 768, 896 | no |\n| 1024, 1280, 1536 | yes |\n| 2048, 3072, 4096 | yes, and the client no longer gets a response |\n\nThe 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.\n\nAn 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.\n\nThe 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.\n\nOne 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.\n\nNow 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.\n\n| Load | Worker crashes | Probes failed | Failure rate |\n|---|---|---|---|\n| one curl thread, 20 seconds | 263 | 2 of 60 | 3.3% |\n| eight curl threads, 20 seconds | 365 | 4 of 60 | 6.7% |\n| ab, concurrency 20, 30 seconds | 594 | 3 of 60 | 5.0% |\n\nLook 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.\n\nAnd the site still stays available: across three measurements, between 3.3 and 6.7 percent of probes failed.\n\nThe 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.\n\nSo 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.\n\nBack 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.\n\nExcept 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.\n\nAn ordinary WordPress or Laravel config, with something like `try_files $uri $uri/ /index.php?$query_string;`\n\n, does not match the condition: try_files is not rewrite, and there are no captures there.\n\nWhere 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.\n\nHence 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.\n\nA 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.\n\nI 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`\n\ndoes 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](https://github.com/CynepMyx/nginx-rift-check). Python 3, no dependencies.\n\n```\nnginx -T | python3 check_rewrite.py\n```\n\nHere 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.\n\n```\n[HIGH] находка #1\n  location:  location /  (/etc/nginx/nginx.conf:14)\n  rewrite:   rewrite ^(.*) /new?c=1  (/etc/nginx/nginx.conf:15)\n  захват $N: set -> set $myvar $1  (/etc/nginx/nginx.conf:16)\n  почему:    обработка продолжается в этом же location без редиректа\n\nИтого находок: 1\n```\n\nA `[LOW]`\n\nmark instead of `[HIGH]`\n\nhappens on the `last`\n\nflag: processing moves to a different location, the pair rarely forms, and I did not measure that case separately.\n\nThree 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.\n\nI tested it on more than the stand. First run on my own server: an ordinary `nginx -T`\n\n, 182 lines, the standard mime types block of a hundred lines and a multiline `log_format`\n\nwith quotes. Zero findings, zero parsing complaints.\n\nSecond on a client's production frontend: 356 lines, nine included files, a `map`\n\nwith 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.\n\nWhat the script deliberately does not catch, so you do not treat it as a guarantee:\n\n`set $tmp $1;`\n\nand then use of `$tmp`\n\n: by the mechanics of the bug what is dangerous is the raw capture, but the tool does not try to check transitive passing;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.\n\nUpgrading 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.\n\nIf you cannot upgrade right now, what is left is watching the logs. There is exactly one sign and it is unambiguous:\n\n```\ngrep \"exited on signal\" /var/log/nginx/error.log\n```\n\nThe line `worker process NNN exited on signal 11`\n\nin 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.\n\nWorth 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.\n\nI 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.\n\nTreat 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.\n\nActive 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.\n\n`nginx -v`\n\n. Everything up to and including 1.30.0 is vulnerable, cured by upgrading to the current stable or mainline branch, see the section above.`grep -c \"exited on signal\" /var/log/nginx/error.log`\n\n. The answer should be zero.`nginx -T`\n\non the live server.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.\n\nAs 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.\n\nAnd 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.\n\nThe config check script is in [nginx-rift-check](https://github.com/CynepMyx/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.\n\n*Originally published in Russian on Habr.*", "url": "https://wpnews.pro/news/nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand", "canonical_source": "https://dev.to/cynepmyx/nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand-5fc8", "published_at": "2026-08-13 13:05:00+00:00", "updated_at": "2026-08-13 13:20:18.729123+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "developer-tools"], "entities": ["nginx", "CVE-2026-42945", "F5", "NVD", "depthfirst", "Leo Lin"], "alternates": {"html": "https://wpnews.pro/news/nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand", "markdown": "https://wpnews.pro/news/nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand.md", "text": "https://wpnews.pro/news/nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand.txt", "jsonld": "https://wpnews.pro/news/nginx-is-not-the-bug-two-lines-of-your-config-are-cve-2026-42945-on-a-live-stand.jsonld"}}