Data Exfiltration via Markdown Images and Links
5 min read · updated August 3, 2026
A chat interface that renders markdown has, by default, an outbound HTTP channel that fires without a click. This has been responsibly disclosed against a long list of assistant products since 2023 — most publicly by Johann Rehberger — and the durable lesson is where the fix belongs, not which vendor was affected.
The shape of the attack
The attack needs four ordinary things and no vulnerability in the conventional sense:
- The model reads attacker-authored content — a document, a page, an email.
- That content is read as instruction, because instructions and data share one channel.
- The model has private data in context, or a tool that fetches some.
- Your interface renders the model’s markdown, and an image in markdown is an HTTP GET to a host of the author’s choosing.
The injected instruction asks the model to encode what it knows into a URL path or query string and emit an image referencing it. The renderer loads the image. The attacker’s server logs the request. Nothing rendered visibly — a broken or 1×1 image is easy to miss — and no user action was required. In OWASP’s terms this is LLM02, Sensitive Information Disclosure, delivered through LLM05, Improper Output Handling.
The variant with a clickable link is slightly weaker, because it needs the victim to click, and considerably more persuasive, because the link text can say anything. Both are the same defect: model output is being rendered as active content.
It is worth being precise about the bandwidth, because “it can only carry a little” is a common and wrong reason to deprioritise this. A URL path comfortably carries a couple of thousand characters, and the interesting secrets are small: an API key, a password reset link, a customer’s address, the contents of the one email the attacker asked about. Where more is wanted, the model can be instructed to emit several images. A channel that leaks one API key per rendered message is not a low-severity channel.
Channels you did not think were channels
Once you look for “output that causes a request”, the list is longer than markdown images:
- Markdown images and autolinked URLs, including bare URLs that your renderer helpfully linkifies.
- HTML passed through —
iframe,form,object,link rel=prefetch, and CSS with aurl()in it. Any renderer that permits raw HTML permits all of these. - Link previews and unfurls, where your own backend fetches a URL the model produced. This one exfiltrates even if the user never sees the message.
- Tool arguments — a fetch, webhook or search tool whose URL the model composes is a channel with a nicer name.
- Anything written to shared state: a commit, a comment, a shared document, a support-ticket reply. The attacker reads it later.
- DNS. Even a blocked request usually resolves a hostname first, and a hostname carries data. This is why a proxy allowlist beats an outbound firewall rule that only inspects HTTP.
The fix is in the renderer
Asking the model not to emit exfiltration URLs is a request to the component that has already been compromised. The renderer, by contrast, is code you control, and the rule it needs is deterministic: a URL in model output is safe if and only if its host is on a list you wrote.
// Post-process model markdown before it reaches any renderer.
// Deterministic: no model, no classifier, no judgement about intent.
const IMAGE_HOSTS = new Set(["cdn.example.com", "avatars.example.com"]);
function safeHost(raw: string, allow: Set<string>): boolean {
let u: URL;
try {
u = new URL(raw);
} catch {
return false; // relative or malformed -> not renderable
}
if (u.protocol !== "https:") return false; // blocks data:, javascript:, http:
if (u.username || u.password) return false; // [email protected] tricks
return allow.has(u.hostname.toLowerCase());
}
export function sanitizeModelMarkdown(md: string): string {
// 1. Images: drop entirely unless the host is allowlisted.
md = md.replace(/!\[([^\]]*)\]\(([^)\s]+)[^)]*\)/g, (_m, alt, url) =>
safeHost(url, IMAGE_HOSTS) ? `` : `[image removed]`,
);
// 2. Links: keep the text, strip the target, show the host so a human
// can judge it. Never silently rewrite to something clickable.
md = md.replace(/\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g, (_m, text, url) => {
let host = "unknown";
try {
host = new URL(url).hostname;
} catch {}
return `${text} (link to ${host}, not opened automatically)`;
});
return md;
}Three details in that snippet are the ones people get wrong. Parsing with URL rather than a regex, so that javascript:, data: and credential-in-host forms are rejected structurally. Allowlisting hosts rather than denylisting bad ones, because a denylist is an infinite set. And comparing the parsed hostname exactly, not with endsWith — cdn.example.com.evil.test passes a careless suffix check.
Rendering to HTML has an equivalent rule: allowlist the tags and attributes you want, using a maintained sanitiser, rather than stripping the ones you fear.
The network-layer backstop
Sanitising is application code and application code has bugs, so put a second control underneath it that does not depend on your parser being correct. In a browser surface, a content security policy is enforced by the browser regardless of what your renderer emitted:
Content-Security-Policy: default-src 'self'; img-src 'self' https://cdn.example.com; connect-src 'self' https://api.example.com; frame-src 'none'; object-src 'none'; form-action 'self'; base-uri 'none'
For server-side agents the equivalent is an egress proxy: outbound requests go through a component with an allowlist, and the agent runtime has no direct route to the internet. That also closes the DNS channel, which host-level HTTP filtering does not.
A review checklist
- Does any surface render model output as markdown or HTML? List them all — chat, email digests, PDF exports, Slack messages, admin dashboards.
- Is there an image host allowlist, compared against a parsed hostname?
- Are links rendered non-clickable, or shown with their host visible, when the model produced them from untrusted content?
- Does anything fetch a URL for a preview or unfurl before a human sees the message?
- Do tools accept a model-composed URL? If so, is the host allowlisted server-side?
- Is there a CSP on the render surface, and an egress allowlist for the agent runtime?
- Are outbound requests from the agent logged with their full URL, so an incident is reconstructable?
The last item is the one that gets skipped and the one you will want. Exfiltration is silent by design; without a log of what left, the post-incident question “what did they get?” has no answer.
A closing note on scope. Everything above is about the rendering and egress surfaces, not about the model, and that is deliberate: these are the controls that keep working when the injection succeeds. A team that spends its budget teaching the assistant not to emit tracking URLs has bought a probabilistic defence for a problem that had a deterministic one available.