Most teams think of their model's output as text to display. It isn't. When you render it as markdown or HTML, it becomes markup the browser will act on — and one of the things markdown does, quietly and automatically, is fetch images. That single feature is enough to turn a chat window into a data-exfiltration channel that needs no clicks, no downloads, and no visible trace.
This is the third leg of the lethal trifecta — the external communication channel — and it doesn't live in your tool layer or your network egress. It lives in the UI, in the component whose entire job is to render whatever the model said. It ships open by default, and it has burned nearly every major AI product.
The zero-click exploit, in one line
An attacker who can get instructions into the model — through a retrieved document, a tool result, a web page, a calendar invite — can get the model to emit a markdown image. The syntax is . When the response renders, the browser fetches that URL immediately to display the image. There is no image; there is a query string carrying whatever the model was coaxed into placing in it, and it lands in the attacker's access logs.
No user interaction. No download prompt. Often no rendered artifact at all — a broken image icon at most. The data is gone the instant the message appears on screen. That is what “zero-click” means here, and it is why the image tag, not script injection, is the reliable vector: it fires automatically and it looks like a feature working exactly as designed.
This is a class, not an incident
The same defect has been documented across ChatGPT, Microsoft Copilot, Google Gemini, Slack AI, and Claude.ai. Two named cases are worth knowing. EchoLeak exfiltrated data out of Copilot with zero clicks through rendered content. CamoLeak went a step further: it abused an image proxy — the very control meant to protect users — as the exfiltration channel, mirroring attacker URLs through a trusted host to smuggle private source code out.
The lesson from CamoLeak matters more than the exploit itself. A proxy or an allowlist is only a boundary if the host it trusts cannot be coerced into fetching an attacker's URL. Bolt a proxy on without that property and you have moved the channel, not closed it.
Why you can't filter your way out
The tempting fix is to scan output for suspicious URLs. It doesn't hold, for the same reason input classifiers don't hold: a permitted image from a permitted host is not an anomaly, it is the product working. The malicious request is structurally identical to a legitimate one. The difference is where the URL points and what data it carries — and by the time your scanner is second-guessing that, you are back to probabilities.
So you do what you do everywhere else in this space: you stop trying to detect the attack and you arrange the system so it doesn't matter. On the render surface, that means making it structurally impossible for output to reach a host you don't control.
The hardening, in layers
Four controls, and you want all of them — each covers a gap the others leave:
- Strict CSP. Lock down
img-srcandconnect-srcto your own origin. This is the backstop the browser enforces even when everything upstream fails — an auto-fetch to an attacker host simply doesn't happen. - Server-side proxying. Fetch every image and link through your own origin instead of letting the client hit an arbitrary URL. The client never talks to the attacker directly.
- Host allowlisting. Permit only hosts you control and that cannot be tricked into mirroring attacker URLs. This is the control CamoLeak defeated when it was done carelessly.
- Sanitization. DOMPurify or bleach to strip active content, and a firm rule that the renderer never executes anything the model emits. Passive rendering only.
Sanitization catches XSS. CSP, proxying, and allowlisting catch the subtler case — the image or link that is not XSS at all, just a feature pointed at the wrong host. You need both halves. Stripping scripts while leaving img-src wide open closes the loud door and leaves the quiet one open.
Coverage is the whole game
Here is where teams lose. They harden the chat window and declare victory — and the email digest, the markdown preview, the Slack unfurl, the PDF export, and the mobile webview each render the same model output through their own, unhardened path. One uncovered surface re-opens the channel for the entire system. The attacker only needs the surface you forgot.
So the deliverable is not “we added a CSP.” It is an enumerated list of every place model output gets rendered, with proof that each one runs through the hardened path. Coverage, not any single control, is the property that actually protects you.
What we tell clients
This is the cheapest high-value control in the catalog. Low-to-medium effort, and it closes a zero-click exfiltration path that has embarrassed every major vendor in the space. If your agent renders model output in a browser and touches anything private, this is not a nice-to-have — it is the exit door on the lethal trifecta, and it is standing open until you close it.
Do all four controls, prove your coverage across every render surface, and remember the one limit that remains: an allowlist can still leak through a host it trusts. So keep this paired with least-privilege tooling and egress control, the way we pair every control here — when a bypass eventually lands, you want it to land on a system that has nothing worth sending and nowhere to send it.
This is one pattern in our Defense Architecture catalog. The pattern page for Output-Rendering Hardening has the trust boundaries, threat surface, and architecture diagram in full.