This is a writeup for codebox, a web challenge from
DiceCTF 2023. It was
written by EhhThing and has the description:
strellic makes csp challs, maybe i should try one sometime
In addition, there are provided links to a hosted Node instance
(https://codebox.mc.ax; not linked because it might be gone by the time
you read this) and an admin bot that will navigate to any link beginning
with that domain.
The server code is provided, and has also been released publically
here
following the end of the contest; the relevant code will be reproduced
here. This is the provided HTML (or at least, the relevant part):
<body>
<div id="content">
<h1>codebox</h1>
<p>Codebox lets you test your own HTML in a sandbox!</p>
<br>
<form action="/" method="GET">
<textarea name="code" id="code"></textarea>
<br><br>
<button>Create</button>
</form>
<br>
<br>
</div>
<div id="flag"></div>
</body>
<script>
const code = new URL(window.location.href).searchParams.get('code');
if (code) {
const frame = document.createElement('iframe');
frame.srcdoc = code;
frame.sandbox = '';
frame.width = '100%';
document.getElementById('content').appendChild(frame);
document.getElementById('code').value = code;
}
const flag = localStorage.getItem('flag') ?? "flag{test_flag}";
document.getElementById('flag').innerHTML = `<h1>${flag}</h1>`;
</script>
And this is the single endpoint exposed by the server, which serves the
above HTML:
The "codebox" is a sandboxed iframe whose contents we can control
entirely via the
code query
parameter, and outside of the server's endpoint, that's really about all
we have to work with.
Scanning through the challenge setup and code, we can identify a few
things that stick out:
The admin bot will only go to the remote server's hostname.
There are some unusually permissive CSP directives specified by
the server.
The CSP header itself is constructed in an odd manner.
The admin bot behaviour is something we can take at face value; we're
(almost) never expected to hack the admin bot in a challenge, and the
HTML inline script suggests that the flag will be in the admin bot's
local storage. Thus, we start by taking a deeper look at the other two
points of interest.
Breaking the CSP header
We'll begin with this one first, because it only requires looking at the
existing code. The
csp array is
used to create a header by joining together its elements with a
"; " string.
There's a way to add directives to the array by using
<img> tags
in our input, which the server then adds
img-src
directives for, ostensibly to allow fetching images. But the HTML parser
only gets the image sources, and doesn't sanitize them! It's sort
of like a SQL injection, where not checking for a delimiter means you
can end something before you're supposed to and add whatever you want.
In other words, if we were to, say, put a semicolon in an image source,
the server will happily add it to the header:
(For the average grass-touching fans, this is simply a convoluted way of
showing that if we include
<img src="*; any-csp-directive-we-want *;" />
in our payload, the server will give us back a CSP header containing the
arbitrary directives.)
Now, this doesn't immediately get us the flag; we can't add additional
headers or hijack the response entirely, because Node will refuse to
send a response if it sees a CR or LF in a header value. This makes
sense, otherwise header injection in general would be trivial.
We're restricted to adding directives in the CSP header, which is
certainly better than nothing, so we'll come back to this when we have a
better idea of what directives to add.
Trying to escape the sandbox
As is usually the case with web challenges, we'll find ourselves reading
through a lot of MDN pages. The first interesting one
here
describes how using
unsafe-inline
is a really bad idea, because it allows any injected inline scripts to
be executed. This happens to be the exact value used by the server for
script-src, so
maybe we can run a script and be basically done??
Haha, nope. (Side note: the above screenshot was taken on Chromium,
because Firefox doesn't even bother telling you that it blocked the
script execution.)
Note that our previous discovery of being able to add anything to the
CSP header doesn't help, because that error comes from the
sandbox attribute of the iframe.
That link includes a worryingly long (in this context) list that details
all the things we cannot do, which includes running scripts. No matter
what CSP directive we can add, the browser will respect the sandbox
attribute first. And the only way to remove the attribute dynamically
would be to use a script...which obviously isn't possible.
We ended up spending quite a bit of time on this, because it seemed like
all we had to do was somehow remove the sandbox attribute from the
iframe, which would allow us to get the flag. The
CSP
sandbox
directive looked promising, and allowing enough things prompts
Chromium to say "An iframe which has both allow-scripts and
allow-same-origin for its sandbox attribute can remove its sandboxing",
but the script execution still refuses to work. That is, we were hoping
that the CSP directive would override the HTML attribute, but that
wasn't happening.
Actually, why take our word for it when you can take a stab at it
yourself?
You might be stuck for a while (or forever) if you try to run a script
in the iframe.
Meanwhile, the insight that got us back on the right track was the
relative simplicity of the challenge's code, and went something like
this:
J: If this challenge was just to break out of the iframe
sandbox, then there wouldn't be all this CSP stuff, right?
M: Right. Also, it's probably impossible to break out of an
iframe sandbox, otherwise there'd be a million Google results on
how to do so.
J: OK, so that means the server code that adds a CSP header
must be relevant. We should start looking through CSP docs to
see if there's anything interesting.
(J and M are entirely made-up names that are definitely not correlated
to the author/s of this writeup.)
One Hundred Years of Reading MDN Docs
There's not much to say here other than that we eventually came across
this,
which looks innocuous at first, but in fact has a big use this for
exfil sign on it. Because this
report-to
directive makes an external request outside of the confines of the
iframe, it'll be an important tool for the final solve.
A proof of concept shows that a) you actually have to use
report-uri,
even though MDN says it's "deprecated", and
report-to
doesn't work, so alright, whatever you say Mozilla, and b) hey it works:
Obviously, we'll need to do something other than block all image sources
and then try to fetch an image, but we do get a JSON sent out to a
webhook containing the following data:
The
script-sample
field is empty, but MDN describes it as such:
The first 40 characters of the inline script, event handler, or
style that caused the violation. Only applicable to
script-src*
and
style-src*
violations, when they contain the
'report-sample'
This is very promising, and we can formulate a plan. If we can find an
appropriate CSP directive to violate, and make the line of code that
sets the contents of the
flag div
violate it, then we might be able to see the flag in the
script-sample
field of the violation report?
Executing the plan (maybe)
Somewhat conveniently, the CSP directive listed immediately after
report-uri is
one called
require-trusted-types-for,
which has
this
description:
The HTTP
Content-Security-Policy
(CSP)
require-trusted-types-for
directive instructs user agents to control the data passed to DOM
XSS sink functions, like
Element.innerHTML
setter.
Wow, the given example is exactly what we want! So we'll use this
directive, the line that sets
innerHTML to
the flag contents will violate it, and we'll get the flag...right? We
end up getting this payload:
...which is not what we want, because the flag is nowhere to be seen. It
turns out setting
frame.srcdoc
is also a violation.
Again, the simplicity of the code saved a lot of time here; that line of
code is only run if
code isn't
empty...or more specifically, if the browser didn't parse it as empty.
That parameter must be parsed as non-empty by the Node server code in
order for the directive injection to work, but what if they disagreed?
That is, what if we sent some non-standard input and the browser and
Node handled it differently? Lots of previous web challenge experience
also helps here - it helps you realize that trying query params with the
same key, with square brackets, with very long strings, etc. is always a
good idea.
It turns out that the manual fuzzing of sorts was a huge success -
adding a second query param with key
code makes
the browser accept the first one that was given, while the fastify
module takes the second one. Thus, all we have to do is begin the query
string with
code=&, and...
which comprises of injected
report-uri and
require-trusted-types-for
CSP directives, and a dummy
code param to
bypass a code block. We get the following data sent to our webhook:
and we're done. Note that it's very important that the flag text not be
much longer, otherwise it'd get cut off.
We found this challenge enjoyable, because it was quite challenging
despite not having a large codebase, and everything that we identified
as potentially relevant ended up being important to the solve. Also, it
demonstrates an important concept: just because you've created something
secure for your own use (e.g. setting the text of a sandboxed iframe),
you're not safe from other people misusing your work (e.g. allowing a
header to exfiltrate the contents of the text). 🧅