Skip to content

DOM-Based XSS — jQuery location.hash + iframe Delivery

Field Value
Platform PortSwigger Web Security Academy
Vulnerability DOM-Based Cross-Site Scripting (XSS)
Difficulty Practitioner
Source location.hash (URL fragment)
Sink jQuery $() selector with untrusted input
Goal Trigger print() on the victim via iframe-delivered hash change

Key Concepts

location.hash — the fragment portion of the current URL, starting with #. In https://site.com/#section1, location.hash returns #section1. Crucially, changing the hash does not trigger a page reload or a new server request — it only fires the hashchange event in the browser.

hashchange event — fires in JavaScript whenever the # portion of the URL changes. Scripts can listen for this event and react without any server involvement.


Phase 1 — Reconnaissance

We land on the familiar blog page and an exploit server.

Screenshot

Inspecting the page source reveals the vulnerable JavaScript:

$(window).on('hashchange', function(){
    var post = $('section.blog-list h2:contains(' +
        decodeURIComponent(window.location.hash.slice(1))
    + ')');
    if (post) post.get(0).scrollIntoView();
});

Whenever the URL hash changes, the code takes the text after #, decodes it with decodeURIComponent, and uses it as a jQuery :contains() selector to find a matching <h2> element and scroll to it. The untrusted hash value is concatenated directly into the jQuery selector — this is the sink. jQuery parses the selector string as HTML when it contains < characters, creating real DOM elements including event handlers.

Testing in the console — navigating to /#The Cool Parent confirms the function finds the matching post and scrolls to it:

Screenshot

Phase 2 — Confirming the Event Fires

Intercepting the response with Burp and adding a console.log to verify the hashchange trigger:

$(window).on('hashchange', function(){
    console.log('teto');
    var post = $('section.blog-list h2:contains(' + decodeURIComponent(window.location.hash.slice(1)) + ')');
    if (post) post.get(0).scrollIntoView();
});

Navigating to /#test:

Screenshot
Screenshot

teto appeared in the console — the hashchange event fires and the function runs as expected.


Phase 3 — Injecting the Payload via Hash

Since the hash value is passed into jQuery without sanitization, jQuery parses it as HTML and creates real DOM elements including event handlers:

https://site.net/#<img src=0 onerror=print()>
Screenshot

This works in our own browser — but won't work when delivered to a victim. The reason is critical.


Why the Hash Alone Doesn't Work as a Delivered Exploit

The hashchange event only fires when the hash changes. If we send a victim the URL /#<img src=0 onerror=print()>, the hash is already set to that value when the page loads — it never changes, so hashchange never fires, and the payload never executes.

We need a mechanism that: 1. Loads the page with an initial hash (or no hash) 2. Then changes the hash to the payload after load

An <iframe> with an onload handler does exactly this.


Phase 4 — iframe Exploit Delivery

An <iframe> embeds another web page inside the current one. We can control both its initial src and modify it after it loads:

<iframe
    src="https://TARGET.web-security-academy.net/#"
    onload="this.src += '<img src=0 onerror=print()>'">
</iframe>

How this works step by step:

  1. The iframe loads the target page with src=".../#" — hash is # (empty)
  2. The page loads; hashchange does not fire yet
  3. The iframe's onload fires when the page finishes loading
  4. this.src += '<img src=0 onerror=print()>' appends to the current src, changing the hash from # to #<img src=0 onerror=print()>
  5. The hash changes — hashchange event fires inside the iframe
  6. jQuery reads the new hash, creates <img src=0 onerror=print()> as a DOM element
  7. The image fails to load, onerror fires, print() executes
Screenshot

Storing the exploit on the exploit server and delivering to the victim solves the lab.


Conclusion

  1. The hashchange listener read location.hash and passed the decoded value directly into a jQuery $() selector — source and sink confirmed.
  2. Navigating to /#<img src=0 onerror=print()> worked locally but would not trigger on a victim because hashchange only fires on hash change, not on initial load.
  3. An iframe loaded the target with an empty hash (#), then the onload handler appended the payload to src, changing the hash and triggering hashchange inside the iframe.
  4. jQuery parsed the new hash as HTML, created the img element, the image failed to load, and onerror executed print().

Key Concepts

location.hash is a DOM XSS source — the URL fragment flows into jQuery's $() selector. jQuery parses the string as HTML when it contains < characters, creating real DOM elements including event handlers. This is the same behavior as innerHTML — the sink is just jQuery's selector engine rather than a direct DOM property.

hashchange only fires on change, not on initial load — this is the fundamental delivery challenge for hash-based DOM XSS. A direct URL with the payload doesn't work because the hash is static. The iframe trick solves this by engineering a hash change after page load.

The iframe onload trick forces a hash change — loading with # first then appending the payload via onload guarantees the hash changes after the page has fully loaded, reliably triggering hashchange.

print() instead of alert() for cross-origin iframes — Chrome 92+ blocks alert() in cross-origin iframes. Since the iframe is served from the exploit server, it is cross-origin relative to the target. print() is not subject to this restriction.

Sink summary across all DOM XSS labs so far:

Sink Payload type
document.write() "><script>alert(1)</script>
innerHTML <img src=0 onerror=alert(1)>
href attribute javascript:alert(1)
jQuery $() selector <img src=0 onerror=print()> via hash + iframe