fnprint 根据函数做什么来匹配二进制文件中的函数,而不是根据它们的字节或控制流图长什么样。它在微型模拟器中用虚构的输入运行每个函数,记录它产生的副作用,并将该行为哈希成指纹。行为相同的两个函数会得到相似的指纹,即使它们由不同的编译器或不同的优化级别构建。
这样做的意义在于:字节签名(FLIRT、FunctionID)在代码重新编译的那一刻就会失效,而 CFG 匹配器(BinDiff、Diaphora)在 -O0 与 -O3 之间会变得不稳定。行为对两者的耐受性都要好得多。
目前仅支持 x86-64 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
named 9 function(s):
0x000022f9 100.0% adler32_z
0x00002a8d 100.0% compress2
0x0000ad5d 100.0% inflateBackEnd
0x0000adc4 86.7% inflate_fast
0x00002e67 80.5% crc32_z
...
那次运行是一个完全剥离符号的 -O0 构建,从一个 -O2 语料库中命名。不同的优化级别,零符号残留,名称却正确恢复了。它对有把握的进行命名,对其余的保持沉默。
它做的另一件事是 diff 两个构建,并告诉你哪些函数改变了行为,这在供应商发布新固件而你想知道实际变动了什么时很方便:
$ fnprint match old.so new.so
compared 84 functions present in both
unchanged: 53
changed: 1
low-signal: 31 (too small to judge)
changed behavior (lowest similarity first):
61.7% deflate_stored
对于实际的 n-day 工作,有 triage。从函数的已知易受攻击版本构建一个语料库,从已修补版本构建另一个语料库,然后根据两者对一个未知构建进行排名。一个接近易受攻击侧且与已修补侧明显分离的函数,才是你想呈现在人类面前的,而不仅仅是一个你必须解读的单一匹配分数:
$ 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 控制两侧在它做出判断之前必须分离到什么程度。
对于每个函数:
没有训练数据,没有模型。同样的想法在文献中以 Blanket Execution(Egele 等人,USENIX Security 2014)出现;fnprint 是它的一种实用、持续维护的实现,带有一个你真正能用的 CLI。
需要 rust 工具链以及 unicorn + capstone 库。
# debian/ubuntu/kali
sudo apt install libunicorn-dev libcapstone-dev
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)
match 和 query 接受 ELF 或你用 index 构建的 .db,因此你可以对语料库进行一次指纹提取并重复使用。
每个命令都接受一个全局 --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 导入桩)的稳定 schema,r2 输出 afn 重命名命令,你在 rizin/radare2 中用 . fnprint.r2 运行。来自目标的符号名称在进入任一格式之前都会被清理,因此精心构造的名称无法注入 r2 命令。schema 和设置见 docs/integrations.md。
索引默认是单进程的。FNPRINT_SHARDS=N fnprint index ... 将大型索引分散到 N 个受限 worker 上;无论 N 是多少,语料库都是字节完全相同的。它只对大型、函数丰富的二进制文件有帮助,所以除非你要求,否则它是关闭的。
rank-1 准确度是:对于构建 A 中的一个函数,按相似度对构建 B 中的每个函数进行排名,排名第一的命中是否正确。这正是剥离符号命名任务。在 zlib 1.3.1(84 个函数)上测量,可用 bench/run.sh 复现:
完整表格以及第二个库(lua)见 bench/NUMBERS.md。
eval 还报告 recall@3 / recall@5 和弃权率,因为仅凭 rank-1 会掩盖很多信息。在 gcc O0 -> O2 上,排名第一的命中在 93% 的情况下是正确的,但正确的函数在 96.6% 的情况下位于前 5 名,因此一个小的审查预算就能弥补大部分差距。它还会在它不确定的配对上弃权(拒绝给出自信的“相同”判断),而不是猜测,这就是为什么在相同阈值下精确率保持高位而召回率较低。
诚实的解读:当至少一侧具有一些行为丰富度(任何带有 -O0/-O1 的,或 -O0 下的跨编译器配对)时,它落在 80-98% 的范围内。当两侧都经过重度优化时,我们能观察到的行为变得稀薄,它会下降到接近抛硬币。这是单遍、免训练匹配器的艰难前沿,本文并不假装不是这样。
match 中。它能捕获结构性和早期路径的更改,而不是每一个深层调整。fnprint 是免训练、行为优先的选项。效应模型已经是架构中立的,这是跨 CPU 匹配的基础工作。
explore_depth,默认关闭),但它会在不可能路径上增加构建特定的噪声,并在测试中损害跨构建准确度,因此它需要一个路径一致性过滤器才能证明自己的价值。--format r2);真正的 ghidra 插件是下一步。与此同时,contrib/ 中有一个实验性的 jython 导入桩。MIT。见 LICENSE。
| pair | rank-1 | precision |
|---|
| gcc O0 -> O1 | 97.1% | 93.8% |
| gcc O0 -> O2 | 93.1% | 83.3% |
| gcc O0 -> O3 | 91.3% | 100.0% |
| gcc/clang O0 | 97.7% | 97.1% |
| gcc O2 -> O3 | 56.5% | 66.7% |
| gcc/clang O2 | 59.1% | 50.0% |