Критическая уязвимость nginx: как map с regex превращает $1 в оружие (CVE-2026-42533)
Критическая уязвимость nginx: как map с regex превращает $1 в оружие (CVE-2026-42533)
У вас в конфиге nginx есть location с regex, из которого вы берёте $1, и рядом — map с regex-паттерном. Ничего необычного: так делает половина инсталляций с проксированием по пути, версионированием API через URL или whitelisting по User-Agent. Проблема в том, что если $1 используется в строковом выражении и после него в той же строке вычисляется regex-переменная map — nginx вычисляет размер буфера для одного значения $1, а пишет туда другое, взятое из тела запроса или заголовка, которые полностью контролирует атакующий. Разница между тем, что померяли, и тем, что записали, — это и есть переполнение кучи.
15 июля 2026 года F5 закрыла эту дыру в nginx 1.30.4 (stable) и 1.31.3 (mainline), а также в NGINX Plus 37.0.3.1. CVE-2026-42533 получила CVSS 9.2 по шкале v4.0 (Critical) и 8.1 по v3.1 (High), CWE-122 — heap-based buffer overflow. Уязвимость затрагивает каждую версию nginx с 0.9.6 по 1.30.3 в stable-ветке и по 1.31.2 в mainline — диапазон, который начинается 21 марта 2011 года, когда directive map получила поддержку regex. На момент публикации этой статьи CVE-2026-42533 не значится в каталоге CISA Known Exploited Vulnerabilities, а SSVC-оценка от CISA-ADP помечает статус эксплуатации как «none». Публичного эксплойта тоже нет — но это не означает, что уязвимость теоретическая, и дальше будет понятно почему.
КАК УСТРОЕН ДВУХПРОХОДНЫЙ ДВИЖОК NGINX
Чтобы понять баг, нужно понять, как nginx вообще вычисляет строковые выражения вроде proxy_set_header X-Path "$1/$something". Движок компилирует выражение в последовательность опкодов и затем вызывает ngx_http_complex_value() в src/http/ngx_http_script.c — дважды. Первый проход, LEN, проходит по опкодам и суммирует, сколько байт займёт каждый кусок. По результату аллоцируется буфер ровно нужного размера через ngx_pnalloc(r->pool, len). Второй проход, VALUE, проходит по тем же опкодам ещё раз и уже пишет байты в этот буфер.
Идея разумная сама по себе: сначала померь, потом режь по мерке. Она ломается только в одном случае — если между двумя проходами что-то меняет данные, которые опкоды читают. И именно это происходит с захваченными группами regex.
КАК УСТРОЕН БАГ
Когда location match содержит regex, результат — смещения захваченных групп и указатель на исходную строку — сохраняется в r->captures и r->captures_data прямо на объекте запроса. Обращение к $1 компилируется в опкоды, которые читают эти поля: на LEN-проходе это ngx_http_script_copy_capture_len_code(), на VALUE-проходе — ngx_http_script_copy_capture_code(). Оба читают один и тот же массив.
Проблема в том, что r->captures — это не снапшот location-матча. Это общее изменяемое состояние запроса, и любой regex, выполненный позже в рамках того же запроса, его перезаписывает. Именно так себя ведёт map с regex-паттерном: вызов уходит в ngx_http_map_find() в src/http/ngx_http_variables.c, который для каждого паттерна карты вызывает ngx_http_regex_exec(). Эта функция гоняет PCRE-матч против входной переменной map и пишет результат прямо в r->captures и r->captures_data запроса — поверх того, что там уже лежало от location-матча. Ни ngx_http_complex_value(), ни ngx_http_map_find() нигде не сохраняют и не восстанавливают это состояние.
Возьмём выражение вида "$1=$bodyvar=$1" в location, который матчит короткий URI вроде «abc» (3 байта), где $bodyvar — map-переменная, гоняющая regex против $request_body длиной 200 байт. На LEN-проходе первый $1 читает оригинальный захват — 3 байта. Затем вычисляется $bodyvar, что вызывает ngx_http_regex_exec() против тела запроса и перезаписывает r->captures смещениями внутри тела. Второй $1 на этом же LEN-проходе читает уже перезаписанный захват — 200 байт. Итоговая длина: 3 + 1 + 6 + 1 + 200 = 211 байт, буфер аллоцируется именно на 211.
На VALUE-проходе состояние захватов остаётся тем же — перезаписанным, никто его не восстановил. Первый $1 теперь тоже читает 200-байтный захват и пишет 200 байт, $bodyvar пишет 6 байт «mapped», второй $1 снова пишет 200 байт. Итого 408 байт в буфер на 211 — переполнение на 197 байт полностью контролируемым атакующим содержимым из тела POST-запроса. LEN-проход увидел подмену только для одного $1 (второго), VALUE-проход — для обоих. Эта асимметрия и есть переполнение.
Механизм работает и в обратную сторону. Если входные данные map короче оригинального захвата, буфер получается больше, чем реально записывается, а неинициализированный хвост буфера уходит в ответ клиенту как есть — потому что ngx_http_complex_value() возвращает длину, посчитанную на LEN-проходе, а не число байт, реально записанных на VALUE-проходе. Для URI-пути в 8160 байт и однобайтного заголовка, вызывающего map-regex, буфер аллоцируется на 8161 байт, а инициализируются только 2. Исследователь Stan Shaw, сообщивший об уязвимости под ником cyberstan, проверил это на Ubuntu 24.04 с glibc 2.39: в неинициализированном хвосте по смещению 0x08 стабильно оказывается указатель на libc, по смещению 0x10 — указатель в кучу. Этого достаточно, чтобы вычислить базовый адрес libc и адрес буфера в памяти одним неаутентифицированным GET-запросом — то есть обойти ASLR.
КАК ЭТО ЭКСПЛУАТИРУЕТСЯ
F5 в своём advisory формулирует условие для RCE осторожно: код исполняется, «если ASLR отключён или может быть обойдён». На дефолтном Ubuntu-сервере с включённым ASLR это звучит как редкий кейс. Shaw утверждает обратное: обход ASLR — часть той же самой уязвимости, а не отдельное условие. По его данным, для полного цикла достаточно одного GET-запроса для утечки указателей, порядка 40 spray-соединений и одного POST-запроса, вызывающего переполнение. В его тестах на Ubuntu 24.04 с glibc 2.39 и полностью включённым ASLR цепочка сработала 10 раз из 10.
Технические детали эксплуатации и proof-of-concept Shaw пока не публикует — обещает выпустить их через 21 день после патча, то есть примерно 5 августа. Причина прямая: майский баг в том же движке nginx, Rift (CVE-2026-42945), требовал отключённого ASLR для надёжной эксплуатации — и всё равно его эксплойт стал публичным в течение нескольких дней после раскрытия, а вскоре после этого началась активная эксплуатация в реальном мире. Этот баг ASLR не требует вообще, поэтому исследователь предпочитает дать администраторам время на патч, а не повторять сценарий Rift.
ЧТО МЕНЯЕТСЯ В ЗАВИСИМОСТИ ОТ КОНФИГУРАЦИИ
Уязвимость затрагивает не любой nginx, а конкретный паттерн: regex-based map, чья выходная переменная встречается в строковом выражении вместе с numbered-захватом ($1, $2) из более раннего regex — location, server_name, rewrite или if-блока. Причём захват и map-переменная не обязаны быть в одной директиве: Shaw показывает, что это работает и между отдельными директивами в одном location-блоке, потому что модули вроде proxy, fastcgi, scgi, uwsgi и grpc каждый реализуют собственный LEN/VALUE-цикл на весь блок с одной аллокацией буфера. Пример из его отчёта — обычная связка proxy_set_header X-Path "$1" и proxy_set_header X-Check "$map_var" в одном location: первая директива даёт $1 обоим проходам, вторая на LEN-проходе вычисляет map и портит захват, а когда VALUE-проход возвращается к первой директиве — читает уже испорченное значение.
Список затронутых директив у Shaw впечатляюще длинный: proxy_set_header, proxy_pass, fastcgi_param, uwsgi_param, scgi_param, grpc_set_header, return, add_header, rewrite, set, root, alias, access_log и другие — всего он насчитал минимум 13 независимых call site в 9 файлах исходного кода, покрывающих и HTTP, и stream-модуль. Есть и второй, независимый путь эксплуатации — через именованные захваты ((?P<name>...)), которые читаются не из r->captures, а из r->variables[]. Если два map-regex определяют одну и ту же именованную группу, второй перезаписывает первый в r->variables[] — тот же самый эффект переполнения, но по другому пути данных.
Это прямо касается временного workaround, который рекомендует F5: перевод regex-map на именованные захваты закрывает основной путь через r->captures, но не закрывает вариант с одинаковыми именованными группами в разных map. То есть workaround снижает поверхность атаки, но не устраняет уязвимость полностью — устраняет её только апгрейд.
ИСТОРИЯ ОШИБКИ, КОТОРОЙ 15 ЛЕТ
Самое неприятное в этой истории — не сам баг, а то, что о нём знали задолго до CVE. В 2014 году пользователь Pascal Jungblut завёл тикет #564 в трекере nginx, описав именно это поведение: map с regex перезаписывает захваченные группы в rewrite-директиве, и $1 молча возвращает разные данные в зависимости от того, сработал ли regex карты. Maxim Dounin, мейнтейнер nginx, признал проблему дефектом: он написал, что текущее поведение явно плохое и должно быть исправлено. За следующие годы к тикету добавилось пять перекрёстных ссылок на похожие отчёты (#1044, #1142, #1285, #1498, #1934), а в 2020 году кто-то из пользователей аргументированно предлагал повысить приоритет тикета с minor до critical.
То есть поведенческая аномалия была известна публично больше десяти лет — просто её воспринимали как баг логики (неожиданное значение переменной), а не как проблему безопасности памяти. Двухпроходная модель вычисления выражений превращает то же самое расхождение в переполнение кучи, а это уже совсем другая весовая категория. Разрыв между «нашли поведенческую странность» и «поняли, что это RCE» — тут именно в том, что для второго нужно проследить связь конкретно с моделью LEN/VALUE-аллокации, а не просто с фактом порчи захватов.
ПОЧЕМУ ЭТО НАШЛИ ИМЕННО СЕЙЧАС
Это уже третий баг такого класса в движке nginx за пару месяцев: в мае — Rift (CVE-2026-42945), где рассинхронизация случалась из-за флага is_args, вскоре после этого — переполнение с перекрывающимися захватами в rewrite-модуле (CVE-2026-9256). Сам Shaw в своём отчёте формулирует это как общий паттерн: у всех трёх разные конкретные причины, но одна и та же структурная уязвимость — LEN/VALUE-разбиение, которое предполагает, что вычисление детерминировано, хотя на деле оно может иметь побочные эффекты. Любое изменяемое состояние запроса, которое опкод читает для определения длины и которое может измениться между проходами — по словам исследователя, кандидат на такую же категорию бага, и захваты regex, флаги вроде is_args и кэшируемые переменные — это только те кандидаты, что уже нашли.
ПОЧЕМУ ЭТО ВАЖНО
Nginx стоит перед огромной долей веб-инфраструктуры — reverse proxy, API gateway, ingress в Kubernetes, terminating TLS перед десятками бэкендов. Уязвимость затрагивает не только сам сервер и NGINX Plus, но и линейку продуктов F5 поверх nginx: NGINX Ingress Controller, Gateway Fabric, App Protect WAF и Instance Manager — хотя на момент публикации advisory для этих четырёх продуктов конкретные патч-версии ещё не были указаны. Для тех, кто эксплуатирует reverse proxy на nginx перед WordPress, Joomla или любым self-hosted API, конфигурация с regex-map для роутинга по версии API, whitelisting по заголовкам или гео-based rewrite — совершенно обычная практика, а не экзотика. Значит, реальная поверхность атаки далеко не ограничивается крупными энтерпрайз-инсталляциями.
Отдельно стоит сказать про CVSS 9.2 у F5: сложность атаки (AC) в векторе помечена как High — но это оценка сложности эксплуатации самой уязвимости движком CVSS, а не оценка реальных препятствий для конкретного атакующего с нужной квалификацией. RCE есть RCE независимо от того, как это отражено в векторе.
КАК НЕ ДОПУСТИТЬ ПОВТОРЕНИЯ ТАКОГО
С инженерной точки зрения проблема — в том, что r->captures представляет собой общее изменяемое состояние запроса, которое читают многие части кода, но которое не снапшотится перед потенциально опасными операциями. Патч nginx решает это не восстановлением состояния захватов между проходами, а иначе: движок получил указатель end на границу аллоцированного буфера, и перед каждым write-опкодом (copy_code, copy_var_code, обе ветки copy_capture_code, включая escape-путь, и raw-разделители в апстрим-модулях) добавлена проверка ngx_http_script_check_length(). Если VALUE-проход пытается писать за границу — выставляется NGX_HTTP_INTERNAL_SERVER_ERROR, выполнение уходит в ngx_http_script_exit, в лог пишется «no buffer space in script copy», и вызывающий код завершается с HTTP 500 вместо порчи кучи. Направление информационной утечки закрыто похожим образом: длина возвращаемой строки теперь считается по фактически записанным байтам (e->pos - e->buf.data), а не по завышенной LEN-оценке, так что переразмеренный буфер больше не отдаёт свой неинициализированный хвост.
Для тех, кто пишет или ревьюит модули nginx или похожий код с раздельным измерением и записью: любое изменяемое состояние запроса, которое читается опкодом для определения длины и может измениться между проходами — кандидат на такую же категорию бага. Явные захваты regex — не единственный пример; кэшируемые переменные и флаги вроде is_args из CVE-2026-42945 показывают, что список шире. Практический вывод для собственных проектов — снапшотить или замораживать любое состояние, влияющее на размер, до начала измерения, либо, как сделал nginx, добавлять bounds-check прямо в точке записи вместо того чтобы полагаться на корректность более раннего измерения.
ОБНОВЛЕНИЕ
Проверить текущую версию nginx можно так: первая команда покажет версию бинарника, вторая — с какими модулями он собран.
nginx -v
nginx -V
Если версия ниже 1.30.4 в stable-ветке или ниже 1.31.3 в mainline — сервер уязвим. Важный нюанс: дефолтные репозитории Ubuntu и Debian сильно отстают от актуальных релизов nginx — это могут быть версии многолетней давности, в которых патча заведомо нет. Поэтому команда apt-get install nginx без подключения официального репозитория nginx.org просто переустановит ту же устаревшую версию. Сначала нужно подключить репозиторий: импортировать подписывающий ключ nginx и добавить источник пакетов.
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
Первая команда забирает официальный ключ nginx.org и конвертирует его в формат, который понимает apt, вторая — добавляет сам репозиторий stable-ветки для вашего дистрибутива (для Ubuntu та же команда, но путь в URL — https://nginx.org/packages/ubuntu; для mainline-ветки в обоих случаях перед именем дистрибутива добавляется mainline/). Дальше стоит закрепить приоритет репозитория nginx.org, иначе следующий плановый apt upgrade рискует откатить пакет обратно на устаревшую версию из дистрибутива:
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
| sudo tee /etc/apt/preferences.d/99nginx
Эта команда создаёт файл pinning-конфигурации, который говорит apt отдавать предпочтение пакетам из репозитория nginx.org перед любыми другими источниками того же имени пакета — без него на многих дистрибутивах может победить более новый по номеру, но не патченный пакет из штатного репозитория. После этого обновление выполняется штатно:
apt-get update
apt-get install nginx
После установки снова проверьте версию через nginx -v — она должна показать 1.30.4 или новее (stable) либо 1.31.3 или новее (mainline). Для NGINX Plus обновление до 37.0.3.1 идёт через обычный канал доставки пакетов Plus; версии R33–R36 закрыты в R36 P7, более старые R-релизы патча не получат и требуют перехода на поддерживаемую ветку.
Если немедленное обновление невозможно, временная мера — перевод всех regex-based map, чьи выходные переменные пересекаются с numbered-захватами в том же location, на именованные захваты вместо $1/$2. Например, уязвимая связка вида
location ~ "^/api/(.+)$" {
proxy_set_header X-Path "$1";
proxy_set_header X-Check "$map_var";
proxy_pass http://backend;
}
переписывается так, чтобы numbered-захват не пересекался с map-переменной в одной директиве или в одной buffer-группе директив семейства proxy/fastcgi/scgi/uwsgi/grpc:
location ~ "^/api/(?<path>.+)$" {
proxy_set_header X-Path "$path";
proxy_set_header X-Check "$map_var";
proxy_pass http://backend;
}
Это закрывает основной путь эксплуатации через r->captures, но не закрывает вариант с двумя map, определяющими одну и ту же именованную группу — README сканера отдельно предупреждает не переиспользовать одну и ту же именованную группу в разных regex-map. Только апгрейд до 1.30.4/1.31.3 закрывает уязвимость полностью, о чём прямо говорит и сам исследователь, и это стоит учитывать при планировании maintenance-окна.
После любой правки конфига — вне зависимости от того, апгрейд это или временный workaround — обязательно проверьте синтаксис перед применением, иначе есть риск перезапустить nginx с битым конфигом и положить сервис:
nginx -t && systemctl reload nginx
Первая часть команды, nginx -t, парсит основной конфиг и все include-файлы, проверяет доступность путей к логам и другим файлам, и возвращает «syntax is ok» и «test is successful», если всё в порядке, либо построчно указывает на ошибку, если нет. Оператор && гарантирует, что systemctl reload nginx выполнится только если проверка прошла успешно — reload применяет новый конфиг, поднимая новые worker-процессы и штатно завершая старые после отработки открытых соединений, без разрыва текущих клиентских подключений и без даунтайма, в отличие от полного restart.
Быстро прикинуть, есть ли в конфигах вообще подозрительный паттерн, можно грепом по всем конфигам на директиву map с regex-паттернами — это не даст точного ответа про уязвимость конкретного блока, но покажет, где смотреть в первую очередь:
grep -rn "map " /etc/nginx/ --include="*.conf"
Для точного ответа — Shaw опубликовал read-only статический сканер, который проходит по конфигу с учётом include, распознаёт кросс-директивный триггер и выводит результат в том числе в JSON — он не эксплуатирует ничего, только показывает, есть ли предпосылки. Устанавливается и запускается так:
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
Первые две команды клонируют репозиторий и переходят в его директорию, третья запускает сканер на основной конфиг nginx — путь нужно заменить на реальный путь к вашему nginx.conf, если он отличается; include-директивы сканер разворачивает автоматически. Сканер завершается с кодом 0, если уязвимых мест не найдено, и с кодом 1, если найдено хотя бы одно — это удобно встраивать прямо в CI:
python3 nginx_capture_clobber_scan.py /etc/nginx/nginx.conf || echo "review needed"
Для машиночитаемого вывода есть флаг --json, который отдаёт файл, строку, enclosing scope, конкретные директивы и переменные захвата/map, участвующие в срабатывании.
ВЫВОДЫ
Если вы держите nginx как reverse proxy перед WordPress, Joomla или любым self-hosted сервисом — обновитесь до 1.30.4 (stable) или 1.31.3 (mainline) в ближайшее плановое окно, не дожидаясь публикации PoC 5 августа. Если у вас NGINX Plus — переходите на 37.0.3.1 или R36 P7 в зависимости от ветки. Если апгрейд прямо сейчас невозможен — прогоните конфиги через сканер Shaw и переведите проблемные regex-map на именованные захваты как временную меру, помня, что это не полное закрытие уязвимости.
Для хостинг-провайдеров, которые управляют nginx на множестве серверов клиентов, это тот случай, когда стоит закладывать плановое обновление в ближайший maintenance-цикл без ожидания требования от клиента — конфигурация с regex-map и captures слишком распространена, чтобы полагаться на то, что она есть только у «продвинутых» пользователей. Для админов dedicated и VPS-серверов — то же самое: если вы когда-либо копировали конфиг nginx с гео-роутингом, версионированием API по URL или whitelisting по заголовкам из статьи или форума, есть смысл прогнать его через сканер прямо сейчас, а не после того, как 5 августа выйдет публичный эксплойт.
