算一算:我们是如何接管 ChatGPT 账户的
一条公开的 ChatGPT 分享链接,能在打开它的人已登录的会话里执行攻击者的 JavaScript,读出他的私密对话,再发到站外。
中文
复制

2026 年 10 月,我们找到了一种方法,可以把一个普通的 ChatGPT 公开分享链接变成攻击者可控的 JavaScript,让它在任何打开该链接的人的已登录会话中执行。
攻击者可以创建一个恶意对话,用 ChatGPT 常规的分享功能把它发布出去,再把链接发给另一个用户。受害者打开这个分享链接时,payload 就以 chatgpt.com 的身份执行,并能访问受害者已通过身份验证的 Web 会话。
我们确认,这段代码可以枚举受害者的对话列表,读取一段从未分享给攻击者的私密对话中的内容,并把该对话中的数据发送到外部服务器。payload 并不局限于修改页面或弹出一个 alert,它能以受害者的身份直接调用 ChatGPT 的已认证 API,实际上等于让我们接管了受害者的 ChatGPT 账号。
受害者不需要安装 App、加入 Project、粘贴代码,也不需要打开开发者工具。唯一要做的动作就是打开那个分享链接。
OpenAI 于 10 月 26 日修复了该问题,并将这份报告定为 P1。
这条攻击链的起点是两个单独看都无害的功能,一个图表,以及一个 HTML Preview。

技术解析
从一个图表和一个 Preview 说起
这项研究最初的问题很简单。如果把 Preview 代码关进另一个网站里,它还能跟谁通信?
ChatGPT 可以用 Preview 功能把一个 HTML 代码块变成一个小型可运行网页。由于这个页面可能包含任意代码,ChatGPT 很合理地把它放在另一个网址下的受限框架里运行。Preview 可以执行 JavaScript,但浏览器把它当作另一个网站,因此它无法直接读取周围已登录的 ChatGPT 页面。
起初,这种隔离看起来完全按预期工作。Preview 里的代码读不到 parent.document,也就是 ChatGPT 的主页面。事情本应到此为止。
对话里还有一个普通的交互式图表。在后台,这个图表加载了一个名为 MathLive 的数学库。加载它不只是画出图表。它还在 ChatGPT 主页面里注册了一个监听器,用来接收与 MathLive 屏幕键盘相关的消息。
浏览器为此提供的 API 是 postMessage。即使浏览器阻止两个框架互相读取对方的页面,它们仍可以通过这个合法途径交换信息。可以把它想成一个投信口。受限的 Preview 走不进主页面,但依然能把消息从投信口塞进去。
只有当接收方检查消息是谁发来的时候,这种做法才安全。
对于我们关注的那项设置,MathLive 没有做这个检查。它的监听器接受了一条修改虚拟键盘布局的消息,而这个布局允许包含 HTML。图表装好了接收端,Preview 则提供了不受信任的发送端。两个互不相关的功能意外地搭出了一条穿过沙箱的路径。
本不该奏效的那条消息
Preview 发了两条消息。第一条用我们的 HTML 替换了虚拟键盘布局。第二条让 MathLive 显示键盘,从而渲染出新的布局:
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 识别出了这个公开的消息类型,但它的顶层控制器使用的 origin 策略是 none,也就是说它接受来自任何网址的消息。它也没有要求 update-setting 消息必须来自此前已获批准的窗口。只要能触达监听器并知道消息格式,任何人都可以替换布局。
键盘打开时,MathLive 会把传入的 layout 拼成一段 HTML 字符串,并把它赋给真实 ChatGPT 页面里的 innerHTML。有个叫 createHTML 的辅助函数,乍看像是会对这个值做净化。它并没有。它只是把字符串标记为可信,然后原样放行了我们的 HTML。
这已经够让人兴奋了,但还拿不到 JavaScript 执行。通过 innerHTML 插入的 script 标签不会自动运行。
下一步是用 srcdoc 构造一个 iframe。这是浏览器的一项特性,允许内嵌 frame 把 HTML 直接写在属性里:
<iframe srcdoc="..."></iframe>
MathLive 把这个 frame 插进了 ChatGPT 主文档,没有加 sandbox 属性。这给了我们两个有用的性质。第一,srcdoc 会创建一个全新的文档,外部脚本可以在其中执行。第二,由于这个 frame 是 ChatGPT 主页面创建的,浏览器赋予了它 https://chatgpt.com 的 Web 身份。于是新 frame 里的代码可以访问父页面。
用 YouTube JSONP 绕过 CSP
ChatGPT 的 Content Security Policy(CSP)又一次拦住了我们。CSP 控制页面可以加载哪些脚本,这条策略正确地阻止了内联 JavaScript。但它允许加载来自 YouTube 的脚本。
YouTube 的 oEmbed 端点支持一个 JSONP 风格的 callback 参数。JSONP 响应是 JavaScript,而不是普通 JSON。这个端点试图拒绝非法的 callback,但它在返回校验错误之前,就已经把 callback 放进了响应里:
https://www.youtube.com/oembed?callback=globalThis.__marker=31337;void
响应开头是这样的:
// API callback
globalThis.__marker=31337;void({
"error": {
"code": 400,
"message": "Invalid JSONP callback name: ..."
}
});
让 callback 以 ;void 结尾,YouTube 追加的那个对象就变得无害了。由于 ChatGPT 的 CSP 本来就允许 YouTube,浏览器把这段响应当作脚本加载,并在新 frame 里执行了我们的 callback。
至此我们拿到了以 chatgpt.com 身份运行的任意 JavaScript。原来的 Preview 仍然隔离在它自己的独立网址上;我们是通过诱使主页面里一个受信任的组件为我们创建一个新的、未加沙箱的文档,才越过了这道边界。
把一个实验室漏洞变成一条公开链接
到这里,这还只是一个有用的沙箱逃逸,但第一个能跑通的版本还不足以构成有说服力的真实攻击。它使用的发送方是由研究者控制的 ChatGPT App。受害者必须先连接或调用那个 App 才行。
接着我们尝试通过原生 HTML 附件投递 payload。ChatGPT 会在同一个沙箱化的 Preview 中打开该附件,因此它可以发出恶意的 MathLive 消息。
然而,当我们把对话导出为公开分享时,附件还在,之前的图表却不见了。图表不可或缺,因为它负责加载 MathLive,并在主页面中安装存在漏洞的消息监听器。没有它,附件仍能发出消息,但没有任何东西在监听或处理这些消息。
突破口在于把两部分直接放进同一个普通的公开对话。
- 一个真实的图表在主页面中初始化了 MathLive。
- 一条稍后由助手生成的 HTML Preview 发出了这两条消息。
由此得到的 /share/ 链接为另一名用户保留了完整的攻击链。攻击者可以像发送任何共享的 ChatGPT 对话一样投递它,比如电子邮件、聊天、工单或社交帖子。受害者无需安装任何东西,无需加入攻击者的工作区,也无需通过攻击者控制的流程进行身份验证。他们只需访问攻击者共享的 ChatGPT URL。
我们观察到了两种交互版本。在最稳定的复现中,受害者打开分享链接并点击一次 ChatGPT 常规的 Preview 按钮。后来我们将该 HTML 块标记为可预览,让 ChatGPT 自动打开它。在这个版本中,打开链接是受害者唯一的点击。我们在另一个账户中复现了自动路径三次,包括硬刷新之后,弹窗大约在 4.5 秒后出现。
图表和 Preview 是独立加载的,因此 payload 有时会在 MathLive 就绪之前就发出消息。最终的载体通过在一段有限时间内重试同样的两条消息来应对这一竞态。主页面中的一道防护确保私有对话验证和出站请求只执行一次。

此时,公开分享页面已经能弹出浏览器标记为来自 chatgpt.com 的警告,还能往主页加一条横幅。这证明了我们的代码在哪里运行,但还没说明这个漏洞为什么重要。
弹个警告远远不够
到这一步,弹警告已经是这个漏洞利用能做的最无聊的事。JavaScript 以 chatgpt.com 的身份运行,意味着它能用与正常 Web 应用相同的已认证上下文去操作 ChatGPT。
payload 读取页面本来就能拿到的会话信息,拿它去调用 ChatGPT 的对话 API。它先枚举受害者最近的对话,然后就能请求某个指定私密对话的消息数据:
GET /backend-api/conversations?...&limit=20&order=updated&offset=0
GET /backend-api/conversations/<conversation-id>?num_turns=2
这是带着受害者身份的 API 直接访问,不是去抓屏幕上恰好可见的文字。目标对话与那个恶意分享毫无关系,也从未分享给攻击者。
响应一旦落到注入的 JavaScript 手里,把数据发到站外就很简单了。payload 向一台我们控制的服务器发了一个普通的图片请求。
我们故意只把响应的一小部分发到站外。注入的代码已经收到了带认证的对话响应,也能读取其内容,所以这个漏洞并不局限于那一个值。

最终得到的是一个可用的跨用户数据窃取原语。恶意的公开对话可以在另一个用户已登录的 ChatGPT 会话中执行,枚举私密对话、读取私密消息内容,并把选中的数据发送到攻击者控制的服务器。
由于代码是以 Web 应用已认证的权限运行的,它能触及的范围并不天然局限于我们概念验证中用到的对话接口。ChatGPT 已认证前端 API 暴露的其他数据和操作都处在同一条安全边界之内。也就是说,我们可以以受害者的 ChatGPT 账号身份执行任意操作。
问题出在哪里,OpenAI 修了什么
Preview 沙箱本身没有问题。Preview 里的代码无法直接读取 ChatGPT 页面。问题在于主页面中的另一个组件愿意接受它的指令。
三个设计决策把这个失误变成了完整的漏洞利用。
- MathLive 接受了一条会改变状态的消息,却没有验证它来自哪个 frame。
- 消息格式允许把原始 HTML 作为键盘布局数据传入。
- 这段 HTML 可以创建一个未加沙箱的文档,而该文档继承了
chatgpt.com的身份。
YouTube 的响应只是绕过 CSP 的最后一步,并非根因。只封掉那个接口,等于堵死了一条通往 JavaScript 的路径,但不受信任的 Preview 仍然能把 HTML 注入 ChatGPT。
OpenAI 复现了 JavaScript 执行和私密对话读取这两项。修复分两个版本在 ChatGPT 的 Web 前端上线。推送期间,CSP 的改动阻断了原始利用链的一部分,而其中一个版本仍需更新。10 月 26 日,OpenAI 确认两个版本都已拒绝报告中使用的跨 frame MathLive 消息,并将该问题标记为已解决。
防御上的经验相当简单。
- 消息接收方应同时校验发送方的网址和获得授权的确切窗口。
- 跨窗口的设置不应包含原始 HTML。
- 当动态创建的文档内容可能被不受信任的 frame 影响时,它应保持沙箱状态。
- CSP 应作为兜底控制,而不是攻击者可控的 HTML 进入主站之后的唯一屏障。
这个漏洞值得注意的地方在于,单看每一个功能都合情合理。Preview 是隔离的。图表可以加载数学键盘。Frame 之间可以互发消息。CSP 允许了一家大型媒体提供商。公开对话可以分享。只有把这些功能全部放进同一个页面,漏洞才会浮现。
时间线
| 日期 | 事件 |
|---|---|
| 2026 年 10 月 13 日 | 对照 ChatGPT 的生产代码,确认了 MathLive 从消息到 HTML 的路径。 |
| 10 月 14 日 | 通过正常 UI 复现了整条利用链,并把原本固定的 alert 升级为任意 JavaScript。 |
| 10 月 16 日 | 确认只需点击一次 Preview,就能在不同账号、不同工作区之间完成公开分享的投递。 |
| 10 月 17 日 | 确认通过分享链接导航即可自动触发 Preview,并在强制刷新后复现。 |
| 10 月 18 日 | 向 OpenAI 的漏洞赏金计划提交报告。 |
| 10 月 19 日 | 补充了受控的私密对话泄露证明。 |
| 10 月 21 日 | OpenAI 复现了该问题;严重级别从初判的 P2 上调至 P1。 |
| 10 月 22 日 | OpenAI 报告其中一个 Web 前端已修复,第二个仍在更新中。 |
| 10 月 23 日 | CSP 变更阻断了利用链的一部分,但仍有一个前端尚未更新。 |
| 10 月 26 日 | OpenAI 确认两个前端均已拒绝所报告的消息,问题已解决,并发放了总额 5,000 美元的赏金。 |
技术细节
上面的正文已经包含完整的利用链。以下细节是给想进一步对照生产代码和 payload 变换过程的读者准备的。
相关的反混淆后控制流等价于:
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;
}
布局归一化保留了 layouts[].layers[].markup。显示键盘最终执行的操作等价于:
keyboardContainer.innerHTML = MathfieldElement.createHTML(keyboardHTML);
createHTML 起的是恒等包装或 Trusted Types 包装的作用,而不是 sanitizer。Trusted Types 可以限制谁有权向 HTML sink 赋值,但恒等策略并不会让攻击者可控的字符串变得安全。
解析器与 CSP 的转换。 innerHTML 操作可以创建一个 iframe,但以这种方式直接插入的脚本会保持惰性。未加 sandbox 的 srcdoc 会触发一次全新的 HTML 文档解析,并继承嵌入方的 ChatGPT 源。该文档同时继承了测试期间捕获到的实际生效 CSP,它阻止了内联代码,但允许 script-src-elem 中的 www.youtube.com。
oEmbed 响应返回 HTTP 200,并带有 Content-Type: text/javascript; charset=UTF-8 和 X-Content-Type-Options: nosniff;未观察到 Cross-Origin-Resource-Policy 头。Google 把回调里的尖括号转义掉了,因此载荷改用经典的 function 语法,而非箭头函数。程序以 ;void 结束,消耗掉了端点附加的错误对象。
在私密对话的验证中,函数通过 toString() 转换为源码,双引号被替换为单引号,空白被折叠。随后 URLSearchParams 将其编码进回调,脚本 URL 在 <script src> 内做了 HTML 转义,整个元素又被再次转义进 iframe 的 srcdoc 属性。最终回调为 5,533 个字符;YouTube URL 为 7,309 个字符;脚本元素为 7,337 个字符;最终的 iframe 标记为 7,390 个字符。
已认证的读取。 同源 frame 从父文档中解析出 #client-bootstrap,提取会话访问令牌和账户 ID,并在两者都存在时要求令牌中的账户 ID 与会话一致。它使用了这些内存中的请求头:
Authorization: Bearer <victim token>
ChatGPT-Account-ID: <victim account>
Accept: application/json
主要请求为:
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=true
只有在详情请求返回 403、404 或 405 时,代码才会尝试旧的 /backend-api/conversation/<id>?include_full_conversation=true 路由。这次验证只把响应里选定的一个小值传了出去。在发起外部图片请求之前,它清除了对令牌、账户、bootstrap、会话和请求头的引用。
关键要点
- 隔离的强度取决于跨越它的每一座桥。一个被沙箱化的 frame,只要某个特权监听器信任了错误发送方的消息,仍然可以攻陷它的父页面。
postMessage接收方在接受改变状态的指令之前,必须同时验证event.origin和event.source。- HTML 注入可以借助一个全新的、未沙箱化的文档以及一个被允许的第三方脚本端点,升级为 JavaScript 执行。CSP 是有价值的纵深防御,但无法让不安全的渲染路径变得安全。
- 同源 JavaScript 的影响必须在受害者的已认证上下文中衡量。代码一旦以
chatgpt.com身份运行,就能触及正常前端可访问的同一批私有数据和账户操作,而不只是弹出一个 alert。 - 跨功能测试很重要。这条利用链存在于公开分享、Preview、MathLive、frame 创建和 CSP 之间的接缝处,而不是单独审视任何一个功能时能看到的。
更广泛的教训是,安全边界必须覆盖整个产品,而不能只存在于各个组件内部。任何公开或可分享的界面都应被视为攻击者可控的输入,每条跨 frame 消息都应经过身份验证,不可信的标记在其整个渲染路径中都应始终保持沙箱隔离。