Критическая уязвимость в nginx, 9.2 по CVSS, пролежала в коде восемнадцать лет, нашёл её не человек, а ИИ-агент, и ему хватило шести часов. В новостях к этому прилагается цифра: 5.7 миллиона серверов в интернете.
А потом два независимых исследователя прогоняют сканеры по реальным конфигурациям nginx с гитхаба. Первый смотрит 1465 конфигов из 528 популярных репозиториев и не находит ни одного уязвимого в проде. Второй смотрит 35633 конфига и находит один, в заброшенном проекте 2011 года.
Между “5.7 миллиона” и “один из тридцати пяти тысяч” разница в десятки тысяч раз. Я поднял стенд, чтобы понять, кто из них прав, и заодно проверить, лежит ли мой собственный сервер.
Короткий ответ: правы оба, потому что считают разное. Длинный ответ ниже, вместе с командами, логами и скриптом, который проверит ваш конфиг за секунду.
Что случилось
13 мая 2026 года вышли nginx 1.30.1 и 1.31.0, закрывающие CVE-2026-42945. Дыра в куче, в модуле переписывания URL. Первая версия с ней это 0.6.27, последняя 1.30.0, то есть под раздачу попадает почти вся история продукта. У коммерческого NGINX Plus, по данным NVD, это выпуски R32 по R36, лечится в R37.
Нашла уязвимость компания depthfirst, прогнав по исходникам nginx собственного ИИ-агента для аудита низкоуровневого кода. За шесть часов агент нашёл пять проблем с памятью, четыре из них nginx подтвердил. Эта самая серьёзная.
Мелкая деталь, которую стоит отметить сразу, потому что она пригодится в конце: в самом коммите с исправлением в графе “кто сообщил” стоит имя живого человека, Leo Lin. Агент нашёл, но в историю коммитов попал инженер.
Ещё одно, раз уж мы про точность цифр. В большинстве источников возраст бага указан как восемнадцать лет, и это сходится: версия 0.6.27 вышла в 2008 году. Но как минимум одно крупное издание написало “шестнадцать лет”, и эта цифра дальше расползлась по пересказам. Правильная восемнадцать.
Оценки разошлись сразу. NVD и F5 дают 9.2 по CVSS v4.0 и 8.1 по v3.1. Сам nginx в своём списке security advisories помечает её как medium. Причину этого расхождения я разберу в конце, когда станет видно, из чего оно растёт.
Механика: флаг, который забыли погасить
Фикс занимает одну строку. Вот он целиком, файл src/http/ngx_http_script.c, функция 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;
Чтобы понять, почему одна строка стоит 9.2, нужно посмотреть, как nginx подставляет захваты регулярного выражения.
Подстановка идёт в два прохода. Сначала движок считает, сколько байт займёт результат, и выделяет буфер. Потом второй проход копирует туда данные. Пока оба прохода считают одинаково, всё хорошо.
Копирование безымянного захвата, то есть $1 по $9, устроено так: если данные уходят в строку запроса, а не в путь, их надо экранировать. Пробел превращается в %20, плюс в %2B, то есть один байт превращается в три. Решение принимается по флагу is_args внутри движка.
Директива rewrite с вопросительным знаком в строке замены этот флаг взводит: всё, что идёт после знака вопроса, это уже аргументы. Логично. Тут нужна оговорка про случай, когда после вопроса нет ничего, но к ней я вернусь в разделе про границу. Дальше обработка rewrite заканчивается, и вот тут флаг надо было погасить, а его не гасили. Он оставался поднятым до конца обработки location.
Теперь смотрим на директиву set. Длину значения она считает через отдельный, только что обнулённый движок, где is_args равен нулю. То есть считает длину без запаса на экранирование. А копирует данные основной движок, тот самый, где флаг остался поднятым от предыдущего rewrite. Копирует с экранированием.
Причём в этом обнулённом движке один флаг из пары всё-таки переносится из основного, вот эта строка стоит в коде явно: le.quote = e->quote;. А про is_args рядом забыли. Половину состояния перенесли, половину нет, и обе половины по отдельности выглядят абсолютно правильными.
Итог: буфер выделен под сырую строку, а записывается в него строка экранированная, которая может быть втрое длиннее. Всё лишнее уходит за границу выделенной памяти.
Отсюда же следует, почему в описаниях уязвимости говорят про безымянные захваты. Обычные переменные вроде $myvar копируются другим кодом, где проверки на экранирование нет вообще, они просто переносятся как есть. Правда, дальше на стенде выяснится, что это правило формулируют неточно, и формулировка эта опасная, но об этом в разделе про границу.
В тексте коммита есть ещё одна деталь, которая мне нравится больше самого бага. Автор фикса пишет: “A similar issue was fixed in 74d939974d43”. Это коммит 2012 года, тикет trac номер 162, тот же класс ошибки: рассинхрон прохода подсчёта и прохода копирования по одному и тому же флагу. Занятно, что чинили тогда ровно наоборот. В 2012 году убрали строку, которая переносила is_args в локальный движок, то есть перестали флаг передавать. В 2026 добавили строку, которая его гасит. Четырнадцать лет между двумя половинами одной и той же ошибки.
Стенд
Собираю на VPS, всё держу на петле: внутри намеренно уязвимый nginx, наружу ему смотреть незачем.
Конфиг беру не свой, а дословно из текста коммита, которым это чинили. Так честнее, и не будет вопросов, не подогнал ли я конфигурацию под результат.
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; } } }
Пять контейнеров: уязвимый nginx 1.30.0 с этим конфигом, три контроля на той же версии и пропатченный 1.30.1 с тем же самым конфигом. Один воркер, чтобы падение было видно однозначно.
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
Остальные четыре сервиса отличаются только образом, портом и подключённым конфигом, поэтому привожу один.
Запрос, которым бью: длинная серия плюсов в пути.
curl "http://127.0.0.1:8099/++++++++++++++++++++++ ... ++++"
Плюс здесь работает сразу на два фронта. Он взводит внутренний признак того, что в URI есть что экранировать, и он же сам подлежит экранированию, превращаясь в три байта вместо одного. То есть даёт нужный рост длины.
Результат первого же прогона:
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
Уязвимый стенд не ответил вообще, соединение оборвано. В логе:
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
Signal 11 это segfault. Мастер тут же поднимает нового воркера, и сервер продолжает работать. Запомните эту строку из лога, к ней вернёмся в разделе про детект.
Три условия, а не одно
Дальше начинается самое интересное, ради чего стенд и собирался. Проверяю, что именно из конфигурации отвечает за падение. Убираю ровно один элемент за раз.
Убрал знак вопроса из строки замены, оставил всё остальное: rewrite ^(.*) /new;. Не падает. Флаг некому взводить.
Оставил знак вопроса, убрал потребителя: rewrite на месте, а строки set $myvar $1; нет, вместо неё return 200 "ok";. Не падает. Флаг взведён, но его некому унаследовать.
Этот второй контроль важнее, чем кажется. Дело в том, что через день после этого фикса вышел ещё один, для другого бага в том же модуле, с перекрывающимися захватами. И форма атакующего запроса у него ровно такая же, длинная серия плюсов. Если бы мой стенд падал и без потребителя, я бы разбирал не ту уязвимость и весь разбор механики выше был бы неверен. Не падает, значит это именно утечка флага.
Заменил безымянный захват на именованный: rewrite ^(?<tail>.*) /new?c=1; и set $myvar $tail;. Не падает, ровно как предсказывает код.
Пропатченная версия с тем же самым опасным конфигом стоит и отвечает 200.
Итак, для падения нужны одновременно три вещи: знак вопроса в строке замены rewrite, безымянный захват, который используется после этого rewrite в том же location, и подходящее содержимое запроса. Про третье отдельно. Оба первых условия я ниже уточню: в таком виде они верны лишь приблизительно.
Где на самом деле проходит граница
На этом я собирался остановиться, но потом решил проверить ещё четыре случая, которые выглядели очевидными. Три оказались не тем, чем казались, а один из этих трёх ещё и опроверг то, что я написал выше.
Именованная группа не спасает. Выше (?<tail>.*) с потребителем $tail не упал, и код это объясняет: именованный захват nginx превращает в обычную переменную, а её копируют без всякого экранирования. Разбор был верный, а вот вывод, который из него напрашивается, нет. Оставляю группу именованной, но обращаюсь к ней по номеру:
rewrite ^(?<tail>.*) /new?c=1; set $myvar $1;
Падает. В PCRE именованная группа сохраняет и свой номер, поэтому $1 здесь рабочая ссылка, и идёт она тем самым уязвимым путём. Значит решает не то, как группа объявлена, а то, как к ней обращаются. Формулировка “именованные захваты не уязвимы”, которая гуляет по описаниям, вводит в заблуждение ровно тех, кто попробует ей защититься.
Одного знака вопроса мало, за ним нужны аргументы. Вот эта строка не падает:
rewrite ^/old/(.*)$ /new/$1?; set $myvar $1;
Знак вопроса в конце замены это стандартный способ сказать “не тащи исходную query string дальше”. Аргументов за ним нет, и связка не складывается. Почему именно так, по коду я не проверял, это наблюдение стенда. А стоит дописать хоть что-то, /new?x=1, и падение возвращается.
Это важно практически: именно такой хвостовой вопрос стоит в огромном количестве миграционных конфигов, где старые адреса переклеивают на новые. Если считать опасным любой ?, огромное количество совершенно здоровых конфигов окажется ложно уязвимым.
Промежуточный rewrite гасит флаг.
rewrite ^/old/(.*)$ /mid?c=1; rewrite ^/mid(.*)$ /new; set $myvar $1;
Не падает. Второй rewrite, уже без опасного вопроса, снимает взведённое состояние, и связка распадается.
Флаг break разрывает цепочку. С ним set просто не выполняется: обработка rewrite-модуля обрывается, и падать нечему.
Сведу в таблицу то, что получилось замерить:
Конфиг внутри одного location | Падение |
|---|---|
| да |
| да |
| нет |
| нет |
| нет |
| да |
| нет |
Чем набит запрос, тоже важно
Я предполагал, что достаточно percent-кодирования: раз оно взводит признак “в URI есть кодированные символы”, значит экранирование включится. Проверил, и оказалось не так.
Чем набит путь | Результат |
|---|---|
| падение |
| ответ 200, падения нет |
| ответ 200, падения нет |
| падение |
Разница между %41 и %20 объясняет всё. %41 это буква A. Она декодируется в обычный символ, который обратно экранировать не нужно, длина не растёт, буфера хватает. %20 это пробел, он декодируется, а при копировании экранируется обратно в три байта, и вот тут длины расходятся.
То есть недостаточно, чтобы в запросе просто было percent-кодирование. Нужен символ, который после декодирования снова подлежит экранированию. В advisory этой детали нет, она вылезает только на стенде.
Порог, которого нет
Логично ожидать: чем длиннее строка, тем вернее падение. Проверяю от восьми символов до четырёх тысяч.
Длина серии | Падение воркера |
|---|---|
8, 16, 32 | нет |
64, 128, 256 | да |
384 | нет |
512, 640 | да |
768, 896 | нет |
1024, 1280, 1536 | да |
2048, 3072, 4096 | да, и ответ клиенту уже не уходит |
Порог рваный. Падает на 64, не падает на 384, снова падает на 512, не падает на 768. Для переполнения кучи это нормально: упадёт процесс или нет, зависит от того, что лежало за границей выделенной памяти и переживёт ли это чужая структура.
Здесь важна оговорка про стенд: я гонял nginx из alpine-образа, а там libc это musl. Аллокатор у него свой, поэтому на сборках с glibc, а это большинство того, что стоит у читателя, конкретные значения порога вполне могут лечь иначе. Сам эффект от этого никуда не денется, а вот числа в таблице выше стоит считать иллюстрацией, а не константой.
Практический вывод неприятнее, чем кажется. Отсутствие падения не означает, что ничего не произошло. По механике на тех же 384 символах запись за границу буфера случилась и там, просто попала во что-то, что не привело к немедленному краху. Инструментами вроде ASAN я это не подтверждал, но тихая порча памяти хуже честного segfault ровно тем, что её никто не заметит.
Ещё одна деталь: на длинах от 64 до 1536 клиент успевает получить ответ 200, и только потом воркер падает. Ответ уходит, повреждение вскрывается позже, когда nginx работает с пулом памяти. По логам доступа такая атака выглядит как обычные успешные запросы.
Насколько это кладёт сайт
Теперь измерим, что значит это падение на практике. Определяю отказ строго: параллельно с атакой шлю на тот же сервер шестьдесят обычных безобидных запросов и считаю, какая доля из них не получила ответ.
Нагрузка | Падений воркера | Проб не прошло | Доля отказа |
|---|---|---|---|
один поток curl, 20 секунд | 263 | 2 из 60 | 3.3% |
восемь потоков curl, 20 секунд | 365 | 4 из 60 | 6.7% |
ab, конкурентность 20, 30 секунд | 594 | 3 из 60 | 5.0% |
Смотрим на последнюю строку внимательно. Ab отчитался ровно о 594 выполненных запросах, и воркер упал ровно 594 раза. Один запрос убивает одного воркера, соотношение один к одному, промахов нет.
И при этом сайт остаётся доступен: в трёх замерах не прошло от 3.3 до 6.7 процента проб.
Причина в том, что атака упирается в собственный результат. Убив воркера, атакующий вынужден ждать, пока мастер поднимет нового, и следующее соединение принять просто некому. Итоговый темп упёрся в 19.8 запроса в секунду, и это потолок не сервера, а самой атаки. Мастер-процесс восстанавливает воркера быстрее, чем его успевают убивать.
Так что называть это надёжным способом положить сайт я не стану. Это деградация обслуживания и километры alert-строк в логе. Настоящая опасность не здесь.
Кто уязвим на самом деле
Возвращаемся к расхождению из начала статьи. 5.7 миллиона это оценка VulnCheck, посчитанная по версии, которую сервер сообщает о себе. Корректный ответ на вопрос “сколько в интернете nginx уязвимой версии”. Там же, в исходной публикации, есть оговорка, которую новости почти не донесли: реально эксплуатируемая часть, по их собственным словам, заметно меньше.
Только для эксплуатации нужна не версия, а конфигурация. Причём конкретная: rewrite со знаком вопроса в замене, и следом использование безымянного захвата в том же location. Сканы реальных конфигураций дали ноль боевых из 1465 и один из 35633.
Обычный конфиг WordPress или Laravel, где стоит что-то вроде try_files $uri $uri/ /index.php?$query_string;, под условие не подходит: try_files это не rewrite, и захватов там нет.
Где связка всё-таки встречается: там, где конфиг не пишут руками, а генерируют по шаблону. Ingress-контроллеры в кластерах, панели управления хостингом, WAF со своими правилами переписывания, мультитенантные платформы, раздающие клиентам правила редиректов. Шаблон, в который подставляется чужой ввод, вполне может собрать нужную комбинацию, и никто её глазами не увидит.
Отсюда и оценка medium от самих разработчиков nginx против 9.2 от NVD. В векторе CVSS v4.0 стоит AC:H, высокая сложность атаки. Это не про то, что атакующему нужны редкие навыки, атака тривиальна. Это про то, что на сервере должна оказаться специфическая конфигурация. NVD оценивает худший случай при выполненных условиях, nginx оценивает вероятность встретить эти условия. Публичного объяснения от самого nginx я не нашёл, так что это моя реконструкция логики, а не их официальная позиция.
Как проверить себя
Наивный греп по слову rewrite даст кучу ложных срабатываний, потому что rewrite есть почти везде, а опасна связка. Знак вопроса внутри регулярного выражения, где он всего лишь квантификатор, тоже безопасен, а простой греп его поймает.
Я написал скрипт, который разбирает вывод nginx -T со всеми подключаемыми файлами, следит за границами location и ищет именно связку, причём по тем правилам, которые я намерил на стенде или вывел из механики, а не по описанию из advisory. То есть: знак вопроса только во втором аргументе rewrite и только если за ним есть аргументы, разоружение на промежуточном rewrite, break не считается находкой, а обращение по номеру считается независимо от того, названа группа или нет. Лежит здесь: github.com/CynepMyx/nginx-rift-check. Python 3, без зависимостей.
nginx -T | python3 check_rewrite.py
Вот что он говорит на конфиге стенда:
[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
Пометка [LOW] вместо [HIGH] бывает на флаге last: обработка уходит в другой location, связка складывается редко, и отдельно я этот случай не замерял.
Три кода возврата, а не два: ноль если чисто, единица если нашлась связка, двойка если конфиг не удалось прочитать или разобрать. Последнее важнее, чем кажется: молчаливое “ничего не найдено” на конфиге, который инструмент не смог разобрать до конца, это худшее, что может сделать проверялка безопасности. Поэтому при незакрытой кавычке или несбалансированных скобках она честно говорит, что результату верить нельзя. Есть флаг --json для машинного разбора.
Проверял я его не только на стенде. Первый прогон на своём сервере: обычный nginx -T, 182 строки, стандартный блок mime-типов на сотню строк и многострочный log_format с кавычками. Ноль находок, ноль жалоб на разбор.
Второй на боевом фронтенде клиента: 356 строк, девять подключаемых файлов, map с регулярным выражением, внутри которого живут и точка с запятой, и знак вопроса, заголовок CSP с десятком точек с запятой внутри кавычек, комментарии на русском внутри блоков. Гонял по копии, где заменил домены и адреса, а блок mime-типов вырезал: 250 строк, ноль находок, ноль жалоб. Потом подсадил в эту же копию настоящую уязвимую связку и получил ровно одну находку с точным указанием обеих строк. То есть на реальном шуме молчит, на реальной проблеме говорит.
Чего скрипт сознательно не ловит, чтобы вы не считали его гарантией:
вторую связку в том же location: сообщается только первая;
rewrite и set, стоящие не внутри location, а прямо в server;
цепочки через промежуточную переменную, вида
set $tmp $1;и затем использование$tmp: по механике бага опасен именно сырой захват, но проверить транзитивную передачу инструмент не пытается;переходы по try_files, error_page и именованным location;
достижимость ветки: если связка лежит внутри заведомо ложного if, она всё равно будет показана.
Этот список печатается в конце каждого отчёта, чтобы читатель видел его там же, где результат, а не только здесь.
Обновление, разумеется, надёжнее любой проверки конфига. И ставить надо не 1.30.1, а текущую стабильную: тот второй баг с перекрывающимися захватами, про который шла речь выше, закрыт уже после неё. На момент написания статьи актуальны 1.30.4 в стабильной ветке и 1.31.3 в основной, но перед установкой посмотрите номер на nginx.org, он меняется. Для NGINX Plus минимально необходимый выпуск R37.
Как заметить попытки
Если обновиться прямо сейчас нельзя, остаётся смотреть в логи. Признак ровно один и он однозначный:
grep "exited on signal" /var/log/nginx/error.log
Строка worker process NNN exited on signal 11 в error.log это segfault воркера. У здорового nginx её нет вообще, ни одной. Появление даже одной такой строки это повод разбираться, независимо от этой конкретной уязвимости.
На это стоит повесить алерт, если у вас есть сбор логов. Отдельно замечу вещь, которая экономит время при разборе. У меня на стенде access_log был выключен намеренно, чтобы не мешал считать падения, так что тут я опираюсь не на журнал доступа, а на коды ответа. Но вывод из них однозначный: при умеренных длинах клиент получает 200, значит в access.log такая атака ляжет обычными успешными запросами. Смотреть надо именно error.log.
Что я не показал
RCE я не демонстрировал. Нашедшая компания описывает путь от переполнения к выполнению кода через раскладку кучи и подмену указателя на функцию очистки пула. Это правдоподобно и опубликовано, но это их результат, а не мой, и он требует обхода рандомизации адресного пространства. У меня переполнение приводило к падению воркера, дальше я не шёл.
Считайте, что в этой статье показан отказ обслуживания и поведение, которое сходится с записью за границу буфера сразу по трём признакам: сегфолт, зависимость от длины строки и зависимость от того, какие именно байты подлежат обратному экранированию. Выполнение кода вынесено за скобки.
Активная эксплуатация: попытки зафиксированы, о них сообщал VulnCheck с 16 мая, через три дня после раскрытия, на своей сети приманок. Это именно попытки и сканирование, ни одного публично названного пострадавшего продакшена я не нашёл. В официальный каталог CISA KEV уязвимость не добавлена: я смотрел их фид от 11 августа, записи нет. При этом сторонний коммерческий трекер поставил ей статус подтверждённой эксплуатации ещё 19 мая, и вот эти два списка регулярно путают, выдавая второй за первый.
Что сделать сегодня
Посмотреть версию:
nginx -v. Уязвимо всё по 1.30.0 включительно, лечится обновлением на текущую стабильную или основную ветку, см. раздел выше.Прогнать конфиг проверкой из раздела выше. Пусто, значит можно выдохнуть.
Убедиться, что падений ещё не было:
grep -c "exited on signal" /var/log/nginx/error.log. Ответ должен быть ноль.Если конфигурация генерируется шаблоном, проверять надо не шаблон, а результат:
nginx -Tна живом сервере.Обновиться, не откладывая на “потом посмотрим”.
Выводы
Восемнадцать лет живого кода, одна забытая строка сброса флага, и та же самая ошибка уже чинилась в этом файле четырнадцать лет назад. Это не про плохой код nginx, это про то, что рассинхрон между “посчитать длину” и “скопировать данные” остаётся одним из самых живучих классов ошибок, и он переживает любые ревью, потому что каждая половина по отдельности выглядит правильной.
Что до паники: если у вас обычный сайт с обычным конфигом, вас это, скорее всего, не касается вообще, даже на уязвимой версии. Если вы генерируете конфигурацию nginx шаблоном и подставляете туда что-то, чем управляет пользователь, проверьте свои шаблоны на связку сегодня.
И отдельно, для тех, кто следит за темой ИИ в безопасности. Агент нашёл за шесть часов то, что не нашли восемнадцать лет ревью, фаззинга и чтения глазами. Но чтобы понять, что это значит на практике, всё равно понадобился стенд, пять контейнеров и полтора часа возни с тем, чем именно набивать запрос. Найти и понять это пока разные работы.
Скрипт проверки конфигов лежит в nginx-rift-check, забирайте. Готовый стенд для воспроизведения краша не выкладываю: конфиг из текста коммита и команды из статьи достаточны, чтобы собрать его самому за пару минут, а раздавать готовую сборку для падения чужих серверов смысла не вижу. Если у вас связка нашлась в проде, напишите, интересно, в каком классе систем она реально встречается.

