Prompt injection only turns into data theft if the model has a way to send stolen data out. The subtlest of these are the exfiltration channels hiding in ordinary output-rendering: a markdown image whose URL carries stolen text, an auto-rendered link, a tool that fetches a URL the model chose.
Tier: Advanced · Workflow area: LLM attack surface & defense
This is the third leg of the lethal trifecta and the class of bug Johann Rehberger's "Month of AI Bugs" work made famous. This playbook hunts those channels in your own app and closes them, so that even a successful injection has nowhere to exfiltrate to.
Threat, briefly. After an injection makes the model read a secret, the attacker needs an egress. The classic vector: the model emits ; your UI auto-loads the image and the secret rides out in the query string — no click required. Variants: auto-rendered links, an agent's fetch/http tool, webhook or email tools, and DNS lookups.
Enumerate every outbound channel. List each way bytes leave the boundary: image/link rendering, any tool that makes network calls, file writes to shared storage, email/webhook actions, and logging to external sinks. This is the attack surface.
Disable auto-loading of model-supplied URLs. The highest-value fix. Do not auto-render images or fetch links whose URL came from model output. Strip image markdown, or gate rendering to an allow-list of trusted domains.
import re
ALLOW = {"cdn.yourco.com", "assets.yourco.com"}
def strip_untrusted_media(md: str) -> str:
def ok(m):
host = re.sub(r"^https?://([^/]+).*", r"\1", m.group(1))
return m.group(0) if host in ALLOW else "" # drop the image
return re.sub(r"!\[[^\]]*\]\((https?://[^)]+)\)", ok, md)
Put an egress allow-list on every network-capable tool. An agent's HTTP/fetch tool must only reach approved hosts; deny by default. Block private/link-local ranges to kill SSRF-style pivots.
from urllib.parse import urlparse
def egress_ok(url: str) -> bool:
host = urlparse(url).hostname or ""
return host in ALLOW and not host.startswith(("10.", "127.", "169.254."))
Encode output at the sink. Escaping model output (pb-28) also prevents it from becoming active markup that triggers a request. Encoding and egress control reinforce each other.
Prefer removing the exfil leg over patching each vector. If the feature doesn't truly need outbound calls or media rendering, remove the capability — that eliminates the whole class, which is the Rule-of-Two logic applied to egress.
Hunt the channels on your local app. Plant a canary secret in a private context, inject a payload that tries to exfiltrate it through each channel, and confirm nothing leaves.
# local target + local sink you own: prove no beacon fires
# tail your local "attacker" listener; it must receive NOTHING
nc -lk 9099 & # canary listener; expect zero connections after the run
A markdown image the user never clicks is a zero-click exfiltration channel. Auto-rendering model-supplied URLs is the single most common way injected data escapes.
The defense holds when the canary never reaches your listener. Set up a local sink you control, plant a canary secret in the model's private context, and run injections that attempt exfil via image, link, and tool fetch. Success criteria:
Automate the listener assertion so any reopening of a channel fails CI.
cdn.yourco.com.attacker.example passes a naive contains check. Parse the host and match exactly.bleach-class) — strip untrusted media and dangerous URI schemes at render.Exfiltration hunting is content-agnostic — you are testing whether any bytes can leave, using a canary you plant, so no real client data is needed. Run the canary listener and the injection payloads inside the client-approved environment, and if their app renders client documents, do the media-stripping and egress allow-listing there. The deliverable is a map of every outbound channel with its control state, which is exactly what a client's data-protection review wants to see for a system that touches personal data: proof that even a compromised model has no path to send that data off-network.