OAuth SDK에서 postMessage Origin Bypass를 찾은 방법

작성자

카테고리:

← 피드로
DEV Community · 幻灵末士 · 2026-08-28 개발(SW)

幻灵末士

幻灵末士

Posted on Aug 28 AI-assisted

I spend a lot of time reading other people’s code. Not because I enjoy it—though honestly, I kind of do—but because that’s where the interesting bugs live. Not the flashy ones that get all the attention on Twitter. The quiet ones. The ones hiding in plain sight, inside a single missing if statement.

This is the story of one of those bugs.

The Backstory

A few weeks ago I was doing a security review of a web3 project. You know the drill: connect wallet, sign in with Google, maybe an NFT drop if you’re lucky. The usual.

The project used an OAuth SDK for their “Sign in with Google” flow. Pretty standard stuff. You click a button, a popup opens, you authenticate with Google, the popup closes, and you’re logged in. Smooth UX, works great.

Under the hood, this flow relies on postMessage to communicate between the popup window and the main application window. And if you’ve done any web security work, you know where this is going.

postMessage is one of those APIs that’s incredibly useful and incredibly easy to get wrong. The browser happily delivers messages between windows regardless of where they come from. It’s up to you, the developer, to check event.origin and make sure you’re only accepting messages from places you actually trust.

Spoiler alert: sometimes people forget.

The Hunt

I started by looking at how the SDK handled messages coming back from the OAuth popup. Here’s roughly what I found:

const redirectEvent = (event: MessageEvent) => {
  this.createIntermediaryEvent(
    OAuthPopupEventEmit.PopupEvent,
    requestPayload.id
  )(event.data);
};

window.addEventListener('message', redirectEvent);

Enter fullscreen mode Exit fullscreen mode

Take a good look at that. What’s missing?

There’s no event.origin check. No validation of where the message is coming from. The listener receives a message, grabs event.data, and forwards it along to be processed. It doesn’t care if the message came from the legitimate OAuth popup, a malicious page, or the void.

This is the equivalent of answering your front door without looking through the peephole. Sure, it’s probably your friend, but you’re not even checking.

The Interesting Part

Now, a missing origin check is bad on its own. But I wanted to know: how bad? What can an attacker actually do with this?

The answer depended on how the SDK matches incoming messages to pending OAuth requests. And here’s where it got interesting.

The SDK uses a payloadId to correlate popup messages with the original login request. When you initiate an OAuth flow, the SDK generates an ID, sends it along with the popup URL, and waits for a message that references that same ID.

I traced this ID generation back to its source, expecting to find a cryptographically secure random generator. Something with crypto.getRandomValues() or at least a timestamp mixed in. Instead, I found something much simpler:

let id = 0;
export const getPayloadId = () => ++id;

Enter fullscreen mode Exit fullscreen mode

That’s it. A module-level counter that increments by one every time it’s called. On the web platform, every OAuth request gets the next integer in sequence. If you see request #7 go out, you know the next one will be #8.

Let me walk through how an attacker could chain this together. As soon as the victim’s OAuth popup opens, the SDK registers its message listener. At that exact moment, the attacker’s page—which the victim has open in another tab—can send a message via window.opener.postMessage(). That message includes a payloadId that the attacker predicted by simply counting the current request number. The listener, with no origin check, passes the message to the SDK’s internal logic, which processes it as if it came from the legitimate Google popup. The attacker doesn’t need to guess anything else.

That’s what turned this from “interesting oddity” into “real exploit.”

The Cherry on Top

While digging through this SDK’s codebase, I noticed something that made me chuckle. The same company maintained another package in the same SDK family. That package did do the right thing:

if (event.origin !== this.endpoint) return;

Enter fullscreen mode Exit fullscreen mode

They knew about origin validation. They’d done it correctly elsewhere. But in the OAuth extension, it was just… absent. A lapse in consistency between two codebases that should have followed the same pattern.

This is actually pretty common in larger codebases. Different teams, different timelines, different levels of review. Knowledge gets siloed. What one maintainer knows, another doesn’t. And sometimes a critical check that exists in iframe-controller.ts never makes it to oauth2/src/index.ts.

The Fix

I reported the issue, and the fix was about as simple as you’d expect. Add the origin check, mirror the pattern that already existed elsewhere in their own codebase:

const redirectEvent = (event: MessageEvent) => {
  if (event.origin !== this.sdk.endpoint) return;
  this.createIntermediaryEvent(
    OAuthPopupEventEmit.PopupEvent,
    requestPayload.id
  )(event.data);
};

Enter fullscreen mode Exit fullscreen mode

One line. That’s it. One line separates “vulnerable” from “not vulnerable.”

What I Learned (And You Should Too)

Always validate event.origin. I know, you’ve heard this a thousand times. But I just found a production SDK that forgot, so apparently we need to keep saying it. Never assume a message event comes from where you expect. Always check.

Predictable IDs are dangerous. Sequential IDs aren’t just bad for enumeration attacks—they can enable cross-window attacks like this one. Use cryptographically random IDs when correlating async operations.

Consistency matters in security. If one part of your codebase does security right and another doesn’t, that’s not just an inconsistency. It’s a roadmap for attackers. They’ll find the weakest link.

Read code. I didn’t find this with a scanner. I just opened the source code, traced the message flow, and followed it until something didn’t add up. Sometimes the best tool is just a text editor and a willingness to ask “what if?”

Final Thoughts

I can’t share specifics about the SDK or the exact details of my report—responsible disclosure means giving the vendor time to patch and publish their own advisory. But the pattern is what matters here. Missing origin validation is one of those bugs that shows up everywhere: SDKs, wallets, analytics tools, chat widgets. If your app uses postMessage anywhere, go check your listeners right now. I’ll wait.

Seriously, go check.

The best bugs aren’t always the ones with the most complex attack chains. Sometimes they’re the ones hiding in the gap between what the code assumes and what the browser actually delivers.

Thanks for reading! If you enjoyed this, I write about web security, code review, and the occasional “how did that even work” bug. Follow along for more.

원문에서 계속 ↗