-
Notifications
You must be signed in to change notification settings - Fork 0
Using Twinflame
A task-oriented guide. For how it works internally see How It Works; for the machine output format see the Change Report Schema.
twinflame compares two Android builds at the DEX/class level and tells you what changed — even when R8/ProGuard has renamed everything. It's a CLI that emits data (matches, a change report, a deobfuscation map); you visualize with your own decompiler.
Each side is an APK, a single .dex, a directory of .dex files (for
dumped/extracted DEX with no APK), or a prepared .tfr record. The input kind is
detected — no flag needed, and the two sides can be different kinds:
twinflame old.apk new.apk # two APKs
twinflame dump_old/ dump_new/ # two directories of dumped classes*.dex
twinflame a/classes.dex b/classes.dex # single .dex each
twinflame old.tfr new.apk # prepared record vs APK (see "prepare" below)DEX-only input has no manifest, so pass --app-package <prefix> (below) instead of --auto-package.
The main use case. Get a ranked, method-localized change report. The semantic diff is the default output: a human summary prints to stdout and the machine report is written to a file automatically.
twinflame old.apk new.apk \
--app-package com.myapp \ # your app's package root(s); repeat for multi-root
-o changes.json # machine-readable (the real deliverable; JSON default)Pick the file format with -f {json,csv,xml}, or --no-file to print only the
summary. Emit just the changed classes with -s changed, or narrow to one class
with -c <name>.
Read the summary top-down. The report is pre-sorted so the first things you see are the
developer's own (app) changes, high-confidence first:
change summary: modified=384 added=282 removed=135 cosmetic=414 unchanged=19649
modified confidence: high=384 low=0 (low = call-only churn in non-app code, likely cross-toolchain noise)
app-only: modified=384 added=282 removed=135 cosmetic=414 unchanged=10436
top modified (app-first, high-confidence first, then by magnitude):
[app] [ 134] SourceFile +47 calls, -40 calls, +6 strings, +9 types, +17 methods [anchored]
- onCreate(Landroid/os/Bundle;)V: method +18 calls, -8 calls
-
cosmetic/unchangedare re-obfuscation/rebuild noise — ignore them. -
app-onlyis the number that matters: the developer's own changed classes. - If the two builds used different toolchains (months apart), add
--min-confidence highto droplow-confidence library churn (call-only relocation from different R8 inlining). - Each
modifiedlocalizes to the methods that changed and how (seemethods[]in the JSON).
Feed changes.json to your tooling to open exactly those classes side-by-side in a decompiler.
Two different apps with no shared package layout. Drop clustering so classes match across package boundaries, and rely on rename-invariant anchors:
twinflame sample_a.apk sample_b.apk --no-cluster --find-obfuscated \
-o pairs.json --components-file shared-components.txtpairs.json is the ranked list of strongly-similar class pairs (the shared-code islands);
treat the high-similarity pairs as the kinship signal.
For malware triage, watch the sensitive components shared by both builds block (printed to
stdout, and dumped to --components-file). twinflame resolves each class's superclass/interface
chain — which R8 can't rename — and flags when both samples implement the same permission-gated
component: BNL (NotificationListener), BAS (AccessibilityService), BDA (DeviceAdmin),
BIM (InputMethod), BAF (Autofill). Two samples sharing an AccessibilityService
implementation is a strong same-family tell. Every paired row in the JSON/CSV/XML also carries
lhs_super/rhs_super, interfaces, and any components code.
Produce a ProGuard-style mapping.txt that renames the new build's obfuscated classes using
names recovered from a clear-named (older/donor) build:
twinflame old.apk new.apk --deobfuscation-map recovered.txtThen hand it to a decompiler / retrace:
# jadx: apply the mapping while decompiling the NEW (obfuscated) build.
# invert=yes is REQUIRED — jadx's PROGUARD_FILE reader expects the opposite
# direction from standard ProGuard mapping.txt / retrace; without it jadx
# silently renames nothing (no error).
jadx --mappings-path recovered.txt \
-Prename-mappings.format=PROGUARD_FILE \
-Prename-mappings.invert=yes \
-d out_new new.apk
# R8/ProGuard retrace a stack trace:
retrace recovered.txt crash.txtOnly high-confidence matches are written (tune with --map-min-confidence; anchored matches are
always included). Only the class simple name is recovered — the obfuscated package stays as-is
(source files carry no package), and method/field propagation is a planned next step.
To diff the two decompiled trees with class names lined up, decompile the old (donor) build
too, telling jadx to use its SourceFile attributes as class names — the same names score/the
map recovered for the new build:
jadx --use-source-name-as-class-name-alias always -d out_old old.apkThen compare out_old and out_new. Note packages stay obfuscated on both sides, so the
directory layouts won't match — diff by class filename (e.g. flatten each tree to <simple>.java
first), not by full path.
The one-shot twinflame a.apk b.apk re-parses both APKs every run — and the parse
(androguard) is the expensive part (seconds to minutes, scaling ~linearly with class
count: ~5 s at 10 k classes, ~20 s at 40 k, ~2 min at 215 k). For pipelines that
compare a sample against many others, split the work: fingerprint each sample once
into a reusable, digest-keyed record, then compare from records (no re-parse).
# fingerprint a sample into a packed .tfr record (parse + signatures + abstract opcodes)
twinflame prepare sample.apk -o sample.tfr # defaults to '<sha256>.tfr' if -o omittedA record is lossless for comparison — a diff off a record is byte-identical to a diff
off a fresh parse. Records use a compact binary format (.tfr: interned string table +
varints + zlib) that is roughly 20× smaller than the old JSON (a 29-sample corpus went
2.9 GB → 140 MB) and loads back in a fraction of the prepare cost (~0.7 s vs ~10 s to
re-prepare an 8 k-class app — see
BENCHMARKS.md).
Prepared records are first-class diff inputs — pass a .tfr anywhere you'd pass an
APK, in any mix (.tfr / APK / .dex, sniffed by magic byte). The compare subcommand
is a discoverability alias for the default diff:
# any combination works; record-vs-record runs ~2× faster end-to-end
twinflame compare old.tfr new.tfr -o changes.json
twinflame old.tfr new.apk -o changes.json # bare form is identicalRecords are version-stamped per layer (extraction / abstract / signature), not by a
single monolithic version, so a large prepared DB can survive most releases. twinflame migrate refreshes records in place — without the original samples — whenever the stale
layer is recomputable (the common case: signature-parameter tuning, rebuilt from the stored
abstract sequences), and re-encodes legacy .tfr.json records into packed .tfr:
twinflame migrate corpus/ --delete-original # scans for *.tfr and legacy *.tfr.jsonOnly extraction/abstract changes require a true re-prepare from the sample — migrate says
so per file rather than silently mis-comparing.
⚠️ WIP / experimental, uncalibrated. The self-containment anchor is reliable (a sample against itself scores 1.000) and the score is correctly asymmetric, but a trustworthy family-vs-benign threshold on renamed builds is not yet achievable — it awaits the library signature dictionary and knob calibration on a real positive/negative corpus. Treat every number below the 1.000 self-anchor as indicative, not final.
score condenses kinship to a single number: the instruction-weighted fraction of a known
family's code structurally present in a candidate (containment, family ⊆ candidate —
chosen over Jaccard because repackaged malware embeds the family plus benign padding). It runs
off the LSH signatures, so it's cheap enough to confirm a prefilter hit (e.g. a YARA flag)
without diffing the whole corpus.
twinflame score family-seed.tfr candidate.apk --evidence 5Boilerplate and known-library classes are dropped from the family side by default so shared
runtimes don't inflate the score (--keep-library disables). --min-shared-calls N adds an
optional precision gate (a matched pair must share ≥ N rename-invariant framework calls; 0 =
signature only). --evidence N prints the closest shared class pairs; --json for tooling.
score compares one family sample against one candidate. To distil several samples into a
persistent, reusable family signature, use family build / match (UC5).
⚠️ Experimental, uncalibrated (likescore). Family members re-identify near-perfectly, but the absolute score floor is not yet calibrated — compare scores and tighten--radiusrather than trusting a fixed threshold. Requires the companion twinflame-libsigs package (the pack format and matcher live there); without it,familyprints an install hint.
Where score compares two samples, family build distils N samples of one family into a
persistent signature — the class structure they share — and family match scores an unknown
sample (or a memory dump) against it. This is the automated-triage shape: ingest a dump,
fingerprint it, check it against a library of known families.
# distil N samples of one family into a pack (records, APKs, .dex, or dumped-DEX dirs)
twinflame family build -o evilbot.tflp --name evilbot sample1/ sample2/ sample3/
# score an unknown sample against it (match radius defaults to the pack's build radius)
twinflame family match evilbot.tflp suspect_dump/ --evidence 5
# or triage one sample against a whole directory of family packs, ranked by score
twinflame family match packs/ suspect_dump/How the signature is built. Each sample is noise-filtered (boilerplate + tiny classes dropped,
known-library classes excluded via the libsigs dictionary), then classes are clustered across
samples by signature within a small Hamming radius, keeping only the clusters present in at least
k of the N samples (-k, default all N). Each surviving cluster is stored by a
representative signature plus the strings common to every member — emitted as a .tflp pack
(+ .idx.json sidecar), the same container the library dictionary uses.
Reading a match. The score is the coverage- and instruction-weighted fraction of the family's entries found in the candidate, with a per-tier breakdown (exact / radius / string). A family member scores near 1.0; an unrelated app scores low. Three things shape the result:
-
Obfuscation drift sets the radius. Heavily obfuscated builds randomise signatures a few bits
per class, so
family buildclusters at a wider radius than the deterministic-R8 default;family matchthen defaults its radius to that build radius. Tighten-Rto sharpen the family-vs-unrelated gap. - Unpack first. A packer exposes only its stub, so a packed sample matches nothing — run the match on the unpacked payload or a memory dump of the running process.
- Shared infrastructure inflates cross-family scores. Different families built with the same packer share that framework; lean on the per-tier evidence and a tighter radius (or exclude the common infrastructure) when separating families that share a packer.
Key flags: -k N (k-of-N coverage), --radius R (build/match Hamming radius), --min-instr N
(class-size floor), --libsigs PACK / --no-libsigs (library exclusion), --no-strings (ignore
string-tier hits), --threshold T (exit non-zero below T, for scripting), --json.
| Flag | Use |
|---|---|
-o, --output PATH |
Diff file to write (default <apk1>__vs__<apk2>.diff.<ext> in the cwd). |
-f, --format {json,csv,xml} |
Diff file format (default json). |
--no-file |
Print the summary only; write no file. |
-s, --select {all,changed,unchanged} |
Emit only-different, only-same, or all classes (default all). |
-c, --class NAME |
Restrict the diff to classes whose name/path/descriptor contains NAME. |
-m, --matches |
Print the raw per-class match list to stdout instead of the change summary. |
--components-file PATH |
Also dump the sensitive-superclass / component report to a file. |
-p, --package PREFIX |
Restrict the whole diff to one package (scope filter). |
-A, --auto-package |
Derive the app package from the lhs manifest and scope to it. |
--app-package PREFIX |
Mark app vs library for ranking (repeatable; multi-root apps). Labeling only — does not filter scope. |
--min-confidence high |
Drop low-confidence modified (cross-toolchain call-churn noise). |
--no-cluster |
One global pool — for cross-app (UC2) or heavily repackaged builds. |
--find-obfuscated |
Route obfuscated-looking packages into a single fallback pool. |
--deobfuscation-map OUT |
Emit a mapping.txt. |
-j, --jobs N |
Parallel workers (defaults to all cores; the diff scales with it). |
--keep-boilerplate |
Keep generated Comparator twins (skipped by default as review noise). |
-t, --threshold T |
Minimum similarity to report a structural match (default 0.8). |
--libsigs PACK / --no-libsigs
|
Known-library signature dictionary (from the companion twinflame-libsigs package) used to label/demote library classes by evidence, not just name prefix. Auto-discovered at $TWINFLAME_LIBSIGS or ~/.cache/twinflame/libsigs.tflp. |
--assignment {greedy,hungarian,auto} |
1-to-1 assignment strategy (default auto: hungarian on small contested pools, greedy elsewhere). |
--normalize |
Run Redex junk-instruction normalization before fingerprinting (needs redex on PATH). |
Single machine (6C/12T): prepare runs at a flat ~1,000 classes/s from 2.6 k to 215 k
classes — Telegram (~40 k classes, 145 MB APK) prepares in ~44 s; Twitter (~210 k classes)
in ~3.3 min. Diffing two prepared records takes ~35 s for the Telegram pair and ~2 min for
the Twitter pair (package clustering + pool-level parallelism; scales with --jobs). Full
tables live in
BENCHMARKS.md.
The verbose DEBUG | androguard.core.dex … lines printed while an APK/DEX loads come from
androguard's logger (loguru), not from twinflame. Cap its level via the environment:
LOGURU_LEVEL=WARNING twinflame old.apk new.apktwinflame's own diagnostics (timings, warnings) go to stderr and are terse; redirect with
2>/dev/null if you want machine output only. Prepared .tfr inputs skip the androguard
parse entirely, so record-based runs are quiet by design.
- Comparing two builds of your app from the same toolchain (e.g. adjacent CI builds):
trust all
modified. - Comparing releases built months apart (different R8/AGP): add
--min-confidence highso library re-optimization churn doesn't drown the real app changes. - The verdict rests on semantic features (framework calls, strings, types, method structure), not raw bytecode — so it does not flag cosmetic re-optimization as a change.
User guide
Internals
Evaluation
Repo