Konferencja Mega Sekurak Hacking Party w Krakowie – 26-27 października!
Mikołajki z sekurakiem! od 2 do 8 grudnia!
Konferencja Mega Sekurak Hacking Party w Krakowie – 26-27 października!
Mikołajki z sekurakiem! od 2 do 8 grudnia!
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.
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”:
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.
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
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).
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.
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…”