
gleiche Funktionen in Binärdateien danach ab, was sie tun, nicht wie ihre Bytes aussehen. verhaltensbasiertes Funktions-Fingerprinting durch Mikroausführung.
fnprint gleicht Funktionen in Binärdateien danach ab, was sie tun, nicht danach, wie ihre Bytes oder Kontrollflussgraphen aussehen. Es führt jede Funktion in einem winzigen Emulator mit erfundenen Eingaben aus, zeichnet die erzeugten Seiteneffekte auf und hashed dieses Verhalten zu einem Fingerabdruck. Zwei Funktionen, die sich gleich verhalten, erhalten ähnliche Fingerabdrücke, selbst wenn sie von einem anderen Compiler, mit einer anderen Optimierungsstufe oder für eine andere CPU gebaut wurden.
Der Sinn dahinter: Byte-Signaturen (FLIRT, FunctionID) brechen in dem Moment, in dem Code
neu kompiliert wird, und CFG-Matcher (BinDiff, Diaphora) werden wackelig zwischen -O0 und -O3
und geben über Architekturen hinweg auf. Verhalten übersteht all das deutlich besser. Keine
Trainingsdaten, kein Modell, eine statische Binärdatei.
Drei Dinge, die es tut, die die anderen nicht tun:
-O0 vs. aarch64 -O0: 97,7 % der Funktionen korrekt benannt.triage liefert dir ein Urteil, keinen Diff. Zwei Korpora (bekanntermaßen verwundbar,
gepatcht), eine Marge und eine Review-Warteschlange der Funktionen, die zu verwundbar tendieren.
Das ist der n-day-Workflow als ein Befehl.x86-64 und aarch64 ELF. Siehe Grenzen, bevor du ihm vertraust.
Richte es auf eine gestrippte Binärdatei und ein Korpus von Dingen, für die du bereits Namen hast:
$ 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)
...
Dieser Lauf ist ein vollständig gestrippter -O0-Build, benannt aus einem -O2-Korpus. Andere
Optimierungsstufe, null Symbole übrig, 27 Namen kommen zurück und alle 27 sind
richtig. Es benennt, wessen es sich sicher ist, und schweigt über den Rest.
(das GIF oben ist von 0.5, das 9 fand; 0.6 holt auch die statischen
Funktionen zurück, die eine gestrippte .so hinter ihren Exporten versteckt.)
Dasselbe über CPUs hinweg. Korpus aus dem x86-64-Build, Ziel ist der aarch64-Build derselben Quelle, Symbole weg:
$ 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 benannt, 42 richtig, aus einem Korpus, der für eine andere CPU gebaut wurde.
Das andere, was es tut, ist, zwei Builds zu diffen und dir zu sagen, welche Funktionen ihr Verhalten geändert haben, was praktisch ist, wenn ein Hersteller eine neue Firmware ausliefert und du wissen willst, was sich tatsächlich bewegt hat. Es funktioniert mit oder ohne Symbolnamen: mit ihnen gleicht es nach Namen ab, ohne sie gleicht es die beiden Builds anhand von Verhalten plus Aufrufgraphen ab, sodass zwei vollständig gestrippte Firmware-Images trotzdem diffbar sind:
$ 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
Für tatsächliche n-day-Arbeit gibt es triage. Baue ein Korpus aus der bekannten
verwundbaren Version einer Funktion und eines aus der gepatchten Version und ranke dann einen
unbekannten Build gegen beide. Eine Funktion nahe an der verwundbaren Seite und klar
von der gepatchten Seite getrennt ist das, was du vor einen Menschen bringen willst, nicht nur ein
einzelner Match-Score, den du interpretieren musst:
$ 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
Die 42 Funktionen, die in beiden Versionen identisch sind, kommen absichtlich als
unschlüssig zurück, sie lassen sich keiner Seite zuordnen und sollten nicht markiert werden. --margin
und --min-sim steuern, wie stark sich die beiden Seiten trennen müssen, bevor es sich festlegt.
Für jede Funktion: