
Faire correspondre les fonctions dans les binaires par ce qu'elles font, et non par l'apparence de leurs octets. Empreinte comportementale de fonctions via micro-exécution.
fnprint fait correspondre les fonctions dans les binaires par ce qu'elles font, et non par l'apparence de leurs octets ou de leurs graphes de flot de contrôle. il exécute chaque fonction dans un minuscule émulateur avec des entrées inventées, enregistre les effets de bord qu'elle produit, et hache ce comportement en une empreinte. deux fonctions qui se comportent de la même manière obtiennent des empreintes similaires, même si elles ont été compilées par un compilateur différent, à un niveau d'optimisation différent, ou pour un CPU différent.
l'intérêt de procéder ainsi : les signatures d'octets (FLIRT, FunctionID) se cassent dès que le code est recompilé, et les matchers de CFG (BinDiff, Diaphora) deviennent instables entre -O0 et -O3 et abandonnent entre architectures. le comportement survit à tout cela bien mieux. pas de données d'entraînement, pas de modèle, un seul binaire statique.
trois choses qu'il fait que les autres ne font pas :
-O0 vs aarch64 -O0 : 97,7 % des fonctions nommées correctement.triage vous donne un verdict, pas un diff. deux corpus (vulnérable connu, patché), une marge, et une file de revue des fonctions qui penchent vers le vulnérable. c'est le workflow n-day en une seule commande.ELF x86-64 et aarch64. voir limites avant de lui faire confiance.
pointez-le vers un binaire strippé et un corpus de choses que vous avez déjà nommées :
$ 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 --threshold 0.6
named 27 function(s):
addr sim graph name
0x000022f9 100.0% 0% adler32_z (libz.so)
0x00002a8d 100.0% 0% compress2 (libz.so)
0x00004500 100.0% 0% deflateStateCheck (libz.so)
0x0000ad5d 100.0% 0% inflateBackEnd (libz.so)
0x0000b69c 100.0% 0% inflateStateCheck (libz.so)
0x00012f8c 100.0% 0% _tr_tally (libz.so)
...
cette exécution est une build -O0 entièrement strippée nommée à partir d'un corpus -O2. niveau d'optimisation différent, zéro symbole restant, 27 noms reviennent et tous les 27 sont corrects. il nomme ce dont il est confiant et reste silencieux sur le reste. (le gif ci-dessus provient de la 0.5, qui en trouvait 9 ; la 0.6 récupère aussi les fonctions statiques qu'un .so strippé cache derrière ses exports.)
la même chose entre CPU. corpus issu de la build x86-64, la cible est la build aarch64 de la même source, symboles disparus :
$ fnprint index libz_x86_64.so -o corpus.db
$ fnprint query libz_arm64_stripped.so --corpus corpus.db
named 42 function(s):
addr sim graph name
0x00001af4 100.0% 0% adler32_z (libz_x86_64.so)
0x0000263c 100.0% 0% compress2 (libz_x86_64.so)
0x00002958 100.0% 100% x2nmodp (libz_x86_64.so)
0x00002a98 100.0% 0% crc32_z (libz_x86_64.so)
0x000034c4 100.0% 100% crc32_combine64 (libz_x86_64.so)
...
42 nommées, 42 correctes, à partir d'un corpus construit pour un CPU différent.
l'autre chose qu'il fait est de differ deux builds et de vous dire quelles fonctions ont changé de comportement, ce qui est pratique quand un fournisseur livre un nouveau firmware et que vous voulez savoir ce qui a réellement bougé. il fonctionne avec ou sans noms de symboles : avec, il aligne par nom, sans, il aligne les deux builds sur le comportement plus le graphe d'appels, donc deux images de firmware entièrement strippées se diffent quand même :
$ fnprint match old.so new.so
aligned 38 function pairs by behavior + call graph (no symbol names needed)
unchanged: 31
changed: 7
low-signal: 57 (too small to judge)
changed behavior (lowest similarity first):
30.5% compress_block
43.0% deflate_rle -> deflate_huff
68.8% deflate_slow
pour le vrai travail n-day il y a triage. construisez un corpus à partir de la version vulnérable connue d'une fonction et un à partir de la version patchée, puis classez une build inconnue par rapport aux deux. une fonction proche du côté vulnérable et clairement séparée du côté patché est ce que vous voulez mettre devant un humain, pas juste un score de correspondance unique que vous devez interpréter :
$ 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
les 42 fonctions identiques dans les deux versions reviennent non concluantes à dessein, elles ne peuvent être rattachées à aucun des deux côtés et ne devraient pas être signalées. --margin et --min-sim contrôlent à quel point les deux côtés doivent se séparer avant qu'il ne tranche.
pour chaque fonction :