0xf4de security labs

XSS Mania

Built as a learning tool for my students during an Intro to Pentesting class.

Five live XSS labs. Load a payload, run it, then inspect why it worked and how to fix it.

back to blog
Lab 01 innerHTML Reflected

Anything you drop into element.innerHTML gets parsed as HTML. That includes event handlers, script tags, and everything else the browser's HTML parser will happily execute for you.

Payloads

img onerror svg onload onmouseover
vulnerable
// user input goes straight into innerHTML
const name = input.value;
div.innerHTML = 'Hello, ' + name + '!';
// textContent never touches the HTML parser
const p = document.createElement('p');
p.textContent = 'Hello, ' + input.value + '!';
div.appendChild(p);
innerHTML is an HTML parser, not a text setter. Whatever string you assign gets tokenized the same way the browser processes your source file. Tags, attributes, event handlers all of it. textContent skips the parser entirely. The value is treated as a raw string; angle brackets become < on screen, not DOM nodes. If you're setting text, use textContent. The only time innerHTML is acceptable with dynamic content is when that content has already been sanitized through something like DOMPurify.
Lab 02 setAttribute inline event handler DOM XSS

setAttribute('onclick', string) writes JavaScript source code into the attribute. The browser compiles and runs it when the element is clicked. User input in that string means the user writes part of your program.

Payloads

"); alert break-out "); cookie exfil \x22 hex break-out
vulnerable
const msg = input.value;
btn.setAttribute('onclick', 'alert("' + msg + "')");
// msg = `"); evil(); ("`  →  onclick becomes: alert(""); evil(); ("")
// pass the value as data, not source code
const msg = input.value;
btn.addEventListener('click', () => alert(msg));
// msg stays a variable the browser never parses it as JS
Inline event attributes are raw JS source. When you use setAttribute('onclick', s), s is compiled by the JS engine at click time. That's fundamentally different from addEventListener, which takes a function reference the value never gets near the parser. The break-out is straightforward: close the quote that wraps the intended value, inject a statement terminator, then write whatever code you want. Quote escaping alone isn't a fix different contexts allow different escape sequences. The only real fix is never building event handler strings from user input.
Lab 03 javascript: href DOM XSS

The browser treats javascript: as an executable URL scheme. Set a link's href to it and clicking the link runs whatever follows the colon in the context of the current page, with full access to the DOM.

Payloads

javascript:alert cookie read page redress
vulnerable
const url = input.value;
link.href = url; // javascript:alert(1) is a valid href value
// allowlist the scheme don't try to blocklist javascript:
function safeUrl(url) {
  try {
    const { protocol } = new URL(url);
    return ['https:', 'http:'].includes(protocol);
  } catch { return false; }
}

if (safeUrl(input.value)) link.href = input.value;
javascript: is a scheme the browser navigates to by executing code. It runs in the current page context with full DOM and cookie access. It predates most modern security thinking and browsers still support it for legacy reasons. You can't reliably filter it with string matching because of encoding tricks: javascript:, javascript:, whitespace before the colon. An allowlist on the scheme is the only solid approach accept https: and http:, reject everything else.
Lab 04 document.write DOM XSS

document.write dumps a raw HTML string into the document stream. Same problem as innerHTML but it looks like a print statement so it gets missed in reviews. You'll find it most in legacy search result pages that reflect the query back.

Payloads

img onerror script tag break-out svg onload
vulnerable
document.write('Results for: ' + input.value);
const p = document.createElement('p');
p.textContent = 'Results for: ' + input.value;
container.appendChild(p);
document.write writes into the live HTML parser stream. It has no encoding layer, no context awareness. The string you pass is processed exactly as if it appeared in your source file. It's a deprecated API that exists purely for legacy compatibility there's no modern use case that requires it. Anywhere you see it in a codebase, especially near URL parameters or user-controlled data, treat it as a finding.
Lab 05 Stored XSS comment board Stored

Stored XSS persists. The payload gets saved and re-executed for every user who loads the page. This board uses localStorage to simulate it post a payload, reload, watch it fire again without any interaction. In a real app it would hit every visitor until someone deletes the record.

Payloads paste into the comment field

img onerror `)">page defacement hover here`)">hover trigger
vulnerable persists across reloads (localStorage)
// raw stored data rendered with innerHTML
comments.forEach(c => {
  container.innerHTML += `<div><b>${c.name}</b><p>${c.body}</p></div>`;
});
// build DOM nodes nothing gets parsed as HTML
comments.forEach(c => {
  const div  = document.createElement('div');
  const name = document.createElement('b');
  const body = document.createElement('p');
  name.textContent = c.name;
  body.textContent = c.body;
  div.appendChild(name);
  div.appendChild(body);
  container.appendChild(div);
});
Stored XSS trades a single exploit for an ongoing one. Reflected XSS fires once against one person who clicks a link. Stored XSS runs in every browser that loads the compromised page passively, silently, for as long as the record exists. Typical impact: session cookie theft, credential harvesting via injected forms, keylogging, silent redirects to phishing pages. The fix applies at both layers: HTML-encode on write (server-side) and use textContent on read (client-side). Either one alone can still be bypassed. Both together close the surface.