
バイナリ内の関数を、バイト列の見た目ではなく動作内容でマッチングします。マイクロ実行による挙動ベースの関数フィンガープリンティング。
fnprintは、バイト列や制御フローグラフの見た目ではなく、関数が何をするかによってバイナリ内の関数をマッチングします。各関数を小さなエミュレータで偽の入力とともに実行し、生じる副作用を記録し、その振る舞いをフィンガープリントにハッシュ化します。同じ振る舞いをする2つの関数は、異なるコンパイラ、異なる最適化レベル、異なるCPU向けにビルドされていても、類似したフィンガープリントを得ます。
この方法を取る理由: バイトシグネチャ(FLIRT、FunctionID)はコードが再コンパイルされた瞬間に壊れ、CFGマッチャー(BinDiff、Diaphora)は-O0と-O3の間で不安定になり、アーキテクチャをまたぐと諦めてしまいます。振る舞いはそうしたすべてにはるかにうまく耐えます。学習データもモデルも不要、単一の静的バイナリだけです。
他にはない3つの特徴:
-O0 対 aarch64 -O0: 関数の97.7%が正しく命名されました。triageは差分ではなく判定を返します。 2つのコーパス(既知の脆弱版、パッチ済み版)、マージン、そして脆弱側に傾く関数のレビューキュー。これがn-dayワークフローを1コマンドにしたものです。x86-64およびaarch64 ELF。信頼する前に限界を参照してください。
ストリップされたバイナリと、すでに名前が分かっているもののコーパスを指定します:
$ strip --strip-all mystery.so
$ nm mystery.so
nm: mystery.so: no symbols
$ fnprint index libz.so -o corpus.db # a build you have symbols for
$ fnprint query mystery.so --corpus corpus.db --threshold 0.6
named 27 function(s):
addr sim graph name
0x000022f9 100.0% 0% adler32_z (libz.so)
0x00002a8d 100.0% 0% compress2 (libz.so)
0x00004500 100.0% 0% deflateStateCheck (libz.so)
0x0000ad5d 100.0% 0% inflateBackEnd (libz.so)
0x0000b69c 100.0% 0% inflateStateCheck (libz.so)
0x00012f8c 100.0% 0% _tr_tally (libz.so)
...
この実行は、完全にストリップされた-O0ビルドを-O2コーパスから命名したものです。最適化レベルが異なり、シンボルはゼロ、27の名前が返り、27すべてが正解です。自信があるものには名前を付け、それ以外については黙っています。(上のgifは0.5のもので、9個見つけました。0.6では、ストリップされた.soがエクスポートの背後に隠している静的関数も復元します。)
同じことをCPUをまたいで行います。コーパスはx86-64ビルドから、ターゲットは同じソースのaarch64ビルドで、シンボルは消えています:
$ fnprint index libz_x86_64.so -o corpus.db
$ fnprint query libz_arm64_stripped.so --corpus corpus.db
named 42 function(s):
addr sim graph name
0x00001af4 100.0% 0% adler32_z (libz_x86_64.so)
0x0000263c 100.0% 0% compress2 (libz_x86_64.so)
0x00002958 100.0% 100% x2nmodp (libz_x86_64.so)
0x00002a98 100.0% 0% crc32_z (libz_x86_64.so)
0x000034c4 100.0% 100% crc32_combine64 (libz_x86_64.so)
...
42個が命名され、42個すべてが正解、異なるCPU向けにビルドされたコーパスからです。
もう1つは、2つのビルドを差分し、どの関数の振る舞いが変わったかを教えてくれることです。ベンダーが新しいファームウェアを出荷し、実際に何が変わったのか知りたいときに便利です。シンボル名があってもなくても動作します。ある場合は名前でアラインし、ない場合は振る舞いとコールグラフで2つのビルドをアラインするので、完全にストリップされた2つのファームウェアイメージでも差分が取れます:
$ fnprint match old.so new.so
aligned 38 function pairs by behavior + call graph (no symbol names needed)
unchanged: 31
changed: 7
low-signal: 57 (too small to judge)
changed behavior (lowest similarity first):
30.5% compress_block
43.0% deflate_rle -> deflate_huff
68.8% deflate_slow
実際のn-day作業にはtriageがあります。ある関数の既知の脆弱版から1つのコーパスを、パッチ済み版からもう1つをビルドし、未知のビルドを両方に対してランク付けします。脆弱側に近く、パッチ側から明確に分離されている関数こそ、人間の前に出すべきものであり、解釈が必要な単一のマッチスコアではありません:
$ fnprint index vuln.so -o vuln.db
$ fnprint index patched.so -o patched.db
$ fnprint triage mystery.so --vuln vuln.db --patched patched.db
43 functions triaged: 1 look vulnerable, 0 patched, 42 inconclusive
review queue (vuln-leaning, strongest first):
addr vuln% patched% margin matches
0x000022f9 100.0 27.3 +72.7 adler32_z vs crc32_z
両バージョンで同一である42の関数は、意図的にinconclusiveとして返されます。どちら側にも固定できず、フラグを立てるべきではないからです。--marginと--min-simは、確定する前に両側がどれだけ強く分離する必要があるかを制御します。
各関数について:
stripを生き残る部分だからです。内部呼び出しは出力では匿名となり、代わりに本体の静的スイープからコールグラフのエッジになります。-O2関数がすべてそれを要求するのを防ぎます。学習データもモデルもありません。同じアイデアは文献ではBlanket Execution (Egele et al, USENIX Security 2014)として登場します。fnprintはそれを実用的かつメンテナンスされた形にし、実際に使えるCLIを備えたものです。エフェクトモデルは構造上アーキテクチャ中立であり、だからこそ同じソースのx86-64とaarch64のプリントが、何も学習せずに一致します。
rustツールチェーン、cmake、Cツールチェーンが必要です。unicorn(no-JITフォーク)とcapstoneはcargoによってソースからビルドされ、aptでインストールするものはありません。
cargo install --path cli
# or just
cargo build --release # binary at target/release/fnprint
fnprint index <binary> [-o out.db] fingerprint every function, optionally to a db
fnprint match <a> <b> diff two binaries (or .db files) by behavior
fnprint query <target> --corpus <db> name unknown functions from a corpus
fnprint triage <t> --vuln <db> --patched <db> rank a build against vuln vs patched corpora
fnprint eval <a> <b> accuracy metrics using symbol names as truth
fnprint dump <binary> <func> print the recorded effect trace (debugging)
fnprint completions <shell> print a shell completion script to stdout
matchとqueryは、ELFまたはindexでビルドした.dbのどちらでも受け取れるので、コーパスを一度フィンガープリントして再利用できます。index -oは追記するので、1つのコーパスに複数のバイナリを保持できます。
match --alignは、シンボルがあるペアに対して名前なしアラインメントを強制し、振る舞いのみのビューが何を言うかを確認できます。--graph-weight(グローバル、デフォルト1.0)は、アライン時に一貫したコールグラフ近傍がペアをどれだけ持ち上げるかを設定します。0は各関数を自身の振る舞いのみでスコアします。
match、query、triageは--limit Nを受け取り、人間向けテーブルが出力する行数を制限します(0 = すべて)。jsonとr2出力は決して制限されないので、スクリプトはすべてを取得できます。
completionsはbash、zsh、fish、powershell、またはelvish用のスクリプトを書き出します。例:
fnprint completions bash > ~/.local/share/bash-completion/completions/fnprint。
出力を早期に読み取りを止めるもの(| head)にパイプすると静かに終了するので、テーブルは--limitなしで安全にざっと見られます。
すべてのコマンドはグローバルな--formatを受け取ります:
fnprint query mystery.so --corpus corpus.db --format json > names.json
fnprint query mystery.so --corpus corpus.db --format r2 > fnprint.r2
jsonはスクリプト(およびcontrib/のGhidraインポートスタブ)向けの安定したスキーマで、r2はrizin/radare2内で. fnprint.r2として実行するafnリネームコマンドを出力します。ターゲットからのシンボル名はどちらかに到達する前にサニタイズされるので、細工された名前がr2コマンドを注入することはできません。スキーマとセットアップはdocs/integrations.mdにあります。
インデックス化はデフォルトでシングルプロセスです。FNPRINT_SHARDS=N fnprint index ...は大きなインデックスをN個のジェイルされたワーカーに分散します。コーパスはNが何であれバイト単位で同一です。大きな関数リッチなバイナリでのみ役立つので、要求しない限りオフです。
ペアごとに2つの数値。rank-1はプリント単体です: ビルドAの関数について、Bのすべての関数を類似度でランク付けし、トップヒットが正しいかどうか。alignedはqueryとmatchが実際に行うことです: コールグラフ付きの1:1割り当て、パートナーが正しい頻度、および「same」閾値で確定したペアのprecision/recall。zlib 1.3.1(84関数)とlua 5.4.6(シグナルのある約600関数)、bench/run.shで再現可能: