
Corresponder funções em binários pelo que elas fazem, não pela aparência de seus bytes. Impressão digital comportamental de funções via microexecução.
fnprint faz correspondência de funções em binários pelo que elas fazem, não pela aparência dos seus bytes ou grafos de fluxo de controle. ele executa cada função em um pequeno emulador com entradas inventadas, registra os efeitos colaterais que ela produz e transforma esse comportamento em um hash de impressão digital. duas funções que se comportam da mesma forma recebem impressões digitais semelhantes, mesmo que tenham sido compiladas por um compilador diferente ou em um nível de otimização diferente.
o ponto de fazer isso dessa forma: assinaturas de bytes (FLIRT, FunctionID) quebram no momento em que o código é recompilado, e comparadores de CFG (BinDiff, Diaphora) ficam instáveis entre -O0 e -O3. o comportamento sobrevive a ambos muito melhor.
apenas ELF x86-64 por enquanto. veja limitações antes de confiar nele.
aponte-o para um binário stripped e um corpus de coisas para as quais você já tem nomes:
$ 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
...
essa execução é um build -O0 totalmente stripped nomeado a partir de um corpus -O2. nível de otimização diferente, zero símbolos restantes, e os nomes voltam corretos. ele nomeia aquilo sobre o que tem confiança e fica quieto sobre o resto.
a outra coisa que ele faz é comparar dois builds e dizer quais funções mudaram de comportamento, o que é útil quando um fornecedor lança um novo firmware e você quer saber o que realmente mudou:
$ 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
para trabalho real de n-day existe o triage. construa um corpus a partir da versão conhecida como vulnerável de uma função e outro a partir da versão corrigida, depois classifique um build desconhecido contra ambos. uma função próxima do lado vulnerável e claramente separada do lado corrigido é o que você quer colocar na frente de um humano, não apenas uma única pontuação de correspondência que você tem que interpretar:
$ 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
as 42 funções que são idênticas em ambas as versões voltam como inconclusivas de propósito, elas não podem ser atribuídas a nenhum dos lados e não deveriam ser sinalizadas. --margin e --min-sim controlam o quão fortemente os dois lados precisam se separar antes que ele se comprometa.
para cada função:
sem dados de treinamento, sem modelo. a mesma ideia aparece na literatura como Blanket Execution (Egele et al, USENIX Security 2014); fnprint é uma abordagem prática e mantida dela com uma CLI que você pode realmente usar.
precisa de um toolchain rust e das bibliotecas 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 e query aceitam tanto um ELF quanto um .db que você construiu com index, então você pode gerar a impressão digital de um corpus uma vez e reutilizá-lo.
todo comando aceita um --format global:
fnprint query mystery.so --corpus corpus.db --format json > names.json
fnprint query mystery.so --corpus corpus.db --format r2 > fnprint.r2
json é um schema estável para scripts (e o stub de importação do Ghidra em contrib/), r2 emite comandos de renomeação afn que você executa dentro do rizin/radare2 com . fnprint.r2. nomes de símbolos do alvo são sanitizados antes de chegarem a qualquer um dos dois, então um nome criado maliciosamente não pode injetar comandos r2. schemas e configuração estão em docs/integrations.md.
a indexação é de processo único por padrão. FNPRINT_SHARDS=N fnprint index ... distribui um índice grande entre N workers isolados; o corpus é byte-idêntico qualquer que seja o N. isso só ajuda em binários grandes e ricos em funções, então fica desligado a menos que você peça.
a precisão rank-1 é: para uma função no build A, classifique cada função no build B por similaridade, o acerto no topo é o correto. essa é exatamente a tarefa de nomeação de stripped. medido no zlib 1.3.1 (84 funções), reproduzível com bench/run.sh:
| par | rank-1 | precisão |
|---|---|---|
| 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% |
tabela completa mais uma segunda biblioteca (lua) em bench/NUMBERS.md.
eval também reporta recall@3 / recall@5 e uma taxa de abstenção, já que o rank-1 sozinho esconde muita coisa. no gcc O0 -> O2 o acerto no topo está correto 93% das vezes, mas a função correta está no top 5 em 96.6% das vezes, então um pequeno orçamento de revisão fecha a maior parte da lacuna. ele também se abstém (recusa uma chamada confiante de "mesmo") nos pares sobre os quais não tem certeza em vez de adivinhar, e é por isso que a precisão permanece alta enquanto o recall no mesmo limiar é baixo.
a leitura honesta: quando pelo menos um lado tem alguma riqueza comportamental (qualquer coisa com -O0/-O1, ou um par cross-compiler em -O0) ele fica na faixa de 80-98%. quando ambos os lados são fortemente otimizados, o comportamento que conseguimos observar fica escasso e ele cai para quase uma moeda ao ar. essa é a fronteira difícil para um matcher de passagem única e sem treinamento, e isto não finge o contrário.
match. ele captura mudanças estruturais e de caminho inicial, não toda alteração profunda.fnprint é a opção sem treinamento e com comportamento em primeiro lugar. o modelo de efeitos já é neutro em relação à arquitetura, o que é a base para correspondência entre CPUs.
explore_depth, desligado por padrão), mas ela adiciona ruído específico do build em caminhos impossíveis e prejudicou a precisão entre builds nos testes, então precisa de um filtro de consistência de caminhos antes de valer a pena.--format r2); um plugin real para ghidra é o próximo. há um stub experimental de importação jython em contrib/ enquanto isso.MIT. veja LICENSE.