0224: Love Letter
Writeup for the Intigriti February 2024 challenge π₯
Last updated
Writeup for the Intigriti February 2024 challenge π₯
Last updated
Read the admin's love letter and win exclusive Valentine's goodies from Intigriti!
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 β
/readTestLetter/136f2739-a918-402b-a9f5-32de39b6db85
Which renders DOMPurified HTML β
<img src="x">
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()
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
These operations are the extended version of happens during the vulnerable line Buffer.from(letter.letterValue, 'base64').toString('ascii');
when given an input of ΔΆ
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.
So:
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:
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:
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:
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.
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.
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.
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=/;"
We grab our JWT token from the developer tools in the browser
We create a javascript payload which sets the JWT token to Path getLetterData
and one to storeLetter
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):
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.
We give it to the admin and observe that he correctly didnβt see the letter set and correctly set the letter for us
We check our own letters and find that the letter is applied to our account which we can then read with our own password
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
.
23
9
20