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>oronerror=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
/pastepage that reflects user input - There is a
/reportendpoint 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
This is the whole challenge in one screenshot.
Fatal assumption:
bodyis escapedtitleis 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:
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:
or
inside title.
That proves HTML injection, but it does not solve the challenge, because the page has CSP:
This is the key reason beginner payloads fail:
- inline event handlers are blocked
- raw
<script>tags are blocked - cross-origin
fetch()is blocked byconnect-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:
This is everything.
The page literally:
- reads
cbfrom the URL - builds JavaScript text from it
- maybe prepends attacker-influenced HTML-derived text
- appends a new
<script>element - 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
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
And the bot enforces its own copy too:
This is why the final working live payload had to use:
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:
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:
So for the special branch to happen:
#content's last element child must be an element withid="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
#quickpasteas the last child
Step 4: The HTML-derived prefix must become a JS comment
The gadget does this:
So if I can make raw start with:
then all the garbage after it becomes a JavaScript comment.
Then I start cb with:
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:
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:
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
cbgadget 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:
not:
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:
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:
in the query string.
The important design choice was:
- use
location=... - do not use
fetch()
Because the CSP includes:
So cross-origin fetch() is blocked.
But full-page navigation is still allowed.
Payload structure
The final reported URL had this shape:
Where:
titlecreated the gadget-friendly DOMcbclosed the comment and redirected to my collector
In simplified form:
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
flagcookie for the challenge origin - The bot opened my crafted paste page
- My malformed
titlereshaped 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:
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
HttpOnlycookies at minimum - Better yet, do not let review bots browse attacker-controlled same-origin pages with privileged state
- Avoid internal-only host assumptions like
webif the public app exposes the same workflow differently
Minimal correct fix
Escape title exactly like body:
And delete the gadget completely:
And if the bot must keep a flag-like secret in the browser, at minimum:
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.