Kilka tygodni temu trafił do mnie klient z pytaniem, które słyszę regularnie od lat: czy mogę mu zresetować hasło bezpośrednio w bazie, bo e-mail z resetem nie dochodzi. Standardowa procedura – wchodzisz do phpMyAdmin, UPDATE wp_users SET user_pass = MD5('nowehaslo') WHERE ID = 1, gotowe. Klient się loguje, WordPress po cichu rehashuje hasło silniejszym algorytmem, wszyscy są szczęśliwi.
Przy okazji zajrzałem do kolumny user_pass w jego bazie. Mieszanina $P$B... i kilku czystych MD5. Klasyczny widok na instalacji, która chodziła przez kilka lat bez większej opieki. I pomyślałem sobie – za kilka tygodni to będzie wyglądać inaczej. Bo WordPress 6.8, wydany w kwietniu 2025, zmienił coś, na co część z nas czekała od dawna.
Przez 20 lat WordPress hashował hasła algorytmem z 2004 roku
Żeby zrozumieć, co zmieniło się w 6.8, warto wiedzieć, co było wcześniej – i dlaczego to był problem.
WordPress korzystał z biblioteki phpass, napisanej przez Solara Designera w 2004 roku. Jak na tamte czasy – solidne rozwiązanie. phpass używał algorytmu portable hashing opartego na MD5, który w bazie danych rozpoznasz po prefixie $P$ (lub $H$ w starszych instalacjach). Wygląda to mniej więcej tak:
$P$BIRXBdpFU30FYPOaFGEFPo8EW8Y7HN0
Sam mechanizm działał następująco: phpass brał hasło, generował losową sól, a następnie wykonywał wielokrotne iteracje MD5 – domyślnie 2^8, czyli 256 razy. Ta wartość, zwana cost factor, była zakodowana na stałe i nie zmieniała się w czasie.
I tu leży sedno problemu. Cost factor zakodowany na stałe to przepis na katastrofę w perspektywie 20 lat. W 2004 roku 256 iteracji MD5 było rozsądnym kompromisem między bezpieczeństwem a wydajnością. W 2024 roku GPU potrafi wykonywać miliardy operacji MD5 na sekundę. Hashcat ma dedykowany tryb -m 400 właśnie dla WordPress/phpass i na przeciętnej maszynie z jedną kartą graficzną robi kilkadziesiąt milionów prób na sekundę. Dedykowany rig z kilkoma GPU – setki milionów.
Przekładając to na praktykę: jeśli atakujący wejdzie w posiadanie wycieku bazy danych WordPressa, słabe i średnie hasła złamie w minuty. Nie godziny – minuty.
phpass nie był złym kodem. Był dobrym kodem na rok 2004, który WordPress niósł na barkach przez dwie dekady, bo projekt tej skali zmienia fundamentalne mechanizmy bezpieczeństwa bardzo ostrożnie. Ticket #21022 na Trac z propozycją przejścia na bcrypt został otwarty w 2012 roku. Czekał 13 lat.
Co dokładnie zmieniło się w WordPress 6.8?
Dwie funkcje, które obsługują hasła od zawsze – wp_hash_password() i wp_check_password() – zostały przepisane. Zamiast oddelegowywać robotę do phpass, korzystają teraz z natywnych funkcji PHP: password_hash() i password_verify() z algorytmem bcrypt.
Nowy hash w bazie wygląda tak:
$wp$2y$10$abcdefghijklmnopqrstuuVwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12
Prefix $wp nie jest przypadkowy. bcrypt ma ograniczenie – przetwarza maksymalnie 72 bajty hasła, reszta jest ignorowana. WordPress obchodzi to przez SHA-384 pre-hashing: zanim hasło trafi do bcrypt, jest najpierw hashowane SHA-384 i enkodowane base64. Wynik mieści się w 72 bajtach, więc nawet bardzo długie hasło jest w pełni uwzględniane. Prefix $wp informuje wp_check_password(), że ma do czynienia właśnie z takim pre-hashowanym bcryptem – żeby nie pomylić go z vanilla bcrypt, który mógł wstawić plugin taki jak roots/wp-password-bcrypt.
Obok tego wprowadzono wp_password_needs_rehash() – wrapper na PHP-ową password_needs_rehash(). Przydatne jeśli budujesz własny flow migracji i chcesz programowo sprawdzić, czy dany hash wymaga aktualizacji.
Co z kluczami aplikacji i tokenami?
Application passwords, klucze resetowania hasła, klucze żądań danych osobowych i klucz recovery mode przeszły na BLAKE2b via Sodium – dostępny przez nowe funkcje wp_fast_hash() i wp_verify_fast_hash(). BLAKE2b jest kryptograficznie bezpieczny, ale celowo szybki – w odróżnieniu od bcrypt, który jest celowo wolny. To właściwy dobór algorytmu do celu: tokeny są losowo generowane z wysoką entropią, więc nie potrzebują kosztownego hashowania odpornego na brute force. Hasła użytkowników – owszem.
Post passwords (hasła chroniące pojedyncze wpisy) zostają na phpass. Na razie. John Blackbourn zaznaczył w ogłoszeniu, że temat wymaga osobnej analizy ze względu na specyficzny mechanizm weryfikacji tych haseł.
Argon2id jako alternatywa
Jeśli serwer obsługuje Argon2, można podmienić algorytm jedną linią przy użyciu filtra wp_hash_password_algorithm:
add_filter( 'wp_hash_password_algorithm', fn() => PASSWORD_ARGON2ID );
Argon2id jest obecnie uznawany za mocniejszy od bcrypt – lepiej radzi sobie z atakami wykorzystującymi dedykowany sprzęt. Problem w tym, że wymaga libargon2 po stronie serwera i PHP skompilowanego z obsługą Argon2. Przed użyciem tego filtra warto sprawdzić dostępność:
if ( in_array( 'argon2id', password_algos(), true ) ) {
add_filter( 'wp_hash_password_algorithm', fn() => PASSWORD_ARGON2ID );
}
Pro tip: $wp prefix pojawia się wyłącznie przy bcrypt. Jeśli przejdziesz na Argon2id, hash będzie miał prefix $argon2id$ bez $wp na początku – WordPress nie stosuje SHA-384 pre-hashingu dla algorytmów innych niż bcrypt.
Stare hasła, MD5 i to, czego się nie spodziewałeś
WordPress od zawsze obsługiwał coś, o czym większość deweloperów nie wie albo dawno zapomniała: czysty MD5 w kolumnie user_pass. Nie phpass, nie bcrypt – zwykły 32-znakowy hexadecymalny string, bez żadnego prefixu. Jeśli wstawiłeś taki hash bezpośrednio do bazy, WordPress przy logowaniu go rozpoznawał, weryfikował i natychmiast rehashował silniejszym algorytmem.
To właśnie na tym opierała się technika resetowania hasła przez phpMyAdmin, którą opisałem na początku.
Co się stało z MD5 w 6.8?
W pierwotnej wersji 6.8 John Blackbourn usunął wsparcie dla czystego MD5. W dyskusji na Make.WordPress.org szybko pojawiły się głosy sprzeciwu – deweloperzy i specjaliści od odzyskiwania stron wskazywali, że MD5 to dla wielu użytkowników jedyna praktyczna metoda ręcznego resetu hasła, szczególnie gdy e-mail nie działa, a dostępu do WP-CLI nie ma. Po tej dyskusji wsparcie dla MD5 zostało przywrócone jeszcze przed finalnym wydaniem 6.8.
Wniosek praktyczny: stary sposób z phpMyAdmin nadal działa. Po zalogowaniu hash zostaje automatycznie wzmocniony do bcrypt. Długoterminowo jednak WP-CLI jest właściwą ścieżką i warto wyrobić ten nawyk:
wp user update 1 --user_pass="nowehaslo"
Jak wygląda baza po aktualizacji do 6.8?
Przez pewien czas – normalnie mieszanie. To nie jest błąd, to zamierzony mechanizm. wp_check_password() w wersji 6.8 rozumie cały ten zestaw:
$wp$2y$ → nowy default: SHA-384 pre-hash + bcrypt
$2y$ → vanilla bcrypt (np. z roots/wp-password-bcrypt)
$P$ / $H$ → stary phpass, nadal weryfikowany
[32 hex] → czysty MD5, nadal weryfikowany
$generic$ → BLAKE2b (klucze aplikacji, nie hasła użytkowników)
Każdy z tych formatów jest poprawnie weryfikowany. Jeśli hash pochodzi ze starszego algorytmu, przy pierwszym logowaniu użytkownika zostaje automatycznie rehashowany do $wp$2y$ i zapisany z powrotem w bazie. Użytkownik nic nie zauważa.
Scenariusz, o którym warto wiedzieć: downgrade
Jeśli zaktualizujesz do 6.8, część użytkowników się zaloguje – ich hasła zostaną rehashowane do bcrypt. Potem z jakiegoś powodu cofniesz WordPress do 6.7. Ci konkretni użytkownicy nie będą mogli się zalogować, bo starsza wersja nie rozumie $wp$2y$. Nie wszyscy – tylko ci, którzy logowali się już po aktualizacji.
Rozwiązanie: reset hasła przez e-mail. Ale lepsza strategia to testowanie aktualizacji na stagingu zanim trafi na produkcję – co zresztą powinno być standardem niezależnie od tej zmiany.
Co to oznacza dla Twojego kodu?
Jeśli korzystasz z wp_hash_password() i wp_check_password() – nic nie musisz zmieniać. To jedyna słuszna odpowiedź na 99% przypadków.
Problemy mogą pojawić się w kodzie, który bezpośrednio sprawdza wartość hasha zamiast weryfikować go przez API. Klasyczny przykład to sprawdzanie prefixu:
// Źle - założenie o formacie hasha
if ( str_starts_with( $user->user_pass, '$P$' ) ) {
// "stary" hash
}
// Dobrze - pozwól core'owi to ocenić
if ( wp_password_needs_rehash( $user->user_pass ) ) {
// hash wymaga aktualizacji
}
wp_password_needs_rehash() to nowa funkcja w 6.8, ale logika jest prosta: zwraca true jeśli hash nie odpowiada aktualnemu algorytmowi i opcjom. Przydatna jeśli budujesz własny flow migracji lub masz kod, który ręcznie zarządza hasłami użytkowników.
Kilka innych przypadków wartych sprawdzenia:
Pluginy SSO, social login i MFA w większości nie są dotknięte tą zmianą – o ile korzystają ze standardowego flow uwierzytelniania WordPressa i nie dotykają bezpośrednio pola user_pass. Warto jednak przejrzeć kod jeśli masz coś niestandardowego.
Jeśli używałeś roots/wp-password-bcrypt – możesz go usunąć. Plugin nadpisywał wp_hash_password() i wp_check_password() własną implementacją bcrypt. WordPress 6.8 robi to samo, a istniejące hashe w formacie $2y$ (vanilla bcrypt bez prefixu $wp) są w pełni obsługiwane i zostaną rehashowane do $wp$2y$ przy następnym logowaniu użytkownika.
Jedna pułapka na którą warto uważać: post passwords. Jeśli masz własny kod obsługujący cookie wp-postpass_, który korzystał z wp_hash_password() – przestał działać. wp_hash_password() nie używa już PasswordHash, więc cookie generowane starym sposobem nie przejdzie weryfikacji. Tymczasowe obejście to bezpośrednie użycie klasy PasswordHash:
require_once ABSPATH . WPINC . '/class-phpass.php';
$hasher = new PasswordHash( 8, true );
$cookie_value = $hasher->HashPassword( wp_unslash( $password ) );
Brzydkie, ale działa do czasu aż core ustandaryzuje obsługę post passwords.
Co ta zmiana oznacza, gdy dojdzie do wycieku bazy?
Wyciek bazy danych to nie abstrakcja. Zdarzają się regularnie – przez niezałataną podatność w pluginie, skompromitowane dane dostępowe do hostingu, źle skonfigurowane uprawnienia do plików. Jak pokazywałem przy okazji analizy CVE-2023-46182, dane użytkowników to często główny cel atakującego.
Załóżmy, że atakujący ma dump tabeli wp_users. Co może z tym zrobić?
Przy bazie sprzed 6.8, z hashami $P$: Hashcat, tryb -m 400, przeciętna karta graficzna – kilkadziesiąt milionów prób na sekundę. Lista najpopularniejszych haseł, słowniki, reguły mutacji. Słabe i średnie hasła – złamane w minuty do godzin. Mocne hasła – dni, tygodnie. Ale większość użytkowników nie używa mocnych haseł.
Przy bazie po 6.8, z hashami $wp$2y$: bcrypt z domyślnym cost factorem 10 oznacza, że ta sama karta graficzna zrobi kilkaset prób na sekundę, nie milionów. Trzy, cztery rzędy wielkości wolniej. Hasło które przy phpass pęka w godzinę, przy bcrypt wymaga lat. Przy Argon2id – jeszcze gorzej dla atakującego.
To jest realna różnica dla użytkowników, którzy używają haseł typu Warszawa2019! albo imienia i roku urodzenia. Przy phpass – do złamania. Przy bcrypt – praktycznie nie do ruszenia w rozsądnym czasie.
Zmiana jest transparentna dla użytkowników i wymaga zera działań ze strony administratora. Ale efekt jest konkretny: każda baza WordPressa zaktualizowana do 6.8, w której użytkownicy zdążyli się zalogować, jest znacząco trudniejszym celem niż była tydzień wcześniej.