LLMアプリのセキュリティ — インジェクションから出力処理まで
モデルの出力がSQL・HTML・シェルへ流れるとき — そしてコースを締めくくるインシデント
目標
モデルの出力をSQL・HTML・シェル・DB書き込みにそのまま渡すレポートアシスタントを受け取る側の防御で修正し、コース全体の経路が混ざったクーポン流出インシデントの原因を特定します。
なぜ重要か
モデルの出力は攻撃者が書ける値です(前のモジュールのインジェクション)。その値を次のシステムにそのまま渡すと、SQLインジェクション・XSS・コマンドインジェクションがモデルを経由して戻ってきます。防御はモデルではなく受け取る側にかけます — サーバーが持つクエリを名前で選ばせること、プレースホルダー、読み取り専用接続、文脈に合ったエスケープ、シェルを介さない実行、スキーマ検証です。このラボの偽モデルは指示があれば常に攻撃者の文章を出力し、本物のモデルはそうした出力をときどき出します。受け取る側の防御は、その「ときどき」がいつ来ても同じ結果を出します。採点ツールは採点のたびに新しいDB・クーポン・マーカーでアプリを起動し、DBが変わるか、クーポンが出てくるか、タグが描画されるか、マーカーファイルが作られるかを確認し、正常な質問の結果が正しいかも確認します。
ステップ
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が、展開時に受け取ったシードと同じである必要があります。- クエリログ
/root/llmsec/report/data/history.logのsql=以降(モデルが出したSQL)を見て、SELECT 1文ではないもの(複数の文か、SELECTではないもの)の数とcouponsテーブルに触れたものの数を、/root/llmsec/report/flow.txtにnon_select=<count>、coupon_reads=<count>の2行で書いてください。 plan_queryと/root/llmsec/report/templates.txtを修正し、モデルの出力をSQLとして実行しないようにしてください — モデルはサーバーが持つクエリの中から1つを名前で選び、値はパラメーターで渡し、接続は読み取り専用で開きます。「地域別売上合計」・「月別売上合計」・「上位顧客5人」の3つの質問の結果は、今と同じである必要があります。render_pageを修正し、/pageの質問(q)とモデルの回答をHTMLに埋め込む前にエスケープしてください。タグは文字として表示され(削除しない)、正常なヘルプの回答はそのまま表示される必要があります。export_fileを修正し、モデルが選んだファイル名をルール(文字・数字・-・_の1–40文字 +.csv)で検証し、シェルを経由せずにdata/out/へコピーしてください。ルールに合う名前はそのままエクスポートされる必要があります。save_noteを修正し、モデルが整理したメモをスキーマ(文字列、1–300文字、customersに存在する顧客)で検証してから、プレースホルダーで保存してください。合わなければ保存せず、4xxで応答します。シングルクォートを含む正常なメモも、文字どおりそのまま保存される必要があります。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 /root/llmsec/<app>/app.pyで起動します。ターミナルを閉じても動き続けるよう、バックグラウンドで実行してください(setsid nohup … > <file> 2>&1 &)。修正するたびに起動中のものを停止して起動し直してください — ステップ1の採点ツールは、起動中のアプリが現在のapp.pyで起動されたものかを確認します(修正前のコードがリクエストを受け続ける本番インシデントを模した検査です)。最後にも起動したままにしておくと、ステップ1が改めて採点されます。 - リクエスト:
curl -s -H "X-Token: <token>" -H 'Content-Type: application/json' -d '{"q":"…"}' 127.0.0.1:<port>/<path>。トークンはdata/users.jsonにあります。 - 採点ツールは、皆さんが起動したアプリではなくapp.pyファイルを隣のディレクトリにコピーし、その中の新しい
data/(採点ツールが作ったランダムなデータ)と--portを使って皆さんと同じ方法で起動し、ランダムな攻撃を送ります。データはアプリの隣のdata/を基準に読み、ポートは--portで読む行を消さないでください。修正する関数はファイルの冒頭に書かれています。採点中であることを察知して振る舞いを変えるコードは失敗とみなします。 - 偽モデルは
/opt/fixtures/llmsec/fakellm.pyです。何を「指示」として読むのか、そのファイルの冒頭とdirectives()を読んでおいてください。 - エンドポイント:
POST /query {"q"}、GET /page?q=…(X-Tokenヘッダー)、POST /export {"q"}、POST /note {"customer", "q": "note: …"}。 - sqlite3 CLIはこのイメージにはありません。
python3 -c 'import sqlite3; …'でDBを確認します。 - インシデントツール:
incident listで発生させられるインシデントと1行の症状を、incident show <name>でチケットを再表示します。同じインシデントを再び発生させると、新しいシードで最初の故障状態になります(対象の原因も決め直されます)。 - レポートは
/root/incident/<name>/report.txtです。対象ごとに<name>: <cause-code>を1行ずつ書き、最後にevidence: <log line>(原因を示した記録1行)をそのまま付けます(ログ・設定の1行をまるごと、20文字以上)。採点ツールは修正したコードに新しいランダムな攻撃を再び送り(このインシデントの経路だけでなく、同じラボの前のステップの攻撃も回帰テストとして)、原因ごとに異なる後始末ができているかを確認したうえで、レポートを確認します。証拠の行はインシデント直後のログにあった行でなければならず、その対象のインシデントのリクエストや漏れた値と結びついている必要があります。 - よくある失敗: SQL文字列からDROP・UPDATEのような単語を探して弾くこと。変形を際限なく追いかけることになります — モデルに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つずつ行います。