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

  1. List sources: location.hash, location.search, document.referrer, window.name, postMessage, sessionStorage.
  2. List sinks: innerHTML, outerHTML, insertAdjacentHTML, document.write, eval, setTimeout(string), script.src, element.href on <a> / <script>.
  3. For each assignment, ask: does any source reach any sink without an allow-list / textContent?
  4. For each window.FOO || default, grep HTML for name="FOO" and id="FOO".
  5. 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: innerHTML on #out
  • Repro: #%3Cb%3EPWN%3C/b%3E renders bold PWN
  • Clobber: <form name="config"> makes window.config an HTMLFormElement; title becomes lab:[object HTMLInputElement]
  • Not included: executable payload, javascript: URL, cookie read
  • Fix: textContent, module-scoped config, CSP script-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