Wgraliśmy paczkę. Dostaliśmy roota.

Klient dał nam jedno zdanie zakresu: sprawdźcie aplikację webową dostępną z internetu. Poczta miała DMARC ustawiony na odrzucanie, ruch szedł po TLS 1.3, logowanie blokowało się po serii błędnych prób, a w logice aplikacji nie znaleźliśmy ani jednej eskalacji uprawnień.
A mimo to jeden przycisk „wgraj paczkę” oddał nam cały serwer, z prawami roota, bez ani jednej zaawansowanej sztuczki. I konto potrzebne do tego nie musiało być kontem administratora.
Poniżej pokazujemy, jak zwykła funkcja wgrywania paczki z plikami zamieniła się w zdalne wykonanie kodu, z mapowaniem na MITRE ATT&CK.
Kontekst
Trafiliśmy na aplikację, która robiła sporo rzeczy dobrze. Cała komunikacja szła po HTTPS, z HSTS i wymuszonym przekierowaniem z HTTP. Certyfikat i szyfr były aktualne. Hasła miały rygorystyczną politykę, a konto zamykało się po kilku nietrafionych logowaniach, więc brute force odpadał od razu. W ciasteczku sesji siedział prefiks __Host-, w żądaniach leciały tokeny anty-CSRF. Nie znaleźliśmy SQL injection, nie znaleźliśmy IDOR-a, nie udało się przeskoczyć autoryzacji poziomej ani pionowej w samej logice aplikacji.
Nie trafiliśmy na dziurawy płot, tylko na mur, który ktoś stawiał z głową. A im równiej ułożone cegły, tym bardziej kusi, żeby znaleźć tę jedną, która się rusza.
Jedna z funkcji aplikacji pozwala wgrywać paczki z plikami, zwykłe archiwa ZIP: manifest i zestaw zasobów, w tym treści webowe (HTML, JavaScript, CSS). Nad tym, co jest w środku archiwum, kontrolę ma osoba, która paczkę importuje. I to jest cały sekret tej historii.
Wyzwanie
Haczyk w takim systemie jest podstępny, bo funkcja wgrywania paczek nie jest błędem. To celowa funkcja, nie usterka. Nie da się jej „wyłączyć”, tak jak nie wyłącza się skrzynki na listy w firmie, która żyje z korespondencji. Trudność nie polega na tym, że użytkownik może wgrać plik. Polega na tym, gdzie ten plik ląduje i za co bierze go serwer.
Zawartość takiej paczki to treść niezaufana. Powinna leżeć obok aplikacji jak zdjęcie w albumie, jako statyczny zasób, który przeglądarka co najwyżej wyświetli. W tej aplikacji lądowała w katalogu aktywnej aplikacji, w tym samym miejscu, z którego serwer oparty na Apache Tomcat kompiluje i wykonuje pliki JSP na żądanie. Paczka miała więc być obrazkiem w albumie, a stała się programem, który serwer chętnie uruchomił.
Do tego doszła druga rzecz, którą łatwo przeoczyć. Import wymagał uprawnień do zarządzania treścią, a nie uprawnień administratora. Brzmi to niegroźnie. W praktyce oznacza, że osoba, której zadaniem jest wrzucanie plików z treścią, dostawała drogę do wykonywania poleceń systemu operacyjnego. Odległość między „mogę wgrać paczkę” a „jestem rootem” powinna być nieskończona. Tu wynosiła jeden upload.
Co zrobiliśmy
Pracowaliśmy w modelu grey box, z przykładowymi kontami i podstawową wiedzą o infrastrukturze, zgodnie z metodyką OWASP. Najpierw rozpoznanie: zmapowaliśmy stack (aplikacja w Javie na serwerze opartym na Tomcacie, baza Postgres) i wszystkie miejsca, w których użytkownik wprowadza dane. Potem zajęliśmy się funkcją importu paczek.
Zbudowaliśmy prawidłową paczkę. Nic egzotycznego, poprawny manifest i dwa dodatkowe pliki:
paczka.zip ├── manifest.xml ├── index.html └── proof.jsp
Mechanizm importu nie zablokował pliku JSP w archiwum. Paczka rozpakowała się w całości, a wszystkie pliki stały się dostępne jako zasoby paczki. Samo wgranie niczego jeszcze nie uruchomiło. Do wykonania doszło, gdy otworzyliśmy zawartość wgranej paczki i serwer sięgnął po plik JSP. Zamiast oddać go jako tekst, przepuścił go przez silnik JSP i wykonał po swojej stronie.
Wynik polecenia id zamknął temat:
$ id uid=0(root) gid=0(root) grupy=0(root)
Plik JSP nie został podany jako statyczny zasób paczki. Został zinterpretowany i uruchomiony przez serwer aplikacyjny, z prawami roota. Dla pewności wystawiliśmy jeszcze połączenie wychodzące do kontrolowanego przez nas hosta i serwer bez oporu się do nas odezwał. Mieliśmy wykonywanie poleceń i ruch wychodzący z maszyny. Ponieważ proces serwera działał jako root, a środowisko nie było odpowiednio zabezpieczone, nie trzeba było kombinować z żadną eskalacją. Prosty reverse shell wystarczył, żeby przejąć serwer z dostępem do danych i do kodu.
Przy okazji sprawdziliśmy te same punkty wejścia od drugiej strony. Zamiast pliku JSP wrzuciliśmy do index.html własny JavaScript. Treść paczki uruchamiała się w tym samym origin co aplikacja, bez żadnej izolacji. Nasz skrypt czytał localStorage aplikacji, sięgał do dokumentu nadrzędnego i mógł wysyłać uwierzytelnione żądania w kontekście sesji ofiary. Jeśli taką paczkę otworzyłby administrator, skrypt mógł jego rękami założyć konto z podwyższonymi uprawnieniami. Jedna paczka, dwa różne zastosowania, w zależności od tego, czego akurat potrzebował atakujący.
Cały łańcuch, w języku, którym mówi blue team:
Poza główną historią zostawiliśmy klientowi kilka mniejszych rzeczy do posprzątania. Logowanie, które prosiło o wybrane znaki poświadczenia zamiast całości, dało się obejść, usuwając z żądania parametry mówiące, które pozycje są sprawdzane. Trzy parametry zapytania odbijały nasze dane do odpowiedzi bez kodowania, co dawało reflected XSS. Front ciągnął przestarzałe wersje bibliotek (jQuery, jQuery UI, TinyMCE) ze znanymi podatnościami. I jeszcze jeden niuans do przemyślenia: skoro konto blokuje się po serii błędnych logowań, to celowe zablokowanie kont administracyjnych potrafi położyć serwis. Twarda blokada bywa mieczem obosiecznym.
Rezultat
Krytyczna podatność, zdalne wykonanie kodu, pełny dostęp do serwera aplikacyjnego z prawami roota, a wraz z nim do danych oraz kodu. Cała solidna obrona wokół, poczta, TLS, hasła, sesje, przestała mieć znaczenie w momencie, w którym jeden upload dał wykonanie kodu na maszynie.
Rekomendacje zostawiliśmy w formie, którą da się odhaczyć, bo mają być czynnościami, nie życzeniami:
- Trzymać wgrywaną treść poza katalogiem aplikacji i poza środowiskiem wykonawczym serwera, wyłącznie jako statyczne zasoby serwowane z osobnego originu.
- Blokować pliki wykonywalne, w szczególności JSP, ale traktować to jako dodatkową warstwę, nie jako główne zabezpieczenie.
- Rozpakowywać archiwum najpierw do losowego katalogu tymczasowego poza katalogiem głównym serwisu i walidować każdy wpis, niezależnie od tego, czy figuruje w manifeście.
- Weryfikować rozszerzenia bez oglądania się na wielkość liter, z uwzględnieniem podwójnych rozszerzeń i różnic w normalizacji ścieżek.
- Uruchamiać wgraną treść w ograniczonym iframe z sandboxem, a komunikację z aplikacją prowadzić przez kontrolowany
postMessagez walidacją originu. - Ustawić politykę CSP jako kolejną warstwę ochrony, bez
unsafe-inline. - Odebrać procesowi serwera prawa roota. Uprawnienie do zarządzania treścią nie ma prawa kończyć się wykonaniem poleceń z uprawnieniami serwera.
- Zakodować dane odbijane w trzech parametrach i zaktualizować przestarzałe biblioteki front-endu.
Jeśli prowadzisz platformę, która pozwala użytkownikom wgrywać „treść”, dokument, paczkę, szablon, cokolwiek, warto zadać sobie dwa pytania. Gdzie ta treść naprawdę ląduje, i za co bierze ją Twój serwer, gdy ktoś ją otworzy? Chętnie to u Ciebie sprawdzimy.
Umów rozmowę z konsultantem, wspólnie omówimy zakres testu aplikacji webowej, ryzyka i to, jak taki wektor 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.