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

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

RAG文書に隠れた指示 — 行動はモデルの外で決める

TT Labで続きを見る

目標

社内ドキュメントアシスタントで、検索された外部ドキュメントの隠れた指示がメールを送ってしまう現象を再現し、宛先・保留と確認・出典をモデルではなくサーバーが決めるように修正します。

なぜ重要なのか

RAGアプリは、他人が書いた文をモデルの入力に入れます。モデルはその文が資料なのか指示なのかを確実に区別できないため、指示のような文1つがアプリのツールを動かしてしまうことがあります。このラボの偽のモデルは、その弱点を常に見えるように誇張したものです — 本物のモデルは確率的なので同じ攻撃がときどきしか通らず、だからこそ「何度か試したら防げた」では安全とは言えません。ラボはマーカーの除去で止まらず、モデルが何を出してもサーバーのルールが行動を決めるようにします。採点ツールは、修正したアプリに採点のたびに新しく作った攻撃を送り、正常なリクエストが引き続き通るかも確認します。

ステップ

  1. python3 /opt/fixtures/llmsec/kit.py ragで素材を展開したあと、/root/llmsec/rag/app.pyを127.0.0.1:8101で起動しておいてください(0.0.0.0は禁止、ターミナルを閉じても動き続けるように)。curl 127.0.0.1:8101/healthのseedが、展開時に受け取ったシードと同じである必要があります。
  2. /root/llmsec/rag/questions.txtの3番目の質問をそのまま/askに送り、モデルが受け取った入力(data/prompts.log)に入ったドキュメントidを、入った順に/root/llmsec/rag/trace.txtへq3_docs=<id,id,...>の1行で書いてください。
  3. data/docs/のドキュメントのうち、偽のモデルが指示として読むマーカーが隠れているものをすべて見つけ、/root/llmsec/rag/poisoned.txtにidを1行に1つずつ書いてください。マーカーには複数の形があります。
  4. メールの指示が隠れたドキュメントが検索されるように質問を送って攻撃を一度再現し、/root/llmsec/rag/repro.txtにmail=<spool-file>(data/spoolのファイル名)とto=<recipient>(そのメールの宛先)の2行を書いてください。
  5. app.pyのrun_callを修正し、メールの宛先をリクエストしたユーザー自身のアドレス(users.json)のみに許可してください。それ以外のアドレスは実行せず、data/audit.logにそのリクエストのridと"event": "blocked"を含むJSONの行を残します。自分のアドレスに送ってほしいという正常なリクエストは、そのまま送信されなければなりません。
  6. gate・handle_confirmを修正し、検索されたドキュメントのうちtrust: internalでないもの(表示がない、または別の値のものを含む)が1つでもあれば、モデルが出した行動を実行せず、レスポンスのactionsに{"status": "pending", "id": <confirm-id>, ...}として保留してください。同じユーザーがPOST /confirm {"id": ...}を送ったときに一度だけ実行し、他のユーザーによる確認・2回目の確認は2xx以外のコードで拒否し、他のユーザーによる確認の試みで保留が消えないようにしてください(本人はその後も確認できるように)。社内ドキュメントだけを使った回答の行動は、すぐに実行します。
  7. sources_ofを修正し、レスポンスのsourcesを、モデルの回答の引用表示ではなくサーバーが実際にプロンプトに入れたドキュメントにしてください: [{"id": ..., "trust": "internal|external"}]。
  8. incident ls-rag-exfilを実行するとインシデントが発生します。3つの部署(faq・hr・sales)のアシスタントから、昨夜、社外のアドレスにメールが送られました。コード/srv/ls-rag/bot.py(3部署共通)を修正してどの経路から来ても社外(@corp.exampleではない)のアドレスにはメールが送られないようにし、部署ごとに原因の発生源を整理したうえで、/root/incident/ls-rag-exfil/report.txtにfaq: <code>、hr: <code>、sales: <code>とevidence: <log line>を書いてください。コードはconfig-cc(設定のCCアドレス)、memory-note(ユーザーメモ内の指示)、poisoned-doc(検索ドキュメント内の指示)、user-prompt(ユーザーの質問内の指示)のいずれかです。

参考

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

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

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

モデルが実際に受け取った入力を読む

/root/llmsec/rag/questions.txtの3番目の質問をそのまま/askに送り、モデルが受け取った入力(data/prompts.log)に入ったドキュメントidを、入った順に/root/llmsec/rag/trace.txtへq3_docs=<id,id,...>の1行で書いてください。

方向: RAGでは、モデルの入力の大部分はユーザーが書いた文ではなく、検索された他人の文です。何が入ったのかは推測せず、記録から読み取ります。 ツール: curl、grep、awk。 具体: レスポンスのridを控え、prompts.logでそのridのpart=contextの行を見て、各行に付いたsrc=を最初に現れた順に集めてください。

形の異なる隠れた指示をすべて見つける

data/docs/のドキュメントのうち、偽のモデルが指示として読むマーカーが隠れているものをすべて見つけ、/root/llmsec/rag/poisoned.txtにidを1行に1つずつ書いてください。マーカーには複数の形があります。

方向: モデルはフィルターより寛容に読みます。1つの形でgrepすると形の違うものを見逃し、似て見えるおとりは指示ではありません。 ツール: fakellm.pyのdirectives()、unicodedata、base64、grep。 具体: 偽のモデルが入力をどのように正規化し、何をデコードして読むのかをまずその関数で読んだうえで、同じルールでドキュメントごとに判定してください。

攻撃を一度再現する

メールの指示が隠れたドキュメントが検索されるように質問を送って攻撃を一度再現し、/root/llmsec/rag/repro.txtにmail=<spool-file>(data/spoolのファイル名)とto=<recipient>(そのメールの宛先)の2行を書いてください。

方向: 攻撃者は私たちのユーザーに話しかけません。ユーザーの平凡な質問がそのドキュメントを引き寄せさえすればよいのです。 ツール: 検索ルール(retrieve)、curl、ls data/spool。 具体: メールの指示が入ったドキュメントを1つ選び、そのドキュメント本文の単語で質問を作ると、そのドキュメントが上位に来ます。レスポンスのactionsとスプールファイルのtoを突き合わせてください。

宛先はサーバーが決める

app.pyのrun_callを修正し、メールの宛先をリクエストしたユーザー自身のアドレス(users.json)のみに許可してください。それ以外のアドレスは実行せず、data/audit.logにそのリクエストのridと"event": "blocked"を含むJSONの行を残します。自分のアドレスに送ってほしいという正常なリクエストは、そのまま送信されなければなりません。

方向: モデルが出した引数(to)は、攻撃者が書いた文かもしれません。その値で何をするかは、リクエストした人の身元で決めます。 ツール: run_callのuser引数、audit()。 具体: 宛先がuserのemailと異なれば送信せず、ブロックした事実を記録してください。採点ツールは、隠れた指示(形はランダム)、ユーザーが自分で外部アドレスを指定したリクエスト、正常なリクエストを一緒に送ります。

信頼できない文が混じったら人が確認する

gate・handle_confirmを修正し、検索されたドキュメントのうちtrust: internalでないもの(表示がない、または別の値のものを含む)が1つでもあれば、モデルが出した行動を実行せず、レスポンスのactionsに{"status": "pending", "id": <confirm-id>, ...}として保留してください。同じユーザーがPOST /confirm {"id": ...}を送ったときに一度だけ実行し、他のユーザーによる確認・2回目の確認は2xx以外のコードで拒否し、他のユーザーによる確認の試みで保留が消えないようにしてください(本人はその後も確認できるように)。社内ドキュメントだけを使った回答の行動は、すぐに実行します。

方向: 宛先を本人に縛っても、外部ドキュメントの指示によって本人に見当違いのメール(偽の決済リンクのような)を送らせることができます。危険な瞬間にだけ人を介在させます。 ツール: PENDING辞書、secrets.token_urlsafe、確認後もrun_callを経由させること。 具体: 確認idは誰が作ったかと一緒に保管し、使ったら削除します。他人のものと存在しないものを同じレスポンスで拒否すれば、idが存在するかどうかさえ漏れません。

出典はサーバーが付ける

sources_ofを修正し、レスポンスのsourcesを、モデルの回答の引用表示ではなくサーバーが実際にプロンプトに入れたドキュメントにしてください: [{"id": ..., "trust": "internal|external"}]。

方向: 「[kb-07]によると」という言葉もモデルの出力です。外部ドキュメントが社内規定を装えば、人はその引用を信じてしまいます。 ツール: sources_ofが受け取るdocs。 具体: 回答テキストを解釈せず、検索結果からidとtrustをそのまま移してください。表示のないドキュメントは外部のものとして扱うほうが安全です。

インシデント — 3つの部署から社外にメールが送られた

incident ls-rag-exfilを実行するとインシデントが発生します。3つの部署(faq・hr・sales)のアシスタントから、昨夜、社外のアドレスにメールが送られました(スプールは/srv/ls-rag/bots/<dept>/spool/、記録はprompts.log・audit.log)。コード/srv/ls-rag/bot.py(3部署共通)を修正してどの経路から来ても社外(@corp.exampleではない)のアドレスにはメールが送られないようにし(宛先とCCの両方、社内アドレスへの正常なメールはそのまま)、部署ごとに原因の発生源を整理してください — 汚染されたドキュメントは検索対象から、汚染されたメモはメモから外し、指示を入れたアカウントはトークンが通らないようにし、設定の外部CCは削除します(原因と無関係なドキュメント・メモは残します)。/root/incident/ls-rag-exfil/report.txtにfaq: <code>、hr: <code>、sales: <code>とevidence: <log line>を書きます。コードは部署ごとに次のいずれかです: config-cc(設定のCCアドレス)、memory-note(ユーザーメモ内の指示)、poisoned-doc(検索ドキュメント内の指示)、user-prompt(ユーザーの質問内の指示)。

方向: 症状は3つとも同じですが、指示が入り込んだ場所が異なります。メール1通から逆にたどり、そのリクエストの入力を断片ごとに見ます。 ツール: grep、jq、spoolのrid・cc、prompts.logのpart=・src=。 具体: 社外のアドレスが入ったスプールファイルのridでprompts.logを開き、指示マーカーがどのpart(memory・context・user)にあったかを確認してください。マーカーがないのに送信されていたら、メールのccがどこで付いたのかconfigを見てください。各部署にはおとり(検索されなかった指示ドキュメント、無害な指示)もあります — 実際に送信されたメールにつながるものだけが原因です。