LLMアプリのセキュリティ — インジェクションから出力処理まで
秘密と個人情報が漏れる場所 — 入れない、ふるい分ける、マスクする、検知する
目標
カスタマーサポートボットで、システムプロンプトのキー、原文のままのログ、顧客の区別のない検索という3つの出口を見つけて塞ぎ、カナリアでプロンプトの漏えいを検知します。
なぜ重要か
モデルの入力に入ったものはいつか回答として出てくる可能性があり、入力を記録するログはその内容をより多くの場所へ複製します。「モデルに話さないよう書いておく」ことは境界ではありません — 本物のモデルは確率的なので、ほとんどの場合は守っても、ある日守らなくなります。このラボでは、秘密をそもそも入れず(ツール層)、他人のものは入れる前にフィルタリングし(検索)、ログはマスクし、それでも漏れたらカナリアで気づく、という順序で直します。採点ツールは採点のたびに新しいキー・カナリア・電話番号を作った一時データでアプリを起動し、その値が応答・ログのどこに残るかを確認します。
ステップ
python3 /opt/fixtures/llmsec/kit.py supportで素材を展開したあと、/root/llmsec/support/app.pyを127.0.0.1:8102で起動しておいてください(0.0.0.0は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8102/healthのseedが、展開時に受け取ったシードと同じでなければなりません。- 昨日のログ
/root/llmsec/support/logs/app-2026-11-04.logに原文のまま残ったものを数え、/root/llmsec/support/exposure.txtにtokens=<count>(ログに出てきた異なるユーザートークンの数)、phones=<count>(異なる電話番号の数)、key_lines=<count>(パートナー企業のキーAPI 키: pk-…を含む行の数)の3行を書いてください。 build_system・run_toolを直して、パートナー企業のキーをシステムプロンプトから外し、注文照会ツールが実行されるときにサーバーがconfig.jsonから直接読むようにしてください。システムプロンプトを出すよう求めるリクエストに対しても、キーが応答・ログのどこにも出ず、注文照会はそのまま動作しなければなりません。log_requestを直して、data/app.logにリクエストごとに1行(rid・ユーザー名)は残しつつ、X-Tokenの値・電話番号・メールアドレス・パートナー企業のキーが原文のまま残らないようにしてください。電話番号は末尾4桁だけを残します(***-****-1234)。build_system・check_outputを直して、config.jsonのcanaryの値をシステムプロンプトに入れ、応答にその値が現れたら回答を丸ごとブロックし、data/audit.logにそのリクエストのridと"event": "canary"を含むJSON行を残してください。正常な回答はそのまま出なければなりません。find_ticketsを直して、モデルに入れるチケットをリクエストした顧客のものだけにフィルタリングしてください。他人のチケットの単語で尋ねても他人の情報が回答に混ざらず、自分のチケットについては答えなければなりません。incident ls-secret-leakを実行するとインシデントが発生します。顧客3人の電話番号が社外で見つかりました(/srv/ls-support/found.txt)。顧客ごとに経路を見つけて発生源を片付け、コードをラボのステップ3–6のように直したあと、/root/incident/ls-secret-leak/report.txtに<customer>: <code>の3行とevidence: <log line>を書いてください。コードはcross-tenant(他の顧客のチケットが回答に混ざる)、log-bundle(パートナー企業に送った診断バンドル)、misfiled-ticket(所有者が誤って記録されたチケット)、prompt-extract(システムプロンプトの抽出)のいずれかです。
参考
- アプリは
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()を読んでおいてください。 - パートナー企業の注文APIは
/opt/fixtures/llmsec/partner.py(偽物)です。キーがconfig.jsonの値と同じでなければ応答しません — 直すファイルではありません。 - インシデントツール:
incident listで発生させられるインシデントと1行の症状を、incident show <name>でチケットを再表示します。同じインシデントを再び発生させると、新しいシードで最初の故障状態になります(対象の原因も決め直されます)。 - レポートは
/root/incident/<name>/report.txtです。対象ごとに<name>: <cause-code>を1行ずつ、最後にevidence: <log line>(原因を示した記録1行)をそのまま付けます(ログ・設定の1行を丸ごと、20文字以上)。採点ツールは直したコードに新しいランダム攻撃を再び送り(このインシデントの経路だけでなく、同じラボの前のステップの攻撃も回帰として)、原因ごとに異なる後始末ができているかを確認したうえでレポートを見ます。証拠の行はインシデント直後のログにあった行でなければならず、その対象のインシデントのリクエストや漏れた値とつながっている必要があります。 - よくあるミス: ログを完全に止めること。障害時に調査できなくなり、採点ツールはリクエストidを含む行が残るかを確認します。
アプリを展開してループバックで起動する
python3 /opt/fixtures/llmsec/kit.py support で素材を展開したあと、/root/llmsec/support/app.py を127.0.0.1:8102で起動しておいてください(0.0.0.0は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8102/health のseedが、展開時に受け取ったシードと同じでなければなりません。
方向: ラボのアプリを外部に開く理由はありません — リッスンするアドレスがそのまま露出範囲です。 ツール: ss -ltnp、curl、setsid・nohup。 具体: 展開コマンドは上記のとおりです。起動後、ss -ltn で8102番が127.0.0.1として表示されるか、/healthのseedが /root/llmsec/support/data/seed.txt と同じかを確認してください。
昨日のログに原文のまま残ったものを数える
昨日のログ /root/llmsec/support/logs/app-2026-11-04.log に原文のまま残ったものを数え、/root/llmsec/support/exposure.txt に tokens=<count>(ログに出てきた異なるユーザートークンの数)、phones=<count>(異なる電話番号の数)、key_lines=<count>(パートナー企業のキー API 키: pk-… を含む行の数)の3行を書いてください。
方向: 原文のままのログは、プロンプトに入ったすべてのもののコピーです。何がいくつ複製されたかを数えることが、被害範囲を確定する第一歩です。 ツール: grep -o、sort -u、wc -l、jq、data/users.json。 具体: トークンはusers.jsonの値がログに現れるかで、電話番号は異なる値の数で、キーは行数で数えます — 3つは数える単位が異なります。
キーをシステムプロンプトからツール層へ
build_system・run_tool を直して、パートナー企業のキーをシステムプロンプトから外し、注文照会ツールが実行されるときにサーバーが config.json から直接読むようにしてください。システムプロンプトを出すよう求めるリクエストに対しても、キーが応答・ログのどこにも出ず、注文照会はそのまま動作しなければなりません。
方向: モデルにキーは必要ありません。キーが必要なのはツールを実行するサーバーコードです。 ツール: build_system、run_tool、jload("config.json")。 具体: システムプロンプトからキーの行をなくし、run_toolがシステムプロンプトを探る代わりに設定を読むように変えてください。採点ツールはキーを新しく作り、応答とconfig.json以外のすべてのファイルを確認します。
ログは残しつつマスクする
log_request を直して、data/app.log にリクエストごとに1行(rid・ユーザー名)は残しつつ、X-Tokenの値・電話番号・メールアドレス・パートナー企業のキーが原文のまま残らないようにしてください。電話番号は末尾4桁だけを残します(***-****-1234)。
方向: 止めれば調査できず、すべて記録すればログが漏えい経路になります。何を残すかを選ぶ作業です。 ツール: re.sub、json.dumps。 具体: ヘッダーは丸ごと残さず身元は名前で、質問・コンテキスト・回答の番号とメールアドレスは正規表現でマスクしてください。システムプロンプトは記録する理由がありません。
カナリアでプロンプトの漏えいを検知する
build_system・check_output を直して、config.json の canary の値をシステムプロンプトに入れ、応答にその値が現れたら回答を丸ごとブロックし、data/audit.log にそのリクエストの rid と "event": "canary" を含むJSON行を残してください。正常な回答はそのまま出なければなりません。
方向: カナリアは秘密の代わりに入れておくマーカーです。それが出力に現れたらシステムプロンプトが漏れたということなので、カナリアだけを消さずに回答を丸ごとブロックします。 ツール: build_system、check_output、audit()。 具体: カナリアは毎回設定から読み、比較の前に空白・大文字小文字をそろえてください。本物のモデルなら言い換えてすり抜けられるという限界も覚えておきます。
他人のチケットは入れる前にフィルタリングする
find_tickets を直して、モデルに入れるチケットをリクエストした顧客のものだけにフィルタリングしてください。他人のチケットの単語で尋ねても他人の情報が回答に混ざらず、自分のチケットについては答えなければなりません。
方向: 入力に入ったものは、話しうるものです。権限は検索の段階でかけます。 ツール: find_ticketsのuser、tickets.jsonのowner。 具体: スコアを付ける前に、所有者が異なるチケットを除いてください。
インシデント — 3人の顧客の番号が外部で見つかった
incident ls-secret-leak を実行するとインシデントが発生します。顧客3人の電話番号が社外で見つかりました — 見つかった断片は /srv/ls-support/found.txt、ボットは /srv/ls-support/app.py、データ・ログは /srv/ls-support/data/、先週パートナー企業に送った診断バンドルは /srv/ls-support/support-bundle.log です。顧客ごとに番号が漏れた経路を見つけて発生源を片付けてください — 誤って記録されたチケットの所有者は番号の所有者に正し、システムプロンプトの番号は外し、診断バンドルは行を残したまま番号だけをマスクします。コード /srv/ls-support/app.py は、ラボのステップ3–6で直した欠陥をすべて防がなければなりません(キーはツール層、ログはマスク、カナリア、検索は顧客ごと — 採点ツールがその攻撃を再び送ります)。/root/incident/ls-secret-leak/report.txt に <customer>: <code> の3行と evidence: <log line> を書きます。コードは顧客ごとに次のいずれかです: cross-tenant(他の顧客のチケットが回答に混ざる)、log-bundle(パートナー企業に送った診断バンドル)、misfiled-ticket(所有者が誤って記録されたチケット)、prompt-extract(システムプロンプトの抽出)。
方向: 症状は3つとも「番号が外部にある」です。見つかった断片の形そのものが、どのログから出てきえたかを示し、それが経路です。 ツール: grep -F、jq、tickets.jsonのowner、users.jsonのphone。 具体: 断片をそのままgrepして、app.logの回答(answer)なのか、診断バンドルの質問(q)なのかを確認してください。回答なら、そのリクエストをした人とコンテキストに入ったチケットの所有者を、番号の実際の所有者(users.json)と突き合わせると、他の顧客のチケットが混ざったケースとチケットの所有者が誤って記録されたケースが見分けられます。