TT Lab
ブログ はじめる
学ぶ 学習パス コース

LLMアプリのセキュリティ — インジェクションから出力処理まで

モデルの出力がSQL・HTML・シェルへ流れるとき — そしてコースを締めくくるインシデント

TT Labで続きを見る

目標

モデルの出力をSQL・HTML・シェル・DB書き込みにそのまま渡すレポートアシスタントを受け取る側の防御で修正し、コース全体の経路が混ざったクーポン流出インシデントの原因を特定します。

なぜ重要か

モデルの出力は攻撃者が書ける値です(前のモジュールのインジェクション)。その値を次のシステムにそのまま渡すと、SQLインジェクション・XSS・コマンドインジェクションがモデルを経由して戻ってきます。防御はモデルではなく受け取る側にかけます — サーバーが持つクエリを名前で選ばせること、プレースホルダー、読み取り専用接続、文脈に合ったエスケープ、シェルを介さない実行、スキーマ検証です。このラボの偽モデルは指示があれば常に攻撃者の文章を出力し、本物のモデルはそうした出力をときどき出します。受け取る側の防御は、その「ときどき」がいつ来ても同じ結果を出します。採点ツールは採点のたびに新しいDB・クーポン・マーカーでアプリを起動し、DBが変わるか、クーポンが出てくるか、タグが描画されるか、マーカーファイルが作られるかを確認し、正常な質問の結果が正しいかも確認します。

ステップ

  1. python3 /opt/fixtures/llmsec/kit.py report で教材を展開してから、/root/llmsec/report/app.py を127.0.0.1:8104で起動しておいてください(0.0.0.0は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8104/health のseedが、展開時に受け取ったシードと同じである必要があります。
  2. クエリログ /root/llmsec/report/data/history.log の sql= 以降(モデルが出したSQL)を見て、SELECT 1文ではないもの(複数の文か、SELECTではないもの)の数とcouponsテーブルに触れたものの数を、/root/llmsec/report/flow.txt に non_select=<count>、coupon_reads=<count> の2行で書いてください。
  3. plan_query と /root/llmsec/report/templates.txt を修正し、モデルの出力をSQLとして実行しないようにしてください — モデルはサーバーが持つクエリの中から1つを名前で選び、値はパラメーターで渡し、接続は読み取り専用で開きます。「地域別売上合計」・「月別売上合計」・「上位顧客5人」の3つの質問の結果は、今と同じである必要があります。
  4. render_page を修正し、/page の質問(q)とモデルの回答をHTMLに埋め込む前にエスケープしてください。タグは文字として表示され(削除しない)、正常なヘルプの回答はそのまま表示される必要があります。
  5. export_file を修正し、モデルが選んだファイル名をルール(文字・数字・-・_ の1–40文字 + .csv)で検証し、シェルを経由せずに data/out/ へコピーしてください。ルールに合う名前はそのままエクスポートされる必要があります。
  6. save_note を修正し、モデルが整理したメモをスキーマ(文字列、1–300文字、customers に存在する顧客)で検証してから、プレースホルダーで保存してください。合わなければ保存せず、4xxで応答します。シングルクォートを含む正常なメモも、文字どおりそのまま保存される必要があります。
  7. incident ls-coupon-leak を実行するとインシデントが発生します。クーポンコード3つが外部で使われました(/srv/ls-report/found.txt)。クーポンの持ち主ごとに経路を特定し、/srv/ls-report/app.py をラボのステップ3–6と同じように修正し、発生源を取り除き、漏れたクーポンだけを新しいコードに差し替えてから、/root/incident/ls-coupon-leak/report.txt に <coupon-owner>: <code> の3行と evidence: <log line> を書いてください。コードは log-bundle(協力会社に送ったログ一式)、prompt-extract(システムプロンプトの抽出)、shell-export(エクスポート名に含まれるシェルコマンド)、sql-from-model(モデルが書いたSQL)のいずれかです。

参考

アプリを展開してループバックで起動する

python3 /opt/fixtures/llmsec/kit.py report で教材を展開してから、/root/llmsec/report/app.py を127.0.0.1:8104で起動しておいてください(0.0.0.0は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8104/health のseedが、展開時に受け取ったシードと同じである必要があります。

方向: ラボのアプリを外部に公開する理由はありません — リッスンするアドレスがそのまま公開範囲です。 ツール: ss -ltnp, curl, setsid・nohup。 具体: 展開コマンドは上のとおりです。起動後、ss -ltn で8104番が127.0.0.1として表示されるか、/healthのseedが /root/llmsec/report/data/seed.txt と同じかを確認してください。

モデルが出したSQLを数える

クエリログ /root/llmsec/report/data/history.log の sql= 以降(モデルが出したSQL)を見て、SELECT 1文ではないもの(複数の文か、SELECTではないもの)の数とcouponsテーブルに触れたものの数を、/root/llmsec/report/flow.txt に non_select=<count>、coupon_reads=<count> の2行で書いてください。

方向: モデルが何を実行させたかは、すでにログに残っています。「モデルが書いたのだから大丈夫」がどれほど頻繁に外れたかを数えます。 ツール: grep, awk -F'sql=', python3。 具体: セミコロンで文を分け、空のものを除いて数えます。2つの数は重なることがあります(クーポンを読むSELECT 1文もあります)。

モデルはクエリを選ぶだけ

plan_query と /root/llmsec/report/templates.txt を修正し、モデルの出力をSQLとして実行しないようにしてください — モデルはサーバーが持つクエリの中から1つを名前で選び、値はパラメーターで渡し、接続は読み取り専用で開きます。「地域別売上合計」・「月別売上合計」・「上位顧客5人」の3つの質問の結果は、今と同じである必要があります。

方向: モデルがSQLを書く限り、そのSQLは攻撃者のものかもしれません。SQLはサーバーが持ち、モデルには名前を選ばせるように変えます。 ツール: templates.txt、辞書(名前 → SQL)、sqlite3.connect(f"file:{path}?mode=ro", uri=True)、プレースホルダー ?。 具体: templates.txtの | 以降をSQLの代わりに名前に変え、plan_queryはその名前が辞書にあるときだけサーバーのSQLを実行するようにしてください。知らない名前は4xxです。

回答を埋め込む前にエスケープする

render_page を修正し、/page の質問(q)とモデルの回答をHTMLに埋め込む前にエスケープしてください。タグは文字として表示され(削除しない)、正常なヘルプの回答はそのまま表示される必要があります。

方向: ブラウザーは文章がどこから来たのかを知りません。モデルの回答も、反射して表示する質問も、埋め込む位置の文脈に合わせて変換します。 ツール: html.escape。 具体: 両方の位置を囲んでください。タグを削除する方式は正常な文字まで壊し、変形を見逃します。

名前はルールで、コピーはシェルなしで

export_file を修正し、モデルが選んだファイル名をルール(文字・数字・-・_ の1–40文字 + .csv)で検証し、シェルを経由せずに data/out/ へコピーしてください。ルールに合う名前はそのままエクスポートされる必要があります。

方向: 文字列をシェルに渡した瞬間、その文字列の文法がシェルの文法になります。シェルが不要な処理はシェルなしで行います。 ツール: re.fullmatch, shutil.copyfile, subprocessの引数リスト。 具体: 許可ルールに合わなければ4xxで拒否してください(直して受け入れない)。ルールにパス区切り文字が含まれていなければ、パスの脱出も一緒に防げます。

スキーマを通過したものだけをプレースホルダーで保存する

save_note を修正し、モデルが整理したメモをスキーマ(文字列、1–300文字、customers に存在する顧客)で検証してから、プレースホルダーで保存してください。合わなければ保存せず、4xxで応答します。シングルクォートを含む正常なメモも、文字どおりそのまま保存される必要があります。

方向: モデルが形式を守ったとは信じません。型・長さ・参照を確認し、値はSQL文字列の外で渡します。 ツール: isinstance, len, SELECT 1 … WHERE name = ?, INSERT … VALUES (?, ?)。 具体: executescriptはプレースホルダーを受け付けません — executeに変えてください。顧客名もリクエストから来た値なので、同じ扱いをします。

インシデント — 3つのクーポンが外部で使われた

incident ls-coupon-leak を実行するとインシデントが発生します。レポートアシスタント(本番版 /srv/ls-report/app.py、データ /srv/ls-report/data/)のクーポンコード3つが外部で使われました(見つかった断片 /srv/ls-report/found.txt、ログ data/app.log、協力会社に送ったログ一式 /srv/ls-report/logs/vendor-bundle.log)。クーポンの持ち主ごとに経路を特定し、/srv/ls-report/app.py をラボのステップ3–6と同じように修正し(クエリ・画面・エクスポート・メモ — 採点ツールはそれらの攻撃をすべて再び送ります。正常な利用はそのまま動く必要があります)、発生源を取り除いてください — システムプロンプトのクーポンは削除し、協力会社向けのログ一式は行を残したままクーポンだけを伏せ、公開ディレクトリ(data/public/)にできたファイルは削除します。そのうえで coupons テーブルの漏れたクーポンだけを新しいコード(CP- + 英大文字・数字6文字以上、他のクーポンと重複しないもの)に差し替えます(行は残し、漏れていないクーポンはそのままにします)。/root/incident/ls-coupon-leak/report.txt に <coupon-owner>: <code> の3行と evidence: <log line> を書きます。コードは持ち主ごとに次のいずれかです: log-bundle(協力会社に送ったログ一式)、prompt-extract(システムプロンプトの抽出)、shell-export(エクスポート名に含まれるシェルコマンド)、sql-from-model(モデルが書いたSQL)。

方向: コースで見てきた出口が一か所に集まりました — 入力(インジェクション)、秘密(システムプロンプト・ログ)、出力(SQL・シェル)。断片の形が、どの出口を通ったかを教えてくれます。 ツール: grep -F, jq, app.logのkind・sql・cmd・answer, python3 -c 'import sqlite3 …'。 具体: 断片ごとにそのまま検索してください — クエリ結果の行の形ならkind=queryのsqlを、システムプロンプトの行ならkind=pageのanswerを、CSVの1行ならkind=exportのcmdとdata/publicを、質問文なら協力会社向けのログ一式を見ます。修正はラボのステップ3・5と同じ考え方で、クーポンの差し替えはUPDATE … WHERE owner = ?で1つずつ行います。