
Сопоставляйте функции в бинарных файлах по тому, что они делают, а не по тому, как выглядят их байты. Поведенческое фингерпринтирование функций через микроисполнение.
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. Другой уровень оптимизации, ноль оставшихся символов, и имена возвращаются правильными. Он называет то, в чём уверен, и молчит об остальном.
Ещё он умеет сравнивать две сборки и сообщать, какие функции изменили поведение, что удобно, когда вендор выпускает новую прошивку и вы хотите знать, что реально изменилось:
$ 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 et al, 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, либо .db, который вы построили с помощью index, так что можно один раз снять отпечатки корпуса и переиспользовать их.
Каждая команда принимает глобальный --format:
fnprint query mystery.so --corpus corpus.db --format json > names.json
fnprint query mystery.so --corpus corpus.db --format r2 > fnprint.r2
json — это стабильная схема для скриптов (и заглушка импорта для Ghidra в contrib/), r2 выдаёт команды переименования afn, которые вы выполняете внутри rizin/radare2 с помощью . fnprint.r2. Имена символов из цели санитизируются, прежде чем попасть в любой из них, так что специально сконструированное имя не может внедрить команды r2. Схемы и настройка — в docs/integrations.md.
Индексация по умолчанию однопроцессная. FNPRINT_SHARDS=N fnprint index ... распределяет большой индекс по N изолированным воркерам; корпус побайтово идентичен при любом N. Это помогает только на больших, богатых функциями бинарниках, поэтому отключено, пока вы сами не попросите.
Точность rank-1 — это: для функции в сборке A проранжировать все функции в сборке B по схожести; является ли верхнее совпадение правильным. Это в точности задача именования без символов. Измерено на zlib 1.3.1 (84 функции), воспроизводится с помощью bench/run.sh:
| 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% |
Полная таблица плюс вторая библиотека (lua) в bench/NUMBERS.md.
eval также сообщает recall@3 / recall@5 и долю воздержаний, поскольку один rank-1 скрывает многое. На gcc O0 -> O2 верхнее совпадение верно в 93% случаев, но правильная функция попадает в топ-5 в 96.6% случаев, так что небольшой бюджет на проверку закрывает большую часть разрыва. Он также воздерживается (отказывается от уверенного вердикта «то же самое») на парах, в которых не уверен, вместо угадывания — поэтому точность остаётся высокой, тогда как полнота при том же пороге низкая.
Честное прочтение: когда хотя бы одна сторона обладает некоторой поведенческой насыщенностью (что угодно с -O0/-O1, или кросс-компиляторная пара на -O0), он попадает в диапазон 80-98%. Когда обе стороны сильно оптимизированы, наблюдаемое нами поведение становится скудным, и он падает почти до подбрасывания монеты. Это трудный фронтир для однопроходного сопоставителя без обучения, и он не притворяется иным.
match. Он ловит структурные изменения и изменения на ранних путях, но не каждую глубокую правку.fnprint — это вариант без обучения, ставящий поведение на первое место. Модель эффектов уже архитектурно-нейтральна, что является основой для сопоставления между процессорами.
explore_depth, по умолчанию выключено), но оно добавляет специфичный для сборки шум на невозможных путях и в тестировании ухудшало кросс-сборочную точность, поэтому ему нужен фильтр согласованности путей, прежде чем оно оправдает себя.--format r2); настоящий плагин для Ghidra — следующий. Пока же в contrib/ есть экспериментальная заглушка импорта на jython.MIT. см. LICENSE.