Another Another CSP by @terjanq
This year I created a copycat challenge of another-csp from DiceCTF Quals 2024. It was only solved by 1 team, DiceGang. Although the challenge looked almost identical, the solutions should be strictly different.
The intended solution of the original challenge was to leak one bit of information per admin visit based on crashing the browser renderer process with malicious CSS. (The below snippet was crashing the browser, but currently it's fixed)
<style>h1[data-token^="${guess}"] {
--c1: color-mix(in srgb, blue 50%, red);
--c2: srgb(from var(--c1) r g b);
background-color: var(--c2);
}</style>
You can read about the original solution in this great writeup from Huli.
The core of my challenge (Another Another CSP) can be visualized by the following snippet.
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'"
/>
<script>
function randomToken() {
const token = crypto
.getRandomValues(new Uint8Array(20))
.reduce((a, b) => BigInt(256) * a + BigInt(b), BigInt(0))
.toString(36)
.padStart(31, "0");
localStorage.setItem("token", token);
return token;
}
console.log(randomToken());
function insertIframe(code) {
iframeContent.textContent = "";
const token = randomToken();
const iframe = document.createElement("iframe");
iframe.srcdoc = `<h1 data-token="${token}">${token}</h1>${code}`;
iframe.sandbox = "";
iframe.csp = "script-src 'none'";
iframeContent.appendChild(iframe);
}
window.onmessage = (e) => {
if (e.data.get_flag) {
const token = localStorage.getItem("token");
if (e.data.token === token) {
const flag = localStorage.getItem("flag") || "justCTF{example_flag}";
e.source.postMessage({ flag }, "*");
}
} else if (e.data.code) {
insertIframe(e.data.code);
}
};
document.getElementById("form").onsubmit = (e) => {
e.preventDefault();
insertIframe(document.getElementById("code").value);
};
</script>
The differences towards the original challenge are:
- Token is much longer and it refreshes with every render.
- Admin visits a user-controlled URL instead of a reflected & length-limited
code
parameter. - A player can make multiple attempts at "guessing" the token instead of having only one chance.
The rest of the challenge is the same.
The CSP inside the sandboxed iframe is super strict - it only allows for inline styles. The challenge's goal was to use conditional styles to leak all the characters from the token. Because recursive styles would not be possible, and the token refreshes with every render, all the leaking had to be done in a single injection point.
The leaking technique goes back to 2019 when I presented a "single injection point CSS" challenge for the first time
(Ugliest Website). The idea is to
leak all letter pairs and then recover a secret from it (e.g. from DO OM MI IN NO
puzzles you can recover the DOMINO
word by
overlapping matching pairs). In CSS language leaking all 2-letter pairs translates to the following snippet.
[data-token*="aa"]{
<leak>
}
[data-token*="ab"]{
<leak>
}
<...>
[data-token*="99"]{
<leak>
}
Because of the very strict CSP, it wasn't possible to leak it via background-image
or other similar techniques.
Instead, it's possible to leak it via XS-Leaks! I used <object name="$PAIR">
elements to make them conditionally
visible, then I was able to see their presence through window reference: window_reference[0][$PAIR]
. Objects
are only parsed when they're visible, but they'll only create a window context (which is detectable cross-origin) if they
point to a document. Because of the CSP, players couldn't point them to an external website, but because about:blank
is not covered
by the strict CSP, players could use it as the object source.
<object name="w_$PAIR" data="about:blank" style="display: none"></object>
By combining it with a CSS selector it would only create a named window context if the selector matches the token.
[data-token*="$PAIR"] ~ [name="w_$PAIR"]{
display: block!important;
}
Steps to solve the challenge:
- Open a window to the challenge page - https://another-another-csp.terjanq.me/
- Send the exploit through postMessage that checks for all the 36*36 letter pairs.
- After some timeout, try to access
window_reference[0][PAIR]
for every pair and store all the accessible pairs. - From the pairs, recover all possible tokens (there could be hundreds of possibilities).
- Through postMessage send every potential token, on match the page responds with a flag.
You can see the exploit in action: https://terjanq.me/solutions/justctf24-aacsp-92351235.html