0224: Love Letter

Writeup for the Intigriti February 2024 challenge 💥


XSS, Cookie Poisoning

Video Walkthrough

Challenge Description

Read the admin's love letter and win exclusive Valentine's goodies from Intigriti!

Useful Resources


Finding the vulnerable route

There is an XSS in the API endpoint /setTestLetter which is easy enough to find by opening the source code and seeing the obvious debug comments to bring attention to it.

Also, to keep this challenge friendly for people who prefer a black-box approach, there is a wildcard for all endpoints that aren’t defined which will tell the user about this endpoint.

The code will take user input via the msg parameter then DOMPurifys it for safe HTML output, then stores it as base64 in the database in it’s own DebugLetter table and then redirects to the /readTestLetter endpoint with the UUID of the DebugLetter you created in the URL.

This endpoint will read the UUID from the URL, find the Debug letter you just wrote, base64 decode it and then output it into the page.


/setTestLetter?msg=<img src=x onerror=alert(1)>

Redirects to →


Which renders DOMPurified HTML →

<img src="x">

What is vulnerable?

The vulnerability exists in the way we decode the data using Buffer.from(letter.letterValue, 'base64').toString('ascii'); . This line might seem arbitrary and very “CTF-Y” but I found this when it was suggested to me by GitHub Copilot when writing parts of the challenge, but was actually not fit for purpose as I had unicode characters which weren’t handled correctly due to it suggesting .toString('ascii'); instead of .toString('utf-8') or just .toString()

What can we do with this?

Because we specify that the base64 string be turned into a buffer, and then that buffer be turned into a string but confined to ascii, we can sneak some characters past DOMPurify since DOMPurify can see a unicode character.

Here is an example, where one unicode character goes in, but two completely different characters come out

> Buffer.from("Ķ").toString('base64')

> Buffer.from("xLY=", "base64")
<Buffer c4 b6>

> Buffer.from([0xc4, 0xb6]).toString("ascii");

These operations are the extended version of happens during the vulnerable line Buffer.from(letter.letterValue, 'base64').toString('ascii'); when given an input of Ķ

How do we control our new power?

In the previous scenario, we can see Ķ makes buffer [0xc4, 0xb6] .

c4 in Decimal = 196

b6 in Decimal = 182

Ascii only maps 128 characters to decimal values, but these are higher than 128 so instead the table is looped around.


196 - 128 = 68

182 - 128 = 54

And if we check those numbers on an ascii table we see they map to D and 6 which is what we got in our output:

Getting the characters we want and achieving XSS

Now we understand how it works, let’s get what we need for XSS.

This part would be easy if we could just send the raw bytes ourselves to output 3C and 3E for < and >, but remember that the endpoint expects strings, not buffers so we need an actual valid character which actually outputs < or >.

Somebody more clever than me could (and probably will) figure a smart way to do this, but for me I just hacked my way through with trial and error by converting random unicode chars into ascii and pulling out ones which output < and >.

For me, I found these two characters:

to output b8<

to output b#>

This meant I could craft a payload of ⸼img src=x onerror=alert(1) id=⣾ which would be considered safe by DOMPurify, but when decoded would output b8<img src=x onerror=alert(1) id=b#> which is a valid XSS payload.

And it worked as expected:

Progressing towards our goal

This challenge is not simply about achieving XSS though, the challenge clearly states “Read the admin's love letter and win exclusive Valentine's goodies from Intigriti!”.

We have a tool available to us to test our payloads upon the admin:

In the challenge info box, it states what the admin does:

1) The admin makes sure that the domain is for the love letter app or at least a subdomain of it
2) The admin clicks your link!
3) The admin gets suspicious and close the tab, but will check on their love letters to make sure.
4) If the admin can't see their love letter set, they'll simply re-add it and continue with their day!

So we can inflict our XSS upon the admin. The admin doesn’t really do anything like enter their password whilst our XSS is “active” since the tab gets closed straight away.

But we do have sitewide CSRF since we have our XSS so maybe that would help us.

It’s also worth noting that iFraming the page is not allowed due to the X-Frame-Options header being set to DENY.

What about CSRF?

This might be the easiest way to pull information from the API. The XSS does exist on the API, rather than the front end anyways, but the one endpoint which returns the letter data /readLetterData requires a password which we don’t have so even if we forge this request using a user’s session it will always be rejected.

We have some persistence

Despite not being able to forge any interesting request with javascript to actually read the data, we can gain some level of persistence so that certain action can be used. This can be done by using cookies.

Gaining persistence could allow us to affect actions done later down the line since we can’t make any meaningful forged backend requests and we can’t iFrame or do some fancy

There’s only really one cookie of interest, which is the session cookie itself called jwt.

Since it’s httponly, we can’t alter this cookie in any direct way since that is the nature of HttpOnly (Only affected by HTTP, not Javascript).

What if we could set our own session cookie to the victim? This would mean that when they go to set their secret message, this would apply to our account rather than the attacker’s account. But could we do this even though we can’t do anything with the HttpOnly jwt cookie set by the attacker?

But then they’d have two jwt cookies, right? Which one is selected first?

It’s quite common knowledge that the token created earliest will be placed before cookies created after. Since the user is already authenticated, the original cookie would take precedence over the newly set cookie we apply using XSS.

However, the RFC for State Management also specifies the following:

So in short, a cookie with a Path attribute which is more specific will be placed before cookies with a less specific path. Since the original cookie was set to apply to all paths, we can then set a cookie with a more specific path such as /storeLetter so that the admin’s token is used by the server for all requests except when the request to /storeLetter is made at which point the attacker set cookie will take precedence, applying the request outcome to the attacker’s account.

Putting the pieces together

Our initial payload is ⸼img src=x onerror=alert(1) id=⣾ and in the onerror handler we will change it to document.cookie = "jwt=eyJ....; Path=/storeLetter;"

We’re actually missing one small piece which is that the admin will check to see if the letter is already set before setting their letter. We can use the exact same cookie idea to simply pull the data from our account with no letters set so when the backend request is made, the front-end will see that no cookies are set since the data came from the attacker account.

So we’ll add another img element but this time with document.cookie = "jwt=eyJ....; Path=/;"

The final exploit

  1. We grab our JWT token from the developer tools in the browser

  2. We create a javascript payload which sets the JWT token to Path getLetterData and one to storeLetter

    document.cookie = "jwt=eyJ...; Path:/getLetterData;";
    document.cookie = "jwt=eyJ...; Path:/storeLetter;";
  3. We create a test letter with a URL which contains unicode characters to bypass the DOMPurify check since it will be decoded into unexpected ascii characters. We include both JS payloads (Will need to be url encoded):

    ⸼img src=x onerror="document.cookie = 'jwt=eyJ...; Path=/getLetterData' id=⣾
    ⸼img src=x onerror="document.cookie = 'jwt=eyJ...; Path=/storeLetter' id=⣾
  4. We get given a url with a readTestLetter UUID like https://api.challenge-0224.intigriti.io/readTestLetter/c19a1b97-7c6f-41db-833a-e3363bec9cbd which we give to the admin.

  5. We give it to the admin and observe that he correctly didn’t see the letter set and correctly set the letter for us

  6. We check our own letters and find that the letter is applied to our account which we can then read with our own password

Player Submission Analysis

Here's a table that breaks down all the payloads we received. In total, 72% of players found the intended solution which was to exploit cookie path precedence. On the other hand, 28% of players exploited an unintended race condition.

All of the unintended solutions used XSS to unset the 4th letter on the admin account whereas around half of the intended solutions did the same, the rest set manipulated the JWT for two separate paths; /getLetterData and /storeLetter.

Cookie path precedenceRace conditionunsetLetter (POST)




Community Writeups

Last updated