Step 1: Recon: prove the renderer auto-fetches
You are red-teaming DV-RAG-Support, ACME Cloud's customer-support assistant. A customer asks a question; the service embeds it, retrieves the most similar documents from a vector store, pastes them into the prompt, asks an LLM to answer, and then the chat client renders the answer as markdown.
This lab is about that last step. The model is an untrusted source, and its output
flows into a sink: the markdown renderer. A markdown image, , is an
outbound HTTP GET. If the model can be steered into putting a record field into
that URL, the field leaves with the request. That is OWASP LLM05:2025 Improper
Output Handling, and it is the exact channel behind EchoLeak
(CVE-2025-32711).
This step is pure recon. You confirm two facts the rest of the lab depends on, and you confirm the second one deterministically, without coaxing the model at all.
1. Build the index and trace one benign account question.
Hit Run, or in the terminal:
python3 dvrag.py --build
python3 dvrag.py "What plan am I on?"
Read the RETRIEVED: line. The account question pulls the customer's confidential
account record (kb_account.md, account reference GLOBEX-ACR-88231). That
retrieved field is your exfil target later, exactly as EchoLeak exfiltrated a
user's own data.
2. Prove the render sink auto-fetches, with no model in the loop.
You do not need the model to cooperate to know the channel exists. Call the sink directly with a benign probe image and watch it fire a real request:
python3 -c "import dvrag; print(dvrag._render(''))"
python3 listener.py --tail
_render() returns the list of URLs it actually loaded, and the in-pod listener on
127.0.0.1:9099 logs the inbound GET. That callback is your deterministic proof:
the renderer auto-fetched an attacker-named URL because RENDER_ALLOWED_HOSTS is None (no allow-list). Read _render() and _extract_image_urls() in dvrag.py:
the sink fires for inline images  AND reference-style images ![a][ref]
with a [ref]: url definition. EchoLeak used the reference style to slip past an
inline-image filter.
Pass criteria
The vector index is built, an account question retrieves the confidential account
record, the renderer ships with no allow-list (RENDER_ALLOWED_HOSTS is None), and
calling _render() directly with a probe image URL actually loads it (the
deterministic EchoLeak auto-fetch).
kb_account-recovery.mdkb_account.mdkb_billing.mdkb_getting-started.mdkb_globex-runbook.mdkb_initech-contract.mdkb_security-sso.mdkb_shipping.mdkb_status-incidents.mdlistener.py