June 19, 2026
Still in the DOM: My Fourth XSS Lab on PortSwigger
After the document.write lab, I jumped straight into another DOM-based XSS challenge, this time using innerHTML as the sink. The…
Mahdiyaa
4 min read
After the document.write lab, I jumped straight into another DOM-based XSS challenge, this time using innerHTML as the sink. The vulnerability lives in the same place (client-side JavaScript), but the payload has to be different, since innerHTML doesn't execute <script> tags directly. That's where event handlers come in.
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 an innerHTML assignment that uses location.search as its source.
Vulnerability Overview
This lab simulates a blog with a search feature. When a search is performed, a client-side script reads the query string from location.search and assigns it directly to the innerHTML property of a <span> element used to display the search term. Because innerHTML interprets its value as raw HTML, any HTML or JavaScript payload passed through the search parameter gets parsed and rendered by the browser as actual markup allowing an attacker to inject arbitrary elements and event handlers.
Unlike document.write, innerHTML does not execute injected <script> tags directly. However, other HTML elements with event handlers (such as <img onerror=...> or <svg onload=...>) work just fine, since the browser processes the injected element and fires the event handler when the expected behavior (like loading an image) fails.
Steps to Reproduce
- Navigate to the lab and locate the blog's search functionality.
- Enter a harmless value (e.g.
abc123) into the search box and open DevTools → Elements tab to inspect how the search term is used. A<script>block can be seen near the search results section. Expanding it reveals the vulnerable sink:
function doSearchQuery(query) {
document.getElementById('searchMessage').innerHTML = query;
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
doSearchQuery(query);
}function doSearchQuery(query) {
document.getElementById('searchMessage').innerHTML = query;
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
doSearchQuery(query);
}The query variable is sourced directly from location.search and assigned to innerHTML without any sanitization or encoding.
- Since
innerHTMLdoesn't execute injected<script>tags, use an image element with anonerrorevent handler instead. Enter the following payload into the search box:
<img src=1 onerror=alert(1)><img src=1 onerror=alert(1)>
- Click Search. The script assigns the payload directly to
innerHTMLof the<span id="searchMessage">element. The browser attempts to load the image fromsrc=1, which is an invalid URL, causing it to fail. This triggers theonerrorevent handler, which executesalert(1).
Technical Evidence
Inspecting the DOM after the payload executes confirms the root cause. The <span id="searchMessage"> element, which is normally used to display the search term now contains the injected <img> element:
<span id="searchMessage">
<img src="1" onerror="alert(1)">
</span><span id="searchMessage">
<img src="1" onerror="alert(1)">
</span>The sink code below it confirms why: innerHTML receives the raw, unencoded query string directly, without any transformation:
document.getElementById('searchMessage').innerHTML = query;document.getElementById('searchMessage').innerHTML = query;
Result
The lab is successfully solved as soon as the onerror handler fires and the alert(1) popup appears.
Root Cause
The vulnerability stems from assigning attacker-controlled data (location.search) to an innerHTML property without any output encoding or sanitization. Because innerHTML parses its value as raw HTML, any injected markup including elements with event handlers is treated as valid page content and rendered by the browser. The fix is straightforward but often overlooked: innerHTML should never be used with untrusted input.
Impact
Like the document.write lab, exploiting this vulnerability requires tricking a victim into visiting a URL with a crafted query string. If successful, an attacker could:
- Steal session cookies or authentication 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:
- Replace
innerHTMLwith safer DOM APIs usetextContentto insert plain text, orsetAttribute()for attribute values. Neither interprets input as HTML. - Sanitize input with a trusted library (e.g., DOMPurify) if rich HTML rendering is genuinely required.
- Encode any user-controlled data sourced from
location.search,location.hash, or other browser APIs before using it to modify the DOM. - Implement a strict Content Security Policy (CSP) to block inline event handlers (
unsafe-inline) as a defense-in-depth measure. - Use linting tools that detect dangerous sink patterns (e.g., ESLint's
no-unsanitizedplugin) during development.
Conclusion
This lab was a great reminder that even when one dangerous sink (document.write) is off the table, there are plenty of others that lead to the same result. innerHTML is one of the most commonly misused DOM APIs it's convenient, it's everywhere, and it's almost always the wrong choice when the input comes from the user. The payload here (<img src=1 onerror=alert(1)>) is a classic example of bypassing the <script> restriction using an event handler, and it's something I'll definitely be looking out for in the labs ahead. Four down, more to go.