open atlas
↑ Back to track
Offensive Security RED · 02 · 02

XSS in practice

XSS comes in three shapes — reflected, stored, and DOM-based — and all three end the same way: attacker JavaScript running in your origin. This is how each one works and why CSP plus context-correct encoding is the fix, not input filtering.

RED Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

On an authorized engagement, a tester drops a single harmless marker — a tiny script that just pops a dialog — into a support-ticket subject line. Nothing happens on their screen. Two minutes later it fires in the browser of an agent who opened the ticket queue, inside the company’s admin origin, carrying that agent’s session. The tester never touched the agent’s machine. They wrote text into a field; the application stored it, then later rendered it back into a page as live HTML for a different, more privileged user. That marker could just as easily have been code that read the agent’s session token and posted it to an attacker-controlled URL. This is stored XSS, and the thing that makes it dangerous is not the payload — it’s that the application chose to treat data as code. The whole point of studying it is to learn where that choice gets made, so you can stop making it.

By the end of this lesson you’ll know the three shapes XSS takes, what an attacker does once their JavaScript runs in your origin, and why CSP plus context-correct encoding — not input filtering — is the real fix.

Authorization first: this is a defender’s lesson

Everything below assumes a signed engagement with explicit scope — a lab, a CTF, a bug-bounty program with an in-scope target, or your own application. Injecting script into a live application you don’t own, even “just to prove a point,” can compromise real users’ sessions and crosses legal lines regardless of intent. On a real assessment, testers use a benign, non-destructive marker (a dialog box, a callback to a logging server they control) to prove the injection point exists — never code that actually steals data from real users. We are studying the mechanism so a defender can see exactly where their framework hands data to the browser as code, and shut that door. Read this as a blue-teamer learning what the attacker already knows about your render path.

Cross-site scripting (XSS) is a member of OWASP’s Injection category — the same family as SQL injection — and the root cause is identical: untrusted data is interpreted as code by an interpreter. For SQLi the interpreter is the database; for XSS it is the browser. The single sentence that captures every XSS bug: the application took data the attacker controlled and placed it into a page in a context where the browser executes it. Everything else is detail about which page and which context.

The three shapes

XSS is classified by where the malicious data is stored and how it reaches the victim’s browser. The shapes share a payoff but differ sharply in blast radius and in where you fix them.

ShapeWhere the payload livesHow the victim is hitBlast radiusWhere it executes
ReflectedIn the request (URL/query/form), echoed straight backVictim must open an attacker-crafted linkOne victim per delivered linkServer reflects into HTML response
StoredPersisted in your DB (comment, profile, ticket)Victim just views the page — no link neededEvery viewer; can self-propagate (worm)Server renders stored data into HTML
DOM-basedNever leaves the browser; in the URL fragment / client stateVictim opens a link; client JS reads & writes it to the DOMOne victim per link; server may never see itEntirely client-side (a sink like innerHTML)

Reflected XSS is the simplest: the server takes a value from the request — a search term, an error parameter — and writes it straight back into the HTML response without encoding it. The attacker’s payload is not stored anywhere; it lives in a URL they have to deliver, so each victim needs a crafted link (phished, posted, or embedded in an ad). One link, one victim — but trivially mass-delivered.

Stored XSS is the dangerous one. The payload is written into your persistence layer — a comment, a username, a support-ticket body — and then served to everyone who views that page, with no link to click. This is what makes stored XSS the “worm” class: the payload can run in each viewer’s session and re-post itself, which is exactly how the 2005 Samy worm hit a million MySpace profiles in under a day. The privileged-viewer angle is the killer: data written by a low-privilege attacker executes in the browser of an admin who opens the moderation queue.

DOM-based XSS is the one server-side defenses miss entirely. Here the vulnerable code is client-side JavaScript: the page reads attacker-controllable input from a source (commonly location.hash, location.search, document.referrer, or postMessage data) and passes it to a dangerous sink that turns strings into live DOM or code (element.innerHTML, document.write, eval, jQuery’s old $(...) HTML path). The malicious string may sit in the URL fragment after #, which browsers never send to the server — so your server logs, your WAF, and your server-side encoding never even see it. The vulnerability is the unsafe source-to-sink flow in the browser, and it has to be fixed there.

What the attacker actually gets

Once attacker JavaScript runs inside your origin, it runs with all the authority the victim’s browser grants your site. That is the part people under-rate: XSS is not “a popup,” it is arbitrary code execution in the security context of your application. Concretely, the script can read any cookie not marked HttpOnly and any token your SPA keeps in localStorage, and exfiltrate it to an attacker server. It can call your own API as the victim — change their email, transfer funds, post content — because the request carries the victim’s session automatically and originates from your own page, so it sails past same-origin and CSRF defenses. It can keylog the page, rewrite the DOM to phish credentials, or pivot: an admin’s session captured via stored XSS in a support tool is often a straight path to the whole tenant. The session cookie is the prize precisely because stealing it lets the attacker skip authentication entirely.

Why this works

Why doesn’t HttpOnly on the session cookie solve XSS? HttpOnly does one specific thing: it hides the cookie from document.cookie, so the script can’t read and exfiltrate the raw token. That’s worth doing — but the attacker’s JavaScript still runs as the user in your origin, and the browser still attaches that HttpOnly cookie to every request the script makes. So instead of stealing the cookie, the script just uses it: it calls your API directly to perform whatever action it wants, in-session. HttpOnly raises the cost of one exfiltration technique; it does nothing about the code execution itself. That’s why the real fix has to stop the script from running, not just hide the cookie from it.

The fix: encode by context, then CSP as the backstop

The instinct to “sanitize input” is the wrong primary control, and seniors know why: a value isn’t dangerous when it’s stored, it’s dangerous when it’s rendered into a specific context. The same string is harmless inside an HTML text node, breaks out of an HTML attribute, and is catastrophic inside a <script> block or a javascript: URL. So the canonical defense is output encoding chosen for the context you’re inserting into — HTML-entity-encode for element text, attribute-encode (with quoting) for attributes, JavaScript-encode for script contexts, URL-encode for URL parameters. Modern frameworks do most of this for you: React, Angular, and Vue auto-escape interpolated values, which is why XSS in those apps clusters around the escape hatches — dangerouslySetInnerHTML, [innerHTML], v-html — and around DOM sinks the framework doesn’t mediate. For DOM XSS specifically, the fix is to avoid string-to-DOM sinks (textContent instead of innerHTML) and, ideally, enforce Trusted Types so the browser refuses raw strings at dangerous sinks.

Then comes the layer that assumes you’ll miss one: a Content-Security-Policy. A strict CSP that disallows inline scripts and only permits scripts from sources you nominate (via a per-request nonce or hashes) means that even when an injection lands, the browser refuses to execute the attacker’s <script> — the policy didn’t authorize it. CSP is defense in depth, not a substitute for encoding: it doesn’t fix the bug, it contains the blast radius of the bug you didn’t catch. The senior posture is both — encode correctly at every sink so injections never form, and ship a nonce-based CSP so the one you miss still doesn’t execute.

Pick the best fit

A React app renders user-submitted markdown by converting it to HTML and passing the result to `dangerouslySetInnerHTML`. A pentest finds stored XSS through it. Pick the control that actually fixes the root cause.

Quiz

A payload sits in the URL fragment after `#`, and client-side JS reads `location.hash` and assigns it to `element.innerHTML`. Why won't server-side encoding or your WAF catch this?

Quiz

Why is output encoding chosen by context, rather than a single input-sanitization pass, the canonical fix for XSS?

Order the steps

Order the lifecycle of a stored XSS as it reaches a privileged victim:

  1. 1 A low-privilege attacker writes a script-bearing value into a field that gets persisted (e.g. a ticket body)
  2. 2 The application stores the value in the database without distinguishing data from markup
  3. 3 An admin opens the page; the server renders the stored value into HTML without context encoding
  4. 4 The attacker's script executes in the admin's origin and acts with the admin's session
Recall before you leave
  1. 01
    Name the three shapes of XSS and explain how each one reaches the victim differently.
  2. 02
    Why is context-correct output encoding the primary fix, and what does CSP add that encoding doesn't?
Recap

XSS is an injection bug — untrusted data interpreted as code, with the browser as the interpreter — and it comes in three shapes that differ only in how the payload travels. Reflected XSS is echoed back from an attacker-crafted link (one victim per link). Stored XSS is persisted in your database and served to every viewer with no link to click, which is why it can self-propagate as a worm and why a low-privilege attacker’s input can execute in a privileged admin’s session. DOM-based XSS lives entirely in the client, flowing from a source like location.hash to a sink like innerHTML, often in the URL fragment the server never sees — so server-side encoding and the WAF are blind to it. All three converge on the same outcome: attacker JavaScript running in your origin, with full authority to read non-HttpOnly cookies and tokens, exfiltrate them, or simply call your API as the victim in-session. The fix is context-correct output encoding at every sink — not input blocklisting, which is trivially bypassed, and not HttpOnly alone, which only hides the cookie while the code still runs. Layer a nonce-based CSP on top as the backstop that denies execution to the injection you inevitably miss. So when you see user data flowing into a page, your reflex is: which context is this rendered in, is it encoded for that context, and would my CSP stop it if it weren’t?

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.