GODSON'S BLOG
← Back to Writeups

Intigriti's Nov XSS Challenge Writeup

This is an XSS challenge on a note-taking application. The goal is to take over the admin's account, which has the flag in it — by abusing Varnish caching to smuggle an XSS past a strict CSP.

💻 Challenge URL: https://challenge-1122.intigriti.io/

This is an XSS challenge. Guess what? This is also a note-taking application like my (shameless plug) previous month’s challenge. The goal of the challenge is to take over the admin’s account, which has the flag in it.


Intro

This challenge is organized in a bit different, weird way.

  • The notes application runs on api.challenge-1122.intigriti.io, and cdn.challenge-1122.intigriti.io is used to store the notes and profile pictures of users.
  • Interestingly, cdn.challenge-1122.intigriti.io uses Varnish to cache static files.
  • Also, one of the JavaScript files contains a reference to the staging.challange-1122.intigriti.io domain. Interestingly enough, this staging domain also runs the same application.
  • JSON Web Token is used for the session. Did that ring a bell? Yes, I found that the production and the staging domain use the same JWT secret to sign the token. This means that we can register godson@0xgodson.com on both the production and staging domains, which are two totally different accounts in different environments. However, we can use the JWT token we obtain on the staging domain to access the godson@0xgodson.com account in the production environment, as the same JWT secret is used to sign the token.
  • So, technically, we can register any username on staging and use the signed JWT on api.challenge-1122.intigriti.io to take over any account. (Note that the admin bot will create new admin accounts every time it visits a submitted URL — username is also unpredictable.)

Notes

Notes are stored in the pattern of cdn.challenge-1122.intigiti.io/<username>-<uuid>.html, where UUID is the note’s unique UUID.

Also, the server sets a CSP header in the HTTP response — Content-Security-Policy: script-src 'none'; object-src 'none'. Very strict CSP, as it blocks any script execution.

CSP header on the notes CDN

Profile Pic Caching

Remember that I mentioned Varnish is used? Varnish is primarily used for caching. After some enumeration, I observed that profile pictures are cached by Varnish. The following screenshot shows that profile pictures are cached and served from cache (X-Cache: HIT) when requested more than once:

X-Cache: HIT on a cached profile picture

But what does Varnish cache? Only image files? After playing around a bit, I observed that Varnish caches files if the extension is something like .png or .jpg, etc.

Caching the Uncached

Remember that the server sets a CSP header to prevent script execution? The CSP header is only set when it returns valid content. When an HTTP request is sent to a non-existing endpoint, the CSP header is not attached to it.

Interestingly, the application does not return a 404 status when a request is sent to a non-existing endpoint — it sets the HTTP status to 200 but includes an error message. It also does not attach the CSP header. The following screenshot shows an HTTP request sent a couple of times so that Varnish can cache it — because it ends in .png, Varnish thinks it’s a static file. The status code is 200, there’s no CSP header, and user input from the URL path is reflected in the response, with the content type set to text/html.

Uncached 404-like response missing the CSP header

Since the URL is reflected in the response, it can be abused for XSS with the help of Varnish. The following request is issued twice to cache a simple alert, and from the response header we can see Varnish has cached it because the request path ends with .png.

A crafted request cached by Varnish

Visiting the URL in the browser gives us a sweet popup 🎉:

The alert popup firing

You could ask why we need to abuse Varnish for XSS when this already looks like a reflected XSS. We need to do it this way because the browser auto-URL-encodes characters like < or >, and the server does not decode them — so even if we inject a payload, the response contains it URL-encoded, exactly as the browser sent it.

But with a proxy tool like Burp Suite, we can send a payload with the encoding done automatically, ensuring the server receives it as-is rather than URL-encoded — so we use Burp Suite to cache the payload and then visit the browser to get the cached response. Varnish decodes it for us, finds the cache key, and returns the cached response (our XSS payload) without forwarding the request to the server.

Final Exploit

I found two possible solutions:

  1. Use the above XSS to register a service worker on cdn.challenge-1122.intigriti.io to cache all requested pages.
  2. Use the XSS to redirect the admin bot to api.challenge-1122.intigriti.io, and when this page loads, notes created by the admin bot load into the page. The service worker can then be used to cache the notes page, which sends us the URL so we can get the post UUID and the admin’s randomly generated username.

With the XSS in cdn.challenge-1122.intigriti.io, we could call window.open("https://api.challenge-1122.intigriti.io"), and when it loads, it loads all posts created by the admin bot by framing cdn.challenge-1122.intigriti.io/<username>-<uuid>.html.

Here, api.challenge is a child and cdn.challenge is the parent window, so it’s possible to read the frame’s src from cdn.challenge on api.challenge since the frame’s origin matches cdn.challenge, as shown here:

Reading the framed note URL from the parent window

After leaking the iframe link (the note URL, which includes the username and the post UUID), we can find the admin’s username and create an account on the staging domain with that username to get a signed JWT for it. That JWT can then be used on api.challange-1122.intigriti.io to take over the admin account in production. The flag wasn’t in the post itself, but in the admin’s profile picture — so after taking over the account, I was able to see it.

🚩 INTIGRITI{workinghardorhardlyworking?}

On This Page