Skip to content

Using Twinflame

ankorio edited this page Jul 15, 2026 · 4 revisions

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.

Inputs

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.


UC1 — what changed between two versions of my app

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 / unchanged are re-obfuscation/rebuild noise — ignore them.
  • app-only is the number that matters: the developer's own changed classes.
  • If the two builds used different toolchains (months apart), add --min-confidence high to drop low-confidence library churn (call-only relocation from different R8 inlining).
  • Each modified localizes to the methods that changed and how (see methods[] in the JSON).

Feed changes.json to your tooling to open exactly those classes side-by-side in a decompiler.


UC2 — do these two apps share code (e.g. malware kinship)?

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.txt

pairs.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.


UC3 — recover original names (deobfuscation)

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.txt

Then 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.txt

Only 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.

Aligning both builds side-by-side

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.apk

Then 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.


Precompute once, compare many (prepare)

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 omitted

A 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 identical

Keeping a record corpus current (migrate)

Records 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.json

Only extraction/abstract changes require a true re-prepare from the sample — migrate says so per file rather than silently mis-comparing.


UC4 — how much of a known family is in this sample? (score, experimental)

⚠️ 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 5

Boilerplate 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).

UC5 — build a reusable family signature (family build / family match)

⚠️ Experimental, uncalibrated (like score). Family members re-identify near-perfectly, but the absolute score floor is not yet calibrated — compare scores and tighten --radius rather than trusting a fixed threshold. Requires the companion twinflame-libsigs package (the pack format and matcher live there); without it, family prints 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 build clusters at a wider radius than the deterministic-R8 default; family match then defaults its radius to that build radius. Tighten -R to 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.


Useful flags

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).

Performance

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.

Silencing parser debug output

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.apk

twinflame'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.

Interpreting confidence at a glance

  • 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 high so 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.