In October 2026, a research team at parameter.ai turned an ordinary public ChatGPT share link into attacker-controlled JavaScript running inside the signed-in session of whoever opened it. The victim only had to open the link. There was no app to install, no project to join, no code to paste, no developer tools. OpenAI classified the report as P1 and shipped a fix on October 26.
The payload ran as chatgpt.com, which is the part that matters. From there it enumerated the victim's conversation list, pulled the content of a private conversation that had never been shared with the attacker, and sent selected data to an external server. It called ChatGPT's authenticated APIs as the victim, so its reach was whatever the normal frontend can do on your account.
The chain is worth walking through because no single piece of it was broken.
A graph and a preview
ChatGPT's Preview feature renders an HTML code block as a working page. Since that HTML can contain anything, ChatGPT runs it inside a restricted frame on a separate web address. The browser treats the Preview as a different site, so code inside it cannot read parent.document or otherwise inspect the signed-in ChatGPT page around it. That isolation held. Preview code could not reach the parent directly.
The same conversation also contained an interactive graph. Rendering it loaded a math library, MathLive. Loading MathLive did one extra thing: it installed a postMessage listener in the main ChatGPT page to handle its on-screen keyboard.
postMessage is the sanctioned way for two frames to talk when the browser won't let them read each other's pages. It is safe only if the receiver checks who sent the message. MathLive's top-level controller used an origin policy of none, so it accepted messages from any origin, and it did not require the setting-change message to come from a window it had previously approved. The graph installed the receiver; the sandboxed Preview supplied a sender nobody had vetted.
HTML into the parent
The Preview sent two messages. The first replaced a virtual-keyboard layout with the attacker's HTML, since MathLive allowed keyboard layouts to contain raw markup. The second told MathLive to show the keyboard, which joined the layout into an HTML string and assigned it to innerHTML in the real ChatGPT page. A helper called createHTML looked like it might sanitize that string. It was an identity Trusted Types wrapper: it marked the string as trusted and passed it through unchanged. Trusted Types can control who writes to an HTML sink, but an identity policy doesn't make an attacker's string safe.
innerHTML alone doesn't execute script; tags injected that way stay inert. The layout therefore inserted an iframe with a srcdoc attribute, and MathLive added that frame to the main document with no sandbox attribute. srcdoc forces a fresh HTML parse, which gives an external script somewhere to run, and because the main chatgpt.com page created the frame, the browser handed the new document the chatgpt.com origin. Code inside it could now read the parent.
Getting script past CSP
Content Security Policy blocked inline JavaScript, as it should. It did allow scripts from YouTube. YouTube's oEmbed endpoint supports a JSONP-style callback parameter, and JSONP responses are JavaScript, not JSON. The endpoint rejected invalid callbacks, but it wrote the callback into the response body before appending its validation error. Ending the callback with ;void turned the trailing error object into a no-op. The response came back HTTP 200 with Content-Type text/javascript and nosniff set, and since YouTube was on the CSP allowlist, the browser loaded it as a script and ran the callback inside the unsandboxed frame.
That produced arbitrary JavaScript executing as chatgpt.com. Google rewrites angle brackets in the callback, so the payload used classic function syntax rather than arrow functions to survive the round trip.
From lab bug to one link
The first working version used a researcher-controlled ChatGPT App as the message sender, which meant a victim would have to connect or invoke that app first. A native HTML attachment removed that step, since ChatGPT opened the attachment in the same sandboxed Preview. But exporting the conversation as a public share kept the attachment and dropped the graph, and the graph was what loaded MathLive and installed the vulnerable listener. The attachment could still send the two messages, but nothing in the parent was listening.
The working approach put both halves in one ordinary public conversation: a genuine graph to initialize MathLive in the main page, and a later assistant-authored HTML Preview to send the messages. The resulting /share/ link carried the whole chain to anyone who opened it, deliverable by email, chat, a support ticket, or a social post.
Because the graph and Preview loaded independently, the payload sometimes fired before MathLive was ready, so the carrier retried the two messages for a short window, with a guard in the parent so the data-theft step ran only once. In the most stable reproduction the victim clicked Preview once; marking the HTML block as previewable made ChatGPT open it automatically, reducing the whole thing to a single click on the link. The automatic path reproduced three times in a separate account, including after a hard refresh, with the proof alert appearing around 4.5 seconds in.
What the code actually did
An alert proves where code runs, not why it matters. The same-origin frame parsed #client-bootstrap out of the parent document, pulled the session access token and account ID, and checked that the account ID in the token matched the session when both were present. It then set its own headers and called the real APIs:
GET /backend-api/conversations?...&limit=20&order=updated&offset=0
GET /backend-api/conversations/<id>?num_turns=2
A 403, 404, or 405 on the detail request fell back to the legacy /backend-api/conversation/?include_full_conversation=true route. This is authenticated API access, not screen scraping. The target conversation had no relationship to the malicious share. The response left over an ordinary image request to the attacker's server, after the code cleared its references to the token, account, and headers. The team sent only a small slice of the response off-site; the injected code already held the full authenticated reply and could have taken anything the frontend can reach.
What OpenAI fixed, and what to take from it
The Preview sandbox itself was fine. The problem was a privileged component in the main page willing to act on the sandbox's instructions. OpenAI reproduced both the script execution and the private-conversation read, rolled the fix across two versions of the web frontend, and on October 26 confirmed both versions reject the cross-frame MathLive messages. During the rollout a CSP change blocked part of the original chain while one version still needed the real fix, which is a fair reminder that CSP was a speed bump here, not the wall.
If you build anything with a shareable or embeddable surface, the pattern generalizes. A postMessage receiver has to check both event.origin and event.source before acting on a state-changing message; neither alone is enough. Settings passed between windows should not carry raw HTML. A dynamically created document whose contents can be influenced by an untrusted frame should keep its sandbox attribute, so a fresh document can't inherit your origin. CSP belongs behind those controls as defense in depth, because an allowlisted third party with a JSONP endpoint is a script source you don't control.
The bug lived in the seams. Preview was isolated, graphs could load a keyboard, frames could exchange messages, CSP allowed a major media host, and conversations could be shared. Each was reasonable alone. Treating every public share as attacker-controlled input would have caught the combination that wasn't.
Top comments (0)