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:

  1. Trigger the victim to create/load the target iframe.
  2. Detect when the child exists (win.length === 1, frames.length > 0, or similar heuristics).
  3. Send a message that reaches an expensive synchronous gadget in the parent.
  4. While the parent event loop is stalled, send your payload to the child iframe.
  5. 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 > 0 to know when the child exists.
  • Reuse a single popup/window across attempts to reduce navigation jitter.
  • Tune small setTimeout delays empirically for the browser/hardware being attacked.
  • If the victim uses wildcard postMessage(..., "*"), keep sending until the child payload is definitely installed.

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:

  1. Open the target in a popup.
  2. Get the victim to render a self-reloading attacker-controlled document.
  3. Render a second payload whose only job is to install onmessage and leak the next secret.
  4. Stall the opener/main page with one of the blocking gadgets above.
  5. When the opener resumes, it may deliver the sensitive postMessage to 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.

References