
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 or at a different optimization level.
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. behavior survives both a lot better.
x86-64 ELF only for now. 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
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
...
that run is a fully stripped -O0 build named from an -O2 corpus. different
optimization level, zero symbols left, and the names come back right. it names
what it is confident about and stays quiet about the rest.
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:
$ 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
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:
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.
needs a rust toolchain and the unicorn + capstone libraries.
# 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 and query take either an ELF or a .db you built with index, so you
can fingerprint a corpus once and reuse it.
every command takes a global --format:
fnprint query mystery.so --corpus corpus.db --format json > names.json
fnprint query mystery.so --corpus corpus.db --format r2 > fnprint.r2
json is a stable schema for scripts (and the Ghidra import stub in contrib/),
r2 emits afn rename commands you run inside rizin/radare2 with . fnprint.r2.
symbol names from the target are sanitized before they hit either, so a crafted
name can't inject r2 commands. schemas and setup are in
docs/integrations.md.
indexing is single-process by default. FNPRINT_SHARDS=N fnprint index ... spreads
a large index across N jailed workers; the corpus is byte-identical whatever N is.
it only helps on big, function-rich binaries, so it's off unless you ask for it.
rank-1 accuracy is: for a function in build A, rank every function in build B by
similarity, is the top hit the right one. that is exactly the stripped-naming
task. measured on zlib 1.3.1 (84 functions), reproducible with bench/run.sh:
full table plus a second library (lua) in bench/NUMBERS.md.
eval also reports recall@3 / recall@5 and an abstention rate, since rank-1
alone hides a lot. on gcc O0 -> O2 the top hit is right 93% of the time but the
correct function is in the top 5 96.6% of the time, so a small review budget
closes most of the gap. it also abstains (declines a confident "same" call)
on the pairs it isn't sure about instead of guessing, which is why precision
stays high while recall at the same threshold is low.
the honest read: when at least one side has some behavioral richness (anything
with -O0/-O1, or a cross-compiler pair at -O0) it lands in the 80-98%
range. when both sides are heavily optimized the behavior we can observe gets
thin and it drops toward a coin flip. that is the hard frontier for a single
pass, training-free matcher and this does not pretend otherwise.
match. it catches structural and early-path changes,
not every deep tweak.fnprint is the training-free, behavior-first option. the effect model is already architecture-neutral, which is the groundwork for matching across CPUs.
explore_depth,
off by default) but it adds build-specific noise on impossible paths and hurt
cross-build accuracy in testing, so it needs a path-consistency filter before
it earns its keep.--format r2); a real ghidra plugin is next.
there's an experimental jython import stub in contrib/ in the meantime.MIT. see LICENSE.
| 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% |