Skip to content
Vinoth Kumar
Go back

DOMinator: a Chrome extension for hunting postMessage DOM XSS

DOMinator is now live on the Chrome Web Store. It’s free, open source under the MIT license (github.com/vinothsparrow/DOMinator), and it runs entirely in your browser: no account, no server, no analytics.

DOMinator Messages panel showing captured postMessage traffic

Why I built it

Back in 2020 I wrote about a $20000 Facebook DOM XSS found through a postMessage handler. To find that bug I wrote a small extension called iframe-broker, which logged the cross-window messages on a page so I didn’t have to set breakpoints by hand.

iframe-broker answered one question: what messages are flying around? Finding a real bug takes a lot more than that:

I used to answer each of those by hand with DevTools, the Sources panel, some console snippets and a lot of patience. DOMinator does all of it in one place.

What it captures

Once installed, DOMinator hooks every frame at document_start, before the page’s own scripts run, so it sees the messages sent and the listeners registered during page load. That early window is often where the interesting init payloads are.

Messages. Every window.postMessage call and every message event is recorded with its direction, its origin and target, its frame, and its payload type and size. It covers more than window: MessagePort, BroadcastChannel, Worker/SharedWorker and ServiceWorker too. Each send records its call site (file:line:column) and full stack. In DevTools, clicking the call site opens the file in the Sources panel, source-mapped when the map is available. Each receive is paired with the send that produced it, so you can see who sent it without guessing.

Listeners. For every addEventListener('message', …) or onmessage handler, DOMinator shows where it was registered and what kind of origin check it does. It doesn’t just say whether a check exists:

KindWhat it means
noneNo origin check at all
strict=== comparison or an allowlist
bypassablestartsWith, endsWith, indexOf, includes or an unanchored regex
sourceCompares event.source to parent/opener and never checks the origin

The bypassable bucket is where the bugs usually are. event.origin.indexOf("example.com") > -1 looks like a check in code review, but example.com.evil.com passes it.

It also lists the dangerous sinks in the handler body (innerHTML, document.write, eval, location, srcdoc, jQuery html(), storage and cookie writes, DOMParser, import() and more). One thing that always bothered me in DevTools is that a listener shows up as some error-monitoring or jQuery wrapper instead of the real code. DOMinator unwraps the common ones, so you see the actual handler.

Findings. Separate from postMessage, the Findings tab picks up prototype pollution (writes onto Object.prototype through Object.assign, jQuery.extend and lodash merge), DOM clobbering (id/name collisions that overwrite globals like getElementById), and URL/DOM taint sources like location.hash, document.referrer and window.name.

DOMinator Findings tab showing confirmed innerHTML sink flows and DOM clobbering

From suspicion to confirmed flow

“This handler has innerHTML in it” is only a hint. What you want to know is whether your value actually reaches the innerHTML.

DOMinator’s dynamic sink trace follows taint through the transforms real apps apply: JSON.parse/stringify, encodeURIComponent/decodeURI, btoa/atob, replace, split/join, case changes and so on. Runtime hooks on the sinks record when a tainted value lands in one. If Trusted Types or CSP blocks the assignment, the flow is still recorded, marked blocked, which is useful to know when you look for a way around it.

When a flow is confirmed, the message gets a red confirmed badge and shows up in the Findings tab, pointing at the exact file:line of the sink.

Active testing

DOMinator doesn’t just record. These features turn a capture into a test:

Replay, Intercept and Auto-probe are off by default, and they only act on the page open in DevTools.

Using it in automation

The page exposes a small helper, so DOMinator also works in headless runs:

// In Playwright, with the extension loaded
await page.goto(url, { waitUntil: "networkidle" });
const result = await page.evaluate(() => window.__DOMINATOR__.dump());
// { messages, listeners, flows }

Run that across a list of targets and you have a first pass that flags pages with none or bypassable listeners and confirmed flows before you look at anything by hand.

Limitations

A send to a cross-origin window (iframe.contentWindow.postMessage(…) or parent.postMessage(…) across origins) goes through the browser’s cross-origin proxy, and no page script can hook that. So those sends have no call site. The message is still captured in the receiving frame, with its origin and the listeners it reaches.

Origin spoofing in Replay is a presented origin: a synthetic MessageEvent or the value the wrapped listener sees. It can’t change the origin the browser enforces on a real postMessage. That’s enough to prove a weak check. For the final report you still host the PoC on a real attacker origin.

Get it

Open DevTools, select the DOMinator panel and reload the page. Any page that embeds a video player, payment widget or chat widget will give you traffic to look at right away.

Replay and probing send payloads into the page you’re testing, so please use DOMinator only on sites you own or are authorised to test.

Issues and PRs are welcome. If DOMinator helps you find a bug, I’d love to hear about it on X.


Share this post:

Previous Post
$20000 Facebook DOM XSS