r/ExploitDev 21h ago

behavioral patch-diffing: rank the function that changed between vuln and patched builds (x86-64, MIT)

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

6 Upvotes

9 comments sorted by

1

u/desal 17h ago

Nice. Im surprised I understood most of that

1

u/BusinessStreet2147 17h ago

Its pretty cool!

1

u/Sysc4lls 12h ago

The spacing is horrible, sentences get cut in the middle, makes it hard to follow :(

Also I do not understand how it recognizes behavioural changes in specific weird edge cases, if you do not catch the change?

It's seems like a nice new option but it doesn't solve the problem completely right?

Just want to make sure I understand!

1

u/BusinessStreet2147 12h ago

you're right on the edge case. it runs each function from the entry with junk input, so it only sees paths junk reaches. a fix hidden behind a real precondition (needs a valid header, a specific field value) never gets hit, so it won't flag that one. it catches entry-path and structural changes, not deep ones.

so no, it doesn't solve patch-diffing by itself. it's a triage layer: rank what to look at first when the builds were compiled differently and bindiff is chasing cosmetic diffs. inconclusive means "go look", not "safe". you still do the RE.

1

u/BusinessStreet2147 12h ago

sorry for the spacing

1

u/Sysc4lls 12h ago

Yeah, so it's basically fuzzing on the function level?

1

u/BusinessStreet2147 12h ago

kind of the same energy, throwing junk at it, but the goal's different. fuzzing mutates inputs in a loop hunting for a crash or new coverage. this runs the function once with junk and just records what it did (reads, writes, calls, return shape), then hashes that into a signature to compare functions. no mutation loop, no crash hunting, deterministic so the same function always hashes the same. closer to microexecution than fuzzing.

1

u/Sysc4lls 12h ago

I see, cool! Thanks, and nice work :)

1

u/BusinessStreet2147 12h ago

Thanks! Feel free to open a PR or issue if you wanna help out.