0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルLLMにFirestoreルールを読ませたら、穴を見つけたのは結局手書きのプローブだった

0
Last updated at Posted at 2026-11-07

はじめに

自作アプリのFirestoreセキュリティルールを、手元のUbuntuノートPCで動くローカルLLM(qwen3.5:9b)に読ませました。返ってきた指摘が本当に穴なのかは、Firebaseエミュレータで一件ずつ試して確かめています。

先に結果を書きます。

  • 実ルールに残っていた穴は3件で、見つけたのは自分で書いたプローブでした。管理者ロールだけが通れる経路の話です
  • 実ルールに対するLLMの指摘は延べ273件で、穴だと裏付けられたものは0件でした
  • 「58件中7件が裏付けられた」という数字もあります。ただしこれは、わざと穴を開けた改変ルールでの結果です。7件の中身を見直すと、改変した箇所に触れていた指摘は1件だけでした

LLMが使えないと言いたいわけではありません。9Bの量子化モデルに、思考オフでブロック単位に読ませたという、かなり狭い条件での記録です。

なお、以前書いたZennの記事にある「58件」などの数字は、この記事のために測り直し、Zenn側にも訂正を追記しています。

LLMの指摘をどう確かめるか

ルールを読ませると、IDOR、権限昇格、入力検証の不足と、それらしい指摘がすぐ返ってきます。読めばどれも一理ある。ただ、本当に穴かどうかはルールを動かすまで分かりませんし、人間が一件ずつ判定していたら先にこちらが疲れます。だから指摘のほうを動かして確かめることにしました。

検証用の環境は、2026年9月11日に最初のコミットをして、LLMの指摘を検証する経路を足したのが9月15日です。マシンはRyzen 5 4600H、RAM 30GiB、GTX 1650(VRAM 4GiB)のノートPCで、OSはUbuntu 24.04.5。LLMはOllama 0.32.0経由の qwen3.5:9b(9.7B、Q4_K_M)を、think:false、temperature:0、num_predict:1100、num_ctx 8192(入力が大きいときは16384)で回しています。エミュレータ側は firebase-tools 15.33.0、@firebase/rules-unit-testing 6.0.0、jest 30.5.2、Node v24.22.2です。

対象は自作アプリ(Kotlin Multiplatform、Android、SwiftUI、Firebase)のルール1プロジェクト分だけ。コレクション名とフィールド名は collectionA のような仮名にしてあります。

流れは5段階です。

  1. ルールをコレクションごとの match ブロックに分けます
  2. ブロックを1つずつLLMに渡し、{line, severity, issue, quote} のJSON配列だけを返させます。迷ったら指摘に回すよう書いた、再現率寄りのプロンプトです
  3. quote がブロックに本当にあるかを文字列で照合します。なければ UNGROUNDED_QUOTE
  4. どのブロックを読ませたかでコレクションが決まります。操作(list get update create delete)は指摘の文面のキーワードから推定して、該当する コレクション:操作 のプローブを引きます
  5. 全プローブをエミュレータで走らせて判定します

4がこの仕組みでいちばん雑な部分で、のちほど効いてきます。判定語はコード上の名前のままです。

判定 意味
CONFIRMED 対応づけたプローブ(期待値 DENIED)のどれかが ALLOWED になった
REFUTED 対応づけたプローブがすべて DENIED だった(集計表では REJECTED)
NO_PROBE その コレクション:操作 にプローブがない

REFUTED は、指摘の中身が誤りだと証明されたという意味ではありません。この点は結果のところで具体例を出します。

プローブは全部手で書いた

プローブ(probes.cjs)にLLMは使っていません。最初は52個で、9月16日にファイルを切り出して103個まで増やし、いまは122個あります。内訳は期待値 ALLOWED の対照が38個、期待値 DENIED の不変条件が84個。操作別だと create 41、read 26、update 26、list 19、get 6、delete 4です。

対照を混ぜたのは、DENIED が並んだだけでは、ルールが正しいのか、エミュレータが何でも拒否しているのか区別できないからです。正当なアクターが同じ操作を通れると一緒に確かめておけば、後者は消えます。

// 不変条件: 参加者ではないユーザーは、スレッドのメッセージを list できない
P("P002", "collectionC", "list", "DENIED",
  "Non-peserta list pesan thread yang bukan miliknya",
  () => attempt(() => listQuery(asOther()).get())),

// 対照: 正当な参加者なら同じ操作が通る
P("P004", "collectionC", "list", "ALLOWED",
  "KONTROL: peserta sah list pesan thread-nya",
  () => attempt(() => listQuery(asDriver()).get()), true),

プローブの説明文とコメントはインドネシア語で書いています。attempt() は操作を実行して、例外が出なければ {allowed: true}、出れば {allowed: false} を返すだけの関数です。アクターは asAdmin asDriver asOther asExpat asAnon の5種類、初期データは withSecurityRulesDisabled で入れています。allow-list にないフィールドを書き込めるか(add-field)を試すプローブは17個で、15コレクションにまたがり、update 経路が6、create 経路が11です。

見つかった3件

9月17日の朝、08:11のコミットで、add-field を全コレクションに総当たりで試しました。そこで3件が通ってしまいました。見つけたのはLLMの指摘ではなく、この総当たりのプローブです。

修正前のルールで122個を走らせた結果は、119個が期待どおり、3個が逆。

対象 修正前(3b14626 の親) 現在の main(ローカル参照 d12e57d)
ユーザープロフィール系コレクションへの update ALLOWED DENIED
同コレクションへの create ALLOWED DENIED
お知らせ系コレクションへの update ALLOWED DENIED

3件とも管理者でないと通れない経路で、ドライバーなど他のロールからは突けません。多層防御の穴で、深刻度は高くないと見ています。

修正は9月20日にブランチで書き、9月24日の13:12(+07:00)にマージしました。同時に足した13個のテストを修正前のルールに当てると、5個が落ちて8個が通ります。通る8個のうち7個は正当な経路が通り続けることの確認で、残り1個は元のルールでも守られていた拒否テストです。10月7日には、既存フィールドの変更検出を changedKeys() から affectedKeys() に直す追加修正(4つの update 経路)も入れました。

ルールの単体テスト(jest)は、修正前が16スイート278テスト、修正後が17スイート291テスト、ローカルの main が22スイート413テストで、どれも全通過です。main でプローブを回すと121/122になります。落ちる1個は、ユーザーが自分の通知トークンを更新できる経路を10月20日の変更で閉じたため、期待値が古くなったプローブでした。

実ルールにLLMをかけた結果

同じ実ルールをLLMに読ませた記録が20回分あります。延べ273件の指摘のうち、CONFIRMED は0件、REFUTED が215件、NO_PROBE が58件でした。

273は重複を除いた数ではありません。同じルールを何度も読ませているので、1回で返ってくる指摘は0件の回もあれば73件の回もあります。回を重ねるごとにプローブが7個から122個に増えたせいで、初期の回ほど NO_PROBE が多くなりました。ユニーク件数は数えていません。

外れ方の例を1つ。

LLMの指摘(要約、HIGH): 自分のUIDが参加者に載っているメッセージなら、所有者でなくても読めてしまうIDOR

参加者として載っている人が読めるのは設計どおりなので、この指摘は文面からしてかみ合っていません。対応づけたプローブ P002(参加者ではないユーザーの list)は DENIED、対照の P004(正当な参加者の list)は ALLOWED。ルールはちゃんと評価されていて、期待どおりに分かれています。

言えるのは、qwen3.5:9b を Q4_K_M、思考オフ、temperature 0 でブロック単位に読ませたとき、このルールからは裏付けのある指摘が出なかった、というところまでです。LLM一般の力は何も測っていません。

穴をわざと開けたルールでは

プローブが穴をちゃんと拾えるかも確かめたくて、改変ルールを12個作りました(9月17日の08:18)。1つのコレクションの add-field ガードだけを外したもので、差分は1行です。

-          && helperHasOnly(request.resource.data.keys(), [ ...許可する9フィールド... ])
+          && true /* MUTAN: guard add-field dilucuti */

12個それぞれで、DENIED から ALLOWED に反転したプローブはちょうど1個。反転は改変したコレクションの中だけで、対照は毎回38/38でした。12/12検出です。この12は改変ルールの数で、対照テストの数ではありません。

そのあと、LLMに12個を1つずつ読ませました。08:20から08:46までの約26分で、各1回です。

指摘は合わせて58件で、3つのコレクションは0件。内訳は CONFIRMED 7、REFUTED 49、NO_PROBE 2です。この58件は、12個の改変ルールそれぞれに読ませた結果の合計で、実ルールの監査結果とは別物です。CONFIRMED の7件は5つの改変コレクションに散らばっていて、当日の検証メモには LLM-confirmed 5/12 とあります。

この読み方は甘すぎました。測り直して7件の中身を読むと、理由が3つ出てきます。

まずヒントです。改変ルールには MUTAN: guard add-field dilucuti というコメントを残していて、それがLLMに渡すブロックにそのまま入っていました。CONFIRMED のうち add-field を話題にしているのは F04 の1件だけで、引用元もこのコメントです。LLMはロジックから穴を見つけたのではなく、書いてあるヒントを読んでいます。

次に判定の雑さ。残り6件は、指摘の中身が別の話(フィールドの検証など)なのに、同じ コレクション:操作 の add-field プローブが改変で ALLOWED になったせいで CONFIRMED になっていました。当たってはいても、理由が合っていません。逆の例もあって、F42 は add-field ガードの欠落を文面ではっきり書いているのに、操作が read と推定されて REFUTED でした。キーワードがなければ read になる仕様で、58件のうち22件がこの既定値に落ち、22件とも REFUTED です。

add-field に触れた指摘を機械的に数えると8件、4コレクションにありました。キーワードでの判定なので、改変箇所と内容が合っているかまでは見ていません。8件のうち CONFIRMED は1件です。

この実験の穴

改変ルールにヒントのコメントが残っているので、LLMの検出力を測る実験としては成立していません。コメントを消してやり直す必要があります。対応づけの粗いキーのせいで、CONFIRMED にも REFUTED にも誤判定が混ざっているのも前述のとおり。LLMは1モデル1設定で、同じ設定で繰り返したときのぶれは測れていません。

プローブにも抜けがあります。値の型や長さの検証、クライアントが送るタイムスタンプ、collection group はプローブが0個。値の範囲や必須フィールドの検証は4個以上です。Storage ルールはこのプロジェクトでは使っていません。Cloud Functions はプローブの外で、別のjestで見ています。

3件の修正が本番のルールに載っているかは、書いている時点で確認できていません。言えるのは、リポジトリの main にあるルールまでです。参照している main もローカルの古い状態で、そのあとのルール変更は見ていません。

再現用のメモ

測定は2026年11月4日の夜(WIB)、エミュレータだけで行いました。本番のFirebaseには触れていません。

LAB_RULES_FILE=<rulesファイル> LLM_REVIEW_FILE=<空のJSON> \
firebase emulators:exec --only firestore --project <テスト用> \
  --config <firebase.lab.json> "node adjudicate.cjs"

122プローブの実行は1回およそ11秒。入力のSHA-256は次のとおりです。

対象 SHA-256
修正前のルール 16faf3a4cdea8eba…(先頭16桁)
main のルール(ローカル参照) 8324188a22f8339c…(先頭16桁)
adjudicate.cjs 755d1c66570484027880e0550e6bba400134105cc6309ae6c33a9fe33573f598
probes.cjs 88be4880e260ea0c4ad42c8273c96829df642920e3fc27c53212414880beae1d

リポジトリは非公開なので、同じ手順をそのまま再現することはできません。載せているのは抜粋とハッシュで、同じ入力を使ったかを照合する材料です。

おわりに

実ルールの穴を見つけたのは、手書きのプローブでした。LLMの指摘は、実ルールでは273件すべてに裏付けがなく、穴を開けたルールでも検出力を言えるデータになっていません。

次は、ヒントのコメントを消した改変ルールで、さらに操作の推定をやめて指摘ごとに手でプローブを対応づける形で、実験をやり直します。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?