August 7, 2026
DOM XSS using web messages
Exploiting an Unchecked postMessage Listener — DOM XSS Using Web Messages (PortSwigger Academy)
By Mubin mujawar
4 min read
Exploiting an Unchecked postMessage Listener — DOM XSS Using Web Messages (PortSwigger Academy)
A missing origin check and an innerHTML sink is all it took to run arbitrary JS in the victim's browser.
Intro
postMessage is the standard way pages talk to each other across origins — iframes, popups, embedded widgets. It's safe by design if the receiving page checks who sent the message. The moment a page trusts e.data without checking e.origin, any other tab or iframe on the internet can hand it data — and if that data ends up in the DOM unsanitized, it's game over. That's exactly what this lab demonstrates.
Lab: DOM XSS using web messages Difficulty: Apprentice Category: DOM-based Cross-Site Scripting
What the app does
The target is a mock shop front page — nothing about it looks unusual to a normal visitor.
The shop homepage, before any exploitation
Finding the bug
I started by reading through the page source rather than clicking around, since DOM XSS bugs almost always live in inline <script> blocks. I found this near a placeholder <div id="ads">:
The vulnerable event listener in the page source
window.addEventListener('message', function(e) {
document.getElementById('ads').innerHTML = e.data;
})window.addEventListener('message', function(e) {
document.getElementById('ads').innerHTML = e.data;
})
Two red flags jumped out immediately. First, there's no check on e.origin — this listener will act on a message from any window, not just ones the app itself controls. Second, it assigns e.data to innerHTML rather than textContent, meaning whatever string arrives gets parsed as HTML rather than displayed as text. Unchecked origin plus an HTML sink is basically an invitation.
I tested the theory directly from the browser console with a plain string first, to confirm the wiring actually worked the way the source suggested:
window.postMessage("hello")window.postMessage("hello")
The word "hello" landing inside the ads div, confirming the sink
Sure enough, hello appeared inside <div id="ads">. The listener was live and the sink was exactly what the source implied.
Exploiting it
With the sink confirmed, I swapped the harmless string for something HTML would actually execute:
window.postMessage("<img src=x onerror=alert(1)>")window.postMessage("<img src=x onerror=alert(1)>")An <img> tag with a broken src fires its onerror handler the moment the browser tries and fails to load the image — a classic way to get script execution out of an innerHTML sink without needing <script> tags (which innerHTML won't execute directly).
alert(1) firing after the payload is injected via postMessage
That confirmed full exploitability. But this only works because I was sending the message from my own console on the target's origin — a real attack needs to deliver this from an external page the victim visits. So I built the actual exploit: a page containing a hidden iframe pointing at the lab, which posts the payload into it the moment the iframe finishes loading.
<iframe src="https://YOUR-LAB-ID.web-security-academy.net" onload="this.contentWindow.postMessage('<img src=x onerror=print()>','*')"><iframe src="https://YOUR-LAB-ID.web-security-academy.net" onload="this.contentWindow.postMessage('<img src=x onerror=print()>','*')">I configured this as the response body on PortSwigger's exploit server:
The exploit configured in the exploit server's response body
Result: Loading the exploit page triggers the iframe's onload, which posts the payload straight into the vulnerable listener on the target origin, which writes it into innerHTML, which fires the onerror handler and executes arbitrary JS — in this case a print() call, visible as a native browser print dialog:
And the lab confirms it:
Lab solved banner
Why it happened
Two independent mistakes stacked on top of each other. The listener trusted messages from any origin instead of checking e.origin against an allowlist, and it rendered untrusted data as HTML instead of text. Either fix on its own would have closed this off — the app happened to get both wrong at once, which is common: postMessage handlers are often written quickly for a narrow use case (like this ad-slot) and nobody threat-models the messaging channel itself.
One detail worth clarifying, since it's easy to misread: the '' in postMessage(payload, '*') is the targetOrigin — it's telling the browser "deliver this to the iframe's window no matter what origin it currently has." It's a property of the sender's call, not something that bypasses any restriction on the receiving console. It's convenient for an exploit, and it's also exactly the kind of thing you should flag in a code review — legitimate code should almost always specify a real target origin instead of ''.
How to fix it
Check event.origin against a strict allowlist before doing anything with event.data, and stop using innerHTML for untrusted data — textContent (or proper sanitization if HTML rendering is genuinely required) closes the sink even if the origin check is somehow bypassed elsewhere. Defense in depth: fix both, not just one.
Takeaway
Any time you see window.addEventListener('message', ...), check two things before anything else: does it validate event.origin, and what does it do with event.data? If the answer is "no" and "puts it in the DOM unsanitized," you've very likely found a DOM XSS.
origins — iframes, popups, embedded widgets. It's safe by design if
the receiving page checks who sent the message. The moment a page
trusts e.data without checking e.origin, any other tab or iframe on the
internet can hand it data — and if that data ends up in the DOM
unsanitized, it's game over. That's exactly what this lab demonstrates.
Lab: DOM XSS using web messages Difficulty: Apprentice Category:
DOM-based Cross-Site Scripting