This is a DOM lab, not a payload pack. Target is a single static HTML file I open as http://127.0.0.1:8000/lab.html. Goal: show location.hash reaching innerHTML, then show a <form name=...> clobbering a global the script thought was a config object. Payloads in this notebook are harmless: they write the text PWN into a <div>, or they rename a global. No javascript: URL that phones home, no cookie steal, no keylogger.
1Figure 1. The server never sees the hash. The sink is still XSS.
2location.hash --> innerHTML(#out) [sink]
3<form name=config> --> window.config [clobber]
Lab layout
1labs/dom_lab/
2 lab.html # two bugs: hash sink, clobberable config
3 lab_fixed.html # textContent + id-based lookup
1<!-- lab.html — toy, served from 127.0.0.1 only -->
2<!doctype html>
3<html>
4<body>
5 <div id="out">empty</div>
6 <form id="search">
7 <input name="q">
8 </form>
9 <script>
10 /* BUG 1: hash → innerHTML */
11 var raw = location.hash.slice(1); // source
12 document.getElementById("out").innerHTML = decodeURIComponent(raw); // sink
13
14 /* BUG 2: assume window.config is our object */
15 window.config = window.config || { endpoint: "/api/me" };
16 document.title = "lab:" + window.config.endpoint;
17 </script>
18</body>
19</html>
python3 -m http.server 8000 --bind 127.0.0.1 in that directory. No backend.
Source → sink, measured in DevTools
I open http://127.0.0.1:8000/lab.html#hello. Console:
1location.hash
2// "#hello"
3document.getElementById("out").innerHTML
4// "hello"
Harmless markup in the hash (redacted shape of a real payload — no event handler that runs code):
1http://127.0.0.1:8000/lab.html#%3Cb%3EPWN%3C/b%3E
2# decodeURIComponent → <b>PWN</b>
1# DevTools Elements, #out
2<div id="out"><b>PWN</b></div>
3
4# Console
5document.getElementById("out").innerHTML
6// "<b>PWN</b>"
7document.getElementById("out").innerText
8// "PWN"
That is DOM XSS as a markup injection. I stop at <b>. A real attacker would reach innerHTML with a tag that executes. I am not pasting that tag. The file I attach to the ticket is this screenshot plus the two lines of JS.
If I need a “crash” analog in a browser, I use a huge hash and watch the tab:
1# 200k of 'A' in the hash — lab only, local file
2# Chromium task manager: tab CPU spike, then
3# "Aw, Snap! Error code: Out of Memory" [REDACTED session]
4# Not an exploit. Evidence the sink copies attacker-length data.
No ASAN in JS. The OOM tab is the crash dump.
Clobber: name=config vs window.config
HTML elements with id or name become properties of window / document in the old named-element namespace. If my script does window.config = window.config || {endpoint: ...} after the parser has already created window.config as a <form>, the || short-circuits and I keep the form.
Lab markup I add above the script (still in lab.html for the clobber run):
1<form name="config" id="config">
2 <input name="endpoint" value="[REDACTED-not-a-url]">
3</form>
Reload http://127.0.0.1:8000/lab.html with that form present:
1typeof window.config
2// "object" // actually an HTMLFormElement
3window.config.constructor.name
4// "HTMLFormElement"
5window.config.endpoint
6// <input name="endpoint"> // NOT the string "/api/me"
7String(window.config.endpoint)
8// "[object HTMLInputElement]"
9document.title
10// "lab:[object HTMLInputElement]"
The script never assigned {endpoint: "/api/me"}. The form won. Any later fetch(window.config.endpoint) would coerce the input element to a string that is not /api/me. In older browsers that stringification was surprising (http://... from name=... plus input values). I do not rely on a specific coerce; I file “global was clobbered, typeof check missing”.
What the clobber is not: it is not XSS by itself. It is a trust bug next to XSS. Combined with the innerHTML sink, attacker-controlled HTML can plant the <form name=config> without a server round-trip if that HTML is written into the same document. In this lab I planted it statically so the trace is obvious.
Analysis steps
- List sources:
location.hash,location.search,document.referrer,window.name,postMessage,sessionStorage. - List sinks:
innerHTML,outerHTML,insertAdjacentHTML,document.write,eval,setTimeout(string),script.src,element.hrefon<a>/<script>. - For each assignment, ask: does any source reach any sink without an allow-list /
textContent? - For each
window.FOO || default, grep HTML forname="FOO"andid="FOO". - Confirm in a real browser, not in Node. Named properties are a browser DOM quirk.
1// grep-shaped review comment I leave on the PR
2// BAD: el.innerHTML = decodeURIComponent(location.hash.slice(1))
3// GOOD: el.textContent = decodeURIComponent(location.hash.slice(1))
postMessage and window.name (same lab, extra sources)
I added two more sources to lab.html behind a query flag so the hash sink stays the default.
1window.addEventListener("message", function (ev) {
2 if (ev.origin !== "http://127.0.0.1:8000") return; // origin check is mandatory
3 document.getElementById("out").textContent = String(ev.data).slice(0, 80);
4});
Without the origin check, any tab can postMessage into this page. I do not assign ev.data to innerHTML. The 80-char slice is a length bound, not encoding.
window.name survives cross-origin navigation. A previous site can leave a string there. Reading it into innerHTML is the same sink with a quieter source. Lab check:
1console.log("name_len", String(window.name).length);
2// do not: document.body.innerHTML = window.name
Clobber of document.getElementById itself is the nasty sibling: an element with id="getElementById" can shadow the function on some older trees when looked up via document.getElementById in a sloppy wrapper. I do not rely on that quirk. I use document.querySelector("#out") in the fixed file and I do not put attacker HTML into the same document before the script runs.
Sanitized reproduction
Two URLs, both local:
1# markup injection (harmless <b>)
2http://127.0.0.1:8000/lab.html#%3Cb%3EPWN%3C/b%3E
3# expected: #out contains <b>PWN</b>
4
5# clobber (static form in the file)
6http://127.0.0.1:8000/lab.html
7# expected: document.title === "lab:[object HTMLInputElement]"
Failed “auth” analog — I added a dummy fragment gate that some SPAs use (#token=). Logging it is the bug.
1// do not do this
2console.log("token", location.hash);
3// lab console after #token=eyJhbGciOi[REDACTED]
4// token #token=eyJhbGciOi[REDACTED]
I replace that with console.log("token_len", location.hash.length).
Mitigation
1<!-- lab_fixed.html -->
2<div id="out">empty</div>
3<script>
4 (function () {
5 var out = document.getElementById("out");
6 var raw = location.hash.slice(1);
7 out.textContent = decodeURIComponent(raw); // not innerHTML
8
9 var cfg = Object.create(null);
10 cfg.endpoint = "/api/me";
11 document.title = "lab:" + cfg.endpoint; // not window.config
12 })();
13</script>
CSP on the lab server (header, not a meta tag I let HTML clobber):
1Content-Security-Policy: default-src 'none'; script-src 'self';
2 object-src 'none'; base-uri 'none';
3# innerHTML <b> still injects markup; inline event handlers and <script> do not run
textContent is the fix for the sink. CSP is defense in depth. For clobber: do not read window.* for config; use a module-scoped const; if you must, document.getElementById and instanceof HTMLFormElement checks.
rel=noopener is a different bug (tabnabbing). I do not mix it into this ticket.
document.write is the same sink family as innerHTML with a worse history. I grep it once per app and treat a hit that concatenates location as equivalent to this lab. insertAdjacentHTML("beforeend", raw) is innerHTML with extra steps. The fixed file uses textContent or document.createElement("b") + textContent if I actually need an element.
1// GOOD — element, not HTML string
2var b = document.createElement("b");
3b.textContent = decodeURIComponent(raw);
4out.replaceChildren(b);
That still lets an attacker pick the text, not the tag. Bold PWN in the lab becomes bold attacker text. No new element types.
What I file after this lab
- Source:
location.hash.slice(1)→ sink:innerHTMLon#out - Repro:
#%3Cb%3EPWN%3C/b%3Erenders boldPWN - Clobber:
<form name="config">makeswindow.configanHTMLFormElement; title becomeslab:[object HTMLInputElement] - Not included: executable payload,
javascript:URL, cookie read - Fix:
textContent, module-scoped config, CSPscript-src 'self',base-uri 'none'
Commands appendix
1python3 -m http.server 8000 --bind 127.0.0.1
2# Chrome DevTools → Sources → Event Listener Breakpoints → DOM Mutation
3# Elements → #out after loading the hash URL