System architecture

ChatGPT share-link account takeover: the postMessage bug, and how to secure your own listeners

How a ChatGPT share link ran code as chatgpt.com through MathLive's postMessage listener, and the listener, iframe and CSP fixes for your own app.

Kemo
10 min read
A green building and blue sky reflected in a three-pane window set into a white wall.
On this page
  1. What did Parameter Labs find in ChatGPT?
  2. How the ChatGPT exploit chain worked, step by step
  3. Why checking event.origin is not enough in a postMessage listener
  4. A srcdoc iframe inherits your origin unless you sandbox it
  5. Why the Content Security Policy did not stop the attack
  6. Which defence stops which step
  7. How to audit your own front end for postMessage bugs
  8. The one thing to remember
  9. Questions people ask about the ChatGPT share-link takeover
  10. Sources and further reading

Opening a public ChatGPT share link was enough to run an attacker's JavaScript as chatgpt.com in your signed-in session, read your private conversations and send them elsewhere. Parameter Labs reported the chain to OpenAI on 18 October 2026; OpenAI rated it P1, fixed it across two web frontends by 26 October, and Parameter published the write-up on 28 October (Parameter Labs).

The bug is not exotic. It is a message listener that trusted a frame it should not have, a setting that carried raw HTML, and an iframe created without a sandbox — three mistakes that are common in any app embedding third-party widgets or user-generated previews. This article walks through the chain, then shows the listener, iframe and Content Security Policy patterns that stop each link, with code you can drop into your own front end.

What did Parameter Labs find in ChatGPT?

A cross-frame takeover: content in ChatGPT's sandboxed HTML Preview could send postMessage instructions to the MathLive maths editor loaded in the main page, and MathLive turned one of them into HTML in the chatgpt.com origin. From there the payload read the session's access token and called ChatGPT's own conversation APIs as the victim. The researchers say they deliberately exfiltrated only a small piece of a response to prove the point (Parameter Labs).

Date (2026)Event
13 OctMathLive message-to-HTML path confirmed against production
14 OctReproduced through the normal UI and escalated to arbitrary JavaScript
16–17 OctDelivery through a public share link confirmed across accounts; Preview runs automatically
18 OctReported to OpenAI's bug bounty
21 OctOpenAI reproduces; severity raised from P2 to P1
22–23 OctFirst frontend fixed; CSP change blocks part of the chain
26 OctOpenAI confirms both frontends reject the messages; resolved
28 OctWrite-up published; reported bounty $5,000
Disclosure timeline for the ChatGPT share-link takeover, as published by Parameter Labs

There is nothing for ChatGPT users to patch: the fix was server-side and in the web app. What is worth your time is checking whether your own product has the same shape, because the researchers' point is that each component behaved "as designed" and the hole only existed where they met.

How the ChatGPT exploit chain worked, step by step

The attacker needed one public conversation containing two things: an interactive graph, which loads MathLive into the main page, and an HTML Preview, which runs in an isolated origin. Sharing that conversation delivered both to anyone who opened the link (Parameter Labs).

Inherits chatgpt.comVictim opens /share linkHTML Previewisolated originchatgpt.com main pageMathLive listenerupdate-settingsrcdoc iframeno sandboxYouTube oEmbedJSONP callback/backend-api/conversationspostMessageinnerHTMLscript srctoken fromparent
  • Victim opens /share link → chatgpt.com main page
  • chatgpt.com main page → HTML Preview
  • HTML Preview → MathLive listener: postMessage
  • MathLive listener → srcdoc iframe: innerHTML
  • srcdoc iframe → YouTube oEmbed: script src
  • srcdoc iframe → /backend-api/conversations: token from parent
The Preview frame was isolated; the frame MathLive created was not, and it inherited the chatgpt.com origin.
  1. Cross-frame message. The Preview posts two messages to the top window: one replaces the virtual-keyboard layout with attacker HTML, the other tells MathLive to show the keyboard.
  2. No sender check. The listener's origin policy was effectively none, and it did not confirm that update-setting came from a window it trusted.
  3. HTML sink. MathLive assigned the layout markup to innerHTML. Its createHTML helper marked the string as trusted without sanitising it.
  4. Unsandboxed frame. The injected HTML created an iframe with srcdoc and no sandbox attribute. Because the main page created it, it ran as chatgpt.com.
  5. CSP bypass. The page blocked inline scripts but allowed www.youtube.com. A YouTube oEmbed endpoint echoed a JSONP-style callback into its error response, which loaded attacker code as a script.
  6. Account access. The script read session bootstrap data from the parent, took the access token and account ID, and called the conversation list and detail endpoints.

All six steps are from the Parameter write-up; OpenAI reproduced both the code execution and the private-conversation read (Parameter Labs). MathLive has had HTML-injection bugs before: CVE-2025-29049 was an XSS through the \htmlData command, fixed in 0.104.0.

Every component behaved as designed; the hole was where a trusted listener took orders from an untrusted frame.

Why checking event.origin is not enough in a postMessage listener

Check the origin and the exact sending window, then validate the message's shape. MDN's guidance is blunt: if you do not expect messages from other sites, do not add a message listener at all, and if you do, "always verify the sender's identity using the origin and possibly source properties" — then "always verify the syntax of the received message" (MDN).

Origin alone fails in two common situations. If your app serves user content from a preview or sandbox origin, an origin allowlist that includes that origin also admits the attacker. And a frame sandboxed without allow-same-origin has an opaque origin, which arrives as the string "null" — so a listener that accepts "null" accepts every sandboxed frame on the page. Comparing event.source with the contentWindow of the one iframe you created pins the message to a single window.

keyboard-host.js — the vulnerable shapeJavaScript
// Accepts orders from any window in the page's frame tree.window.addEventListener("message", (event) => {  const msg = event.data;  if (msg && msg.type === "update-setting") {    keyboardEl.innerHTML = msg.layout;   // HTML chosen by the sender  }  if (msg && msg.type === "show") keyboardEl.hidden = false;});
keyboard-host.js — origin, source and shape checkedJavaScript
const KEYBOARD_ORIGIN = "https://keyboard.example.com";const LAYOUTS = new Map([  ["numeric", buildNumericLayout],  ["greek", buildGreekLayout],]); window.addEventListener("message", (event) => {  if (event.origin !== KEYBOARD_ORIGIN) return;                 // who  if (event.source !== keyboardFrame.contentWindow) return;      // which window  const msg = event.data;  if (typeof msg !== "object" || msg === null) return;           // what  if (msg.type === "update-setting" && LAYOUTS.has(msg.layout)) {    keyboardEl.replaceChildren(LAYOUTS.get(msg.layout)());       // a name, never markup  } else if (msg.type === "show") {    keyboardEl.hidden = false;  }});

The second version carries a layout name, and the host builds the DOM itself. That removes the HTML sink altogether, which is the fix Parameter recommends: verify both event.origin and event.source before accepting state-changing instructions, and do not pass raw HTML through cross-window settings (Parameter Labs). When you send, give an exact target origin rather than *, so a frame that has been navigated elsewhere cannot read your message (MDN).

Library authors can check this in their own changelogs. MathLive 0.109.2 (28 April 2026) changed its virtual-keyboard message guard to check "the source window rather than only the top-level window" to fix a recursion bug in sandboxed iframes (MathLive changelog). The changelog does not describe that as a security fix, and it predates this report, so do not read it as the patch for this chain; if you embed MathLive's virtual keyboard, test what your version accepts.

A srcdoc iframe inherits your origin unless you sandbox it

An iframe built from srcdoc loads about:srcdoc, and without a sandbox attribute it runs with the embedding page's origin — which is how the injected frame in step 4 became chatgpt.com (Parameter Labs; MDN on iframe). Add sandbox to every frame whose content anyone else can influence.

render-preview.jsJavaScript
function renderUntrusted(html) {  const frame = document.createElement("iframe");  // Opaque origin: no access to the parent DOM, cookies or storage.  frame.setAttribute("sandbox", "allow-scripts");  frame.setAttribute("referrerpolicy", "no-referrer");  frame.srcdoc = html;  previewEl.replaceChildren(frame);  return frame;}

Do not add allow-same-origin next to allow-scripts for content you do not control. MDN warns that for a same-origin embedded document the combination "lets the embedded document remove the sandbox attribute — making it no more secure than not using the sandbox attribute at all" (MDN). If the preview needs to talk to the page, use postMessage with the checks above, and remember that its origin will be "null", so identify it by event.source.

Why the Content Security Policy did not stop the attack

Because it was an allowlist. The page blocked inline scripts but trusted every script on www.youtube.com, and one YouTube endpoint would wrap attacker-chosen text in a callback (Parameter Labs). Google's strict CSP guide says allowlist policies "can be bypassed by attackers", citing research into exactly this class of trusted-host gadget, and recommends a nonce- or hash-based policy instead:

Strict CSP header (nonce-based, from web.dev)Text
Content-Security-Policy:  script-src 'nonce-{RANDOM}' 'strict-dynamic';  object-src 'none';  base-uri 'none';

With a nonce, a script loads only if the tag carries this response's random value, or was created by a script that did; a JSONP URL on a host you trust gets no special pass. The nonce must be fresh on every response (web.dev). To close the innerHTML sink as well, the Trusted Types API lets a CSP require that DOM sinks receive values from a policy you wrote. MathLive's createHTML shows the trap: a policy that marks a string as trusted without sanitising it satisfies the browser and protects nothing.

trusted-types.jsJavaScript
// Served with: Content-Security-Policy: require-trusted-types-for 'script'import DOMPurify from "dompurify"; export const htmlPolicy = trustedTypes.createPolicy("app-html", {  createHTML: (input) => DOMPurify.sanitize(input),   // sanitise, don't just wrap}); // keyboardEl.innerHTML = untrusted;                 // now throws a TypeErrorkeyboardEl.innerHTML = htmlPolicy.createHTML(untrusted);

Which defence stops which step

Any one of the first three controls below would have broken the chain on its own; the rest are there for the day one of them slips. That is why Parameter frames CSP as defence in depth rather than the barrier: blocking one YouTube endpoint leaves the injection path open for the next gadget.

DefenceBreaks stepWhat it does not cover
Check event.origin and event.source2 · untrusted senderA compromised frame you do trust
Send names or data, never HTML, across windows3 · HTML sinkOther sinks in the same component
sandbox on every iframe with influenced content4 · origin inheritancePhishing inside the frame
Strict nonce-based CSP5 · script gadget on an allowed hostCode that runs through an existing trusted script
Trusted Types with a sanitising policy3 · HTML sinkBrowsers without enforcement
Tokens kept out of page-readable bootstrap data6 · token theftRequests made from the page with cookies
Defences against a cross-frame takeover, mapped to the steps of the ChatGPT chain they break

How to audit your own front end for postMessage bugs

Start with a search, because the risky code is small and easy to find. List every message listener, every srcdoc and every iframe you create from script, then ask of each listener: which window is it meant to hear, and does it check that window?

Find message listeners, srcdoc frames and HTML sinksShell
rg -n "addEventListener\(\s*['\"]message['\"]|onmessage\s*=" src/rg -n "srcdoc|createElement\(['\"]iframe" src/rg -n "innerHTML|outerHTML|insertAdjacentHTML" src/

Do the same for third-party scripts you load into the top page — maths editors, chat widgets, payment and analytics SDKs — since a vendor's listener runs with your origin. Then test the combinations, not the features: share a document containing every embed type, open it signed in as a second account, and post messages at the top window from the browser console inside each frame. Parameter's last recommendation is the one that generalises: treat anything a user can share as attacker-controlled input (Parameter Labs).

The one thing to remember

A message listener is an API endpoint with no authentication unless you write it. Give it an exact sender — origin and window — accept data rather than markup, and sandbox any frame whose contents someone else can shape; the strict CSP is there for the day one of those slips.

Could a ChatGPT share link really take over my account?

Before 26 October 2026, yes: Parameter Labs showed that opening a crafted public share link could run JavaScript as chatgpt.com and read private conversations. OpenAI rated it P1 and fixed it; nothing needs updating on the user side (Parameter Labs).

Is the ChatGPT share-link vulnerability fixed?

Yes. OpenAI confirmed on 26 October 2026 that both affected web frontends reject the malicious messages, and Parameter published its write-up on 28 October (Parameter Labs).

How do I validate postMessage securely?

Check event.origin against an exact expected origin, check event.source is the specific window you expect, then validate the message's structure before acting on it. When sending, pass an exact target origin instead of * (MDN).

Is checking event.origin enough?

Not always. If attacker-controlled content can be served from an origin you allow, or you accept the opaque origin "null" from sandboxed frames, origin checks pass. Comparing event.source with your iframe's contentWindow ties the message to one window.

Does an iframe with srcdoc share my page's origin?

Yes, unless it has a sandbox attribute without allow-same-origin. The injected frame in the ChatGPT chain had no sandbox, so it ran as chatgpt.com (Parameter Labs).

Why didn't ChatGPT's Content Security Policy block the script?

It allowed scripts from www.youtube.com, and a YouTube endpoint reflected a JSONP-style callback, so attacker code loaded from an allowed host. Nonce-based strict CSP with 'strict-dynamic' avoids trusting whole hosts (web.dev).

How much did OpenAI pay for the bug?

Parameter Labs reports a total bounty of $5,000 for the P1 finding (Parameter Labs).

Sources and further reading

Discussion

Join the discussion

Sign in with Google, Apple or an email code to comment and reply.

One practical article a week

Follow k13 in any RSS reader. No account needed.