
Compara funciones en binarios por lo que hacen, no por cómo se ven sus bytes. Fingerprinting conductual de funciones mediante microejecución.
fnprint empareja funciones en binarios por lo que hacen, no por cómo se ven sus bytes o sus grafos de flujo de control. ejecuta cada función en un pequeño emulador con entradas inventadas, registra los efectos secundarios que produce y convierte ese comportamiento en una huella digital. dos funciones que se comportan igual obtienen huellas similares, incluso si fueron compiladas por un compilador distinto o con un nivel de optimización diferente.
el objetivo de hacerlo así: las firmas de bytes (FLIRT, FunctionID) se rompen en el
momento en que el código se recompila, y los emparejadores de CFG (BinDiff, Diaphora) se vuelven frágiles entre
-O0 y -O3. el comportamiento sobrevive mucho mejor a ambos.
solo ELF x86-64 por ahora. consulta limitaciones antes de confiar en él.
apúntalo a un binario stripped y a un corpus de cosas para las que ya tienes nombres:
$ 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
...
esa ejecución es un build -O0 completamente stripped nombrado a partir de un corpus -O2. distinto
nivel de optimización, cero símbolos restantes, y los nombres vuelven correctos. nombra
aquello de lo que está seguro y se queda callado sobre el resto.
la otra cosa que hace es comparar dos builds y decirte qué funciones cambiaron de comportamiento, lo cual es útil cuando un proveedor publica un nuevo firmware y quieres saber qué se movió realmente:
$ 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 trabajo real de n-day existe triage. construye un corpus a partir de la versión
vulnerable conocida de una función y otro a partir de la versión parcheada, luego clasifica un
build desconocido contra ambos. una función cercana al lado vulnerable y claramente
separada del lado parcheado es lo que quieres poner delante de un humano, no solo una
única puntuación de coincidencia que tengas 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
las 42 funciones que son idénticas en ambas versiones vuelven como inconclusas a
propósito, no pueden asignarse a ninguno de los dos lados y no deberían marcarse. --margin
y --min-sim controlan cuánto tienen que separarse los dos lados antes de que se decida.
para cada función:
sin datos de entrenamiento, sin modelo. la misma idea aparece en la literatura como Blanket Execution (Egele et al, USENIX Security 2014); fnprint es una versión práctica y mantenida de ella con una CLI que realmente puedes usar.
necesita un toolchain de rust y las librerías 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 y query aceptan tanto un ELF como un .db que hayas construido con index, así que
puedes generar la huella de un corpus una vez y reutilizarlo.
cada comando acepta un --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 es un esquema estable para scripts (y el stub de importación de Ghidra en contrib/),
r2 emite comandos de renombrado afn que ejecutas dentro de rizin/radare2 con . fnprint.r2.
los nombres de símbolos del objetivo se sanean antes de llegar a cualquiera de los dos, así que un nombre
manipulado no puede inyectar comandos de r2. los esquemas y la configuración están en
docs/integrations.md.
la indexación es de un solo proceso por defecto. FNPRINT_SHARDS=N fnprint index ... reparte
un índice grande entre N workers enjaulados; el corpus es idéntico byte a byte sea cual sea N.
solo ayuda en binarios grandes y ricos en funciones, así que está desactivado a menos que lo pidas.
la precisión rank-1 es: para una función en el build A, clasifica cada función del build B por
similitud, ¿es el primer resultado el correcto? esa es exactamente la tarea de nombrar
stripped. medido en zlib 1.3.1 (84 funciones), reproducible con 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% |
tabla completa más una segunda librería (lua) en bench/NUMBERS.md.
eval también informa recall@3 / recall@5 y una tasa de abstención, ya que solo el rank-1
oculta mucho. en gcc O0 -> O2 el primer resultado es correcto el 93% de las veces pero la
función correcta está en el top 5 el 96.6% de las veces, así que un pequeño presupuesto de revisión
cierra la mayor parte de la brecha. también se abstiene (rechaza una llamada confiada de "misma")
en los pares de los que no está seguro en lugar de adivinar, por eso la precisión
se mantiene alta mientras que el recall en el mismo umbral es bajo.
la lectura honesta: cuando al menos un lado tiene cierta riqueza de comportamiento (cualquier cosa
con -O0/-O1, o un par entre compiladores en -O0) aterriza en el rango del 80-98%.
cuando ambos lados están fuertemente optimizados el comportamiento que podemos observar se vuelve
escaso y cae hacia el cara o cruz. esa es la frontera difícil para un emparejador de una sola
pasada y sin entrenamiento y esto no pretende lo contrario.
match. captura cambios estructurales y de rutas tempranas,
no todos los ajustes profundos.fnprint es la opción sin entrenamiento y centrada en el comportamiento. el modelo de efectos ya es neutral respecto a la arquitectura, lo cual es la base para emparejar entre CPUs.
explore_depth,
desactivado por defecto) pero añade ruido específico del build en rutas imposibles y perjudicó
la precisión entre builds en las pruebas, así que necesita un filtro de consistencia de rutas antes
de ganarse su lugar.--format r2); un plugin real de ghidra es lo siguiente.
mientras tanto hay un stub de importación jython experimental en contrib/.MIT. consulta LICENSE.