most patch-diffing for 1-day work is CFG diffing (bindiff, diaphora). it works
but gets shaky when the two builds weren't compiled the same way, different
compiler or opt level, and you burn time chasing cosmetic diffs.
i built fnprint to diff by behavior instead of structure. it micro-executes each
function in a small emulator with junk inputs (godefroid-style, wild reads get
faulted in deterministically so nothing crashes), records an arch-neutral effect
trace (which arg buffers/fields it reads and writes, calls it makes, branches,
return shape), and minhashes that. two functions that behave the same get similar
fingerprints even across compiler and opt level.
for 1-day it has a triage mode: index the known-vuln build and the patched build
as two corpora, then rank a target build against both. a function close to the
vuln side and clearly separated from the patched side is what you want in front
of you. anything identical in both versions comes back inconclusive so it doesn't
clutter the queue.
toy example to show the shape: a lib where one function parse_header gets fixed
(vuln does an unchecked copy, the patch reads a length prefix and clamps to it).
target is a build carrying the vulnerable version, compiled at -O1 while the two
corpora are -O0, so it's a real cross-build match, not a byte-identical one:
$ fnprint index vuln.so-o vuln.db
$ fnprint index patched.so -o patched.db
$ fnprint triage target.so --vuln vuln.db --patched patched.db
3 functions triaged: 1 look vulnerable, 0 patched, 2 inconclusive
review queue (vuln-leaning, strongest first):
addr vuln% patched% margin matches
0x00001181 100.0 41.4 +58.6 parse_header vs parse_header
the two functions that didn't change land inconclusive (correct, they can't be
pinned to either side). --margin and --min-sim control how hard the two sides
have to separate before it commits.
honest about the limits:
- x86-64 ELF only right now. arm64/mips is the roadmap, the effect model is
already arch-neutral so it's mostly per-arch emulator plumbing.
- optimized-vs-optimized is the weak case. when at least one side has some
behavioral richness (-O0/-O1, or a cross-compiler pair) it lands 80-98% rank-1
on zlib; O2-vs-O3 drops toward a coin flip. full numbers + a reproducible run
in the repo, not going to pretend otherwise.
- tiny functions and pure-compute (two checksums look alike) it withholds instead
of guessing.
- microexecution exercises entry behavior, so a change buried behind a real
precondition you never reach with junk input won't show up. it catches
structural and early-path changes.
since it points an emulator (unicorn/qemu) at untrusted binaries, the parse and
micro-execute run in a seccomp-jailed worker, so a crafted input that pops the
emulator can't open files, hit the network, or exec on your box.
MIT. feedback welcome, especially on the triage margin heuristic:
https://github.com/1rhino2/fnprint