Elementrica
03Realizacje04Referencje05Firma06Aktualności07Kontakt
PLENDE

159 CVE w bibliotekach. Błąd, który bolał najbardziej, numeru CVE nie ma

Testy penetracyjne
159 CVE w bibliotekach. Błąd, który bolał najbardziej, numeru CVE nie ma

Klient udostępnił nam repozytoria dwóch aplikacji i zadał pytanie, na które skaner nie odpowiada: co z tego naprawdę nas boli.

Zrobiliśmy analizę statyczną kodu i zależności. Wyszło 115 ustaleń, w tym 68 o wysokiej wadze. Najgroźniejsze nie miało numeru CVE, nie zaświeciło się na czerwono w żadnym zestawieniu i mieściło się w jednej linijce PHP.

Poniżej pokazujemy, jak od licznika 159 CVE doszliśmy do jednej metody liczącej md5($pass), z mapowaniem na MITRE ATT&CK.

Kontekst

Zaczęliśmy od tego, co robi każdy: puściliśmy analizę zależności i patrzyliśmy, jak licznik dobija do 159. Prototype pollution, ReDoS, SSRF, cross-realm object access w bundlerze. Ładna lista. Trzy godziny później czytaliśmy encję użytkownika linijka po linijce i znaleźliśmy metodę, która zwracała md5($pass). Bez soli, bez kosztu, bez niczego.

Zakres obejmował dwie części systemu: panel operatorski napisany w Angularze w wersji, która wyszła w 2018 roku i straciła wsparcie w 2020, oraz starszy panel administracyjny oparty na jQuery, z backendem w PHP na frameworku MVC. Do tego moduł płatności integrujący się z zewnętrznym operatorem i wewnętrzny silnik wyszukiwania. Dostaliśmy pełne źródła, oba pliki lock i zgodę na czytanie wszystkiego. Zdarza się to rzadziej, niż się wydaje, i bardzo skraca drogę do sensownych wniosków.

Trzeba oddać klientowi sprawiedliwość. Repozytoria miały spójne pliki lock, ruch zewnętrzny szedł po TLS, panele były rozdzielone, a zespół sam zamówił test, zamiast czekać, aż wymuszą go regulacje. Klasa z md5 też nie wzięła się z niedbalstwa. Przeżyła kilka zespołów deweloperskich i nikt nie miał powodu do niej wracać, bo przecież działała.

Wyzwanie

Analiza SCA dała obraz, który zna każdy, kto raz odpalił skaner na projekcie frontendowym. W drzewie zależności 62 paczki, z tego 60 z przynajmniej jedną znaną podatnością, 159 trafień, 106 unikalnych CVE. Gdyby potraktować to dosłownie, zespół miałby zajęcie do końca roku i zero pewności, że cokolwiek z tego zmniejsza ryzyko.

Bo większość tych trafień siedziała w zależnościach deweloperskich, tych od budowania i testów. webpack-dev-server, karma, webpack-dev-middleware, terser, loader-utils. Kod, który nigdy nie trafia do przeglądarki użytkownika i nigdy nie stoi na produkcji. Krytyczny prototype pollution w loader-utils jest realny dla procesu CI, jeśli buduje on niezaufany kod. W aplikacji, którą widzi użytkownik, nie robi nic.

Skaner ma jedną zaletę i jedną wadę. Nigdy się nie nudzi i nigdy nie myśli. Drzewo zależności miało w sobie trzy różne wersje semver, biblioteki, której całe zadanie polega na porównywaniu wersji. Sama ze sobą się nie dogadała. Do tego dwie wersje json5, dwie cross-spawn, trzy ws.

$ npm ls semver          # wynik przycięty
[email protected]
├─┬ <toolchain builda>
│ └── [email protected]
├─┬ <runner testów>
│ └── [email protected]
└── [email protected]

Podobnie z frontendem panelu administracyjnego. Wtyczka walidacji formularzy w wersji podatnej na ReDoS została policzona dziesięć razy, bo obok źródła leżały wersje zminifikowane i pliki tłumaczeń: holenderski, fiński, chilijski hiszpański i kilka innych. Każdy plik jako osobne ustalenie. Wyrażenie regularne nie robi się groźniejsze przez to, że katalog ma sześć plików lokalizacji.

Prawdziwe pytanie brzmiało więc inaczej niż „ile mamy CVE”. Brzmiało: co z tego jest osiągalne dla kogoś, kto ma tylko przeglądarkę i konto w aplikacji.

Co zrobiliśmy

Podzieliliśmy wynik SCA na trzy grupy: kod wykonywany u użytkownika, kod wykonywany na serwerze i kod, który żyje wyłącznie w czasie budowania. Dopiero po tym cięciu lista zaczęła cokolwiek znaczyć. Ze 159 trafień w grupie „użytkownik albo serwer” zostało kilkanaście.

Potem ręcznie przeszliśmy przez wszystko, co wychwyciła reguła o słabym algorytmie skrótu. Jedenaście trafień. Dziesięć z nich siedziało w bibliotece integracyjnej operatora płatności, w funkcjach liczących podpis żądania. Ten operator wymaga MD5 w swoim starszym API i nie ma tam miejsca na decyzję projektową, jest tylko zgodność z cudzą specyfikacją. Opisaliśmy to jako ryzyko akceptowane, z rekomendacją migracji na nowszą wersję interfejsu, w której podpis liczony jest algorytmem SHA-384.

Jedenaste trafienie wyglądało tak:

class User extends Entity
{
    protected $attributes = [
        'email' => null,
        'password' => null,
        // ... trzydzieści kilka pól adresowych i rozliczeniowych
    ];

    public static function getHashedPassword($pass)
    {
        return md5($pass);
    }
}

Cała klasa obsługuje dane osobowe klienta: adres, dane do faktury, dane rozliczeniowe. Na końcu jest jedna metoda, która zamienia hasło w skrót MD5 bez soli i bez żadnego kosztu obliczeniowego. To CWE-916, użycie funkcji skrótu o niewystarczającym nakładzie obliczeniowym, i A02:2021 w OWASP Top 10. Publicznie dostępne wyniki hashcata dla trybu 0 na pojedynczej współczesnej karcie graficznej sięgają dziesiątek miliardów skrótów na sekundę. Baza z takimi hasłami po wycieku nie jest zaszyfrowana, jest opóźniona o kilka godzin.

Brak soli ma jeszcze jeden skutek, o którym rzadziej się mówi. Identyczne hasła dają identyczne skróty, więc bez łamania czegokolwiek widać, którzy użytkownicy mają to samo hasło, i widać, które hasło jest w bazie najpopularniejsze. Wystarczy GROUP BY.

Przy okazji czytania kodu znaleźliśmy zaszytą w źródłach stałą sól używaną do znakowania fragmentów treści (CWE-798), komunikację z wewnętrznym silnikiem wyszukiwania po zwykłym HTTP, z adresem i portem zaszytym w konfiguracji, oraz backend na wersji frameworka podatnej na CVE-2023-46240. W tej wersji nawet na produkcji potrafi wyświetlić się pełny raport błędu razem ze śladem stosu. Ten ostatni jest o tyle wredny, że sam w sobie niczego nie daje atakującemu, ale przy pierwszym błędzie 500 oddaje mu ścieżki plików, wersje i strukturę aplikacji.

Ustalenia zmapowaliśmy na MITRE ATT&CK, żeby zespół po stronie klienta miał je w tym samym języku, w którym pisze reguły detekcji. Zastrzeżenie: to była analiza statyczna, więc mapowanie pokazuje, co da się z tym zrobić, a nie co zrobiliśmy.

IDTechnikaCo to znaczy w tym kodzie
T1190Exploit Public-Facing ApplicationNieaktualne komponenty osiągalne z zewnątrz, w tym ujawnianie błędów przez framework
T1110.002Brute Force: Password CrackingSkróty MD5 bez soli, łamane offline po dowolnym wycieku bazy
T1078Valid AccountsZłamane hasła działają w aplikacji, a przy powielaniu haseł także w innych usługach
T1552.001Unsecured Credentials: Credentials In FilesSól i parametry połączeń zaszyte w repozytorium
T1040Network SniffingZapytania do silnika wyszukiwania po HTTP wewnątrz sieci
T1499.003Endpoint DoS: Application Exhaustion FloodReDoS w bibliotekach walidacji i w kodzie własnym

Rezultat

W raporcie wylądowało 115 ustaleń, z czego 68 wysokich, 37 średnich i 10 niskich. Do naprawy w pierwszej kolejności wskazaliśmy cztery rzeczy.

Migracja przechowywania haseł na Argon2id, zgodnie z OWASP Password Storage Cheat Sheet i NIST SP 800-63B, bez wymuszania resetu na wszystkich użytkownikach naraz. Rehash przy pierwszym poprawnym logowaniu załatwia to po cichu:

public function verifyAndUpgrade(string $plain, string $stored, int $userId): bool
{
    if (str_starts_with($stored, '$argon2id$')) {
        return password_verify($plain, $stored);
    }

    if (!hash_equals($stored, md5($plain))) {
        return false;
    }

    $this->users->update($userId, [
        'password' => password_hash($plain, PASSWORD_ARGON2ID),
    ]);

    return true;
}

Aktualizacja frameworka backendu do wersji z poprawką ujawniania błędów oraz wymuszenie w konfiguracji produkcyjnej, żeby display_errors było na sztywno wyłączone, niezależnie od wersji.

Wyniesienie soli i parametrów połączeń z repozytorium do zmiennych środowiskowych albo menedżera sekretów, z rotacją wartości, która już trafiła do historii gita. Sama zmiana pliku nie wystarczy, bo stara wartość zostaje w commitach.

Aktualizacja bibliotek frontendowych osiągalnych dla przeglądarki, czyli walidacji formularzy i biblioteki interfejsu, oraz usunięcie z katalogu assets plików lokalizacji, których aplikacja nie ładuje. Reszta trafień z SCA poszła do grupy „proces budowania”, z terminem przy kolejnej większej aktualizacji i z jednym warunkiem: budowanie niezaufanego kodu w tym samym runnerze przenosi je z powrotem na górę listy.

Uczciwie: analiza statyczna nie powie, czy panel administracyjny ma poprawną kontrolę dostępu do funkcji, czy tokeny sesji unieważniają się przy zmianie hasła i czy moduł płatności da się oszukać na poziomie logiki zamówienia. To są pytania na test dynamiczny i tak też je zapisaliśmy w rekomendacjach na następny etap.

Jeśli masz w kodzie funkcję, która liczy skróty haseł, i nikt jej nie ruszał od lat, sprawdź ją teraz, zanim zaczniesz sortować raport ze skanera po CVSS. Najgroźniejsza podatność potrafi nie mieć żadnego numeru CVE.

Ile trafień w Twoim ostatnim raporcie SCA ktoś naprawdę przeczytał?

Umów rozmowę z konsultantem. Wspólnie omówimy zakres analizy kodu i zależności (SAST/SCA), realne ryzyka i to, jak taki przegląd mógłby wyglądać u Ciebie.

Poprzedni
Wgraliśmy paczkę. Dostaliśmy roota.

Wszystkie realizacje zanonimizowano sektorowo, bez nazw, dat i danych pozwalających zidentyfikować klienta, zgodnie z poufnością. Nie publikujemy żadnych prawdziwych podatności ani danych technicznych klienta.