Niedziela wieczorem, 26 lipca. Przez formularz zgłoszeniowy wpada infekcja: strona klienta przekierowuje odwiedzających na obcą domenę, w panelu wisi konto administratora, którego nikt z zespołu nie zakładał. Klasyka przejęcia. Godzinę później drugie zgłoszenie, inny klient, ten sam obraz — świeży admin, podrzucony plugin, przekierowania. W poniedziałek doszły kolejne.

Niezależne strony przejęte w ten sam sposób w ciągu kilkunastu godzin to nie zbieg okoliczności. To fala. I ma nazwę: wp2shell — łańcuch dwóch podatności w core WordPressa, który pozwala nieuwierzytelnionemu atakującemu wykonać kod na serwerze. Bez logowania, bez żadnej wtyczki, na czystej instalacji.

CISA dopisała ją do listy aktywnie wykorzystywanych podatności 21 lipca. Publiczne PoC krążyły w sieci w ciągu godzin od ujawnienia. Od tamtej pory skanowanie i masowe przejęcia tylko przyspieszają — to, co opisuję wyżej, dzieje się teraz, na produkcyjnych stronach, w tym tygodniu.

Jeśli prowadzisz stronę na WordPressie 6.9 albo 7.0 i nie aktualizowałeś jej od zeszłego tygodnia, przestań czytać i zrób to teraz. Aktualizacja do 7.0.2, 6.9.5 lub 6.8.6 łata luki. Reszta tekstu poczeka — wyjaśnię w niej, na czym dokładnie polega ten błąd, dlaczego jest groźniejszy niż typowa podatność wtyczki, i co w tej historii jest naprawdę nowe: exploit, którego nie napisał człowiek.

Czym właściwie jest wp2shell?

Zacznijmy od tego, że „wp2shell” to nie jedna podatność, tylko nazwa nadana łańcuchowi dwóch osobnych błędów, które dopiero razem prowadzą do przejęcia serwera:

  • CVE-2026-60137 — SQL injection w core WordPressa, w parametrze author__not_in klasy WP_Query. Samo w sobie pozwala czytać z bazy to, czego atakujący nie powinien widzieć.
  • CVE-2026-63030 — „route confusion” w REST-owym endpoincie batch (/wp-json/batch/v1). To ten błąd zmienia SQL injection z uciążliwego wycieku danych w pełne zdalne wykonanie kodu.

Każdy z osobna jest groźny. Połączone dają pre-auth RCE — trzy litery, które w świecie bezpieczeństwa oznaczają najgorszy możliwy scenariusz. „Pre-auth” znaczy, że atakujący nie musi mieć żadnego konta ani danych logowania. „RCE” znaczy, że wykonuje własny kod na twoim serwerze. Bez kliknięcia z twojej strony, bez zalogowanego użytkownika, bez wtyczki, którą trzeba było zainstalować.

I tu jest sedno, dlaczego ta sprawa jest inna niż większość podatności, o których tu piszę. Zdecydowana większość CVE w ekosystemie WordPressa dotyczy wtyczek i motywów — kodu firm trzecich o różnej jakości. Podatną wtyczkę można wyłączyć, można jej nie mieć, można ją podmienić na lepszą.

wp2shell jest w core. W kodzie, który ma każda instalacja WordPressa — te 40-parę procent całego internetu. Nie ma tu wtyczki do odinstalowania ani feature’a do wyłączenia. Jeśli twoja wersja jest podatna, jesteś podatny, kropka. Dlatego WordPress.org zrobił rzecz, którą robi wyjątkowo rzadko: wymusił automatyczną aktualizację nawet na stronach, które ją mają wyłączoną. Skala i łatwość eksploatacji nie zostawiły innego wyjścia.

Tyle że wymuszona aktualizacja nie dociera do każdej strony samoistnie. Żeby zadziałała, musi się faktycznie wykonać — a jest cały zestaw powodów, dla których nie zawsze się wykonuje. Mechanizm auto-update odpala się przez wp-cron, więc strona bez ruchu albo z wyłączonym cronem może godzinami nie dostać sygnału. Aktualizacja wymaga zapisu do plików core — jeśli uprawnienia na to nie pozwalają, dysk jest pełny albo hosting trzyma pliki core w trybie tylko do odczytu, proces cicho pada. A na wielu stronach ktoś kiedyś dopisał do wp-config.php AUTOMATIC_UPDATER_DISABLED albo DISALLOW_FILE_MODS — najczęściej „dla bezpieczeństwa” lub „żeby nic się samo nie zmieniało” — i tym samym wypiął się z dokładnie tego mechanizmu, który miał go teraz uratować.

Jest jeszcze jedna rzecz, o której łatwo zapomnieć: aktualizacja łata podatność, ale nie usuwa skutków wcześniejszego włamania. Jeśli atakujący dostał się na stronę, zanim patch dojechał — a przy tempie tej fali okno było wąskie — to konto admina, backdoorowy plugin i przekierowania zostają na miejscu po aktualizacji. Zaktualizowana strona potrafi być jednocześnie zazałatana i wciąż przejęta. Dokładnie takie przypadki wpadały do mnie w poniedziałek.

Na czym polega ten błąd? Część pierwsza: SQL injection

Zacznijmy od dolnej warstwy łańcucha, czyli CVE-2026-60137. Żeby zrozumieć ten błąd, trzeba wiedzieć, czym jest author__not_in.

To całkowicie legalny, udokumentowany parametr WP_Query — klasy, przez którą WordPress odpytuje bazę praktycznie przy każdym ładowaniu strony. Znaczy tyle co „daj mi wpisy, ale pomiń tych autorów”. Normalnie używasz go tak:

new WP_Query( array(
    'author__not_in' => array( 4, 7 ),
) );

WordPress bierze tę tablicę, rzutuje każdy element na liczbę całkowitą i wkleja do zapytania. Rzutowanie na int to darmowa ochrona przed SQL injection — cokolwiek atakujący by tam wcisnął, po (int) zostaje goła liczba. Efekt to bezpieczny kawałek SQL:

AND wp_posts.post_author NOT IN (4,7)

Fragmenty SQL w tym tekście to rekonstrukcja pokazująca mechanizm, nie dosłowne listingi z kodu core.

Problem pojawia się, gdy wartość przychodzi nie jako tablica, tylko jako pojedynczy string. Kod, który miał obsłużyć listę ID-ków, przy skalarnej wartości gubi krok rzutowania i wstawia ją do zapytania bez zmian. Atakujący, który kontroluje ten string, kontroluje fragment surowego SQL-a. To jest cała podatność w jednym zdaniu: parametr, który miał być listą liczb, staje się otwartym wejściem do zapytania.

Co to daje w praktyce? Odczyt tego, czego w bazie widzieć nie powinien — hashy haseł z wp_users, kluczy i tokenów, zawartości dowolnej tabeli. To klasyczny blind SQL injection: atakujący nie dostaje wyniku wprost, tylko wyciąga dane bit po bicie, obserwując, czy zapytanie zwróciło prawdę, czy jak długo trwało. Wolne, ale w pełni zautomatyzowane.

Jest tylko jeden haczyk — i to on przez chwilę trzymał tę podatność w ryzach. Żeby author__not_in trafiło do WP_Query jako skalarny string, trzeba je tam najpierw przemycić. A na czystej instalacji REST API waliduje ten parametr i oczekuje tablicy liczb, nie stringa. Samo SQL injection to zamknięte drzwi z dobrym zamkiem. Do ich otwarcia potrzebny jest drugi błąd — i tu wchodzi batch endpoint.

Na czym polega ten błąd? Część druga: batch route confusion

Batch endpoint jest w core WordPressa od wersji 5.6 (2020). Pozwala spakować kilka wywołań REST API w jedno żądanie do /wp-json/batch/v1 — zamiast wysyłać pięć osobnych żądań do pięciu endpointów, wysyłasz jeden request z listą pięciu podżądań. Jest dostępny bez logowania, bo to publiczny endpoint REST API. Można go wywołać nawet przy wyłączonych ładnych linkach, przez /?rest_route=/batch/v1.

Jak WordPress obsługuje taką paczkę? W dwóch osobnych przebiegach. Najpierw pętla walidacji przechodzi po wszystkich podżądaniach i sprawdza, czy każde jest poprawne. Potem druga pętla, wykonania, odpala je po kolei. Żeby oba przebiegi wiedziały, które podżądanie jest które, kod trzyma trzy równoległe listy indeksowane tą samą pozycją: $requests (co przyszło), $validation (wynik sprawdzenia) i $matches (do jakiego endpointu i handlera pasuje).

Cała sztuczka polega na rozjechaniu tych list o jeden.

Gdy w pętli walidacji trafi się podżądanie ze ścieżką na tyle nieprawidłową, że wp_parse_url() jej nie przetworzy, kod zapisuje błąd do $validation, ale robi continue i pomija dopisanie do $matches. Od tego momentu $matches jest o jeden element krótsza niż reszta. Indeksy przestają się zgadzać.

Efekt: w pętli wykonania każde podżądanie po tym celowo zepsutym jest dopasowane do endpointu i handlera należących do następnego w kolejce. Żądanie N wykonuje się pod handlerem żądania N+1.

To brzmi jak drobny off-by-one, a jest kluczem do całości, bo psuje dwie rzeczy naraz:

  • Uprawnienia. permission_callback, czyli bramka „czy ci wolno”, jest sprawdzana dla handlera, który nie należy do tego żądania. Nieszkodliwe, publiczne podżądanie przechodzi przez bramkę uprzywilejowanego endpointu. Tak właśnie „pre-auth” dosięga endpointów, które normalnie wymagają zalogowanego użytkownika.
  • Walidację parametrów. Sanityzacja argumentów też należy do złego endpointu. Ten sam mechanizm, który miał odrzucić skalarny author_exclude i zażądać tablicy liczb, po prostu nie jest już tym, który patrzy na ten parametr.

Do tego dochodzi jeszcze jeden szczegół. Batch normalnie nie przepuszcza GET-ów, a samo wstrzyknięcie SQL akurat GET-a wymaga. Atak obchodzi to rekurencyjnie — zagnieżdżając batch w batchu, tak że pole metody wewnętrznego żądania nigdy nie przechodzi walidacji i GET dojeżdża do /wp/v2/posts.

Zbierzmy to razem. Anonimowe żądanie na /wp-json/batch/v1 przemyca skalarny string do author_exclude, omija sanityzację, która trzymała SQL injection w ryzach, i wpuszcza go prosto do author__not_in w WP_Query. Zamknięte drzwi z części pierwszej stoją otworem. Payload w rodzaju 0) OR 1=1 -- potwierdza, że wstrzyknięcie działa.

Jak z odczytu bazy robi się przejęcie serwera?

W tym miejscu ktoś słusznie zapyta: dobrze, SQL injection pozwala czytać bazę, ale skąd nagle wykonanie kodu? Odczyt to nie zapis, a już na pewno nie uruchomienie własnego PHP. I to jest najciekawsza część tej historii — bo droga od blind SQLi do webshella wiedzie przez kilka najzupełniej legalnych mechanizmów WordPressa, poskładanych w nieprzewidzianej kolejności.

Pomaga tu druga własność błędu batcha z poprzedniej sekcji: obejście permission_callback samo z siebie otwiera atakującemu uprzywilejowane endpointy. Odczyt z SQLi dokłada do tego dane, których normalnie by nie zdobył. Reszta to kilka najzupełniej legalnych mechanizmów WordPressa, poskładanych w jedną całość. Według opisu badacza wygląda ona tak:

  • UNION-forged post — atakujący używa UNION w injection, żeby WP_Query zwróciło spreparowany wiersz „wpisu”, którego w bazie realnie nie ma, ale który wygląda dla WordPressa na prawdziwy.
  • Zatrucie cache oEmbed — pod ten spreparowany kontekst podkładane są własne dane w cache oEmbed, czyli tam, gdzie WordPress sięgnie po nie później jako po zaufane.
  • customize_changeset — spreparowany changeset Customizera pozwala atakującemu chwilowo działać z uprawnieniami administratora.
  • Konto admina — mając te uprawnienia, tworzy realne, trwałe konto administratora.
  • Wtyczka-webshell — loguje się na nie i wgrywa wtyczkę z webshellem. To już zwykłe, „legalne” wgranie pluginu przez panel — i jednocześnie moment, w którym atakujący wykonuje dowolny kod PHP na serwerze.

I stąd właśnie te zgłoszenia z niedzieli: świeże konto admina, podrzucony plugin, przekierowania. Nie były przypadkowym wandalizmem — to końcówka dokładnie tego łańcucha, zautomatyzowana i puszczona masowo.

Kogo to dotyczy naprawdę?

Tu jest niuans, który w większości nagłówków ginie, a ma realne znaczenie dla oceny ryzyka. „wp2shell dotyczy 6.8, 6.9 i 7.0″ to skrót, który miesza dwie różne rzeczy.

Sama podatność SQL injection (CVE-2026-60137) w WP_Query jest obecna już od gałęzi 6.8. Batch route confusion (CVE-2026-63030), czyli mechanizm, który czyni z niej pre-auth RCE, pojawił się dopiero w 6.9. To rozróżnienie zmienia obraz ryzyka:

  • 6.9.0–6.9.4 i 7.0.0–7.0.1 — pełny łańcuch. Oba błędy obecne, nieuwierzytelnione RCE działa. To są wersje, na których dzieje się ta fala.
  • 6.8.0–6.8.5 — obecna jest tylko sama podatność SQLi. Bez batcha nie ma jej jak nakarmić skalarnym stringiem z anonimowego żądania na czystej instalacji. WordPress i tak załatał ją defensywnie w 6.8.6 — bo wystarczy jedna wtyczka podająca do tego parametru niewłaściwy typ, żeby zrobiło się gorąco.
  • Przed 6.8 — nietknięte.

Fix jest w 6.8.6, 6.9.5 i 7.0.2. Jeśli testowałeś betę 7.1, właściwa wersja to Beta 2.

Jak sprawdzić, jaką masz wersję? Najszybciej wp core version przez wp-cli, ewentualnie stopka panelu admina albo pole Version w readme.html w katalogu głównym (o ile nie zostało usunięte). Jeśli widzisz cokolwiek z zakresu 6.9.0–7.0.1, należysz do grupy pełnego ryzyka i aktualizacja nie może czekać.

Real talk: nawet jeśli używasz wersji 6.8 i „technicznie” pełny łańcuch cię nie dotyczy, to nie jest argument, żeby przy niej pozostać. Zostajesz dwie gałęzie w tyle, z niezałataną podatnością SQLi w środku. Zaktualizuj do 7.0.2.

Exploit, którego nie napisał człowiek

Najciekawsze w tej całej historii nie jest samo RCE. Podatności w core zdarzały się wcześniej i zdarzą znowu. Nowe jest to, kto — a właściwie co — złożyło ten exploit.

Batch route confusion i pełny, uzbrojony łańcuch to praca Adama Kuesa z Assetnote/Searchlight Cyber. Ale nie w pojedynkę: narzędziem był model językowy, GPT-5.6 „Sol”. Według relacji badacza to model, przez rekurencyjne wywołania batcha, wyszedł na podatny author_exclude i poskładał całą sekwencję post-exploitation — tę samą, którą rozebraliśmy wyżej, z UNION-forged postem, cache oEmbed i changesetem Customizera włącznie.

Liczby, które obiegły branżę: około 10 godzin i jakieś 25 dolarów w tokenach, mniej więcej połowa tygodniowego budżetu badacza. Tyle kosztowało zbudowanie działającego pre-auth RCE na kod, który obsługuje 40 procent internetu.

Zanim jednak ogłosimy, że maszyny piszą już exploity same — precyzja. To nie był scenariusz „AI znalazło 0-day, człowiek tylko patrzył”. Kues to doświadczony badacz, a Assetnote to zespół z dorobkiem w dokładnie takich łańcuchach. Model był prowadzony: ktoś stawiał hipotezy, weryfikował wyniki, odrzucał ślepe uliczki i odpowiadał za responsible disclosure. Co więcej, druga połowa łańcucha — samo SQL injection — została zgłoszona niezależnie przez trzech innych badaczy: TF1T, dtro i haongo. Ludzi. „Exploit, którego nie napisał człowiek” to więc skrót myślowy. Ściślej: exploit, w którym najżmudniejszą, kreatywną robotę poskładania tych mechanizmów w jeden łańcuch wykonał model, kierowany przez eksperta.

Ale nawet z tym zastrzeżeniem to jest zmiana jakościowa, nie kosmetyczna. Co realnie się zmienia:

  • Koszt i czas budowy spadają do poziomu weekendowego projektu. To, co kiedyś było tygodniami pracy specjalisty, mieści się w 10 godzinach i 25 dolarach.
  • Okno między patchem a masową eksploatacją się kurczy. Przy wp2shell publiczne PoC pojawiły się w godziny od ujawnienia. Im tańsze uzbrojenie podatności, tym mniej czasu masz na aktualizację.
  • Model nie męczy się przeszukiwaniem. Powiązanie kilku niepozornych mechanizmów, które człowiek łatwo przeoczy, bo trzeba naraz utrzymać w głowie UNION-forged post, cache oEmbed i changeset, maszyna wypracowuje cierpliwie i systematycznie.

Dla ciebie jako właściciela albo administratora strony płynie z tego prosty, choć niewygodny wniosek. Popularny model „opieki” nad stroną — aktualizacje według stałego harmonogramu, powiedzmy dwa razy w miesiącu — zakładał, że między wyjściem patcha a realnym zagrożeniem masz zapas czasu. Przy wp2shell tego zapasu nie było: PoC krążyły w godziny, fala ruszyła w dni. Strona aktualizowana „w najbliższym oknie serwisowym” mogła zostać przejęta na długo przed tym oknem.

Nie chodzi o to, żeby wyrzucić harmonogram do kosza — regularne aktualizacje to wciąż podstawa. Chodzi o to, żeby obok niego istniała druga ścieżka: krytyczne łatki poza kolejnością, w godzinach, nie w najbliższym wolnym terminie. A to wymaga rzeczy, o której łatwo zapomnieć — stałego monitoringu podatności. Żeby zaktualizować szybko, najpierw trzeba w ogóle wiedzieć, że jest co aktualizować. wp2shell trafiło na listę KEV w kilka dni, ale strona bez kogoś — albo czegoś — kto śledzi takie sygnały, dowiaduje się o problemie dopiero z przekierowań na obcą domenę. Jeśli twoja umowa serwisowa nie obejmuje ani szybkiej ścieżki na krytyczne łatki, ani monitoringu, który ją uruchamia, wp2shell jest dobrym pretekstem, żeby o to dopytać. O tym, dlaczego samo zwlekanie z aktualizacjami tak regularnie kończy się przejęciem, pisałem osobno — Aktualizować, zwlekać, ignorować?

Co z tym zrobić?

Jeśli dobrnąłeś tutaj, a nie zaktualizowałeś jeszcze na początku — zrób to teraz. Reszta tej sekcji zakłada, że wersja jest już bezpieczna, i mówi o tym, co dalej: jak sprawdzić, czy nie jest za późno.

1. Potwierdź wersję i aktualizację. wp core version powinno pokazać 7.0.2, 6.9.5 albo 6.8.6. Jeśli auto-update nie zadziałał z powodów, które opisałem wcześniej, zaktualizuj ręcznie — z panelu albo wp core update.

2. Sprawdź logi pod kątem prób. Ślad ataku jest charakterystyczny: żądania na /wp-json/batch/v1 albo /?rest_route=/batch/v1, często metodą POST, z zagnieżdżoną strukturą batcha w treści. W logach dostępowych szukaj tych ścieżek — pojedyncze trafienia to zwykle skanery, ale to sygnał, że jesteś na celowniku.

3. Sprawdź, czy cię nie przejęto. To jest ważniejsze niż same logi, bo aktualizacja niczego nie cofa. Konkretnie:

  • Nowe konta z rolą administratora, których nikt nie zakładał — wp user list --role=administrator.
  • Świeże albo nieznane wtyczki, zwłaszcza takie, których nie ma w repozytorium WordPressa — wp plugin list.
  • Nieoczekiwane pliki PHP w wp-content/uploads/ i przekierowania w .htaccess albo w treści strony.

Jeśli którykolwiek z tych punktów wypadł podejrzanie, przyjmij, że strona jest przejęta, i traktuj to jak każdą inną infekcję: czyszczenie z backupu sprzed kompromitacji, nie ręczne usuwanie tego, co widać. O tym, dlaczego niezależny backup jest tu wart tyle, ile cała reszta zabezpieczeń razem, pisałem osobno — Pożar w serwerowni, czyli na co mi niezależny backup. A jak w praktyce wygląda rozkładanie takiej infekcji na czynniki pierwsze, pokazywałem tutaj — Infekcja letsmakeparty3 / sdfsd234.

Wnioski

wp2shell zapamiętamy z dwóch powodów, i tylko jeden z nich dotyczy WordPressa.

Ten pierwszy: pre-auth RCE w core, na czystej instalacji, bez wtyczek — to najgorszy scenariusz, jaki może spotkać CMS, i przypomnienie, że REST API jest pełnoprawną powierzchnią ataku, nie dodatkiem. Batch endpoint był w kodzie od 2020 roku i przez pięć lat nikt nie zauważył, że rozjeżdżają się w nim trzy równoległe listy. To nie jest zarzut wobec WordPressa — to natura złożonego kodu, w którym dwa niewinne błędy w dwóch różnych miejscach dopiero razem tworzą katastrofę.

Ten drugi jest większy niż WordPress. Exploit złożył model językowy w dziesięć godzin za dwadzieścia pięć dolarów. Można się spierać, ile w tym zasługi maszyny, a ile prowadzącego ją człowieka — i słusznie, bo ten balans ma znaczenie. Ale kierunek jest jasny: uzbrajanie podatności właśnie stało się o rząd wielkości tańsze i szybsze. Obrona po drugiej stronie musi za tym nadążyć — a to znaczy krótsze okna na aktualizację, monitoring, który wyłapuje krytyczne CVE w dniach, nie tygodniach, i backup, który działa, zanim będzie potrzebny.

Zaktualizuj do 7.0.2. A potem sprawdź, czy nie jesteś już po fakcie.

Change consents