════════════════════════════════════════════════════════════════ VERIDIFF the exact instruction where two runs of the same code split ════════════════════════════════════════════════════════════════
Two traces go in. One address comes out: the exact basic block where your
two runs stopped agreeing, disassembled, with the branch that split them
marked — cmp/jne on x86, subs/b.ne on ARM64, a jump-table dispatcher
if the code is flattened. (When a compiler decides a condition without
branching at all — csel, cmov — the comparison itself sits in a block
both runs executed, and what you get is the block where the paths actually
forked. That distinction is demonstrated, not glossed over: see
Obfuscated control flow below.)
That's the whole product. Not a framework. Not a platform. An engine.
Current release: v0.4.0. Fixes silent trace truncation: Frida's default
Stalker queue holds 16384 events and drops the rest without an error, so
every trace longer than that was quietly incomplete — measured at up to 98%
of blocks lost while reporting success. The engine now sizes the queue itself
(and lets you size it), and every Trace carries queue_saturated so
truncation can never be silent again. Everything in this README is now
live-verified on both x86-64 and ARM64/Android, including control-flow
flattening and a 1.5-million-block trace. Breaking: trace_call gained an
option field; see Quickstart. Full history in
CHANGELOG.md.
Contents — Quickstart · Proof, Not Promises · How it works · Warm-up mode · Obfuscated control flow · Scale · Branch classification · Field notes · The Law
Reverse engineering a license check, an anti-cheat heuristic, or an OLLVM-flattened dispatcher usually comes down to the same manual loop: run it once with good input, run it again with bad input, and stare at two disassembly listings trying to spot where they stopped agreeing with each other. That loop doesn't scale past a few hundred basic blocks, and it doesn't survive control-flow flattening at all — every path already goes through the same dispatcher block, so staring at addresses tells you nothing.
Veridiff automates the diffing, not the reversing. Attach Frida's Stalker to a thread, run it twice, and get back the one thing that actually matters: the last block both runs agreed on, the first block they didn't, and the disassembly of the instruction that split them. Millions of basic blocks in, one address out.
It ships as two things, on purpose:
python/main.py— one file,pip install frida, done.rust/— a real library crate (src/lib.rs) plus a thin demo binary (src/main.rs), for when Python's call overhead is the bottleneck instead of Frida's own instrumentation cost.
Both expose the same four calls (trace_call, disassemble_block,
find_first_divergence, find_divergence_regions) and produce
byte-identical results against the same target. Neither one has a
single line of CLI, GUI, or presentation logic in it. That's not an
oversight — see The Law below.
Python — one file, one dependency:
pip install frida
python3 python/main.py <executable> <hex-address-of-target-fn> <arg-a> <arg-b>Rust — a real library, importable by path/git from any other crate:
# your Cargo.toml
[dependencies]
veridiff = { path = "../Veridiff/rust" }let options = TraceCallOptions { warm_up: true };
let trace = engine.trace_call(&mut script, addr, &args, "int", None, options)?;
let d = VeridiffEngine::find_first_divergence(&trace_a, &trace_b);src/main.rs in this repo is nothing but a thin CLI wrapper over that
same API — read it as a usage example, not as the product.
New in v0.2.0, both languages: trace_call(..., warm_up=True) / a
TraceCallOptions { warm_up: true } argument (see Warm-up mode), and
instruction.is_conditional_branch / insn.is_conditional_branch() on
every Instruction returned by disassemble_block (see Cross-architecture
branch classification). Both are additive on the Python side; the Rust
trace_call signature gained a required trailing parameter, which is a
breaking change for existing callers on this pre-1.0 crate -- pass
TraceCallOptions::default() for the old behavior.
Pre-1.0, so these come as minor bumps. Both are small.
v0.4.0 added stalker_queue_capacity to TraceCallOptions, which breaks
Rust struct-literal construction. Use ..Default::default() and future knobs
won't break you either:
let options = TraceCallOptions { warm_up: true, ..Default::default() };Trace also gained event_count and queue_capacity, and with them
queue_saturated() / .queue_saturated — check it on any trace long
enough to matter, because a saturated trace is silently truncated (see
Field notes). Python's trace_call takes the capacity as a keyword
argument, so nothing there breaks.
v0.3.0 made DivergenceRegion's branch_a/branch_b Option<u64> in
Rust (Optional[int] in Python), so "one trace ran out while the other kept
going" can be reported instead of silently dropped.
The agent and host also exchange a per-call id and a queue capacity, so a host
and an AGENT_SOURCE from different versions must not be mixed. Within a
release they always match, since each engine embeds its own copy.
No benchmarks against products we've never touched, no "battle-tested
against Denuvo" fantasy claims. Here is a small, deliberately
unremarkable C function — examples/licensecheck.c —
compiled with nothing that makes Frida's job easier, traced twice with
a valid and an invalid key, on this machine, moments before this
paragraph was written:
$ gcc -O0 -no-pie -fno-pie -fno-inline -o licensecheck examples/licensecheck.c
$ nm licensecheck | grep check_license
0000000000401146 T check_license
$ python3 python/main.py ./licensecheck 0x401146 VALID-KEY-123 WRONG-KEY
trace A ('VALID-KEY-123'): 9 blocks, returned 1
trace B ('WRONG-KEY'): 6 blocks, returned 0
first divergence at trace index 1
last common block : 0x114e
run A took block : 0x1164
run B took block : 0x116a
disassembly of the deciding block:
0x40114e mov qword ptr [rbp - 0x18], rdi
0x401152 mov dword ptr [rbp - 4], 0
0x401159 mov rax, qword ptr [rbp - 0x18]
0x40115d movzx eax, byte ptr [rax]
0x401160 cmp al, 0x56
0x401162 jne 0x40116a <-- decides here
(Trace A is 9 blocks here, not 11 -- v0.1.0's README showed this same run
before warm-up mode existed. See Warm-up mode below for what changed
and why. The <-- decides here marker is v0.2.0's conditional-branch
classification, covered in Cross-architecture branch classification.)
0x56 is 'V'. The engine never saw the source. It never saw
"VALID-KEY-123" as a string to search for — it doesn't know what a
license key is. It ran two traces, found where they stopped matching,
and handed back the exact comparison, because that's the first byte
the two arguments actually disagree on. That's the entire trick, and
it's the only trick: turn "where did these diverge" into a linear
scan instead of a stare-at-two-listings exercise.
The Rust engine, same binary, same two keys:
$ cd rust && cargo run --release -- ../licensecheck 0x401146 VALID-KEY-123 WRONG-KEY
trace A ("VALID-KEY-123"): 9 blocks, returned Some("1")
trace B ("WRONG-KEY"): 6 blocks, returned Some("0")
first divergence at trace index 1
last common block : 0x114e
run A took block : 0x1164
run B took block : 0x116a
disassembly of the deciding block:
0x40114e mov qword ptr [rbp - 0x18], rdi
0x401152 mov dword ptr [rbp - 4], 0
0x401159 mov rax, qword ptr [rbp - 0x18]
0x40115d movzx eax, byte ptr [rax]
0x401160 cmp al, 0x56
0x401162 jne 0x40116a <-- decides here
Identical answer. Two independent implementations, two different
language runtimes, one ground truth. Go build licensecheck yourself
and run both — the whole point of putting the source in this repo
instead of just the output is that you don't have to take our word
for any of this.
Same source file, cross-compiled for Android instead of the host, spawned
and traced on a real rooted phone (POCO F7 Ultra, Android 16, arm64-v8a)
over adb, same two keys:
$ $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang \
-O0 -fno-inline -o licensecheck-arm64 examples/licensecheck.c
$ adb push licensecheck-arm64 /data/local/tmp/licensecheck-arm64 && adb shell chmod 755 /data/local/tmp/licensecheck-arm64
$ $NDK/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-nm licensecheck-arm64 | grep check_license
0000000000000798 T check_license
trace A ('VALID-KEY-123'): 12 blocks, returned 1
trace B ('WRONG-KEY'): 9 blocks, returned 0
first divergence at trace index 1
last common block : 0x7a8
run A took block : 0x7bc
run B took block : 0x7d0
disassembly of the deciding block:
0x5b247bc7a8 str wzr, [sp, #0xc]
0x5b247bc7ac ldr x8, [sp, #0x10]
0x5b247bc7b0 ldrb w8, [x8]
0x5b247bc7b4 subs w8, w8, #0x56
0x5b247bc7b8 b.ne #0x5b247bc7d0 <-- decides here
0x56 is 'V', same as x86 -- subs/b.ne instead of cmp/jne, same
decision, correctly identified as the deciding instruction by the exact
same is_conditional_branch classifier the x86 proof exercises, on a
completely different instruction set. Both engines produce this same
result (block counts, divergence point, disassembly) against the same
binary -- only the addresses differ, because ASLR gives every run of a
PIE executable a different load address, which is exactly why the module
base gets resolved at trace time rather than assumed. No source change
between this and the x86 build above -- examples/licensecheck.c is
untouched; only the compiler target changed. Full walkthrough, including
why this needs spawn() (not attach() to a system-library hook) and
what didn't work first, is in Field notes below.
This capture is re-run on real hardware every release that touches the agent.
The numbers above are v0.4.0's, taken on a POCO F7 Ultra running Android 16,
and they are identical to the ones v0.2.3 first produced — same block counts,
same divergence index, same deciding instruction — with both engines agreeing
on every field. The Android walkthrough below is not a one-off demo either:
six separate targets covering recursion, concurrency, flattened control flow,
every argument kind, the full set of ARM64 conditional-branch forms, and a
1.5-million-block trace all run against this device, and all of them are in
examples/ so you can repeat them.
Seven small C files, one claim each, rather than one big binary that proves nothing in particular. Every one is built for both x86-64 and ARM64, run through both engines, and carries its exact build flags and resulting symbol address in its own header comment.
| File | What it is there to prove |
|---|---|
licensecheck.c |
The baseline proof above: find the byte comparison that split two runs. |
depth.c |
A recursive target traces completely and two identical calls do not diverge (the v0.3.0 bug). |
mt.c |
A second thread calling the traced function concurrently neither hangs the trace nor corrupts it. |
flattened.c |
OLLVM-shaped control-flow flattening: the resync heuristic on real obfuscated code, and what branchless codegen does to the answer. |
branches.c |
is_conditional_branch against real compiled branches of every form, not hand-written mnemonics. |
argtypes.c |
Every argument kind (int, uint, int64, uint64, pointer, string) arrives intact. |
volume.c |
The million-block claim, and that a truncated trace is detectable. |
mt.c deliberately contains a copy of licensecheck.c's function, so the
concurrency test varies the thread and nothing else.
target process host (Python / Rust)
─────────────── ─────────────────────
NativeFunction call, run A ──► Interceptor.attach bridges into
basic blocks execute ──► Stalker.follow, which streams the
RAW GumEvent buffer via send() --
no Stalker.parse(), no per-event
JS object, no JSON round-trip
NativeFunction call, run B ──► same path, second trace
│
▼
host decodes the fixed-stride GumEvent
records directly (struct/bytes, O(N))
│
▼
find_first_divergence(trace_a, trace_b)
-- longest-common-prefix scan, O(min(n,m))
│
▼
disassemble just the 1-2 blocks that
actually matter, via the agent's own
Instruction.parse() (no host-side
disassembler dependency, ever)
Two design decisions carry the whole thing, and both are deliberate departures from the obvious approach:
The diff is a prefix scan, not a Myers/LCS diff. Text-diff
algorithms solve "what's the minimal edit script between these two
sequences" — the wrong question here. After a real branch splits two
paths, they routinely rejoin a shared library call or a common
epilogue. A minimal-edit-script algorithm reads that coincidental
address match as "unchanged" and hands you a fragmented, misleading
alignment instead of the one thing you actually want: the first
point the paths split. A longest-common-prefix scan answers exactly
that question, in O(min(n,m)) with no hashing, instead of O(N·D) for
Myers or O(N log N) for the best hash-assisted variants. find_divergence_regions
adds a bounded-lookahead resync on top for the case where a check runs
several independent conditionals in sequence — still not a general
diff, still O(N + regions × window) in the typical case (see
find_divergence_regions' own docs for the quadratic worst case and why
it's accepted), still answering "where did they split" rather than "how do
these align." As of v0.3.0 it also reports a trailing region when one trace
simply runs out while the other continues, so an empty result means
"identical" and nothing else.
Disassembly happens inside the agent, not the host. Frida-gum
already bundles Capstone and exposes it as Instruction.parse(). Both
hosts ask the agent to disassemble the handful of blocks that matter
after the diff is computed, and get back structured
{address, mnemonic, opStr} records — so neither main.py nor
lib.rs carries a disassembler dependency, and the decode is always
guaranteed to match the live target's actual architecture and mode,
because it's running inside that exact process.
A third, smaller note on the Rust engine specifically: find_first_divergence
and the raw GumEvent decode were already allocation-free (plain slice
scans, no heap traffic) before v0.2.0 — there was nothing to fix there,
only to state plainly rather than imply otherwise. What v0.2.0 actually
changed is real but narrower: reserve()-ing trace-assembly buffers by
each chunk's upper-bound event count instead of growing incrementally, and
reusing one scratch hashmap across every region find_divergence_regions
finds instead of allocating one per region. An unsafe transmute of the raw
event buffer into a &[GumEvent] slice was considered and rejected: a
Vec<u8> arriving over IPC has no alignment guarantee, and reinterpreting
unaligned bytes as a #[repr(C)] struct with 8-byte fields is undefined
behavior regardless of whether any given CPU tolerates it in practice.
from_le_bytes on a byte slice is the correct idiom and already compiles
to the same loads.
A real, reproducible confound, found by actually tracing a process rather
than trusting synthetic data: a target's first-ever call to an
externally-linked function (strcmp, anything else routed through the
PLT/GOT) takes the dynamic linker's lazy-binding resolver path. Every later
call to that same function skips straight to the now-resolved GOT entry.
Two calls with identical arguments can therefore still report a
divergence -- not because your logic differs, but because one of them paid
a one-time linker tax the other didn't.
trace_call(..., warm_up=True) (Python) / TraceCallOptions { warm_up: true }
(Rust) has the agent call the target once, untraced, with the same
arguments, immediately before the call that's actually measured -- so
whatever GOT entries that code path touches are already resolved by the
time tracing starts. Proof, on the same binary as above, two identical
"VALID-KEY-123" calls:
warm_up=False: divergence = DivergencePoint(index=4, last_common_block=4160, block_a=4166, block_b=4479)
warm_up=True: divergence = None
That's the actual raw repr() of the return value, decimal fields and all
-- last_common_block=4160 is 0x1040, strcmp@plt. Without warm-up, two
identical calls report a divergence there. With it, find_first_divergence
correctly returns nothing to report. Off by default -- it means the target
executes an extra time before being traced, which isn't safe for a target
with side effects or other non-idempotent state -- so it's your call to
opt in.
This is the case the whole resync machinery exists for, and through v0.3.0 it
was the weakest-tested thing in the project: the heuristic was only ever
checked against hand-written block sequences, because no flattened binary was
on hand. examples/flattened.c fixes that. It is
control-flow flattening written by hand in the shape OLLVM's -fla pass
emits — every original basic block becomes a case of one switch, and a single
dispatcher is re-entered between every pair of them — with an opaque predicate
guarding a block that never runs. It is not output from OLLVM itself, which is
stated plainly because it matters for how much the result proves; the
structural property Veridiff has to cope with is identical either way.
find_divergence_regions' old rule was "the first address both traces share,
within the lookahead window, is where they resynced." Flattening breaks that
rule completely: the dispatcher shows up in both windows almost immediately
after any divergence, without the paths having merged. Since v0.2.0 a
candidate has to hold up — at least three consecutive blocks must keep
agreeing after it. Here is what that produces on the real thing, traced on
the phone, three independent conditionals in the source:
trace GOOD ('VALID-KEY-123',1): 37 blocks, returned 7
trace BAD ('WRONG-KEY',0): 37 blocks, returned -7
GOOD most-repeated blocks: [('0x7b8', 9), ('0x7d0', 9), ('0x93c', 8)]
BAD most-repeated blocks: [('0x7b8', 9), ('0x7d0', 9), ('0x93c', 8)]
region 0: last_common=0x7d0 branch_a=0x81c branch_b=0x834 resync_block=0x93c
region 1: last_common=0x7d0 branch_a=0x86c branch_b=0x884 resync_block=0x93c
region 2: last_common=0x7d0 branch_a=0x8b8 branch_b=0x8d0 resync_block=0x93c
Three regions for three conditionals, each one resyncing at the loop
back-edge, with the dispatcher at 0x7d0 visited nine times in both traces —
that repetition is the flattening, and it is exactly what a naive first-match
rule would have tripped over. Change only the third condition and you get one
region; pass identical arguments and you get none at all. Both engines
produce these numbers identically.
Now the part a tool like this has to be honest about. The last common block is the dispatcher, so disassembling it does not show you a comparison:
0x7d0 ldr x11, [sp, #8] <- load the state variable
0x7d4 nop
0x7d8 adr x10, #0x...528 <- jump table
0x7dc adr x8, #0x...7dc
0x7e0 ldrsw x9, [x10, x11, lsl #2]
0x7e4 add x8, x8, x9
0x7e8 br x8 <- unconditional, correctly NOT marked
The real comparison ran two blocks earlier, in a block both runs executed, and it never branched at all:
0x7fc ldr x8, [sp, #0x20]
0x800 ldrb w10, [x8] <- key[0]
0x804 mov w9, #3 <- state 3 (score -= 1)
0x808 mov w8, #2 <- state 2 (score += 1)
0x80c subs w10, w10, #0x56 <- compare with 'V'
0x810 csel w8, w8, w9, eq <- branchless: picks the state as DATA
0x814 str w8, [sp, #0x18]
0x818 b #0x...93c <- back to the dispatcher
So on this target the decision is a value, not a branch, and control flow
forks later, at the dispatcher. Veridiff reports that fork precisely — which
is its actual promise — and the two blocks it hands you are immediately
legible: run A went to add w8, w8, #1, run B to subs w8, w8, #1, which is
score += 1 versus score -= 1 in the source. The <-- decides here marker,
however, does not fire on a flattened dispatcher, and chasing the comparison
means walking back through the shared prefix the engine already gave you
(about twenty lines against the public API).
This is not theoretical, and it is not an ARM64 quirk either — it is a
codegen question. The same examples/flattened.c built for x86-64 with gcc -O0 keeps the comparison as a real branch, so there the deciding block does
end in cmp al, 0x56 / jne with the marker on it, and the branchier code
yields six regions instead of three. Same source, same engine, two different
shapes of answer. Know which one you are looking at.
The synthetic unit tests that came first are still in both suites
(dispatcher_style_coincidental_match_is_rejected and its paired acceptance
test), because they pin the exact boundary the live target only exercises
incidentally:
# same shared block (0xD0) in both cases -- only the outcome differs
a = [0x10, 0x20, 0x30, 0xD0, 0xAA, 0xAB, 0xAC]
b = [0x10, 0x20, 0x31, 0xD0, 0xBB, 0xBC, 0xBD] # diverges again right after 0xD0
-> resync_block: None # correctly rejected
a = [0x10, 0x20, 0x30, 0xD0, 0xAA, 0xAB, 0xAC]
b = [0x10, 0x20, 0x31, 0xD0, 0xAA, 0xAB, 0xAC] # genuinely continues identically
-> resync_block: Some(0xD0) # correctly accepted
The claim at the top of this file is "millions of basic blocks in, one address
out." Until v0.4.0 that was never measured, and when it finally was, it turned
out to be false for a reason that had nothing to do with the diff: Frida's
Stalker queue defaults to 16384 events and drops the rest silently, so
every long trace came back short and successful. See Field notes for the
measurements. With the queue sized properly — which the engine now does by
default — here is examples/volume.c on the phone:
iters blocks events capacity saturated s blocks/s
1000 7505 7527 2097152 False 0.03 267529
10000 75023 75045 2097152 False 0.14 546701
50000 375103 375125 2097152 False 0.49 762960
200000 1500403 1500425 2097152 False 1.81 829526
1.5 million basic blocks off a phone over USB in under two seconds, scaling linearly, with no events lost. Diffing two traces of that size costs milliseconds, because the diff is a prefix scan rather than an edit-distance algorithm:
750203 vs 750213 blocks
find_first_divergence: 25.7 ms -> index 750202
find_divergence_regions: 61.2 ms -> 1 region
And when the queue is too small, that is now visible rather than silent:
capacity=16384 -> events=16384 blocks=16364 saturated=True
The Rust engine produces the same numbers (1.30 s for the 1.5M-block trace against Python's 1.81 s, which is the only number in this README where the two engines differ by design rather than by accident).
Instruction.parse (Capstone, via frida-gum) already decodes every
architecture Frida attaches to, ARM64 included -- there was never any
x86-specific code in the agent to "extend" for CBZ/CBNZ/B.cond/
TBZ/TBNZ. What both engines now add is is_conditional_branch on each
disassembled instruction, so the deciding branch in a block is flagged
instead of left for you to spot by eye (that's the <-- decides here
marker in the proof output above).
This has to be mnemonic-based, not Instruction.groups-based, and that's
an empirical finding, not a guess: live-captured against the real jne in
the proof output above, Capstone reports groups: ["branch_relative", "jump"] -- and the unconditional jmp two instructions later in the
same block reports the exact same groups. Nothing in .groups
distinguishes them. Mnemonic text is the only signal that does, on any
architecture, which is what is_conditional_branch actually checks.
Through v0.3.0 the live evidence behind that claim was one instruction:
jne on x86 and b.ne on ARM64. Everything else was a unit test feeding
the classifier strings, which proves the string matching and nothing about
what compilers actually emit. examples/branches.c
closes that: a function shaped to force real branches of every kind — a null
test, single-bit tests, signed and unsigned compares, and a switch. Every
arm calls a noinline helper, because an earlier version of that file
without the calls compiled on ARM64 at -O1 into one cbz and a wall of
branchless csel, which is its own lesson about this architecture.
Traced on the device, then disassembled and checked instruction by instruction against what ARM64 actually calls conditional:
b x2 engine=[False] expected=False OK
b.ge x1 engine=[True] expected=True OK
b.le x1 engine=[True] expected=True OK
b.ls x1 engine=[True] expected=True OK
b.lt x1 engine=[True] expected=True OK
b.ne x1 engine=[True] expected=True OK
bl x8 engine=[False] expected=False OK
cbz x1 engine=[True] expected=True OK
ret x2 engine=[False] expected=False OK
tbnz x1 engine=[True] expected=True OK
tbz x2 engine=[True] expected=True OK
Eight conditional forms live-verified on ARM64 — b.ge, b.le, b.ls,
b.lt, b.ne, cbz, tbnz, tbz — plus the indirect br from the
flattened dispatcher above, correctly left unmarked. On x86-64 the same
target gives ja, je, jg, jl, jne flagged and jmp, call, ret
not. cbnz is the one form still unproven live: no build produced it
(clang preferred cbz with inverted logic), so it remains covered by unit
test only. Said rather than quietly counted as verified.
The underlying disassembly is still Frida's own multi-arch Capstone integration, unmodified by any of this — what changed is evidence, not mechanism.
Reverse engineering tools should tell you where the bodies are buried. These cost real debugging time to find; they're documented inline in both agents, and repeated here because they're the kind of thing you only learn by actually tracing a real process instead of trusting synthetic test data.
-
Frida's Stalker drops events once its queue is full, silently, and the default queue holds 16384 of them. This was the worst thing in the project when v0.4.0 went looking for it, because the failure mode is a trace that is short, wrong, and reports success. The traced call runs synchronously on the thread being followed, so the queue cannot drain while it runs: the whole trace has to fit. Measured against
examples/volume.con ARM64, at a default capacity:iters capacity events expected 10000 16384 16384 75050 -> 78% of the trace lost 50000 16384 16384 375250 -> 96% lostThe ceiling is exactly the capacity, to the event — 32768 gives 32768, 65536 gives 65536 — which is what makes the fix detectable rather than a guess.
Stalker.queueDrainIntervalmakes no difference at all (250 ms, 1 ms and 0 behaved identically) for the same synchronous-call reason, so the engine deliberately does not expose it. What it does expose is the capacity (stalker_queue_capacity, defaulting to 2²¹ events rather than Frida's 16384) andTrace.queue_saturated, which is true exactly when the event count reached the capacity. If that flag is true, a divergence you found is still real, but "no divergence" means nothing and neither does anything near the end of the trace. Capacity is close to free until it is used: the buffer grows with events actually recorded, measured at ~32 bytes each on a 64-bit target (150065 events cost 4.8 MB of target RSS, and the same trace cost the same 4.8 MB whether the capacity was 2¹⁸ or 2²⁴). -
A root module running its own
frida-serverwill quietly answer instead of yours, and may refuse to spawn anything. On the Android device used here,frida.get_usb_device()connected fine and enumerated 363 processes, thenspawn()of a plain executable failed withfrida.NotSupportedError: only able to spawn apps. The server answering was not the one started moments earlier: MagiskFrida runsfrida-serverunder a randomised process name, so it survivespkill -f frida-server, is invisible tops | grep frida, and wins the USB lookup. Killing "the" server and watching the host stay connected is how that gets diagnosed. The fix is to stop guessing which server you are talking to: start your own on an explicit port and address it directly.adb forward tcp:27500 tcp:27500 adb shell 'su -c "nohup /data/local/tmp/frida-server -l 0.0.0.0:27500 &"' # host side: add_remote_device("127.0.0.1:27500") / get_remote_device(...)
Nothing in the engine needed to change for this — both halves take whatever Frida device you hand them — but it cost real time to work out, and it will bite anyone testing on a daily-driver phone with root tooling installed.
-
Warm-up mode does nothing on Android, and that is correct. The PLT/GOT lazy-binding confound that Warm-up mode above documents is a glibc behaviour. Measured on the device: two identical calls diverge at
strcmp@plton x86-64 without warm-up, and on Android they do not diverge either way, because bionic resolves these eagerly rather than on first call. Leavingwarm_upon costs an extra untraced call and buys nothing there. -
Stalker.follow()silently does nothing if you call it from a plainrpc.exportshandler and then invoke aNativeFunctiondirectly. No error, no event,blockCountstays zero forever. It only engages from genuine native execution context — which is whytraceCallbrackets the actual call inside a throwawayInterceptor.attach(target, {onEnter, onLeave})instead of callingStalker.follow/fn()back to back. If you're extending the agent and your event count is mysteriously zero, this is almost certainly why. -
The published
fridaRust crate (0.17.2on crates.io) has a real soundness bug inScript::handle_message: it casts the signal'suser_datato the wrong pointer type before calling a method through it — undefined behavior for any handler that carries real state, matching upstream issue #189 (reported symptoms: crashes inside atomic ops, garbage field reads, a Windows deadlock). Fixed onmainin commit080e8a99a5, not yet in a crates.io release —rust/Cargo.tomlpins a git rev with the full reasoning inline. Check for a>=0.17.3release before "helpfully" switching that back to a version string. -
Two calls with identical arguments can still report a spurious first divergence, if the target's first call to some external function (
strcmp, anything else that goes through the PLT) hasn't had its GOT entry resolved yet. The first call takes the lazy-binding resolver path; every call after it jumps straight to the resolved address.find_first_divergencewill honestly report this as a real difference, because it is one —find_divergence_regionsis what shows you it's a single region that immediately resyncs, at which point you can recognize a PLT stub address and move on. As of v0.2.0 you can also just not have the problem: see Warm-up mode. -
frida-rust's blocking RPC calls have no timeout or dead-session detection.Exports::call's implementation is a barerx.recv().unwrap()on an internal channel — if the target process exits (including because you calleddevice.resume()and itsmain()ran to completion) while a call is in flight, or before the next one starts, that channel never receives anything and the call blocks forever, not with an error. Found live: callingdisassemble_blockafterdevice.resume()hung this engine's own demo binary indefinitely.frida-python's equivalent detects this and raisesfrida.InvalidOperationError: script has been destroyedcleanly instead — this is Rust-specific. The fix isn't a timeout wrapper, it's ordering: both demos now do every RPC call (trace, diff, disassemble) before callingresume(), since disassembly only needs the target's static, already-mapped code bytes and was never dependent on the process actually running.resume()is the last line in both, purely so the spawned process doesn't get left stopped. If you're extending either demo, keep it that way. -
Creating several remote devices in sequence inside one Rust process crashed it, intermittently. Seen while running the ARM64 parity checks: a harness that obtained a fresh
get_remote_device()per test, spawned, attached, traced, and dropped everything, segfaulted partway through one run in three. Every individual test passed in isolation, and a loop doing device + spawn + attach + handler + trace five times over never crashed, so this is a race in teardown rather than something reproducible on demand — reported at that confidence and no higher, with no claim about which layer owns it. The engine is not implicated: every result it produced in the runs that completed matched the Python engine field for field. If you drive this from Rust over a remote device, obtain the device once and reuse it rather than rebuilding the connection per target. -
resync_confirmedconfirmed a resync against itself, twice over. Condensed, becauseCHANGELOG.mdhas the full autopsy and the tests now pin both halves: the comparison window started at the candidate, whose first element matches by construction, so a resync could be accepted on zero evidence (v0.2.1). The fix special-cased only "zero blocks left", while the window still silently shrank belowMIN_CONFIRMwhenever either trace had one or two blocks left — the same bug one block further along (v0.4.0's predecessor, v0.3.0). Both were found by re-reading the code against the docstring stating what it was supposed to guarantee, not by any test. The rule now states intent rather than enumerating cases: a short confirmation window is acceptable only when it is short because both traces ran out together, and then every remaining block must match. Worth repeating as a general lesson — a fix that enumerates the cases you thought of will miss the one you didn't. -
Warm-up mode's untraced pre-call used to share one argument buffer with the traced call. For a
'string'argument that meant a target which decodes its argument in place — routine in obfuscated checks — would have the warm-up call mutate the buffer the traced call then read. Fixed in v0.2.1 by giving the warm-up call its own allocation; still not live-verified against a self-mutating target, since none was built. -
Tracing a re-entrant (recursive) target used to produce a false divergence between two identical calls. Two mechanisms, one root cause, both found by tracing a recursive function rather than by any test:
Interceptor.attach'sonLeavefires innermost-first. Stopping Stalker there cut tracing off while the outer frames were still running, so the trace lost every outer frame's unwind block.Stalker.unfollow()does not stop instrumented execution instantly. Those outer frames kept running already-generated instrumented code and kept emitting block events — delivered aftertraceCallhad returned, i.e. into the next call's message stream, where the host prepended them to an unrelated trace.
Measured on
examples/depth.c— six lines, recursion and nothing else — callingdepth(2)twice with the same argument: 9 blocks, then 11 blocks,find_first_divergencereporting a divergence at index 0. For a tool whose entire promise is "the traces differ here", silently inventing a difference between two identical runs is as bad as it gets. Fixed by tracking call depth in the agent — follow on the outermost entry only, stop on the outermost return only — and by stamping every message with the id of thetraceCallthat produced it, so late events are discarded by construction instead of being absorbed by whoever is listening next. After the fix, both engines return byte-identical 11-block traces fordepth(2)andNonefor the diff, and the full sequence matches the expected one derived by hand fromobjdump, unwind blocks included. -
A function another thread is calling concurrently used to hang
trace_callforever.Interceptorhooks are process-wide, not per-thread: another thread entering the target ranStalker.follow()for our thread id from its callback, and itsonLeavestopped stalking in the middle of the call actually being measured. Reproduced withexamples/mt.c, whose background thread calls the traced function in a loop: the very firsttrace_callnever returned, and was killed at 75 seconds. Fixed by gating both callbacks onthis.threadId !== tid; the same target now returns three identical 9-block traces. Honest limit: the fix is verified by the symptom disappearing, and the precise mechanism of the hang — as opposed to the obvious trace corruption — was never established. -
close()hangs if the spawned process was never resumed. Observed twice out of two attempts while building the recursion test above:script.unload()/session.detach()on a process still suspended at its post-spawn stop point never returns, whileresume()thenclose()always worked. Not investigated further and not fixed in v0.3.0 — documented because it costs real debugging time to rediscover. Both demos callresume()before exiting, so neither shows the problem; if you write your own harness that only ever traces a suspended target, resume it before closing anyway. -
Live ARM64 testing took two attempts across v0.2.2 and v0.2.3, and the two failures on the way are as worth documenting as the eventual success. Against a rooted POCO F7 Ultra, Android 16,
arm64-v8a:- Frida cannot inject into a statically-linked ELF binary on this
device. The first, obvious move — cross-compile the test target
statically, no NDK required, no runtime dependencies to push — failed
identically for both
spawn()andattach():frida.NotSupportedError: bootstrapper crashed with signal 11. Attach crashing the same way as spawn rules out spawn-gating specifically — the real cause is almost certainly that Frida's injection depends on the target having a dynamic linker todlopen()the agent into, which a fully static binary never touches at all. Not a Veridiff bug; a property of Frida's injection mechanism. Fixed by building with the Android NDK instead (aarch64-linux-android26-clang, no-static) — a normal PIE executable, dynamically linked against/system/bin/linker64, and injection just worked.examples/licensecheck.citself needed zero changes; only the compiler did. Interceptor.attachon a function inside hardened Bioniclibc.so(tried:strcmp) crashed the target process — reproducibly, twice, in the direct-libc-hook attempt that preceded the NDK build above. The same hook mechanism on a function inside a normal app's own bundled native library (tried: Chrome Beta'sbase.odex) installed and survived cleanly, which is why the eventual successful run traces the test binary's owncheck_license, not a system library function reached through it. Likely cause: ARM64 control-flow hardening (PAC/BTI/CFI) onlibc.soon a current Android build conflicting with inline hooking — a reasonable hypothesis given the evidence, held to a lower confidence bar than the static-binary finding above, which is confirmed. Not re-tested since working around it, so this remains a live landmine for anyone hooking system libraries specifically, not just a historical note.
What finally worked, once the target was a proper NDK build:
spawn()directly (no sleep-and-attach workaround needed for a normal dynamically-linked binary — that workaround was only ever needed while chasing the static-binary and hardened-libc dead ends above), then the sametrace_call/find_first_divergence/disassemble_blocksequence as any other target, module-base resolved once up front since Android requires PIE. The engine's own device-selection design needed zero changes to point at the phone through any of this — Python'sVeridiffEngine(device=...)already took an arbitrary device, and Rust's engine not owning aDevicemeant the caller just usedDeviceType::USBinstead of local. Every real obstacle was in the target binary and Frida's injection layer, never in this engine.The full recipe, so you don't have to rediscover it:
# device side: frida-server's version must line up with your host frida. # Bind it to an explicit port and address it directly -- a root module may # be running its own frida-server that would otherwise answer instead # (see the field note above). adb push frida-server-<ver>-android-arm64 /data/local/tmp/frida-server adb shell chmod 755 /data/local/tmp/frida-server adb forward tcp:27500 tcp:27500 adb shell 'su -c "nohup /data/local/tmp/frida-server -l 0.0.0.0:27500 &"' # host side: build the SAME examples/licensecheck.c, dynamically linked $ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android26-clang \ -O0 -fno-inline -o licensecheck-arm64 examples/licensecheck.c adb push licensecheck-arm64 /data/local/tmp/licensecheck-arm64 adb shell chmod 755 /data/local/tmp/licensecheck-arm64 llvm-nm licensecheck-arm64 | grep check_license # the offset, e.g. 0x798
Then
add_remote_device("127.0.0.1:27500")(Python) /get_remote_device(...)(Rust),spawn()the pushed binary, and resolve the module's runtime base once before computingbase + offset— Android requires PIE, so the load address differs every run, exactly like any other ASLR target. All six targets inexamples/are driven exactly this way. - Frida cannot inject into a statically-linked ELF binary on this
device. The first, obvious move — cross-compile the test target
statically, no NDK required, no runtime dependencies to push — failed
identically for both
Veridiff is a reference-grade core engine, not an application. Its entire value is being small, fast, and dependency-light enough to embed into anything — a CLI, a GUI, a Ghidra or IDA plugin, a CI gate, a Discord bot, whatever the next person building on top of it needs. Every dependency or abstraction we let into the core is a tax paid by every single integrator downstream of us, forever — including the ones who never wanted it.
So the rule is simple, and we intend to enforce it without exception:
We welcome, with open arms:
- Core algorithm improvements — a faster or more accurate divergence scan, a smarter resync heuristic, a real reduction in allocations or copies on the hot path.
- Memory and correctness fixes, especially ones found the way the Stalker/Interceptor bug above was found: by actually tracing a real process and proving the fix against real output.
- New architecture support — ARM32/Thumb, MIPS, RISC-V, wherever Frida and Capstone already reach and our own classification logic (branch-type detection, and whatever comes after it) doesn't yet. x86-64 and ARM64 are both live-verified across the whole feature set as of v0.4.0 (see Proof, Not Promises); ARM32/Thumb, MIPS, and RISC-V remain untried.
- Portability fixes for platforms the current code handles badly.
We will not merge, ever, under any framing:
- CLI frameworks. No
argparse, noclap, no flag parsing of any kind. If you want flags, that's a wrapper, not a patch to this repo. - GUI code, TUI code, web dashboards, progress bars.
- Colored terminal output, fancy logging frameworks, spinners —
print()/eprintln!()or nothing. - "Convenience" functions that exist only to save a caller three lines at the cost of a new dependency for everyone who doesn't need them.
- Config file formats, plugin systems, telemetry, auto-update checkers — anything that turns an engine into an application.
This isn't gatekeeping for its own sake — it's the only way a core
engine stays a core engine instead of slowly becoming somebody's
particular CLI with an API bolted on as an afterthought. If you want
a CLI, a GUI, or a Ghidra plugin: fork the engine, build
veridiff-cli or veridiff-gui or veridiff-ida on top of it as its
own project, and depend on this repo like any other library. We
mean that as an invitation, not a brush-off — tell us it exists and
we'll link to it from here.
Apache License 2.0 — see LICENSE. Copyright 2026 Veridiff.