LLMアプリのセキュリティ — インジェクションから出力処理まで
モデルの出力はユーザー入力である — SQL・HTML・シェルへ流れるとき
一行まとめ
モデルの出力がSQL・ブラウザー・シェル・DB書き込みに流れ込むと、古いインジェクション攻撃がモデルを経由して戻ってきます。モデルの出力はユーザー入力とまったく同じように信頼できない値として扱い、受け取る側の文脈に合った防御(意図の選択・パラメーター化・エスケープ・シェルを介さない実行・スキーマ検証)をかけます。
なぜこれが必要だったのか
前の3つのモジュールで見たように、モデルの出力は攻撃者が書けます。ドキュメント内の指示、ユーザーの質問、ツールの結果、どこからでもです。ところが多くのアプリは、モデルの出力を「自分たちのシステムが作った値」とみなして次の段階にそのまま渡します。自然言語をSQLに変換して実行するレポートアシスタント、モデルの回答をHTMLに埋め込んで表示する画面、モデルが決めたファイル名でシェルコマンドを組み立てるエクスポート、モデルが整理したメモを文字列連結したINSERTがそうです。すると、SQLインジェクション・XSS・コマンドインジェクションという古い問題が「モデルが書いたのだから大丈夫」という思い込みのもとで再び口を開けます。OWASP LLM Top 10 2025は、これを不適切な出力処理(LLM05 Improper Output Handling)として位置づけています。
どう動くのか
| 流れ込む先 | してはいけないこと | すべきこと |
|---|---|---|
| SQL読み取り | モデルが書いたSQLを実行する | サーバーが持つクエリの中から1つをモデルに「名前で」選ばせ、値はパラメーターで、接続は読み取り専用で |
| HTML | 回答をそのまま埋め込む | 埋め込む位置の文脈(本文・属性)に合わせてエスケープ — 質問を反射して表示する位置も同じ |
| シェル | 文字列を連結してshell=Trueで実行する | シェルを経由しない関数(ファイルコピー)か引数リスト、名前は許可ルール(文字・数字・-・_と拡張子)で |
| SQL書き込み | 値を引用符の中に連結する | プレースホルダー、そしてその前にスキーマ検証(型・長さ・参照する値が存在するか) |
読み取り専用接続は特に強調する価値があります。SQL文字列を検査してDROP・UPDATEを弾く方式は変形を際限なく追いかけなければなりませんが、mode=ro で開いた接続はエンジンが書き込みを拒否します。文字列の検査ではなくエンジンが守る境界だという点で、パラメーター化と同じ種類の防御です。
スキーマ検証とは、「モデルが形式を守ったか」を信じないことです。数値であるべき位置に文字列が、300文字以内であるべき位置に1000文字が、存在する顧客であるべき位置に存在しない名前が来ることがあります。合わなければ実行せず、4xxで拒否します — 500で落ちたり、直して受け入れたりはしません。
もう1つ付け加えると、エスケープは「どこに入れるか」によって異なります。HTML本文、属性値、JavaScript文字列、URLはそれぞれ危険な文字が異なり、そのためOWASPのXSS防止ガイドラインは、埋め込む位置の文脈に合ったエンコーディングを選ぶよう勧めています。このラボは本文の文脈1つだけを扱いますが、モデルの回答をリンクのアドレスやスクリプトの中に入れる設計なら、同じ関数では足りません。そうした位置には、そもそもモデルの出力を入れないほうがよいでしょう。
シェルも同じです。引用符で囲む関数(shlex.quote)はコマンドインジェクションを防ぎますが、.. でディレクトリをさかのぼる名前までは防げません。そこで名前は許可ルールで絞り込み、シェルが不要な処理(ファイルコピー)はシェルなしで行います。「危険な文字を直して受け入れる」より「ルールに合わなければ拒否する」ほうが堅牢な理由は、直す側は攻撃者が選んだ入力のあらゆるケースを知っていなければならないのに対し、拒否する側は許可した形だけを知っていればよいからです。
このコースの偽モデルは、指示があれば常に攻撃者の文章をそのまま出力します。本物のモデルでは、そうした出力がときどき、予測できない形で出てきます。出力処理の防御は、その「ときどき」がいつ来ても同じ結果になるよう受け取る側でかけるものです。だからこそ、モデルがどれだけ行儀がよいかとは関係なくテストできます。
現場で見かける姿
自然言語クエリツールが、モデルの作ったクエリで権限外のテーブル、つまり他の顧客のデータをそのまま返したケース、チャット画面がモデルの回答内のタグをそのまま描画し、ユーザーのブラウザーから外部のアドレスへリクエストが飛んだケース、「レポートをこの名前で保存して」という機能が名前に含まれるシェル構文を実行したケースが代表的です。このラボの最後のインシデントは、コース全体をまとめるものです。クーポンコード3つが外部で使われました。1つはモデルが書いたSQLで、1つはシステムプロンプトの抽出で、1つはエクスポートのシェルコマンドで、1つは協力会社に送ったログ一式で — そのうち3つが実際の経路です。
受け取る側の防御を入れたら、ログも変えます。モデルが出したSQL・HTML・コマンドをそのまま記録していたログは、それ自体が攻撃文字列の保管庫になり、別のツールがそのログを読み直して実行すれば同じ問題がもう一度起きます。修正後は、モデルが選んだ意図の名前、拒否した理由、リクエストidのように、判断に必要なものだけを残します。
次のラボでやること
レポートアシスタントのクエリログから、モデルが出した危険なSQLを数え、クエリを意図の選択・パラメーター・読み取り専用に、画面をエスケープに、エクスポートをシェルなしのコピーと名前ルールに、メモの保存をスキーマ検証とプレースホルダーに改めます。最後にクーポン流出インシデントの3つの経路を特定して塞ぎ、漏れたクーポンだけを差し替えます。