Przemyt żądań na serwerze, którego nie miało być w sieci

Klient dał nam dwie aplikacje mobilne i jedno zdanie zakresu: sprawdźcie, czy da się je złamać. Aplikacje okazały się dobrze zrobione, więc weszliśmy inną drogą.
Nie przez telefon, tylko przez serwer deweloperski, który stał w publicznym internecie z włączonym trybem debug i podatnością na desynchronizację proxy. W środku znaleźliśmy mechanizm, który pozwala przechwycić żądanie innego użytkownika.
Poniżej cała ścieżka, z mapowaniem na MITRE ATT&CK i fragmentami do odhaczenia u siebie.
Kontekst
Aplikacja mobilna była najbezpieczniejszym elementem całej układanki. To rzadki widok. Zwykle to właśnie na telefonie znajdujemy zostawione tokeny, klucze w plikach, dane w otwartym schowku. Tu nie. Dane szyfrowane lokalnie, cała komunikacja po HTTPS, binarki podpisane, tryb debug w wersji produkcyjnej wyłączony. Ktoś, kto budował te aplikacje, odrobił lekcje z bezpieczeństwa mobilnego.
Klient poprosił nas o zewnętrzny test dwóch natywnych aplikacji, na iOS i Android, oraz powiązanych z nimi domen. Pracowaliśmy w trybie grey-box: dostaliśmy schemat API i konta testowe, bez kodu źródłowego i bez kont z podwyższonymi uprawnieniami. Backend to REST API, ta sama warstwa obsługuje obie platformy, więc po stronie logiki iOS i Android nie różniły się niczym.
Wyzwanie
Skoro sama aplikacja była zrobiona porządnie, ciekawe rzeczy musiały siedzieć gdzieś obok. Mobilny pentest, w którym binarka i lokalny magazyn danych są czyste, przesuwa ciężar na to, z czym aplikacja rozmawia: API, proxy, konfigurację serwera.
Warstwa mobilna trzymała się dobrych praktyk. Haczyk był w tym, że obok wersji produkcyjnej w internecie stała wersja deweloperska tej samej aplikacji. Z konfiguracją, której nikt nie wyłącza, bo środowisko nie jest produkcyjne. Włączony tryb debug frameworka Django, a piętro niżej, w warstwie proxy, podatność na przemyt żądań HTTP. Obrona nie była głupia. Po prostu pilnowała frontu, a wejście było bocznymi drzwiami, o których łatwo zapomnieć.
Co zrobiliśmy
Zaczęliśmy od rekonesansu domen powiązanych z aplikacją i szybko trafiliśmy na serwer deweloperski. Przy każdym nieobsłużonym błędzie wyrzucał pełną stronę debug Django. Taka strona to spowiedź serwera: wypluwa wersje frameworka i bibliotek, listę zainstalowanych komponentów i middleware, ścieżki na dysku, wewnętrzne porty i usługi, a w jednym miejscu adres i login do usługi wewnętrznej, której z internetu widać nie powinno. Dla obrońcy to lista rzeczy do naprawienia. Dla atakującego gotowa mapa zaplecza.
Prawdziwe wejście było jednak w warstwie proxy. Na endpoincie logowania do panelu administracyjnego serwer był podatny na desynchronizację żądań w wariancie CL.TE. Reverse proxy czyta długość żądania z nagłówka Content-Length, backend z Transfer-Encoding: chunked. Kiedy oba nagłówki mówią co innego, jedno żądanie po drodze zamienia się w dwa, a to drugie backend obsługuje tak, jakby przyszło prosto od proxy, bez ponownej weryfikacji.
POST /admin/login/?next=/admin/ HTTP/1.1 Host: dev.klient.example Content-Type: application/x-www-form-urlencoded Content-Length: 6 Transfer-Encoding: chunked 0 X
Proxy widzi sześć bajtów treści i przepuszcza całość. Backend kończy żądanie na „0” i zostawia „X” jako początek kolejnego żądania w kolejce. To „X” doklei się z przodu do żądania następnej osoby, która trafi na ten serwer.
Najpierw potwierdziliśmy, że da się zmusić serwer, żeby odesłał nasze żądanie we własnej odpowiedzi. Na logowaniu do panelu wartość pola z adresem e-mail i parametr przekierowania wracały w treści odpowiedzi. To znaczyło, że przemycony fragment można później odczytać. Potem sięgnęliśmy po endpoint zapisujący dane profilu użytkownika, przechwyciliśmy żądanie innego konta testowego i zapisaliśmy jego pierwsze 150 bajtów do jednego z pól. Mówiąc wprost: cudze żądanie wpadło nam do pola, które sami mogliśmy potem odczytać.
Konta nie przejęliśmy. Zakres kończył się dokładnie tam, gdzie robi się najciekawiej, a 150 bajtów cudzego żądania to jeszcze nie cudze konto. Ale resztę drogi opisaliśmy krok po kroku. W API był endpoint przyjmujący pole tekstowe z określoną dolną granicą długości i bez górnej. Taki brak limitu zamienia pole w bufor: można w nim zaparkować znacznie dłuższy przemycony fragment, a potem go odczytać, razem z tokenem autoryzacji należącym do innego użytkownika. Stąd do przejęcia konta jest już blisko.
Cały łańcuch w języku MITRE ATT&CK:
Przy okazji rejestracja zdradzała, które adresy e-mail już są w systemie. Nowy adres zwracał 201 i zakładał konto, adres istniejący 400 z informacją, że taki użytkownik już istnieje. Dwie różne odpowiedzi pozwalają wprost enumerować konta: można spokojnie zbudować listę prawdziwych kont i dopiero na nią kierować dalsze ataki, choćby password spraying.
POST /rejestracja HTTP/2
Host: dev.klient.example
Content-Type: application/json
{"email":"nieznany@klient.example"} -> 201 Created (konto założone)
{"email":"istnieje@klient.example"} -> 400 Bad Request (adres już w systemie)Do tego doszła jeszcze jedna rzecz z warstwy sesji: token uwierzytelniający dawał się użyć ponownie po długim okresie bezczynności. Sam w sobie drobiazg, ale w parze z przechwytywaniem cudzych żądań wydłuża okno, w którym przejęty token wciąż działa.
Rezultat
Z całego testu wyszło jedno ryzyko wysokie i sześć średnich, plus dłuższy ogon drobiazgów. Ryzyko wysokie to opisany łańcuch: publiczny serwer deweloperski, tryb debug i desynchronizacja proxy, które razem dają komuś z zewnątrz dostęp do cudzych żądań. Warstwa mobilna, ta, którą klient przejmował się najbardziej, przeszła test czysto.
Rekomendacje zostawiliśmy jako listę czynności do odhaczenia, z konkretnym działaniem przy każdej pozycji:
- Wymusić HTTP/2 na całej ścieżce komunikacji, a tam gdzie trzeba zejść do HTTP/1.1, walidować przepisane żądanie względem specyfikacji i zamykać połączenie TCP przy każdej niejednoznaczności.
- Zdjąć serwer deweloperski z publicznego internetu albo schować go za białą listą adresów IP lub za VPN.
- Wyłączyć tryb debug na wszystkim, co osiągalne spoza sieci wewnętrznej.
- Ustawić serwerowe unieważnianie sesji po czasie bezczynności i po czasie bezwzględnym, tak żeby stary token po prostu przestawał działać.
- Ujednolicić komunikaty rejestracji i logowania, żeby odpowiedź dla adresu istniejącego i nieistniejącego wyglądała identycznie.
- Nałożyć górny limit długości na pola tekstowe przyjmowane przez API, żeby żadne z nich nie mogło posłużyć za bufor.
Ten test dobrze pokazuje pewną pułapkę. Zespół włożył sporo pracy w bezpieczeństwo aplikacji mobilnej i miał rację, robiąc to. Rzecz w tym, że atakujący nie wchodzi tam, gdzie włożyłeś najwięcej pracy. Wchodzi tam, gdzie akurat stoi zapomniany serwer z konfiguracją z zeszłego kwartału. Ile Twoich środowisk deweloperskich jest w tej chwili osiągalnych z publicznego internetu? Jeśli nie znasz tej liczby na pamięć, ustalmy ją razem.
Umów rozmowę z konsultantem, wspólnie omówimy zakres testu aplikacji mobilnych i ich zaplecza (API, proxy, serwery dev) oraz to, jak taki łańcuch mógłby wyglądać u Ciebie.
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.