September 10, 2026
postMessage XSS: The Listener Nobody Audits
The victim visits your page. Your page frames the target and tells it what to render. Nothing malicious ever reaches their server.

By Nitin yadav
7 min read
Hello, I am Nitin.
Day ten. If you only take one technique from this entire month, consider taking this one.
Here is why I rate it so highly. Most XSS requires you to find a filter gap โ some place where the developers escaped nine contexts and missed the tenth. That is a search through a space they have actively defended.
postMessage handlers are different. They are frequently undefended by construction, because the developer who wrote one was not thinking about security at all. They were thinking about plumbing: how do I get this widget in an iframe to talk to the page that embeds it. They wrote a listener, it worked, they moved on. Nobody reviewed it, no scanner flags it, and it has been sitting there since 2021.
And the exploit does not need a crafted link with a payload in it. The victim visits your page. Your page frames the target and sends it a message. Everything malicious lives on your infrastructure, which means there is nothing in the target's logs, nothing for a WAF to inspect, and nothing suspicious-looking in the URL the victim clicked.
The check takes two minutes. Do it on every target.
What postMessage is and why the hole exists
Browsers enforce the same-origin policy: a page on one origin cannot read or script a page on another. That is the foundation of web security.
But sometimes two documents on different origins genuinely need to talk. A payment widget in an iframe needs to tell the parent page the payment succeeded. An embedded video player needs to report playback state. A single sign-on popup needs to hand a token back to the opener.
postMessage is the sanctioned channel for that. One document calls it, another receives a message event.
Here is the critical property, and it is the whole bug: any document can send a message to any other document it has a reference to. If a page can be framed, the framing page can message it. If a page opened a popup, either can message the other. There is no allow-list, no handshake, no authentication in the mechanism itself.
The browser does hand the receiver everything it needs to defend itself. The event carries an origin property saying exactly who sent it. But checking that property is entirely the developer's responsibility, and it is optional, and it is skipped constantly.
Step one: find every listener
Two ways, and you should use both.
Grep the bundles from day nine:
grep -rnE "addEventListener\(\s*[\"'\`]message[\"'\`]|onmessage\s*=" pretty/grep -rnE "addEventListener\(\s*[\"'\`]message[\"'\`]|onmessage\s*=" pretty/That regex matters. A minifier will not rename the string 'message', so this pattern survives minification perfectly. Even in a wall of mangled code, this grep works.
Hook it live, which catches listeners registered dynamically or by third-party scripts you did not enumerate. Paste this in the console before the page finishes loading:
(() => {
const orig = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
if (type === 'message') {
console.log('%c[message listener]', 'color:#C0392B', fn);
console.log(fn.toString());
}
return orig.call(this, type, fn, opts);
};
})();(() => {
const orig = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, fn, opts) {
if (type === 'message') {
console.log('%c[message listener]', 'color:#C0392B', fn);
console.log(fn.toString());
}
return orig.call(this, type, fn, opts);
};
})();Now every registration prints the handler's source. You get to read the actual function, including ones injected by analytics, chat widgets, and embedded third parties โ which are often the weakest.
Also check the frames. Listeners frequently live in the embedded document rather than the top page:
[...document.querySelectorAll('iframe')].map(f => f.src)[...document.querySelectorAll('iframe')].map(f => f.src)Then run the whole process against each iframe URL directly.
Step two: read the origin check
This is the decision point. Look at the first few lines of the handler and classify what you find.
No check at all. The handler goes straight to using the data:
window.addEventListener('message', (e) => {
document.getElementById('panel').innerHTML = e.data.content;
});window.addEventListener('message', (e) => {
document.getElementById('panel').innerHTML = e.data.content;
});Anyone can send this anything. Done โ go to step three.
A check that uses substring matching. This is the most common flawed pattern:
if (e.origin.indexOf('trusted.com') !== -1) { ... }if (e.origin.indexOf('trusted.com') !== -1) { ... }The test is "does the string appear anywhere in the origin." Origins you control that satisfy it:
<https://trusted.com.evil.com> subdomain of your domain
<https://eviltrusted.com> prefix concatenation
<https://trusted.com.evil> different TLD entirely<https://trusted.com.evil.com> subdomain of your domain
<https://eviltrusted.com> prefix concatenation
<https://trusted.com.evil> different TLD entirelyRegister one of those and the check passes.
A check that uses startsWith:
if (e.origin.startsWith('<https://trusted.com>')) { ... }if (e.origin.startsWith('<https://trusted.com>')) { ... }Satisfied by https://trusted.com.evil.com, because the origin string genuinely does start with that prefix.
A check that uses endsWith:
if (e.origin.endsWith('trusted.com')) { ... }if (e.origin.endsWith('trusted.com')) { ... }Satisfied by [https://eviltrusted.com](https://eviltrusted.com.).
A regex with an unescaped dot:
if (/^https:\/\/app.trusted\.com$/.test(e.origin)) { ... }if (/^https:\/\/app.trusted\.com$/.test(e.origin)) { ... }The unescaped dot matches any character, so https://appXtrusted.com passes. Subtle, common, and easy to miss when skimming.
A correct check:
if (e.origin !== '<https://app.trusted.com>') return;if (e.origin !== '<https://app.trusted.com>') return;Strict equality against a full origin. This is what right looks like. If you see it, verify there is no second listener elsewhere and move on.
A check on the wrong thing. Watch for handlers that validate e.data.origin or some field inside the message rather than e.origin. The attacker controls everything inside the message, so that check is decorative.
A check that happens too late. Occasionally the handler does something with the data before it validates. Read the order carefully.
Step three: reverse the message format
A weak origin check is only half of it. You still need to know what shape of message the handler expects, and where the data ends up.
Read the handler body and answer three questions:
What structure does it want? Messages are usually objects, sometimes JSON strings that get parsed, occasionally plain strings with a delimiter. Look for property access on the event data.
Is there a type or action discriminator? Most handlers switch on something:
window.addEventListener('message', (e) => {
const msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
switch (msg.type) {
case 'RESIZE': frame.style.height = msg.height + 'px'; break;
case 'RENDER': panel.innerHTML = msg.html; break; // sink
case 'NAV': location.href = msg.url; break; // sink
}
});window.addEventListener('message', (e) => {
const msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
switch (msg.type) {
case 'RESIZE': frame.style.height = msg.height + 'px'; break;
case 'RENDER': panel.innerHTML = msg.html; break; // sink
case 'NAV': location.href = msg.url; break; // sink
}
});Now you know exactly what to send. Two sinks here, and the second is a navigation sink, which from day five means the javascript scheme is on the table.
Where does the data land? Trace each branch. Common endpoints: an inner-HTML assignment, a location assignment, an evaluation, a script source, or storage that some other component later reads.
If reading minified code is slow, get the runtime to tell you. Send a probe and search for it:
const w = window.open('<https://target.com/widget>');
setTimeout(() => w.postMessage({type:'RENDER', html:'nitn4041x'}, '*'), 2000);const w = window.open('<https://target.com/widget>');
setTimeout(() => w.postMessage({type:'RENDER', html:'nitn4041x'}, '*'), 2000);Then search the target's DOM for your marker. If it appears, you have confirmed the path without reading a single line of minified code.
Step four: build the proof of concept
The delivery page is short. An HTML file containing an iframe pointing at the target, plus this logic:
const payload = {
type: 'RENDER',
html: '<img src=x onerror="alert(document.domain)">'
};
const frame = document.getElementById('t');
frame.addEventListener('load', () => {
frame.contentWindow.postMessage(payload, '*');
});const payload = {
type: 'RENDER',
html: '<img src=x onerror="alert(document.domain)">'
};
const frame = document.getElementById('t');
frame.addEventListener('load', () => {
frame.contentWindow.postMessage(payload, '*');
});Note the wildcard as the second argument. That is the target origin parameter โ using it means "deliver this regardless of who receives it." Attackers always use it.
A wildcard on the sending side in the target's own code is separately worth reporting, because it means the page will hand its message contents to whatever origin happens to occupy that frame. That is an information disclosure even when no sink is involved.
If the target cannot be framed โ it sets frame-ancestors in its policy, or the legacy frame options header โ use a popup instead:
const w = window.open('<https://target.com/widget>');
setTimeout(() => w.postMessage(payload, '*'), 3000);const w = window.open('<https://target.com/widget>');
setTimeout(() => w.postMessage(payload, '*'), 3000);That needs a click to open the popup, which slightly weakens the finding, but it still works and still demonstrates the flaw.
Timing matters. Send too early and the listener is not registered yet. The load event plus a short delay is the reliable pattern. If a message seems to be dropped, increase the delay before concluding the handler is safe.
Where these listeners live
Prioritise your hunt. Handlers cluster in predictable places:
- Embedded widgets โ chat, support, payment, booking, maps, video players
- Single sign-on and OAuth popup flows โ the popup hands a token back to the opener, and that channel is often loosely validated
- Payment iframes โ the card form is sandboxed on another origin and must report success upward
- Design and preview panes โ editors that render a live preview in a frame
- Analytics and tag managers โ third-party scripts that register their own listeners in your page
- Cross-subdomain communication โ where the app and the account portal are different origins
- Anything with
embed,widget,frame,sdk, orbridgein the path
That last one is a genuinely good grep against your URL list from day two:
grep -Ei "(embed|widget|iframe|frame|sdk|bridge|connector|player)" urls-with-params.txt | sort -ugrep -Ei "(embed|widget|iframe|frame|sdk|bridge|connector|player)" urls-with-params.txt | sort -uEmbed endpoints are built to be framed by third parties. That is their entire purpose, which means they cannot use frame-ancestors to protect themselves, which means they are always reachable by your delivery page.
What to check even when there is no sink
A listener with a good sink is XSS. A listener without one may still be a finding:
- Does it leak? A handler that responds by posting data back โ user details, tokens, session state โ to
event.sourcewith a wildcard target origin is an information disclosure to any framing page. - Does it perform an action? Handlers that trigger state changes (log out, change a setting, submit a form) are a cross-origin CSRF equivalent that bypasses token protections entirely, because the request originates from the page itself.
- Does it write to storage? A value written into storage by an unvalidated message may be read by a sink elsewhere. That is a two-step DOM XSS and it is easy to miss.
Impact ladder
- Informational โ listener with a strict origin check. No finding. Note it and move on.
- Low โ wildcard target origin when sending non-sensitive data. Report briefly; it is a hardening issue.
- Medium โ unvalidated listener that changes UI state or leaks non-sensitive data.
- High โ unvalidated or weakly validated listener reaching a script-execution sink. Fires on visiting your page, no interaction, no crafted link. Origin is the target's.
- High to critical โ the same on an authenticated origin, especially one where session material or tokens are reachable. Combine with day 28 for the takeover chain.
- Critical โ a listener that leaks tokens back to the sender. No XSS needed at all; the data walks out on its own.
The severity argument to make explicitly in your report: this requires no phishing lure beyond a page visit, and the target's server-side controls cannot observe or prevent it. Triage teams respond to that framing because it is accurate.
Conclusion โ steal this checklist
- Any document that can reference another can message it. There is no authentication in the mechanism โ validation is entirely the receiver's job, and it is skipped constantly.
- Grep for the message listener registration. The event-name string survives minification perfectly, so this grep works on any bundle.
- Hook the listener registration in the console to catch dynamic and third-party handlers you never enumerated.
- Check the iframes too. The listener is often in the embedded document, not the top page.
- Read the origin check and classify it: absent, substring, prefix, suffix, unescaped-dot regex, or strict equality.
- Substring, prefix, and suffix checks are all defeated by a domain you register.
- Validation of a field inside the message is decorative. The attacker controls the whole message.
- Reverse the message shape from the handler's switch statement, then probe with a marker and search the DOM.
- Delivery is an iframe plus a send with a wildcard target origin. If framing is blocked, use a popup.
- Wait for load plus a delay before sending, or you will get false negatives.
- Hunt embed, widget, sdk, bridge, and player endpoints โ they cannot use frame-ancestors, so they are always reachable.
- Even without a sink: check for data leaked back to the sender, state-changing actions, and writes into storage that another sink later reads.
- Argue severity on delivery: a page visit, no lure, invisible to server-side defences.
Tomorrow: DOM clobbering โ turning plain HTML that every sanitizer considers safe into JavaScript variables that hijack the page's own logic.
If you Love reading my blogs. Check my Youtube Channel too.