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


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('測試')
// '測試'
// '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 偾
🤔 Why an alert? 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):

  1. The admin opens a browser and creates a new tab.
  2. The admin signs in to their own account.
  3. The admin visits the webpage we provided.
  4. The admin closes the page.
  5. The admin opens a new tab.
  6. The admin calls GET /user to check if the current user is the admin account. If not, the admin will terminate the session.
  7. The admin calls GET /getLetterData and see if the “intigriti community letter” they wrote still exists. If not, he will call POST /storeLetter to create the letter again.
  8. 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 -->
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)

<!-- https://api.challenge-0224.intigriti.io/readTestLetter/b -->
window.open("https://api.challenge-0224.intigriti.io/readTestLetter/a", name="_blank")

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!