June 19, 2026
Diving into the DOM: My Third XSS Lab on PortSwigger
After working through reflected and stored XSS, this lab introduced me to a different flavor of the same vulnerability class: DOM-based…
Mahdiyaa
4 min read
After working through reflected and stored XSS, this lab introduced me to a different flavor of the same vulnerability class: DOM-based XSS. Unlike the previous two, this one doesn't live in the server's response at all the vulnerability sits entirely in the client-side JavaScript running in the browser.
Category: Cross-Site Scripting (XSS) — DOM-based
Difficulty: Apprentice
Lab Link: PortSwigger Web Security Academy
Objective
The goal of this lab is to perform a DOM-based cross-site scripting attack that calls the alert() JavaScript function, by exploiting a document.write sink that uses location.search as its source.
Vulnerability Overview
This lab simulates a blog with a search feature that also tracks search queries for analytics purposes. When a search is performed, a client-side script reads the query string from location.search and uses it to dynamically write an <img> tag to the page via document.write(), presumably to log the search term against a tracking pixel. Because this script passes the raw, attacker-controlled value straight into document.write() without any sanitization or encoding, it's possible to break out of the intended HTML attribute context and inject arbitrary HTML/JavaScript that executes in the victim's browser entirely on the client side, without the payload ever needing to be reflected by the server.
Steps to Reproduce
- Navigate to the lab and locate the blog's search functionality.
- First, test the search feature with a harmless value (e.g.
abc123) to observe how it behaves.
- Inspect the page using DevTools to understand how the search term is being used. In the Elements tab, a
<script>block and an<img>tag referencing/resources/images/tracker.gif?searchTerms=abc123can be seen. this is the analytics tracking mechanism that consumes the search term client-side.
- Expand the
<script>block (or locate the corresponding JS source in the Sources tab) to reveal the actual sink. The script takes thesearchquery parameter and writes it directly into an<img>tag usingdocument.write(), without encoding it:
function trackSearch(query) {
document.write('<img src="/resources/images/tracker.gif?searchTerms='+query+'">');
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
trackSearch(query);
}function trackSearch(query) {
document.write('<img src="/resources/images/tracker.gif?searchTerms='+query+'">');
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
trackSearch(query);
}- Since the value is inserted directly inside an HTML attribute (
src="..."), it's possible to break out of that attribute using a payload that closes the<img>tag and injects a new element with an event handler:
"><svg onload=alert(1)>"><svg onload=alert(1)>-
Enter this payload into the search box and click Search.
-
As the page reloads, the script writes the payload unsanitized into the DOM. The browser parses the injected
<svg onload=alert(1)>element, and sinceonloadfires as soon as the element is parsed, thealert(1)popup triggers immediately.
Technical Evidence
The root cause of this vulnerability lies entirely in client-side JavaScript rather than the server's HTTP response. Inspecting the sink code confirms that the search parameter. sourced from location.search is passed directly into document.write() with no output encoding:
function trackSearch(query) {
document.write('<img src="/resources/images/tracker.gif?searchTerms='+query+'">');
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
trackSearch(query);
}function trackSearch(query) {
document.write('<img src="/resources/images/tracker.gif?searchTerms='+query+'">');
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
trackSearch(query);
}Because document.write() parses its argument as raw HTML, any HTML special characters in the source value are interpreted literally rather than escaped, allowing the payload to break out of the intended attribute context and introduce a new, executable HTML element.
Result
The lab is successfully solved as soon as the alert(1) popup fires after submitting the crafted payload.
Root Cause
The vulnerability stems from using document.write() to insert attacker-controlled data (location.search) directly into the DOM without any sanitization or encoding. Because document.write() interprets its input as raw HTML rather than plain text, any unescaped HTML metacharacters in the source data can be used to inject new elements, breaking out of the intended context entirely on the client side with no need for the payload to ever be reflected by the server.
Impact
Because this is a client-side vulnerability, exploitation typically requires tricking a victim into visiting a malicious URL containing the crafted query string (similar to reflected XSS, but the execution path runs purely through JavaScript). If successful, an attacker could:
- Hijack the victim's session by stealing cookies or tokens accessible to JavaScript
- Perform actions on behalf of the victim within the application
- Modify the page content as seen by the victim
- Redirect the victim to a malicious or phishing site
Remediation
To prevent this type of vulnerability, the application should:
- Avoid using
document.write()(or similarly unsafe sinks likeinnerHTML,eval()) with untrusted data. Use safer DOM APIs such astextContentorsetAttribute()that don't interpret input as HTML. - Encode or validate any data sourced from
location.search,location.hash, or other user-controllable browser APIs before using it to construct HTML. - Apply a strict Content Security Policy (CSP) to limit the impact of any successful injection, particularly by disallowing inline event handlers and inline scripts.
- Use static analysis or linting rules that flag dangerous sink usage (e.g., ESLint's
no-unsanitizedplugin) during development.
Conclusion
Working through this lab really drove home how different DOM-based XSS feels compared to reflected and stored XSS. There's no malicious request hitting the server, no payload sitting in a database the entire vulnerability exists in a few lines of client-side JavaScript that nobody thought twice about, simply because document.write felt like a quick way to drop a tracking pixel onto the page. It's a good reminder that XSS doesn't always come from "the backend forgot to encode something" sometimes it's the frontend code itself that opens the door. Three labs in, and I'm starting to appreciate just how many different shapes the same underlying bug can take.