June 20, 2026
Exploiting the Event Listener: My Sixth XSS Lab on PortSwigger
I’ve been working through the PortSwigger Web Security Academy XSS labs one by one, and this sixth one introduced something I hadn’t seen…
Diya
5 min read
I've been working through the PortSwigger Web Security Academy XSS labs one by one, and this sixth one introduced something I hadn't seen in the previous labs, a vulnerability that requires delivering an exploit to a victim rather than just triggering it directly yourself. Here's how it went.
Category: DOM-Based Cross-Site Scripting (XSS)
Difficulty: Apprentice
Lab Link: DOM XSS in jQuery selector sink using a hashchange event
Objective
Deliver an exploit to a simulated victim that triggers the print() function in their browser, exploiting a DOM-based XSS vulnerability in the site's jQuery-based auto-scroll feature.
Vulnerability Overview
This lab demonstrates a DOM-based XSS vulnerability where the source is location.hash and the sink is jQuery's $() selector function. Unlike reflected or stored XSS, the payload never touches the server, the entire vulnerability lives in client-side JavaScript.
The site uses a hashchange event listener to auto-scroll to a blog post whose title matches the value in the URL hash. The problem is that location.hash is passed directly into jQuery's $() selector without any sanitization. Because older versions of jQuery interpret selector strings as HTML when they begin with <, an attacker can inject arbitrary HTML including event handlers, through the hash fragment.
Because location.hash can't be set cross-origin by a third-party page, the exploit requires wrapping the payload in an <iframe> that dynamically appends the malicious hash after the page loads.
Steps to Reproduce
- Opening the lab reveals a standard blog home page with a list of posts. There's a "Go to exploit server" button in the lab header, this is where the attack payload will be hosted.
- Opening DevTools (F12) and inspecting the Elements tab reveals an inline
<script>block near the bottom of the page body containing the following code:
$(window).on('hashchange', function(){
var post = $('section.blog-list h2:contains(' +
decodeURIComponent(window.location.hash.slice(1)) + ')');
if (post) post.get(0).scrollIntoView();
});$(window).on('hashchange', function(){
var post = $('section.blog-list h2:contains(' +
decodeURIComponent(window.location.hash.slice(1)) + ')');
if (post) post.get(0).scrollIntoView();
});
This confirms the sink: window.location.hash is read, decoded, and concatenated directly into a jQuery selector string with no sanitization whatsoever. In jQuery 1.8.2 (which this lab uses), passing a string starting with < to $() causes it to create and insert DOM elements, not just query for them.
- The lab URL in the address bar gives the lab ID needed to craft the exploit payload.
- Navigate to the exploit server via the "Go to exploit server" button. In the Body field, paste the following iframe payload, replacing
YOUR-LAB-IDwith the actual lab ID:
<iframe src="https://0ac700580433e135812dacd400c70082.web-security-academy.net/#" onload="this.src+='<img src=1 onerror=print()>'"></iframe><iframe src="https://0ac700580433e135812dacd400c70082.web-security-academy.net/#" onload="this.src+='<img src=1 onerror=print()>'"></iframe>How this works:
- The
<iframe>loads the lab's home page with an empty hash (#). - Once the iframe finishes loading (
onload), it appends<img src=1 onerror=print()>to the hash. - This triggers the
hashchangeevent, which passes the new hash value into the jQuery $() selector. - jQuery interprets the string as HTML, creates the
<img>element, and appends it to the DOM. - The
src=1attribute fails to load (invalid URL), triggering theonerrorhandler, which callsprint().
Click Store, then View exploit to test the payload against yourself.
- Clicking "View exploit" loads the exploit page in a new tab. The print dialog appears immediately, confirming the payload executed successfully.
- Return to the exploit server and click "Deliver exploit to victim". The simulated victim's browser loads the exploit page, the payload fires in their browser context, and the lab is marked as solved.
Technical Evidence
The root cause is visible in the inline script captured during Step 2. The key line:
var post = $('section.blog-list h2:contains(' +
decodeURIComponent(window.location.hash.slice(1)) + ')');var post = $('section.blog-list h2:contains(' +
decodeURIComponent(window.location.hash.slice(1)) + ')');The value of window.location.hash is decoded and concatenated directly into the jQuery selector string. When the hash contains <img src=1 onerror=print()>, the full string passed to $() becomes:
$('section.blog-list h2:contains(<img src=1 onerror=print()>)')$('section.blog-list h2:contains(<img src=1 onerror=print()>)')jQuery 1.8.2 (used by this lab) detects that the string starts with < and treats it as an HTML string, creating the element and inserting it into the DOM triggering the onerror handler.
If the application had used a safe API or sanitized the hash before passing it to $(), the injection would not be possible.
Root Cause
The vulnerability stems from passing attacker-controlled data (location.hash) directly into jQuery's $() selector without validation or encoding. Older versions of jQuery treat selector strings beginning with < as raw HTML, creating and inserting DOM elements rather than querying for existing ones. Combined with a hashchange event listener that fires whenever the URL fragment changes, this allows a cross-origin attacker to trigger the sink remotely by embedding the target page in an iframe and manipulating its hash after load.
Impact
Because the source is location.hash, the attacker cannot set it directly from another origin, which is why the iframe technique is necessary. However, once the exploit page is delivered (via phishing, social engineering, or any link the victim clicks), the payload executes automatically with no further interaction required. Potential consequences include:
- Hijacking the victim's session by stealing cookies or tokens accessible to JavaScript
- Performing actions on behalf of the victim within the application
- Redirecting the victim to a malicious or phishing site
- Modifying page content as seen by the victim
In this lab, the objective was limited to calling print(), but in a real scenario the same technique could be used to exfiltrate credentials or session data.
Remediation
To prevent this type of vulnerability, the application should:
- Upgrade jQuery to a version that does not interpret selector strings as HTML (jQuery 3.x changed this behavior by requiring explicit use of
$.parseHTML()for HTML string handling). - Avoid passing user-controlled data into $() as a selector. If auto-scrolling to a post by title is genuinely needed, use
document.querySelector()with a properly escaped string, or look up the post by a safe identifier (e.g., post ID) rather than a raw hash value. - Validate and sanitize
location.hashbefore using it in any DOM operation — reject or strip any input that contains HTML metacharacters. - Implement a strict Content Security Policy (CSP) to limit the impact of any successful injection, particularly by disallowing inline event handlers.
Conclusion
This lab was the most interesting one so far, because it introduced a completely different attack delivery model. Instead of just plugging a payload into a search box or comment form, the exploit had to be hosted on a separate server and delivered to a victim, which is much closer to how real-world XSS attacks actually work. It also showed how a seemingly harmless feature (auto-scrolling to a blog post by title) can become a serious vulnerability when built on an outdated library that interprets strings as HTML. Six labs in, and each one keeps adding a new layer to how the same underlying bug can show up in completely different shapes.