こんにちは、魚脳です。今回は引き続き静的解析ツールOpengrepを紹介したいと思います。 前編では、Opengrep の 後編となる本記事では、その中でも テイント解析の拡張機能である Opengrep には 特に “cross-function” という表現から、より広範囲なデータフロー解析を想像すると、実際の挙動との間にギャップが生じる可能性もあります。 本記事では、 まず前提として、テイント解析(taint analysis) は、プログラム中で 「汚染されたデータ(tainted data)」がどのように伝播するか を追跡する静的解析手法です。 ここでいう「汚染」とは、ユーザー入力や外部ファイル、ネットワーク経由のデータなど、安全性が保証されていない値を指します。こうした値は、プログラムの内部でそのまま利用されると、意図しない動作や脆弱性につながる可能性があります。 典型的には、解析は次の 3 つの要素を追います。 この場合、 という流れをプログラム中から見つけ出し、脆弱性につながる可能性のある経路を報告します。 もっとも、実際のコードでは値は 1 つの関数の中だけで完結するとは限りません。 今回扱う 次の章では、Opengrep において OpengrepはSemgrep OSSからフォークして独自に開発を進めているプロジェクトであるため、Semgrep Proが提供されている高度なテイント解析にはいままで対応できていませんでした。 いままででは 一方、下記のようなデータが複数の関数によって処理されるケースに対して、Semgrep Proがそれに対応するオプション なお、Semgrep Proにはさらに上位互換となるオプション これらの手法と役割については、以降の小節で順に詳しく説明します。 静的解析で関数をまたいだ影響を追うには、「どの関数がどの関数を呼ぶか」を表す コールグラフ(Call Graph) が必要になります。Opengrepでは バージョン1.16.0からサブコマンド 前章で例として挙げたPythonプログラムのコールグラフは以下のようになります。 コールグラフ構造は「双方向に辿れるラベル付き有向グラフ」 Opengrepはこのコールグラフに基づいて、到達可能関数の探索や影響範囲解析を行っています。 コールグラフが「関数間の呼び出し関係」を表すのに対し、CFG(Control Flow Graph) は 関数内部の制御フローを表すグラフです。 ノードは基本的に「文や式などのプログラムの処理単位」で、エッジは「実行が次に進む可能性のある経路」を表します。 関数内の文を順番に処理し、ノードを生成しながら 生成されたCFGを確認する方法も実は用意されています。 自前でコンパイルしたバイトコードならそれを確認するオプション 画像は しかし、関数をまたいだ解析を行う場合、毎回呼び出し先の関数のCFGを一から展開して解析するのは効率が悪くなります。
そこで助けになるのが 関数のシグネチャーとなります。
ここでいうシグネチャーは型の情報ではなく、 たとえば、次のような関数を考えます。 この関数の内部では単に引数を返しているだけですが、汚染追跡の観点では という重要な情報を持っています。 例として、前章扱ったPythonのプログラムをOpengrepのシグネチャーを確認する機能に投げます。 その結果は以下のようになります。 これを読むと、それぞれ次のような意味になっています。 このように、シグネチャーは 関数の振る舞いを再利用可能な形で保存したものです。 たとえば というシグネチャーだけで十分扱えます。 前の小節で説明したように、関数のシグネチャーは CFG 上の解析結果から計算されます。しかし、その値は必ずしも一度の解析で決まるわけではありません。特に、関数同士が互いに呼び合う場合や、再帰が存在する場合には、解析結果が循環依存を持つことがあります。 例えば次のような再帰関数を用いた階乗を計算する関数考えます。 この関数では、 つまり、関数の性質を求めるためには、その関数自身の結果を参照する必要があります。このような場合、解析は一度の計算では完了しません。 そこで用いられるのが 不動点反復(fixed-point iteration)です。 不動点反復では、まず関数の性質について仮の状態から解析を開始し、その結果を使って解析を繰り返します。結果がそれ以上変化しなくなった時点で計算を停止します。この「結果が変化しなくなった状態」を 不動点(fixed point)と呼びます。 概念的には、次のような流れになります。 この仕組みによって、再帰関数や相互再帰がある場合でも、関数のシグネチャーを徐々に更新しながら最終的な結果を求めることができます。 実装では、この反復計算は この関数は CFG 上のデータフロー解析を繰り返し実行し、関数の 汚染効果が収束するまで更新を続けます。その結果が最終的な関数シグネチャーとして保存されます。 このように、不動点反復は再帰や循環依存を含むプログラムに対しても解析を成立させるための基本的な手法です。静的解析では、CFG やコールグラフと組み合わせて広く用いられているのだそうです。 コールグラフ、CFGが構築できると、次はそのグラフ上で データフロー解析を行います。 この場合、トポロジカルソートを行うと次のような順序が得られます。 この順序では、常に 依存される側のノードが先に処理されるようになっています。 Opengrepでは、このアルゴリズムを コールグラフに適用して関数の解析順序を決定しています。例えば前章で扱ったPythonプログラムのコールグラフは次のようになります。 これをトポロジカルソートしたあとの順序はOpengrepのデバッグ情報から確認できます。 この順序に従って解析を行えば、 一方、コールグラフに再帰が含まれる場合、グラフには循環が存在します。
このような場合はトポロジカルソートがそのまま適用できないため、まず 強連結成分(SCC: Strongly Connected Component)に分解します。 例えば次のような関数呼び出し関係があるとします。 この場合、 その後、強連結成分を単一ノードとして縮約すると、グラフは循環を持たない構造(DAG)になります。これに対してトポロジカルソートを適用することで、依存関係に従った解析順序を得ることができます。 循環部分については、前の小節で説明する 不動点反復によって解析結果を収束させます。 ここまで、コールグラフ、CFG、トポロジカルソート、不動点反復、そして関数シグネチャーといった仕組みを紹介しました。 Opengrep では、この関数間解析を行うかどうかは コメントの部分に言及された通り、オプションが起用されなかった場合、フォーク元であるSemgrep OSSのメインブランチの挙動に準じます。 大まかに言うと、 という違いがあります。 この経路では、関数ごとの解析は行われますが、 は行われません。 その代わり、 このモードで依存しているのは 関数内の CFG と、その関数に入る時のグローバル環境 ( 一方、 最初に行うのは、各関数の情報収集です。 続いて、必要ならオブジェクト初期化情報を検出し、シグネチャーデータベースへ反映します。 その後、同一ファイル内の関数呼び出し関係からコールグラフを構築します。 ただし、ここで作ったグラフ全体をそのまま解析に使うわけではありません。 つまり 部分グラフが得られると、次にそのグラフに対してトポロジカル順序を計算します。 この順序は、呼び出される側を先に、呼び出す側を後に処理するためのものです。 続いて、この順序に従って各関数のシグネチャーを抽出していきます。 ここで得られたシグネチャーは という流れが実際にコードとして実装されています。 シグネチャーを構築した後、各関数に対して この挙動の違いが重要で、 つまり、呼び出し先を毎回展開するのではなく、必要に応じてまとめた情報として再利用できるようになります。 そのため、Opengrepはファイルをまたいだ複雑なデータフローや、フレームワーク特有の暗黙的な挙動まで網羅的に追跡する用途には現状向いていません。 一方で、単一ファイル内にロジックがまとまっているコードに対して、 また、Semgrep OSSから独立してから約1年、更新頻度は一定水準に保たれている、近い将来に本家を超える可能性すら秘めていると思います。 *1:https://github.com/opengrep/opengrep/releases/tag/v1.11.0 *2:https://semgrep.dev/docs/writing-rules/data-flow/taint-mode/overview#interprocedural-analysis- *3:https://github.com/opengrep/opengrep/issues/88 *4:https://semgrep.dev/docs/writing-rules/data-flow/taint-mode/advanced#assume-function-calls-are-safe *5:https://semgrep.dev/docs/writing-rules/data-flow/taint-mode/advanced#propagate-only-through-assignments-
はじめに
scan サブコマンドを中心に、ツール全体の構成と処理の流れを整理しました。
scan は、ルールの読み込みからコードのパース、ルールの適用、結果の出力までをつなぐ役割を担っており、pattern ルールや taint ルールといった複数の解析機能をまとめて実行するエントリーポイントになっています。taint-intrafile に焦点を当てます。taint-intrafile という、scan機能のオプションとして提供されている、関数をまたいだテイント解析を行う機能があります。
比較的最近追加された機能でもあり、「どこまで解析してくれるのか」がドキュメントだけでは少し分かりづらいと感じる方もいるかもしれません。
こうしたズレは、ツールの問題というよりも、仕組みや前提を十分に理解しないまま使ってしまうことが原因で起きがちです。taint-intrafile を実運用の観点で評価するのではなく、
実装を軽く読み解きながら「何をしている機能なのか」「何を期待しすぎない方がよいのか」を整理します。
Opengrep をこれから触る方や、他の静的解析ツールとの違いが気になっている方の参考になれば幸いです。テイント解析とは
汚染されたデータが入り込む場所
代入や関数呼び出しによって値が別の変数や戻り値へ移ること
汚染された値が渡されると問題になりうる処理
たとえば、次のようなコードを考えます。def route():
data = request.args["name"]
return html_output(data)
request.args["name"] は外部入力なので「ソース」、data への代入は伝播、html_output(data) は 「シンク」 になりえます。
テイント解析は、このようなソース→ 伝播 → シンク
入力を受け取る関数、加工する関数、最終的に出力する関数が分かれていることも珍しくありません。
そのため、関数内だけを見るテイント解析と、関数をまたいで追跡するテイント解析とでは、検出できる範囲に差が出てきます。taint-intrafile は、まさにこの差に関係する機能です。
名前の通り intra-file(ファイル内)という意味で、同一ファイル内の関数呼び出しをまたいで汚染の伝播を追跡するためのオプションです。
つまり、単一の関数の中だけでなく、同じファイルに定義された別の関数を経由するデータフローも解析対象になります。taint-intrafile オプションを有効にすると、実際の検出結果がどのように変化するのかを見ていきます。taint-intrafile とは何かtaint-intrafileは2025年の後半でOpengrepに追加されたオプションであります*1。mode: taintと設定されたルールに対してSemgrep OSSと同様、下記のような一つの関数の中にあるデータの伝播、「関数内のテイント解析」しかできませんでした。def route2():
data = get_user_input()
# ruleid: taint-example
return html_output(data)
--pro-intrafileがありました*2。
今回紹介するOpengrepのオプション--taint-intrafileは推測ですが、おそらくそれに追随するように打ち出された機能だと思われます。これによって、本来検出できなかった単一ファイル内における複数の関数を跨いだテイント解析ができる様になります。def pass_through(value):
return value
def get_input():
return source()
def main():
input_data = get_input()
result = pass_through(input_data)
sink(result)
opengrep scan -c demo_taint.yaml example1.py --taint-intrafile
...
┌────────────────┐
│ 1 Code Finding │
└────────────────┘
example1.py
❯❯❱ demo-taint
Taint flow detected from source to sink
10┆ sink(result)
┌──────────────┐
│ Scan Summary │
└──────────────┘
Some files were skipped or only partially analyzed.
Scan was limited to files tracked by git.
Ran 1 rule on 1 file: 1 finding.
--proが存在して、ファイル間のテイント解析まで対応できます。一方、執筆した時点Opengrepにはまだ同等の機能は存在しませんが、開発者の話による近いうちに追加されるとのことです*3。重要な手法
taint-intrafileオプションの挙動の前に、まず Opengrep が内部で用いている代表的な解析手法を簡単に整理します。
コールグラフ
src/tainting/Graph_from_AST.mlのbuild_call_graph関数がその役割を担ってくれます。showにオプションdump-intrafile-graphが追加され、簡単にコールグラフを確認することができるようになりました。ソースコード上ではデバッグ向けにコールグラフを別ファイルに書き出すコードもコメントアウトに残されています、そちらからでも同じ結果を得られます。digraph G {
"get_input at l:4 c:4\nexample1.py";
"main at l:7 c:4\nexample1.py";
"<top_level> at l:0 c:0\nunknown";
"pass_through at l:1 c:4\nexample1.py";
"get_input at l:4 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";
"pass_through at l:1 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";
}
ConcreteBidirectionalLabeledというものを採用しています。コールグラフ自体は、関数を頂点(node)、呼び出し関係を辺(edge)とする有向グラフとして表現します。プリントされた時にそれぞれ頂点と辺、上下2段に分かれて出力されています。module G =
Graph.Imperative.Digraph.ConcreteBidirectionalLabeled
(struct
type t = node
let compare = compare_node
let hash = hash_node
let equal = equal_node
end)
(struct
type t = edge
let compare = compare_edge
let default = default_edge
end)
CFG(Control Flow Graph)
type ('node, 'edge) t = {
graph : ('node, 'edge) Ograph_extended.ograph_mutable;
entry : nodei;
exit : nodei;
reachable : NodeiSet.t;
}
type ('node, 'edge) cfg = ('node, 'edge) t
実装では、CFG はノードとエッジを持つグラフとして構築されます。
エントリノード (Enter) と終了ノード (Exit) を作成し、そこに文や式などのプログラムの処理単位を接続していきます。let enteri = g#add_node (IL.mk_node F.Enter)
let exiti = g#add_node (IL.mk_node F.Exit)
add_arc系関数を使ってアークを追加していきます。(有向グラフのため、辺のことをアークと呼んでいると思われます)let newi = state.g#add_node (IL.mk_node new_)
state.g |> add_arc_from_opt (previ, newi)
_build/default/src/main/Main.bc -cfg_il ~/test/example1.py
-cfg_ilを使って可視化したソースコードのCFGを確認できます。
main関数のCFGとなります、エントリノード (Enter) と終了ノード (Exit)の間にIL(中間言語)に変化された文が繋がれています。テイント解析はまさに画像上の矢印にそって解析を行われます。関数のシグネチャー
def pass_through(value):
return value
引数 value の汚染 → 戻り値
テイント解析において、呼び出し元から見ると、関数内部の細かな文の流れよりも、この「入力が出力にどう対応するか」という要約の方が重要です。opengrep show dump-taint-signatures demo_taint.yaml example1.py
Taint signatures for rule demo-taint:
get_input (example1.py:4:4): { Shape_and_sig.sig_ = => {return ({ [source() :l.5] } & _|_ & CTRL:{ })};
arity = 0 }
main (example1.py:7:4): { Shape_and_sig.sig_ =
=> {[[1> source() :l.5]] ~~~> (sink(result) at l.10 by sink:0:0:rules)};
arity = 0 }
pass_through (example1.py:1:4): { Shape_and_sig.sig_ =
value => {return ({ 'arg(value#0) } & '{arg(value#0)} & CTRL:{ })};
arity = 1 }
get_input
引数は取らず、source() に由来する汚染を戻り値として返すpass_through
第1引数 value に入った汚染が、そのまま戻り値へ流れるmain
source() に由来する汚染が、最終的に sink(result) へ到達する
解析の流れとしては、まず CFG とコールグラフを使って各関数の要約を作り、その後は呼び出し先の本体を毎回展開する代わりに、その要約を参照します。pass_through のような補助関数は、関数本体を何度も解析しなくても引数 → 戻り値
この仕組みにより、関数をまたぐ汚染解析を効率よく進められます。不動点反復
def factorial(n):
if n <= 1:
return 1
return n * factorial(n-1)
factorial の戻り値は再び factorial の呼び出しに依存しています。
解析の観点では、次のような依存関係になります。factorial → factorial
初期状態
↓
解析
↓
結果更新
↓
解析
↓
結果更新
↓
変化なし → 不動点
Dataflow_tainting.fixpoint によって行われます。let fixpoint_effects, mapping =
Dataflow_tainting.fixpoint taint_inst ~in_env:combined_env
?name ?signature_db ?builtin_signature_db ?call_graph func_cfg
トポロジカルソート
その際に重要になるのが トポロジカルソート(Topological Sort)です。
トポロジカルソートとは、有向グラフにおいて 依存関係を壊さない順序でノードを並べるアルゴリズムです。特に、循環を持たない有向グラフ(DAG: Directed Acyclic Graph)に対して定義されます。
例えば次のような依存関係を考えます。A → B → C
A
B
C
"get_input at l:4 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";
"pass_through at l:1 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";
SEMGREP_LOG_SRCS="semgrep.tainting" ./_build/install/default/bin/opengrep-core -rules ~/test/demo_taint.yaml -l py ~/test/example1.py -taint_intrafile -debug
[00.06][DEBUG](default)(semgrep.tainting): TAINT_TOPO: [0] get_input
[00.06][DEBUG](default)(semgrep.tainting): TAINT_TOPO: [1] pass_through
[00.06][DEBUG](default)(semgrep.tainting): TAINT_TOPO: [2] main
main を解析する時点で get_input や pass_through のシグネチャーがすでに計算されているため、解析を効率よく進めることができます。
また、関数シグネチャーがない場合、特殊な設定がされていない限り*4*5、Opengrepはその関数のことを汚染の伝搬関数として認識して実行するため、false positiveの結果が出される可能性が高まります。トポロジカルソートにそれを軽減するという効果も含まれていると思われます。 | None -> (
let call_taints =
if not (propagate_through_functions env) then Taints.empty
else
(* Otherwise assume that the function will propagate
* the taint of its arguments. *)
all_args_taints
強連結成分とは、グラフの中で互いに到達可能なノードの集合のことです。つまり、あるノードから別のノードへ到達でき、さらにそのノードから元のノードへ戻ることができる場合、それらは同じ強連結成分に属します。f → g
g → f
f と g は互いに呼び合っているため、同じ強連結成分に属します。解析ではこのような循環部分を 一つのまとまりとして扱います。taint-intrafileオプションによる挙動の違い
これらの仕組みは、関数をまたいだ taint 伝播を解析するための基盤になっています。taint-intrafile オプションによって制御されます。
このオプションの有無によって、解析の挙動は大きく変わります。 (* Only use signature database if cross-function taint analysis is enabled *)
let final_signature_db, relevant_graph =
if taint_inst.options.taint_intrafile then (
(* ... *)
)
else (
(* Cross-function taint analysis disabled: use main branch behavior *)
(* ... *)
)
taint-intrafile: false
taint-intrafile: true
taint-intrafileオプション無効taint-intrafile が無効な場合、check_rule は各関数定義を順に走査し、その場で check_fundef を呼んで解析します。Visit_function_defs.visit
(fun opt_ent fdef ->
...
let _flow, fdef_effects, _mapping =
check_fundef taint_inst name !ctx ~glob_env
?builtin_signature_db fdef
Visit_function_defs.visit で関数定義を順に取り出し、各関数に対して直接 check_fundef を適用します。
つまり解析エンジンは、呼び出し先の関数の情報を参照するのではなく、今見ている関数の本体だけを対象に、データの流れを追跡します。glob_env) です。
そのため、同一関数内で完結する汚染伝播は扱えますが、他関数を経由するデータフローまではカバーできません。taint-intrafileオプション有効taint-intrafile を有効にすると、check_rule はまず関数間解析の準備を始めます。
Visit_function_defs.fold_with_parent_path を使って、関数名、所属クラス、メソッドプロパティ、IL 変換後の CFG などを集め、info_map に保存しています。let _collected_infos, info_map =
Visit_function_defs.fold_with_parent_path
...
let object_mappings =
Taint_signature_extractor.detect_object_initialization ast
taint_inst.lang
コールグラフの構築と絞り込み
let call_graph =
match shared_call_graph with
| Some (graph, _shared_mappings) -> graph
| None ->
Graph_from_AST.build_call_graph ~lang
~object_mappings:all_object_mappings ast
実装では、ルールに一致した source と sink の位置を起点にして、幅優先探索でそれらに関連する関数だけを取り出した部分グラフを作っています。let relevant_graph =
Graph_reachability.compute_relevant_subgraph call_graph
~sources:source_functions ~sinks:sink_functions
taint-intrafileオプションは、単に「関数間解析をする」だけでなく、そのルールに関連する関数だけに解析対象を絞るようになっています。
これによって不要な関数まで解析するのを避けて、時間の短縮につながると思われます。
トポロジカル順序でシグネチャーを構築する
let analysis_order =
Call_graph.Topo.fold
(fun fn acc -> fn :: acc)
relevant_graph []
|> List.rev
これにより、ある関数を解析するときに、可能な限り呼び出し先のシグネチャーが先に用意されるようになります。let updated_db, _signature =
Taint_signature_extractor.extract_signature_with_file_context
~arity ~db ?builtin_signature_db taint_inst ~name:info.name
~method_properties:info.method_properties
~call_graph:(Some relevant_graph) info.cfg ast
signature_database に追加され、後続の関数解析で参照されます。
つまり taint-intrafileオプションが有効の場合では、CFG から関数シグネチャーを作る
↓
それを signature database に格納する
↓
次の関数はそのシグネチャーを参照して解析する
シグネチャーを使って関数本体を解析する
check_fundef を実行します。
このときは、先ほど構築した updated_db と relevant_graph が渡されます。let _flow, fdef_effects, _mapping =
check_fundef taint_inst info.name !ctx ~glob_env
?class_name:info.class_name_str ~signature_db:updated_db
?builtin_signature_db ?call_graph:(Some relevant_graph)
info.fdef
taint-intrafileオプションが無効の場合では単に関数を単体で見るだけだったのに対し、taint-intrafileオプションが有効の場合では 既に計算済みの他関数のシグネチャーを参照しながら関数本体を解析できます。まとめ
taint-intrafileオプションは、同一ファイル内における関数間の汚染伝播を対象とした、軽量な解析機能です。
「まず怪しい箇所を拾い上げる」目的で使うのであれば、十分に価値のある機能だと考えられます。