Obejście uwierzytelniania – historia podatności znalezionej w programie Bug Bounty (Node.js)

03 grudnia 2018, 20:40 | Teksty | komentarze 3
: zin o bezpieczeństwie - pobierz w pdf/epub/mobi.

Poniższy wpis opisuje jak udało mi się obejść mechanizm uwierzytelniania w aplikacji opartej o NodeJS w jednym z prywatnych programów bug bounty. Zaprezentuję także metodologię stosowaną w przypadku podobnych sytuacji, która bardzo często pozwala na znalezienie luki (bądź luk) w aplikacjach z pozoru wyglądających na takie, które nie zawierają żadnej funkcjonalności do “złamania”. Przykładem może być strona gdzie jedynym widocznym i dostępnym do testowania elementem jest formularz logowania.

Metodologia

Jeśli kiedykolwiek brałeś/brałaś udział w programie bug bounty z tzw. “otwartym” zakresem hostów dostępnych do testowania (przykładem mogą tu być programy GM, Sony, Oath czy Twitter) to wiesz, że pierwszą rzeczą, jaką wykonuje się w takim przypadku jest uruchomienie narzędzia do enumeracji subdomen. Pozwala to na utworzenie listy potencjalnych celów, która może zawierać kilkaset, a czasami nawet  kilka tysięcy hostów. Jeśli specjalizujesz się w aplikacjach internetowych, jednym z narzędzi w Twoim arsenale może być np. Aquatone. Wynik jego działania to obszerny raport w formacie HTML, zawierający listę adresów, na których uruchomione są serwery www (porty 80, 443, 8080, 8443 itp.). Dodatkowo raport zawiera szczegółowe informacje o wysyłanych żądaniach wraz z odpowiedziami, a także zrzuty ekranu prezentujące treść serwowaną przez web serwer.

Bliższa analiza tych wyników z reguły sprowadza się do dużej liczby tzw. false-positives – standardowych odpowiedzi w rodzaju 404 Not Found, 401 Unauthorized, 500 Internal Server Error. Można się natknąć także na standardowe panele logowania urządzeń sieciowych, VPN czy domyślne strony instalacji serwerów www. Szansa na odkrycie w pełni funkcjonalnej, działającej aplikacji webowej, przeciwko której można wytoczyć “najcięższe działa” w poszukiwaniu błędów SQL Injection bądź Remote Code Execution jest bardzo znikoma.

Czasami jednak może dopisać nam szczęście i znajdziemy aplikację, która da nam szansę na znalezienie błędu i zgłoszenie go do programu. Może to być np. firmowy intranet stworzony przez programistów na potrzeby firmy z użyciem jednej z popularnych technologii (LAMP, Ruby on Rails czy NodeJS) czy napisany na zamówienie system do zarządzania zasobami ludzkimi (HR). Z reguły jedynym widocznym elementem, który możemy poddać testom jest formularz logowania.

Gdy uda mi się natknąć na podobną aplikację, z reguły podejmuję kilka działań, które pozwalają na zorientowanie się czy gra jest “warta świeczki”:

  1. W pierwszym kroku przeprowadzam wstępną analizę dostępnego kodu HTML. Szczególną uwagę zwracam na linki do dokumentów CSS oraz zewnętrznych skryptów JavaScript. Dzięki temu można odkryć foldery jak public/, assets/, scripts/, w których programista zapisał pliki używane przez aplikację – często ich sprawdzenie ujawnia tzw. directory listing oraz pliki, które nie są bezpośrednio podlinkowane z poziomu dokumentu HTML
  2. Wappalyzer (popularna wtyczka dostępna do większości przeglądarek) dokonuje analizy technologii używanych przez aplikację i zwraca informacje o zastosowanych rozwiązaniach zarówno po stronie serwerowej, jak i w aplikacji klienckiej (serwer www, języki programowania, biblioteki itp.). Dzięki tym informacjom wiemy jakich podatności możemy się spodziewać. Pozwala to zaoszczędzić mnóstwo czasu straconego na próbach exploitowania podatności specyficznych dla np. JavaEE, gdy wiemy, że backend zbudowany jest przy użyciu Ruby on Rails
  3. Jeśli aplikacja używa zewnętrznych plików JavaScript dokonuję ich statycznej analizy pod kątem adresów endpointów API czy zakomentowanych fragmentów kodu, w których często odkryć można działające funkcjonalności niedostępne z poziomu samej aplikacji. Często kod JavaScript zawiera też logikę walidacji danych wejściowych, co w niektórych przypadkach umożliwia całkowite obejście takiej walidacji lub znalezienie optymalnego sposobu na skonstruowanie payloadu.
  4. Dysponując informacjami zgromadzonymi w poprzednich krokach, przystępuję do testowania aplikacji. Używam w tym celu pakietu Burp Suite, w którym przechwytuję wszelką komunikację sieciową wymienianą pomiędzy przeglądarką a serwerem www i testuję każde żądanie. Testowanie polega na wysyłaniu zmodyfikowanych żądań z użyciem narzędzia pakietu Burp Suite o nazwie Repeater. Za każdym razem zmieniam jeden z parametrów żądania: metodę HTTP np. GET, POST, PUT czy DELETE), nagłówki żądania takie jak Content-Type, Host, User-Agent oraz zawartość (body). Używam wtedy rozmaitych payloadów – obiektów JSON, dokumentów XML itd. Za każdym razem staram się zidentyfikować jakiekolwiek zachowanie serwera, które odbiega od normy (poprzez porównanie z tzw. żądaniem wzorcowym, czyli wysłanym bezpośredni z aplikacji bez żadnych dodatkowych modyfikacji). Jeśli aplikacja zawiera jakąkolwiek podatność to jest to moment, w którym występuje największa szansa na jej znalezienie
  5. Jako tzw. “ostatni krok” uruchamiam skaner zasobów (tzw. enumeracja folderów i plików) starając się odkryć pliki i katalogi na serwerze www, które nie są dostępne z poziomu aplikacji. Używam w tym celu narzędzia o nazwie wfuzz oraz własnoręcznie zbudowanego słownika zawierającego najczęściej występujące zasoby, takie jak domyślne foldery popularnych aplikacji webowych, foldery systemów kontroli wersji, środowisk deweloperskich, najczęściej występujące nazwy bądź aliasy plików zawierających kopie zapasowe, czy “tymczasowe” pliki. Lista ta obecnie zawiera około 175 tysięcy wpisów i w zasadzie zawsze udaje mi się odkryć coś ciekawego pozostawionego na serwerze.

Jeśli żaden z powyższych kroków nie przyniesie oczekiwanych rezultatów, z czystym sumieniem można założyć, że odkrycie jakiejkolwiek podatności będzie bardzo trudne, o ile nie niemożliwe.Należy zadać sobie pytanie, jak dużo czasu możemy jeszcze poświęcić na szukanie błędów.

Tym razem jednak szczęście dopisało i parę wstępnych testów aplikacji, na którą się natknąłem dało nadzieję na znalezienie czegoś ciekawego, a może nawet na pomyślne “włamanie”. Był to prosty formularz logowania do czegoś, co wyglądało na customowy serwis stworzony od podstaw na potrzeby firmy.

Szybki rzut oka na nagłówki odpowiedzi HTTP oraz wyniki z Wappalyzera potwierdził, że mam do czynienia z aplikacją napisaną w JavaScript z użyciem frameworka ExpressJS, uruchomioną na NodeJS. Biorąc pod uwagę moje doświadczenie z JavaScript oraz aplikacjami zbudowanymi w oparciu o NodeJS zdecydowałem, że przyjrzę się tej aplikacji bliżej.

Odkrycie podatności

Testowanie rozpocząłem od wysłania standardowego żądania z wymyśloną nazwą użytkownika oraz hasłem (bez żadnych dodatkowych modyfikacji) tak, by otrzymać  zwracany przez aplikację standardowy komunikat o błędnym logowaniu:

W dalszej części artykułu pominę nagłówki żądań HTTP oraz odpowiedzi serwera dla zachowania czytelności. W tym przypadku i tak nie miały one żadnego wpływu na rezultaty testów.

Odpowiedź na wysłane żądanie nie zawierała niczego ekscytującego, poza jednym drobnym szczegółem, na który w pierwszym momencie w ogóle nie zwróciłem uwagi:

Wspomnianym szczegółem była nazwa użytkownika zwrócona w nawiasach kwadratowych. Nawiasy kwadratowe ([]) w języku JavaScript to element składni oznaczający tablicę, więc to, co znajdowało się w odpowiedzi serwera wyglądało jak przesłany login będący pierwszym elementem tablicy. Aby potwierdzić moje przypuszczenia, w kolejnym żądaniu jako nazwę użytkownika wysłałem pustą tablicę:

Odpowiedź była co najmniej podejrzana:

Pusta tablica? Czy po prostu ciąg “[]” użyty jako nazwa użytkownika? Postanowiłem sprawdzić, co stanie się, gdy użyję pustego obiektu zamiast tablicy:

Odpowiedź jedynie utwierdziła mnie w przekonaniu, że cokolwiek zostanie wysłane w polu username, zostanie użyte jako login w aplikacji:

Oto krótkie wyjaśnienie powyższego komunikatu. Obiekt w JavaScript (“{}”) nie posiada metody .replace(), więc wywołanie val.replace() spowoduje błąd składniowy. Można to bardzo łatwo sprawdzić np. przy pomocy konsoli w Chrome DevTools:

Eksploitacja

Odkrycie podatności a możliwość jej wykorzystania do “złamania” aplikacji to dwie różne rzeczy. Po potwierdzeniu, że błąd istnieje, zacząłem zastanawiać się, jak może wyglądać kod stojący za weryfikacją poprawności nazwy użytkownika i hasła. Na początek postanowiłem użyć jakiejś niestandardowej składni i zmusić aplikację do zwrócenia innych błędów. Zacząłem od zagnieżdżonych, pustych tablic:

Okazało się to strzałem w przysłowiową dziesiątkę:

Pierwszą rzeczą, która przychodzi na myśl na widok takiego komunikatu to próba ataku SQL Injection. W pierwszej kolejności musiałem zbadać jak wygląda zapytanie SQL, by móc przygotować odpowiedni payload. Pamiętając, że nazwa użytkownika jest używana jako element tablicy, próbowałem odczytać jej zerowy element:

Tym razem komunikat błędu był zupełnie inny:

Aplikacja wykonała żądanie HTTP do serwera znajdującego się prawdopodobnie w wewnętrznej sieci, udostępniającego API na porcie 21110. W rezultacie tego zapytania zwrócony został komunikat informujący o błędzie uwierzytelniania. Zerowy element tablicy okazał się wskazywać na użytkownika o nazwie “super.adm”. Payload zadziałał i byłem w stanie dokonać enumeracji użytkowników w bazie danych (oprócz “super.adm” znalazłem jeszcze dwóch użytkowników, o indeksach 1 i 2). Dodatkowo, dzięki kilku kolejnym przeprowadzonym testom odkryłem, że indeksy są używane w klauzuli IN() zapytania SQL, które zwracało odpowiadającą nazwę użytkownika za każdym razem, gdy na liście znalazł się jeden z indeksów 0,1 lub 2:

Do “obejścia” pozostała zatem walidacja hasła. Zacząłem od najprostszej rzeczy, która przyszła mi do głowy: jako wartość “password” użyłem JavaScriptowej logicznej wartości false (Boolean false):

Tym razem serwer odpowiedział komunikatem błędu o braku danych do uwierzytelnienia:

Tego błędu nie widziałem wcześniej, ale po wykonaniu paru dodatkowych testów upewniłem się, że jest on zwracany zawsze gdy w żądaniu brakuje jednej z dwóch wymaganych wartości: loginu lub hasła. Ponieważ login w żądaniu był obecny, komunikat oznaczał, że aplikacja nie otrzymała hasła. Kilka prób z innymi wartościami, które w JavaScript zawsze są typu false (0, null czy pusty string – tzw. “falsy values”) potwierdziły moje podejrzenia, że hasło jest używane bezpośrednio w warunku sprawdzającym dane logowania bez żadnej dodatkowej walidacji, a ciąg false jest traktowany jako brak hasła (a nie jako błędne hasło).

Finalny “Proof of Concept”

Skoro false zwraca błąd, co stanie się, gdy użyjemy true?

W tym momencie aplikacja pomyślnie “uwierzytelniła” mnie jako użytkownika posiadającego istniejący login oraz prawidłowe hasło. Zerowy element tablicy (pierwszy rekord listy użytkowników pobranej z bazy danych) wraz z logicznym true podanym jako hasło wystarczył do ominięcia uwierzytelniania.

Podsumowanie

Niestety, nie udało mi się uzyskać dostępu do aplikacji z powodu dodatkowego elementu, jakim był PIN wymagany w następnym kroku (aplikacja posiadała implementację działającą na zasadzie 2FA -PIN był drugim krokiem w procesie logowania). Niemniej Program uznał, że obejście uwierzytelniania jakie zaprezentowałem jest jednak warte uwagi.

W ciągu kilku dni błąd został naprawiony, a na moje konto wpłynęło całkiem sympatyczne bounty.

– Rafał „bl4de” Janicki

 

Spodobał Ci się wpis? Podziel się nim ze znajomymi:



Komentarze

  1. oioi

    kozak👍

    Odpowiedz
  2. RFN

    No ladnie. Lubie takie recki. Czytam jak kryminal. Gratki dla kolegi.

    Odpowiedz
  3. Marcin

    Rafał bardzo dziękuję za kapitalny opis, także wskazanie na narzędzie Aquatone.
    Olbrzymią wartością artykułu jest opisanie krok po kroku metodyki użytej do badania podatności, za to ogromny plus.

    P.S.
    Literówka pkt 4 „…wysłanym bezpośredni z aplikacji…”

    Odpowiedz

Odpowiedz