Kritinis nginx pažeidžiamumas: kaip map su regex paverčia $1 ginklu (CVE-2026-42533)
Kritinis nginx pažeidžiamumas: kaip map su regex paverčia $1 ginklu (CVE-2026-42533)
Jūsų nginx konfige yra location su regex, iš kurio imate $1, o šalia — map su regex šablonu. Nieko neįprasto: taip daro pusė diegimų su proxy pagal kelią, API versijavimu per URL arba whitelisting pagal User-Agent. Problema ta, kad jei $1 naudojamas string išraiškoje, o po jo toje pačioje eilutėje apskaičiuojamas map regex kintamasis — nginx apskaičiuoja buferio dydį vienai $1 reikšmei, o įrašo į jį visai kitą, paimtą iš request kūno arba antraštės, kuriuos pilnai kontroliuoja užpuolikas. Skirtumas tarp to, kas buvo pamatuota, ir to, kas buvo įrašyta, — tai ir yra heap perpildymas.
2026 m. liepos 15 d. F5 uždarė šią spragą nginx 1.30.4 (stable) ir 1.31.3 (mainline), taip pat NGINX Plus 37.0.3.1. CVE-2026-42533 gavo CVSS 9.2 pagal v4.0 skalę (Critical) ir 8.1 pagal v3.1 (High), CWE-122 — heap-based buffer overflow. Pažeidžiamumas paliečia kiekvieną nginx versiją nuo 0.9.6 iki 1.30.3 stable šakoje ir iki 1.31.2 mainline — diapazonas prasideda 2011 m. kovo 21 d., kai direktyva map gavo regex palaikymą. Šio straipsnio publikavimo metu CVE-2026-42533 nėra CISA Known Exploited Vulnerabilities kataloge, o CISA-ADP SSVC vertinimas eksploatacijos statusą žymi kaip „none”. Viešo exploit’o irgi nėra — bet tai nereiškia, kad pažeidžiamumas teorinis, ir toliau paaiškės kodėl.
KAIP VEIKIA DVIEJŲ PRAĖJIMŲ NGINX VARIKLIS
Norint suprasti bugą, reikia suprasti, kaip nginx apskritai apskaičiuoja string išraiškas, tokias kaip proxy_set_header X-Path "$1/$something". Variklis sukompiliuoja išraišką į opkodų seką ir tada iškviečia ngx_http_complex_value() faile src/http/ngx_http_script.c — du kartus. Pirmasis praėjimas, LEN, eina per opkodus ir susumuoja, kiek baitų užims kiekviena dalis. Pagal rezultatą alokuojamas buferis tiksliai reikiamo dydžio per ngx_pnalloc(r->pool, len). Antrasis praėjimas, VALUE, eina per tuos pačius opkodus dar kartą ir jau įrašo baitus į šį buferį.
Idėja pati savaime protinga: pirmiausia išmatuok, tada pjauk pagal matavimą. Ji lūžta tik vienu atveju — jei tarp dviejų praėjimų kažkas pakeičia duomenis, kuriuos skaito opkodai. Ir būtent tai vyksta su regex capture grupėmis.
KAIP VEIKIA BUGAS
Kai location match turi regex, rezultatas — capture grupių poslinkiai ir rodyklė į pradinę eilutę — išsaugomas r->captures ir r->captures_data tiesiai ant request objekto. Kreipimasis į $1 kompiliuojasi į opkodus, kurie skaito šiuos laukus: LEN praėjime tai ngx_http_script_copy_capture_len_code(), VALUE praėjime — ngx_http_script_copy_capture_code(). Abu skaito tą patį masyvą.
Problema ta, kad r->captures nėra location match’o snapshot’as. Tai bendra kintama request būsena, ir bet koks regex, įvykdytas vėliau to paties request rėmuose, ją perrašo. Būtent taip elgiasi map su regex šablonu: iškvietimas eina į ngx_http_map_find() faile src/http/ngx_http_variables.c, kuris kiekvienam map šablonui iškviečia ngx_http_regex_exec(). Ši funkcija paleidžia PCRE match’ą prieš map įvesties kintamąjį ir įrašo rezultatą tiesiai į request r->captures ir r->captures_data — ant to, kas ten jau gulėjo iš location match’o. Nei ngx_http_complex_value(), nei ngx_http_map_find() niekur neišsaugo ir neatstato šios būsenos.
Paimkime išraišką tokią kaip "$1=$bodyvar=$1" location bloke, kuris atitinka trumpą URI, pavyzdžiui „abc” (3 baitai), kur $bodyvar — map kintamasis, paleidžiantis regex prieš 200 baitų $request_body. LEN praėjime pirmasis $1 skaito originalų capture — 3 baitus. Tada apskaičiuojamas $bodyvar, kuris iškviečia ngx_http_regex_exec() prieš request kūną ir perrašo r->captures poslinkiais kūno viduje. Antrasis $1 tame pačiame LEN praėjime jau skaito perrašytą capture — 200 baitų. Galutinis ilgis: 3 + 1 + 6 + 1 + 200 = 211 baitų, buferis alokuojamas būtent 211 baitų.
VALUE praėjime capture būsena lieka ta pati — perrašyta, niekas jos neatstatė. Pirmasis $1 dabar irgi skaito 200 baitų capture ir įrašo 200 baitų, $bodyvar įrašo 6 baitus „mapped”, antrasis $1 vėl įrašo 200 baitų. Iš viso 408 baitai į 211 baitų buferį — perpildymas 197 baitais, kurių turinį pilnai kontroliuoja užpuolikas iš POST request kūno. LEN praėjimas pastebėjo pakeitimą tik vienam $1 (antrajam), VALUE praėjimas — abiem. Ši asimetrija ir yra perpildymas.
Mechanizmas veikia ir atvirkščiai. Jei map įvesties duomenys trumpesni už originalų capture, buferis gaunasi didesnis, nei realiai įrašoma, o neinicijuota buferio uodega keliauja į atsakymą klientui tokia, kokia yra — nes ngx_http_complex_value() grąžina ilgį, apskaičiuotą LEN praėjime, o ne realiai VALUE praėjime įrašytų baitų skaičių. 8160 baitų URI keliui ir vieno baito antraštei, sukeliančiai map regex, buferis alokuojamas 8161 baitui, o inicijuojami tik 2. Tyrėjas Stan Shaw, pranešęs apie pažeidžiamumą slapyvardžiu cyberstan, tai patikrino Ubuntu 24.04 su glibc 2.39: neinicijuotoje uodegoje poslinkyje 0x08 stabiliai atsiduria rodyklė į libc, poslinkyje 0x10 — rodyklė į heap. To pakanka, kad būtų apskaičiuotas libc bazinis adresas ir buferio adresas atmintyje vienu neautentifikuotu GET užklausimu — tai yra, apeiti ASLR.
KAIP TAI EKSPLOATUOJAMA
F5 savo advisory atsargiai formuluoja RCE sąlygą: kodas įvykdomas, „jei ASLR išjungtas arba gali būti apeitas”. Numatytajame Ubuntu serveryje su įjungtu ASLR tai skamba kaip retas atvejis. Shaw teigia priešingai: ASLR apėjimas yra tos pačios pažeidžiamybės dalis, o ne atskira sąlyga. Pagal jo duomenis, pilnam ciklui pakanka vieno GET užklausimo rodyklėms nutekinti, apie 40 spray jungčių ir vieno POST užklausimo, sukeliančio perpildymą. Jo testuose Ubuntu 24.04 su glibc 2.39 ir pilnai įjungtu ASLR grandinė suveikė 10 iš 10 kartų.
Techninių eksploatacijos detalių ir proof-of-concept Shaw kol kas nepublikuoja — žada išleisti juos po 21 dienos nuo pataisos, tai yra maždaug rugpjūčio 5 d. Priežastis tiesioginė: gegužės mėnesio bugas tame pačiame nginx variklyje, Rift (CVE-2026-42945), reikalavo išjungto ASLR patikimai eksploatacijai — ir vis tiek jo exploit’as tapo viešas per kelias dienas po atskleidimo, o netrukus po to prasidėjo aktyvi eksploatacija realiame pasaulyje. Šis bugas ASLR nereikalauja visai, todėl tyrėjas nori duoti administratoriams laiko pataisai, o ne kartoti Rift scenarijų.
KAS KEIČIASI PRIKLAUSOMAI NUO KONFIGŪRACIJOS
Pažeidžiamumas paliečia ne bet kokį nginx, o konkretų šabloną: regex-based map, kurios išvesties kintamasis atsiranda string išraiškoje kartu su numbered capture ($1, $2) iš ankstesnio regex — location, server_name, rewrite arba if bloko. Capture ir map kintamasis neprivalo būti vienoje direktyvoje: Shaw parodo, kad tai veikia ir tarp atskirų direktyvų viename location bloke, nes moduliai kaip proxy, fastcgi, scgi, uwsgi ir grpc kiekvienas realizuoja savo LEN/VALUE ciklą visam blokui su vienu buferio alokavimu. Pavyzdys iš jo ataskaitos — įprasta pora proxy_set_header X-Path "$1" ir proxy_set_header X-Check "$map_var" viename location: pirmoji direktyva duoda $1 abiem praėjimams, antroji LEN praėjime apskaičiuoja map ir sugadina capture, o kai VALUE praėjimas grįžta prie pirmosios direktyvos — skaito jau sugadintą reikšmę.
Shaw paveiktų direktyvų sąrašas įspūdingai ilgas: proxy_set_header, proxy_pass, fastcgi_param, uwsgi_param, scgi_param, grpc_set_header, return, add_header, rewrite, set, root, alias, access_log ir kiti — iš viso jis suskaičiavo mažiausiai 13 nepriklausomų call site 9 pradinio kodo failuose, apimančių ir HTTP, ir stream modulį. Yra ir antras, nepriklausomas eksploatacijos kelias — per pavadintus capture ((?P<name>...)), kurie skaitomi ne iš r->captures, o iš r->variables[]. Jei du map regex apibrėžia tą pačią pavadintą grupę, antrasis perrašo pirmąjį r->variables[] — tas pats perpildymo efektas, tik kitu duomenų keliu.
Tai tiesiogiai liečia laikiną workaround, kurį rekomenduoja F5: regex-map perkėlimas į pavadintus capture uždaro pagrindinį kelią per r->captures, bet neuždaro varianto su vienodomis pavadintomis grupėmis skirtingose map. Vadinasi, workaround sumažina atakos paviršių, bet nepašalina pažeidžiamumo visiškai — tai padaro tik atnaujinimas.
15 METŲ SENUMO BUGO ISTORIJA
Nemaloniausia šioje istorijoje — ne pats bugas, o tai, kad apie jį žinojo dar gerokai prieš CVE. 2014 m. vartotojas Pascal Jungblut sukūrė ticket #564 nginx trackeryje, aprašydamas būtent šį elgesį: map su regex perrašo capture grupes rewrite direktyvoje, ir $1 tyliai grąžina skirtingus duomenis priklausomai nuo to, ar suveikė map regex. Maxim Dounin, nginx maintaineris, pripažino problemą defektu: jis parašė, kad dabartinis elgesys akivaizdžiai blogas ir turi būti ištaisytas. Per kitus metus prie ticket’o pridėtos penkios kryžminės nuorodos į panašius pranešimus (#1044, #1142, #1285, #1498, #1934), o 2020 m. vienas iš vartotojų argumentuotai siūlė pakelti ticket’o prioritetą nuo minor iki critical.
Kitaip tariant, elgesio anomalija buvo viešai žinoma daugiau nei dešimtmetį — tik ji buvo suvokiama kaip logikos bugas (netikėta kintamojo reikšmė), o ne atminties saugumo problema. Dviejų praėjimų išraiškų skaičiavimo modelis paverčia tą patį neatitikimą heap perpildymu, o tai jau visai kita kategorija. Skirtumas tarp „rado elgesio keistenybę” ir „suprato, kad tai RCE” — būtent tame, kad antrajam reikia atsekti ryšį konkrečiai su LEN/VALUE alokacijos modeliu, o ne tik su capture sugadinimo faktu.
KODĖL TAI RANDAMA BŪTENT DABAR
Tai jau trečias tokios klasės bugas nginx variklyje per kelis mėnesius: gegužę — Rift (CVE-2026-42945), kur desinchronizacija atsirado dėl is_args vėliavėlės, netrukus po to — perpildymas su persidengiančiais capture rewrite modulyje (CVE-2026-9256). Pats Shaw savo ataskaitoje tai formuluoja kaip bendrą šabloną: visi trys turi skirtingas konkrečias priežastis, bet tą pačią struktūrinę pažeidžiamybę — LEN/VALUE padalijimą, kuris daro prielaidą, kad skaičiavimas determinuotas, nors realybėje jis gali turėti šalutinius efektus. Bet kokia kintama request būsena, kurią opkodas skaito nustatydamas ilgį ir kuri gali pasikeisti tarp praėjimų — paties tyrėjo žodžiais, kandidatas į tokią pačią bugo kategoriją, o regex capture, vėliavėlės kaip is_args ir cache’inami kintamieji — tai tik tie kandidatai, kurie jau rasti.
KODĖL TAI SVARBU
Nginx stovi priešakyje didžiulės dalies pasaulio web infrastruktūros — reverse proxy, API gateway, ingress Kubernetes, TLS terminavimas prieš dešimtis backend’ų. Pažeidžiamumas paliečia ne tik patį serverį ir NGINX Plus, bet ir F5 produktų liniją virš nginx: NGINX Ingress Controller, Gateway Fabric, App Protect WAF ir Instance Manager — nors advisory publikavimo metu šiems keturiems produktams konkrečios pataisytos versijos dar nebuvo nurodytos. Tiems, kas eksploatuoja reverse proxy ant nginx priešais WordPress, Joomla ar bet kokį self-hosted API, konfigūracija su regex-map API versijos routinimui, whitelisting pagal antraštes ar geo-based rewrite — visiškai įprasta praktika, o ne egzotika. Vadinasi, realus atakos paviršius nė iš tolo neapsiriboja didelėmis enterprise instaliacijomis.
Atskirai verta pasakyti apie CVSS 9.2 pas F5: atakos sudėtingumas (AC) vektoriuje pažymėtas kaip High — bet tai CVSS vertinimo variklio įvertis eksploatacijos sudėtingumui pačiam pažeidžiamumui, o ne realių kliūčių įvertinimas konkrečiam užpuolikui su reikiama kvalifikacija. RCE yra RCE, nepriklausomai nuo to, kaip tai atsispindi vektoriuje.
KAIP NELEISTI TAM PASIKARTOTI
Inžinerine prasme problema ta, kad r->captures yra bendra kintama request būsena, kurią skaito daugybė kodo dalių, bet kuri niekada nesnapshotinama prieš potencialiai pavojingas operacijas. Nginx pataisa tai sprendžia ne atstatydama capture būseną tarp praėjimų, o kitaip: variklis gavo end rodyklę į alokuoto buferio ribą, ir prieš kiekvieną write opkodą (copy_code, copy_var_code, abi copy_capture_code šakas, įskaitant escape kelią, ir raw skirtukų įrašymus upstream moduliuose) pridėtas patikrinimas ngx_http_script_check_length(). Jei VALUE praėjimas bando rašyti už ribos — nustatomas NGX_HTTP_INTERNAL_SERVER_ERROR, vykdymas nukreipiamas į ngx_http_script_exit, žurnale įrašoma „no buffer space in script copy”, o iškviečiantis kodas baigiasi su HTTP 500 vietoj heap sugadinimo. Informacijos nutekėjimo kryptis uždaryta panašiai: grąžinamos eilutės ilgis dabar skaičiuojamas pagal realiai įrašytus baitus (e->pos - e->buf.data), o ne pagal padidintą LEN įvertį, todėl per didelis buferis nebeatiduoda savo neinicijuotos uodegos.
Tiems, kas rašo ar peržiūri nginx modulius ar panašų kodą su atskirtu matavimu ir įrašymu: bet kokia kintama request būsena, kurią opkodas skaito ilgiui nustatyti ir kuri gali pasikeisti tarp praėjimų — kandidatas į tą pačią bugo kategoriją. Aiškūs regex capture nėra vienintelis pavyzdys; cache’inami kintamieji ir vėliavėlės kaip is_args iš CVE-2026-42945 parodo, kad sąrašas platesnis. Praktinė išvada savo projektams — snapshotinti arba užšaldyti bet kokią būseną, veikiančią dydį, prieš pradedant matavimą, arba, kaip padarė nginx, pridėti bounds-check tiesiai įrašymo taške, o ne pasitikėti ankstesnio matavimo teisingumu.
ATNAUJINIMAS
Patikrinti dabartinę nginx versiją galima taip: pirma komanda parodys binarinio failo versiją, antra — su kokiais moduliais jis surinktas.
nginx -v
nginx -V
Jei versija žemesnė nei 1.30.4 stable šakoje arba žemesnė nei 1.31.3 mainline — serveris pažeidžiamas. Svarbi detalė: numatytieji Ubuntu ir Debian repozitorijai gerokai atsilieka nuo aktualių nginx leidimų — tai gali būti daugiametės senumos versijos, kuriose pataisos tikrai nėra. Todėl komanda apt-get install nginx be prijungto oficialaus nginx.org repozitorijaus tiesiog perinstaliuos tą pačią pasenusią versiją. Pirmiausia reikia prijungti repozitorijų: importuoti nginx pasirašymo raktą ir pridėti paketų šaltinį.
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
Pirma komanda paima oficialų nginx.org raktą ir konvertuoja jį į formatą, kurį supranta apt, antra — prideda patį stable šakos repozitorijų jūsų distributyvui (Ubuntu ta pati komanda, tik URL kelias — https://nginx.org/packages/ubuntu; mainline šakai abiem atvejais prieš distributyvo pavadinimą pridedama mainline/). Toliau verta įtvirtinti nginx.org repozitoriaus prioritetą, kitaip kitas planinis apt upgrade rizikuoja grąžinti paketą atgal į pasenusią distributyvo versiją:
echo -e "Package: *\nPin: origin nginx.org\nPin: release o=nginx\nPin-Priority: 900\n" \
| sudo tee /etc/apt/preferences.d/99nginx
Ši komanda sukuria pinning konfigūracijos failą, kuris sako apt teikti pirmenybę paketams iš nginx.org repozitoriaus prieš bet kokius kitus to paties paketo pavadinimo šaltinius — be jo daugelyje distributyvų gali laimėti naujesnis pagal numerį, bet nepataisytas paketas iš standartinio repozitoriaus. Po to atnaujinimas vyksta įprastai:
apt-get update
apt-get install nginx
Po instaliavimo dar kartą patikrinkite versiją per nginx -v — ji turi rodyti 1.30.4 arba naujesnę (stable) arba 1.31.3 arba naujesnę (mainline). NGINX Plus atnaujinimas iki 37.0.3.1 vyksta per įprastą Plus paketų pristatymo kanalą; versijos R33–R36 uždarytos R36 P7, senesni R-leidimai pataisos negaus ir reikalauja perėjimo į palaikomą šaką.
Jei nedelsiamas atnaujinimas neįmanomas, laikina priemonė — visų regex-based map, kurių išvesties kintamieji persidengia su numbered capture tame pačiame location, perkėlimas į pavadintus capture vietoj $1/$2. Pavyzdžiui, pažeidžiama pora tokia kaip
location ~ "^/api/(.+)$" {
proxy_set_header X-Path "$1";
proxy_set_header X-Check "$map_var";
proxy_pass http://backend;
}
perrašoma taip, kad numbered capture nesidengtų su map kintamuoju vienoje direktyvoje ar vienoje proxy/fastcgi/scgi/uwsgi/grpc šeimos direktyvų buferio grupėje:
location ~ "^/api/(?<path>.+)$" {
proxy_set_header X-Path "$path";
proxy_set_header X-Check "$map_var";
proxy_pass http://backend;
}
Tai uždaro pagrindinį eksploatacijos kelią per r->captures, bet neuždaro varianto su dviem map, apibrėžiančiais tą pačią pavadintą grupę — skenerio README atskirai įspėja nepakartotinai naudoti tos pačios pavadintos grupės skirtingose regex-map. Tik atnaujinimas iki 1.30.4/1.31.3 uždaro pažeidžiamumą visiškai, ką tiesiogiai sako pats tyrėjas, ir tai verta įtraukti planuojant priežiūros langą.
Po bet kokio konfigo pakeitimo — nesvarbu, ar tai atnaujinimas, ar laikinas workaround — būtinai patikrinkite sintaksę prieš pritaikymą, kitaip rizikuojate perkrauti nginx su sugadintu konfigu ir sustabdyti servisą:
nginx -t && systemctl reload nginx
Pirma komandos dalis, nginx -t, išparsuoja pagrindinį konfigą ir visus include failus, patikrina prieigą prie kelių į žurnalus ir kitus failus, ir grąžina „syntax is ok” bei „test is successful”, jei viskas tvarkoje, arba nurodo konkrečią klaidos eilutę, jei ne. Operatorius && garantuoja, kad systemctl reload nginx įvykdomas tik jei patikrinimas praėjo sėkmingai — reload pritaiko naują konfigą, pakeldamas naujus worker procesus ir tvarkingai baigdamas senuosius po atviro ryšio užbaigimo, be esamų kliento jungčių nutraukimo ir be prastovos, skirtingai nei pilnas restart.
Greitai įvertinti, ar konfiguose apskritai yra įtartinas šablonas, galima grep’u per visus konfigus map direktyvai su regex šablonais — tai neduos tikslaus atsakymo apie konkretaus bloko pažeidžiamumą, bet parodys, kur žiūrėti pirmiausia:
grep -rn "map " /etc/nginx/ --include="*.conf"
Tiksliam atsakymui Shaw publikavo read-only statinį skenerį, kuris pereina konfigą atsižvelgdamas į include, atpažįsta cross-direktyvinį trigerį ir išveda rezultatą, taip pat JSON formatu — jis nieko neeksploatuoja, tik parodo, ar yra prielaidos. Instaliuojamas ir paleidžiamas taip:
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
Pirmos dvi komandos klonuoja repozitorijų ir pereina į jo direktoriją, trečia paleidžia skenerį pagrindiniam nginx konfigui — kelią reikia pakeisti į realų kelią iki jūsų nginx.conf, jei jis skiriasi; include direktyvas skeneris išplečia automatiškai. Skeneris baigia darbą su kodu 0, jei pažeidžiamų vietų nerasta, ir su kodu 1, jei rasta bent viena — tai patogu integruoti tiesiai į CI:
python3 nginx_capture_clobber_scan.py /etc/nginx/nginx.conf || echo "review needed"
Mašininiam nuskaitomam rezultatui yra vėliavėlė --json, kuri grąžina failą, eilutę, enclosing scope, konkrečias direktyvas ir capture/map kintamuosius, dalyvaujančius suveikime.
IŠVADOS
Jei laikote nginx kaip reverse proxy priešais WordPress, Joomla ar bet kokį self-hosted servisą — atnaujinkite iki 1.30.4 (stable) arba 1.31.3 (mainline) artimiausiu planiniu langu, nelaukdami PoC publikavimo rugpjūčio 5 d. Jei turite NGINX Plus — pereikite į 37.0.3.1 arba R36 P7, priklausomai nuo šakos. Jei atnaujinimas dabar neįmanomas — perleiskite konfigus per Shaw skenerį ir perkelkite probleminius regex-map į pavadintus capture kaip laikiną priemonę, atsimindami, kad tai nėra pilnas pažeidžiamumo uždarymas.
Hostingo tiekėjams, valdantiems nginx daugybėje klientų serverių, tai atvejis, kai verta įtraukti planinį atnaujinimą į artimiausią priežiūros ciklą nelaukiant kliento prašymo — konfigūracija su regex-map ir capture per daug paplitusi, kad būtų galima pasikliauti, jog ji yra tik pas „pažengusius” vartotojus. Dedikuotų serverių ir VPS administratoriams — tas pats: jei kada nors kopijavote nginx konfigą su geo-routing, API versijavimu per URL ar whitelisting pagal antraštes iš straipsnio ar forumo, verta jį perleisti per skenerį dabar, o ne po to, kai rugpjūčio 5 d. pasirodys viešas exploit’as.
