
match functions in binaries by what they do, not what their bytes look like. behavioral function fingerprinting via microexecution.
fnprint matches functions in binaries by what they do, not by what their bytes or control-flow graphs look like. it runs each function in a tiny emulator with made-up inputs, records the side effects it produces, and hashes that behavior into a fingerprint. two functions that behave the same get similar fingerprints, even if they were built by a different compiler, at a different optimization level, or for a different CPU.
the point of doing it this way: byte signatures (FLIRT, FunctionID) break the
moment code is recompiled, and CFG matchers (BinDiff, Diaphora) get shaky across
-O0 vs -O3 and give up across architectures. behavior survives all of that
a lot better. no training data, no model, one static binary.
three things it does that the others dont:
-O0 vs aarch64 -O0: 97.7% of functions named right.triage gives you a verdict, not a diff. two corpora (known-vulnerable,
patched), a margin, and a review queue of the functions that lean vulnerable.
thats the n-day workflow as one command.x86-64 and aarch64 ELF. see limits before you trust it.
point it at a stripped binary and a corpus of things you already have names for:
$ 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)
...
that run is a fully stripped -O0 build named from an -O2 corpus. different
optimization level, zero symbols left, 27 names come back and all 27 are
right. it names what it is confident about and stays quiet about the rest.
(the gif above is from 0.5, which found 9; 0.6 also recovers the static
functions a stripped .so hides behind its exports.)
the same thing across CPUs. corpus from the x86-64 build, target is the aarch64 build of the same source, symbols gone:
$ 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 named, 42 right, from a corpus built for a different CPU.
the other thing it does is diff two builds and tell you which functions changed behavior, which is handy when a vendor ships a new firmware and you want to know what actually moved. it works with or without symbol names: with them it aligns by name, without them it aligns the two builds on behavior plus the call graph, so two fully stripped firmware images still diff:
$ 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
for actual n-day work there is triage. build one corpus from the known
vulnerable version of a function and one from the patched version, then rank an
unknown build against both. a function close to the vulnerable side and clearly
separated from the patched side is what you want in front of a human, not just a
single match score you have to interpret:
$ 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
the 42 functions that are identical in both versions come back inconclusive on
purpose, they can't be pinned to either side and shouldn't be flagged. --margin
and --min-sim control how hard the two sides have to separate before it commits.
for each function:
strip. internal calls are anonymous in the
print and become call-graph edges instead, from a static sweep of the body.-O2 functions that all inline
the same state check from all claiming it.no training data, no model. the same idea shows up in the literature as Blanket Execution (Egele et al, USENIX Security 2014); fnprint is a practical, maintained take on it with a CLI you can actually use. the effect model is arch-neutral by construction, which is why x86-64 and aarch64 prints of the same source line up without anything learned.
needs a rust toolchain, cmake, and a C toolchain. unicorn (the no-JIT fork) and capstone are built from source by cargo, nothing to apt install.
cargo install --path cli
# or just
cargo build --release # binary at target/release/fnprint