
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 encontra funções em binários pelo que elas fazem, não pela aparência de 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 gera um fingerprint desse comportamento. Duas funções que se comportam da mesma forma obtêm fingerprints semelhantes, mesmo que tenham sido compiladas por um compilador diferente ou em um nível de otimização diferente.
A ideia de fazer 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 muito melhor a ambos.
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 é uma build totalmente stripped em -O0, identificada a partir de um corpus -O2. Nível de otimização diferente, zero símbolos restantes, e os nomes voltam corretos. Ele nomeia aquilo de que está confiante e fica em silêncio sobre o resto.
A outra coisa que ele faz é fazer diff de duas 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 conhecidamente vulnerável de uma função e outro a partir da versão corrigida; depois classifique uma build desconhecida contra ambos. Uma função próxima do lado vulnerável e claramente separada do lado corrigido é o que você quer colocar diante de um humano, e não apenas uma pontuação única de correspondência que você precise 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 idênticas nas duas versões retornam inconclusivas de propósito; elas não podem ser atribuídas a nenhum dos lados e não devem ser sinalizadas. --margin e --min-sim controlam o quão separados os dois lados precisam estar 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 dessa ideia, com uma CLI que você pode realmente usar.
Requer uma toolchain Rust e as 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 um ELF ou um .db que você construiu com index, para que você possa gerar a fingerprint de um corpus uma vez e reutilizá-lo.
A precisão rank-1 é: para uma função na build A, classificar todas as funções na build B por similaridade; o primeiro resultado é o correto? Essa é exatamente a tarefa de nomear binários sem símbolos. Medido no zlib 1.3.1 (84 funções), reproduzível com bench/run.sh:
Tabela completa, além de uma segunda biblioteca (lua), em bench/NUMBERS.md.
eval também reporta recall@3 / recall@5 e uma taxa de abstenção, já que apenas o rank-1 esconde muita coisa. Em gcc O0 -> O2, o primeiro resultado está certo 93% das vezes, mas a função correta está no top 5 em 96,6% das vezes; portanto, um pequeno orçamento de revisão fecha a maior parte da lacuna. Ele também se abstém (recusa uma classificação confiante de "iguais") nos pares sobre os quais não tem certeza, em vez de adivinhar; é por isso que a precisão permanece alta enquanto o recall no mesmo limite é baixo.
A leitura honesta: quando pelo menos um dos lados tem alguma riqueza comportamental (qualquer coisa com -O0/-O1, ou um par entre compiladores diferentes em -O0), ele fica na faixa de 80-98%. Quando ambos os lados são fortemente otimizados, o comportamento que conseguimos observar fica ralo e ele cai para algo próximo de um cara ou coroa. Essa é a fronteira difícil para um comparador de passagem única e sem treinamento, e ele não finge o contrário.
match. Ele captura mudanças estruturais e de caminho inicial, não qualquer ajuste profundo.fnprint é a opção sem treinamento e com foco em comportamento. O modelo de efeitos já é neutro em relação à arquitetura, o que é a base para a correspondência entre CPUs.
explore_depth, desativada por padrão), mas adiciona ruído específico de build em caminhos impossíveis e prejudica a precisão entre builds nos testes; por isso precisa de um filtro de consistência de caminhos antes de merecer seu lugar.MIT. Veja LICENSE.
| 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% |