Blocking main page to steal postmessage
Winning RCs with Iframes
According to this Terjanq writeup, blob documents created from null origins can end up process-isolated from the parent page. This makes an interesting race possible: if you can force the parent window to spend enough time inside a synchronous code path, a malicious child document may still keep running, finish bootstrapping its JS, register onmessage, and steal the next sensitive postMessage.
A simplified vulnerable flow is:
iframe.addEventListener(
"load",
() => {
iframe.contentWindow?.postMessage(secret, "*")
},
{ once: true }
)
window.addEventListener("message", (e) => {
if (e.data == "blob loaded") {
$("#previewModal").modal()
}
})
Therefore, the goal of the attacker is to let the parent create the iframe, but before the parent page sends the sensitive data, keep it busy and send a payload to the child iframe. While the parent is busy, the iframe executes attacker-controlled JS, installs onmessage, and waits for the next sensitive postMessage. Once the parent becomes responsive again, it sends the secret and the malicious child leaks it.
A practical flow is usually:
- Trigger the victim to create/load the target iframe.
- Detect when the child exists (
win.length === 1,frames.length > 0, or similar heuristics). - Send a message that reaches an expensive synchronous gadget in the parent.
- While the parent event loop is stalled, send your payload to the child iframe.
- Let the payload leak the next secret the parent sends to the child.
Blocking gadgets
The original 2022 challenge used a loose comparison gadget:
window.addEventListener("message", (e) => {
if (e.data == "blob loaded") {
$("#previewModal").modal()
}
})
Because == coerces non-strings, a large Uint8Array/ArrayBuffer can make the parent spend noticeable time converting attacker-controlled data to a string:
const buffer = new Uint8Array(1e7)
victim.postMessage(buffer, "*", [buffer.buffer])
Passing the ArrayBuffer in the transfer list is useful here: ownership moves to the victim, so the sender usually avoids paying the full copy/clone cost locally.
Recent Postviewer variants showed that any attacker-controlled synchronous work reachable from the parent's message handler can be enough. Examples worth hunting for are loops over attacker-controlled lengths or debug leftovers such as:
window.onmessage = (e) => {
if (e.data.type === "share") {
for (let i = 0; i < e.data.files.length; i++) {
// expensive per-file work
}
}
if (e.data.slow) {
for (let i = 0; i < e.data.slow; i++) {}
}
}
So, when auditing, don't only look for a == coercion gadget: also look for loops over attacker-controlled length fields, debug leftovers, or any other synchronous path reachable before the sensitive postMessage is sent. Conceptually this abuses the same single-thread primitive used in busy event loop XS-Leaks, but here the goal is to arm the malicious child before the parent resumes.
Timing the race
The race window is usually only a few milliseconds, so use cheap synchronization signals before firing the slow gadget:
- Poll for
win.length === 1/frames.length > 0to know when the child exists. - Reuse a single popup/window across attempts to reduce navigation jitter.
- Tune small
setTimeoutdelays empirically for the browser/hardware being attacked. - If the victim uses wildcard
postMessage(..., "*"), keep sending until the child payload is definitely installed.
Popup / non-frameable variant
A useful 2025 evolution of the same idea appeared in Postviewer v5². When the target page was not frameable, the race was still winnable from a popup. Instead of directly changing iframe.location, the attacker used a child/popup payload that continuously reloads itself, creating another onload just before the victim cleans up its listener:
<script>
setTimeout(() => {
location = URL.createObjectURL(
new Blob([document.documentElement.innerHTML], { type: "text/html" })
)
}, 150)
</script>
This turns the primitive into:
- Open the target in a popup.
- Get the victim to render a self-reloading attacker-controlled document.
- Render a second payload whose only job is to install
onmessageand leak the next secret. - Stall the opener/main page with one of the blocking gadgets above.
- When the opener resumes, it may deliver the sensitive
postMessageto the attacker payload before it processes the child's cleanup/ack message.
This is handy when you only control a window.open() flow, or when frame restrictions stop you from directly hijacking nested iframe locations.