Błąd bcrypt „72 bajty”: dlaczego zawodzą też krótkie hasła
Dwa zupełnie różne problemy dają ten sam komunikat, a tylko jeden z nich ma cokolwiek wspólnego z hasłem.
Jeśli hasło rzeczywiście przekracza limit 72 bajtów, bcrypt czyta pierwsze 72 bajty i odrzuca resztę. Policzyliśmy hash dla dwóch 82-bajtowych haseł o wspólnych pierwszych 72 bajtach, przy stałej soli. Oba dały $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S, a bcrypt.compareSync(p2, hash(p1)) zwróciło true. Drugie hasło otwiera konto założone na pierwsze.
Jeśli hasło jest ewidentnie krótkie, a mimo to pojawia się to:
password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
(po polsku: „hasło nie może być dłuższe niż 72 bajty, w razie potrzeby skróć je ręcznie”) — wtedy komunikat myli się co do przyczyny. Przy passlib 1.7.4 z bcrypt 5.0.0 wywołuje go hasło o długości 14 bajtów.
Winowajcą jest w tym przypadku stała, 255-bajtowa sonda autotestu ukryta w passlib. Uruchamia się raz, przy inicjalizacji backendu, zanim hasło w ogóle dotrze do wywołania liczącego hash. bcrypt 5.0.0 odrzuca sondę, wyjątek wydostaje się na zewnątrz i widać skargę na hasło, którego nikt nie wpisał.
Warto od razu wykluczyć najczęstszą podpowiedź: monkey patch na __about__, który dominuje w wynikach wyszukiwania dla tego błędu, niczego nie naprawia. Powtórzyliśmy go w czystym procesie, z łatką nałożoną przed import passlib, i ValueError wrócił bez zmian.
Triage w 30 sekund: który to przypadek
| Hasło | Kiedy pojawia się błąd | Przyczyna źródłowa | Przejdź do |
|---|---|---|---|
| Dłuższe niż 72 bajty | Przy wywołaniu liczącym hash | Naprawdę za długie. bcrypt 5.0 zgłasza wyjątek, bcrypt 4.x po cichu obcina | Sekcje 2 i 3 |
| Krótsze niż 72 bajty, przez passlib | Przy pierwszym wywołaniu w procesie | 255-bajtowa sonda passlib. Nie ma nic wspólnego z hasłem | Sekcja 4 |
| Zawiera znaki chińskie, japońskie lub emoji | Wygląda krótko, ale takie nie jest | Znaki to nie bajty | Sekcja 3 |
| Zaczęło się psuć po aktualizacji zależności | Po wdrożeniu | Zmiana łamiąca zgodność w bcrypt 5.0 | Sekcje 4 i 5 |
Kto trafił do drugiego wiersza, może przeskoczyć dalej. Nic z dwóch kolejnych sekcji nie pomoże, a rozwiązanie jest zupełnie inne.
Co limit 72 bajtów bcrypt robi z hasłem
Dlaczego bcrypt zatrzymuje się na 72
bcrypt opiera się na Blowfishu i podaje hasło jako klucz Blowfisha. Blowfish rozwija swój klucz w tablicę P złożoną z 18 podkluczy, każdy o szerokości 32 bitów. To daje 18 × 4 = 72 bajty materiału klucza, a pętla rozwijania wraca na początek klucza, gdy tylko zapełni wszystkie 18 miejsc.
Sufit jest więc strukturalny. To nie jest niedbała implementacja ani konfigurowalny bufor, który ktoś zapomniał powiększyć. Każda zgodna ze specyfikacją implementacja bcrypt na każdej platformie ma ten sam limit, dlatego liczbę 72 widać tak samo w Pythonie, Node, Go, Javie i PHP.
Dwa różne hasła, jeden hash
Obcinanie hasła przez bcrypt to sprawa bezpieczeństwa, a nie drobna niedogodność związana z długością.
Używając bcryptjs 3.0.3 ze stałą solą $2a$10$abcdefghijklmnopqrstuv, policzyliśmy hash dla dwóch haseł po 82 bajty każde:
| Hasło | Wartość | Bajty |
|---|---|---|
| p1 | "A"×72 + "XXXXXXXXXX" | 82 |
| p2 | "A"×72 + "ZZZZZZZZZZ" | 82 |
Oba dały ten sam skrót:
$2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S
Weryfikacja krzyżowa też przechodzi:
bcrypt.compareSync(p2, hash(p1)) // true
Napastnik, który zna pierwsze 72 bajty długiego passphrase, może dopisać cokolwiek i się uwierzytelnić. Każdy bajt za granicą wnosi dokładnie zero do siły zapisanego hashu, niezależnie od tego, jak starannie użytkownicy go dobrali. Kto chce sprawdzić posiadany hash względem kandydującego hasła bez pisania skryptu, może wygenerować i zweryfikować hashe bcrypt w przeglądarce.
Gdzie naprawdę leży granica
Zawężaliśmy odcięcie bajt po bajcie, zachowując wspólny prefiks i zmieniając dokładnie jeden bajt po nim:
| Identyczne bajty prefiksu | Bajt N+1 różni się na pozycji | Ten sam hash? |
|---|---|---|
| 70 | 71 | false |
| 71 | 72 | false |
| 72 | 73 | true |
| 73 | 74 | true |
Bajt 72 jeszcze się liczy. Bajt 73 jest pierwszym, który już nie. Nie ma tu wygaszania ani częściowego mieszania, więc sprawdzenie tego lokalnie we własnej bibliotece kosztuje niewiele.
Znaki to nie bajty
bcrypt liczy bajty UTF-8, a użytkownicy wpisują znaki. Dla ASCII obie liczby przypadkiem się pokrywają, więc problem uderza w zespoły dokładnie w chwili wejścia poza rynek anglojęzyczny.
| Rodzaj znaku | Przykład | Bajtów na znak | 72 bajty to |
|---|---|---|---|
| Litery łacińskie ASCII | A | 1 | 72 znaki |
| Chińskie znaki han | 密 | 3 | 24 znaki |
| Japońskie kana | あ | 3 | 24 znaki |
| Emoji | 🔒 | 4 | 18 znaków |
| Cyrylica | я | 2 | 36 znaków |
| Niemieckie umlauty | ü | 2 | 36 znaków |
Potwierdziliśmy oba skrajne przypadki: przy haśle chińskim różnice po 24. znaku są ignorowane (true), a przy haśle z emoji różnice po 18. znaku również (true).
Chińskie passphrase o długości 25 znaków wygląda w polu hasła całkiem okazale. A linia jest już przekroczona. Użytkownik, który wybierze 20 emoji, przekroczył limit o dwa znaki i nikt mu tego nie powie.
Pomiar długości w bajtach we własnym kodzie
Kontrole długości napisane na licznikach znaków przejdą, choć sama wartość jest już za długa. Trzeba mierzyć bajty:
# Python
len(pw.encode("utf-8"))
// Node.js
Buffer.byteLength(pw, "utf8")
// Go
len([]byte(pw))
W przeglądarkach bez Buffer tę samą liczbę daje new TextEncoder().encode(pw).length. Warto postawić tę kontrolę przed wywołaniem liczącym hash i zwrócić prawdziwy komunikat walidacyjny, zamiast zostawiać decyzję bibliotece o 3 w nocy. Jeśli przy okazji na warsztat idzie polityka minimalnej długości, tekst o tym, jak naprawdę mierzy się siłę hasła, pokazuje, co reguła długości daje, a czego nie.
Dlaczego krótkie hasła też zawodzą: 255-bajtowa sonda passlib
Hasło ma czternaście znaków, a biblioteka upiera się, że przekracza 72 bajty. To ten przypadek wysyła większość ludzi do wyszukiwarki.
Odtworzenie problemu
Trzy linie, na Pythonie 3.14.5 z bcrypt 5.0.0 i passlib 1.7.4:
from passlib.hash import bcrypt
bcrypt.hash("short-password") # 14 bajtów
# ValueError: password cannot be longer than 72 bytes, truncate manually if necessary (e.g. my_password[:72])
Czternaście bajtów na wejściu, skarga na 72 bajty na wyjściu. Błąd passlib z bcrypt jest jak najbardziej prawdziwy, ale liczba w nim opisuje coś zupełnie innego.
Pełny stos wywołań
To nie jest domysł. Prześledziliśmy tę ścieżkę w passlib 1.7.4:
- Pierwsze wywołanie uruchamia inicjalizację backendu:
_calc_checksum→_stub_requires_backend()→set_backend(). _load_backend_mixinodczytujebcrypt.__about__.__version__. Atrybut nie istnieje, więc zgłaszany jestAttributeError. passlib go połyka i wypisuje(trapped) error reading bcrypt version.- Inicjalizacja idzie dalej do
_finalize_backend_mixin(passlib/handlers/bcrypt.py:421), które wywołujedetect_wrap_bug(IDENT_2A). detect_wrap_bug(ten sam plik,:378) weryfikuje stałą, 255-bajtową sondę.- bcrypt 5.0.0 zgłasza
ValueErrordla czegokolwiek dłuższego niż 72 bajty, więc sonda wysadza samą siebie. - Wyjątek propaguje się do miejsca wywołania. Widać komunikat o 72 bajtach, który nigdy nie dotyczył podanych danych.
Cała ta sekwencja dzieje się raz na proces, przy pierwszym liczeniu hashu lub pierwszej weryfikacji. Dlatego awaria jest tak niezawodnie odtwarzalna i całkowicie niewrażliwa na to, co się przekaże.
Jak wygląda sonda
secret = (b"0123456789" * 26)[:255]
Ta stała pochodzi z błędu zawijania w bcrypcie z BSD, który Openwall ujawnił w 2012 roku: długie klucze zawijały się i zapadały w słabsze hashe. passlib sprawdza przy starcie, czy właśnie załadowany backend nosi tę wadę, i odmawia zaufania backendowi, który ją ma.
detect_wrap_bug nie jest błędem passlib. To kod defensywny robiący dokładnie to, po co go napisano, przy użyciu wektora testowego ważnego od ponad dekady. Zmieniło się to, że bcrypt 5.0.0 traktuje teraz 255-bajtowe wejście jako błąd, a nie jako coś do policzenia, co zamienia zdawany autotest w autotest nie do zdania. Dyskusja w zgłoszeniu #1082 w pyca/bcrypt opisuje to zderzenie dwóch bibliotek.
Dlaczego łatka na __about__ tego nie naprawia
Po wyszukaniu tego błędu w kółko wraca informacja, że bcrypt usunął __about__ i że przywrócenie atrybutu naprawia passlib. Obie połowy tego zdania są nieprawdziwe. Pomiar:
| Wersja | hasattr(bcrypt, "__about__") | Wypisuje ostrzeżenie trapped | passlib działa |
|---|---|---|---|
| bcrypt 5.0.0 | False | Tak | Nie (ValueError) |
| bcrypt 4.3.0 | False | Tak | Tak |
bcrypt 4.3.0 też nie ma __about__. Wypisuje tę samą linię (trapped) error reading bcrypt version. A passlib działa na nim bez narzekania. Brakujący atrybut nie jest więc linią podziału między działającym a zepsutym. Jest nią zmiana zachowania ValueError w 5.0.0.
Popularna łatka nie może więc zadziałać i nie działa:
import bcrypt, types
bcrypt.__about__ = types.SimpleNamespace(__version__=bcrypt.__version__) # przed zaimportowaniem passlib
from passlib.hash import bcrypt as pl
pl.hash("short-password")
# nadal ValueError: password cannot be longer than 72 bytes, ...
Uruchomiliśmy to w czystym procesie, z łatką nałożoną przed import passlib, właśnie po to, żeby nikt nie przypisał awarii kolejności importów. Nadal zawodzi. Jedyne, co łatka osiąga, to wyciszenie nieszkodliwego ostrzeżenia. 255-bajtowa sonda z kroku 4 to osobny etap, który od początku nigdy nie zaglądał do __about__, i detonuje tak czy inaczej.
Co bcrypt 5.0 faktycznie zmienił
Zmiana łamiąca zgodność w bcrypt 5.0 to jedna linijka zachowania o dużym promieniu rażenia:
| Wejście | bcrypt 4.3.0 | bcrypt 5.0.0 |
|---|---|---|
| 72 bajty | OK | OK |
| 73 bajty | OK (po cichu obcięte) | ValueError |
| 100 bajtów | OK (po cichu obcięte) | ValueError |
| 255 bajtów | OK (po cichu obcięte) | ValueError |
Obcinanie w kolumnie 4.x nie jest przenośnią. Na 4.3.0 hash(73 bajty) i hash(100 bajtów) zbudowane z tego samego prefiksu wychodzą identyczne: true.
bcrypt 5.0 jest więc tutaj bardziej poprawną biblioteką. Ciche wyrzucanie materiału klucza jest gorszym wynikiem niż odmowa działania, a odmowa działania jest tym, co biblioteka licząca hash powinna zrobić, gdy nie może uszanować otrzymanego wejścia. Nie sprawia to, że aktualizacja przebiega bezboleśnie. Kod, który latami po cichu gubił bajty, teraz rzuca wyjątkiem, a jeśli ta ścieżka siedzi za passlib, rzuca nim, zanim jakiekolwiek dane w ogóle wejdą w grę.
Z tej tabeli wynikają dwie rzeczy i trafiają w różne zespoły. Kto woła bcrypt bezpośrednio, ten widzi aktualizację od razu: wyjątek przy rejestracji albo logowaniu, w miejscu w kodzie, które sam kontroluje, ze stosem wskazującym na własne wywołanie liczące hash. Wystarczy dołożyć przed nim kontrolę długości w bajtach i sprawa zamyka się w jedno popołudnie.
Kto idzie przez passlib, ten aktualizacji nie widzi, dopóki nie stanie się całkowita. Awaria nie jest proporcjonalna do liczby użytkowników z długimi hasłami, bo w ogóle nie zależy od danych wejściowych. Każde liczenie hashu i każda weryfikacja w procesie zawodzi, od pierwszego wywołania, w kodzie, w którym nic dotyczącego obsługi haseł się nie zmieniło. Dlatego objawia się to jako incydent wdrożeniowy, a nie zgłoszenie błędu, i dlatego treść komunikatu wysyła ludzi w dokładnie złą stronę.
Naprawa
Jeśli można zmienić kod
Wystarczy porzucić passlib i wołać bcrypt bezpośrednio. Ostatnie wydanie passlib to 1.7.4, a projekt od dawna milczy, więc ta warstwa daje bardzo niewiele w projekcie, który potrzebuje wyłącznie bcrypt:
import bcrypt
password = "correct horse battery staple".encode("utf-8")
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
bcrypt.checkpw(password, hashed) # True
hashpw i checkpw przyjmują bajty, więc warto kodować na granicy i trzymać resztę kodu na str. Nie ma tu wykrywania backendu ani sondy autotestu, a więc i trybu awarii raportującego o haśle, którego nikt nie podał. Powstały hash albo ten wyprodukowany przez aplikację można obejrzeć w generatorze bcrypt, który działa w całości w przeglądarce. Serwery używające bcrypt do HTTP Basic Auth mają to samo ograniczenie strukturalne w innym formacie pliku, co omawia przewodnik po htpasswd.
Jeśli kodu dziś zmienić się nie da
Przypnij wersję poniżej 5:
bcrypt<5
Sprawdziliśmy bcrypt 4.3.0 z passlib 1.7.4 i to działa. Warto jednak jasno nazwać, co się w ten sposób kupuje. To opaska uciskowa, nie naprawa. Zostaje się na wersji, której zachowaniem wobec długiego hasła jest ciche wyrzucanie bajtów — czyli dokładnie ten problem, który 5.0 miał zatrzymać. Warto postawić datę przy tym przypięciu i zaplanować przeprowadzkę.
Jeśli użytkownicy naprawdę wpisują długie passphrase
Policz najpierw hash SHA-256 z hasła, zakoduj skrót w Base64, a dopiero to podaj do bcrypt:
import base64, hashlib, bcrypt
def prehash(password: str) -> bytes:
return base64.b64encode(hashlib.sha256(password.encode("utf-8")).digest())
hashed = bcrypt.hashpw(prehash(pw), bcrypt.gensalt(rounds=12))
bcrypt.checkpw(prehash(pw), hashed)
Wynik ma zawsze 44 bajty, z zapasem poniżej 72, niezależnie od długości wejścia. Przywraca też właściwość, którą obcinanie niszczyło: dwa 82-bajtowe hasła z początku artykułu, przepuszczone przez to, dają checkpw(prehash(p2), hash(prehash(p1))) = False. Kolizja znika.
Krok z Base64 wykonuje realną pracę, więc nie należy go pomijać. Surowy skrót SHA-256 to dowolne dane binarne i może zawierać bajty NUL, które implementacje bcrypt obsługują niespójnie. Base64 daje wolny od NUL ciąg ASCII o stałej długości. Tę samą funkcję trzeba stosować przy rejestracji i przy logowaniu, inaczej każdy istniejący hash przestanie się weryfikować.
Czego nie robić
Monkey patch na __about__ nie działa. Pomiar jest w sekcji 4. Jeśli ktoś w zespole właśnie zamierza go wkleić, powyższe cztery linie oszczędzą mu popołudnia.
Samodzielne obcinanie przez pw[:72] jest gorsze niż nierobienie niczego. Zamienia głośną awarię z powrotem w cichą i odtwarza kolizję z sekcji 2 we własnym kodzie. Byłoby to ręczne odtworzenie dokładnie tego zachowania, które bcrypt 5.0 miał wyeliminować, a w odróżnieniu od wersji bibliotecznej ta własna nigdy nikogo nie ostrzeże. Jeśli długie hasła mają działać, policz wstępny hash. Jeśli nie mają, sprawdź długość w bajtach i odrzuć z czytelnym komunikatem.
Co z hashami, które już są w bazie
Których wierszy to dotyczy
Wyłącznie kont, których właściciele zarejestrowali się hasłem dłuższym niż 72 bajty. W większości produktów konsumenckich to niewielki zbiór, a przy treściach wyłącznie ASCII zwykle oznacza entuzjastów długich passphrase. W produktach, w których użytkownicy wpisują chiński, japoński lub emoji, zastosowanie ma sekcja 3, a zbiór dotkniętych kont bywa znacznie większy, niż sugeruje audyt ślepy na bajty.
Tych wierszy nie da się rozpoznać po samych hashach. Skrót bcrypt ma stałą szerokość i nie niesie żadnego śladu długości swojego wejścia. Jeśli długość hasła była logowana przy rejestracji, ten log jest jedyną inwentaryzacją. Większość zespołów tego nie robiła, a odtworzenie tego po fakcie nie jest możliwe. Planować trzeba więc wokół niewiedzy, a nie wokół listy.
Nie da się przeliczyć hurtem
Nie ma tekstu jawnego do ponownego policzenia hashu, co jest przecież całym sensem przechowywania hashy. Migracja musi być więc leniwa: aktualizuj każde konto przy najbliższym udanym uwierzytelnieniu jego właściciela, kiedy tekst jawny przez chwilę jest w pamięci.
def login(user, password: str) -> bool:
if not verify_legacy(password, user.password_hash):
return False
if needs_rehash(user.password_hash):
user.password_hash = hash_new_scheme(password)
save(user)
return True
Najpierw weryfikacja starym schematem, dopiero potem nowy hash. Odwrócenie tych dwóch kroków nadpisuje zapisany hash, zanim potwierdzi się poprawność hasła. Warto trzymać identyfikator schematu obok każdego hashu, żeby needs_rehash był porównaniem pola, a nie zgadywanką. Zostanie jeszcze długi ogon uśpionych kont, które nigdy się nie zalogują. Te obsługuje się przy resecie hasła, a nie siłą.
Kiedy pełna migracja się opłaca
Jeśli i tak powstaje ścieżka leniwego przeliczania, to najtańszy moment, jaki się kiedykolwiek trafi, żeby podmienić pod nią algorytm. Sufit 72 bajtów nie istnieje w Argon2id, a szczegółowe porównanie Argon2id i bcrypt opisuje, kiedy zmiana się zwraca, a kiedy pozostanie przy bcrypt jest właściwą decyzją. OWASP Password Storage Cheat Sheet to punkt odniesienia do sprawdzenia własnych parametrów.
Nie warto zaczynać migracji wyłącznie z powodu tego błędu. Jeśli hasła mieszczą się z zapasem poniżej 72 bajtów, bcrypt pozostaje rozsądnym wyborem, a sekcja 6 już rozwiązała problem.
FAQ
Dlaczego bcrypt twierdzi, że moje hasło jest dłuższe niż 72 bajty, skoro jest krótkie?
Bo komunikat dotyczy wewnętrznej sondy passlib, a nie hasła. Przy pierwszym wywołaniu passlib uruchamia detect_wrap_bug ze stałym, 255-bajtowym ciągiem testowym. bcrypt 5.0.0 zgłasza ValueError dla czegokolwiek dłuższego niż 72 bajty, więc sonda zawodzi, a błąd wypływa w miejscu wywołania. Wyzwala go już 14-bajtowe hasło.
Czy bcrypt naprawdę ignoruje wszystko po 72 bajtach?
Tak, bcrypt pomija wszystko po 72 bajcie, całkowicie. Dwa 82-bajtowe hasła o wspólnych pierwszych 72 bajtach dają identyczny hash $2a$10$abcdefghijklmnopqrstuu.hioaszd4nGKdJlcuRzR1xqPIcN/X.S i każde weryfikuje się względem hashu drugiego. Granica jest dokładna: różnica na bajcie 72 zmienia hash, różnica na bajcie 73 już nie.
Czy limit 72 bajtów to problem bezpieczeństwa?
Tak, limit 72 bajtów bywa problemem przy długich passphrase. Ktokolwiek zna pierwsze 72 bajty, może dopisać dowolne bajty i się uwierzytelnić, więc każdy bajt ponad limit nic nie wnosi. Dla haseł poniżej 72 bajtów nie zmienia to zupełnie nic. Wstępne policzenie hashu SHA-256 usuwa ekspozycję, jeśli długie wejścia mają liczyć się w całości.
Ile znaków to 72 bajty?
Liczba znaków w 72 bajtach zależy od kodowania. 72 litery ASCII, 36 znaków cyrylicy lub umlautów, 24 chińskie znaki han, 24 japońskie kana albo 18 emoji. bcrypt liczy bajty UTF-8, a nie znaki, więc długość mierzy się przez len(pw.encode("utf-8")) w Pythonie lub Buffer.byteLength(pw, "utf8") w Node.
Czy załatanie __about__ naprawia błąd passlib?
Nie, łatka na __about__ nie naprawia błędu passlib. Nałożyliśmy ją przed import passlib w czystym procesie i ValueError i tak wystąpił. bcrypt 4.3.0 również nie ma __about__, a z passlib działa bez problemu, co dowodzi, że brakujący atrybut nie jest przyczyną. Łatka wycisza jedynie ostrzeżenie (trapped) error reading bcrypt version.
Czy powinienem cofnąć bcrypt poniżej 5.0?
Jako doraźne rozwiązanie — tak, cofnięcie bcrypt poniżej 5.0 pomaga. bcrypt 4.3.0 z passlib 1.7.4 działa. Ale 4.x po cichu obcina wszystko powyżej 72 bajtów, czyli zachowuje się dokładnie tak, jak 5.0 miało to zatrzymać, więc traktuj przypięcie jako tymczasowe i przejdź na bezpośrednie wołanie bcrypt.
Czy mogę po prostu sam obciąć hasło do 72 bajtów?
Nie obcinaj hasła do 72 bajtów samodzielnie. pw[:72] odtwarza opisaną wyżej kolizję we własnym kodzie, po cichu, bez żadnego ostrzeżenia biblioteki, które można by przechwycić. Albo policz wstępny hash SHA-256 z kodowaniem Base64, żeby długie wejścia pozostały rozróżnialne, albo sprawdź długość w bajtach z góry i odrzuć z czytelnym komunikatem błędu.
Co dzieje się z hasłami, których hash powstał zanim to naprawiłem?
Hasła z hashami policzonymi wcześniej weryfikują się dalej, bo ścieżka weryfikacji obcina tak samo, jak obcinała ścieżka licząca hash. Osłabione są wyłącznie konta zarejestrowane hasłem dłuższym niż 72 bajty i bez tekstu jawnego nie da się ich przeliczyć. Policz nowy hash leniwie, przy najbliższym udanym logowaniu, a uśpione konta obsłuż przy resecie hasła.