Skip to content
HN On Hacker News ↗

Do the Math: How We Took Over ChatGPT Accounts

▲ 325 points • 162 comments • by armcat • 22h ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this entire text is AI.

100 %

AI likelihood · overall

AI
0% human-written 100% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,410
PEAK AI % 100% · §1
Analyzed
Oct 31
backend: pangram/v3.3
Segments scanned
1 windows
avg 1410 words each
Distribution
0 / 100%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,410 words · 1 segments analyzed

Human AI-generated
§1 AI · 100%

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 walkthroughIt started with a graph and a PreviewThe 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 workedThe 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 JSONPChatGPT'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 linkSo 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.The public share modified the real ChatGPT pageAt 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 enoughAt 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