twinflame 0.1.0b1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. twinflame-0.1.0b1/PKG-INFO +274 -0
  2. twinflame-0.1.0b1/README.md +253 -0
  3. twinflame-0.1.0b1/pyproject.toml +52 -0
  4. twinflame-0.1.0b1/setup.cfg +4 -0
  5. twinflame-0.1.0b1/src/twinflame/__init__.py +46 -0
  6. twinflame-0.1.0b1/src/twinflame/_hot.py +41 -0
  7. twinflame-0.1.0b1/src/twinflame/accurate.py +409 -0
  8. twinflame-0.1.0b1/src/twinflame/anchor.py +146 -0
  9. twinflame-0.1.0b1/src/twinflame/api.py +232 -0
  10. twinflame-0.1.0b1/src/twinflame/boilerplate.py +39 -0
  11. twinflame-0.1.0b1/src/twinflame/changes.py +443 -0
  12. twinflame-0.1.0b1/src/twinflame/cli.py +288 -0
  13. twinflame-0.1.0b1/src/twinflame/cluster.py +73 -0
  14. twinflame-0.1.0b1/src/twinflame/components.py +91 -0
  15. twinflame-0.1.0b1/src/twinflame/deobf.py +331 -0
  16. twinflame-0.1.0b1/src/twinflame/features.py +333 -0
  17. twinflame-0.1.0b1/src/twinflame/loader.py +392 -0
  18. twinflame-0.1.0b1/src/twinflame/manifest.py +81 -0
  19. twinflame-0.1.0b1/src/twinflame/model.py +243 -0
  20. twinflame-0.1.0b1/src/twinflame/normalize.py +83 -0
  21. twinflame-0.1.0b1/src/twinflame/opcodes.py +156 -0
  22. twinflame-0.1.0b1/src/twinflame/parallel.py +28 -0
  23. twinflame-0.1.0b1/src/twinflame/prepare.py +260 -0
  24. twinflame-0.1.0b1/src/twinflame/propagate.py +265 -0
  25. twinflame-0.1.0b1/src/twinflame/provenance.py +119 -0
  26. twinflame-0.1.0b1/src/twinflame/report.py +68 -0
  27. twinflame-0.1.0b1/src/twinflame/signature.py +238 -0
  28. twinflame-0.1.0b1/src/twinflame.egg-info/PKG-INFO +274 -0
  29. twinflame-0.1.0b1/src/twinflame.egg-info/SOURCES.txt +31 -0
  30. twinflame-0.1.0b1/src/twinflame.egg-info/dependency_links.txt +1 -0
  31. twinflame-0.1.0b1/src/twinflame.egg-info/entry_points.txt +2 -0
  32. twinflame-0.1.0b1/src/twinflame.egg-info/requires.txt +8 -0
  33. twinflame-0.1.0b1/src/twinflame.egg-info/top_level.txt +1 -0
@@ -0,0 +1,274 @@
1
+ Metadata-Version: 2.4
2
+ Name: twinflame
3
+ Version: 0.1.0b1
4
+ Summary: DEX-level Android APK class-diffing engine (SimHash + LSH + abstract-opcode comparison)
5
+ Author: twinflame contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ankorio/twinflame
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Topic :: Security
11
+ Classifier: Topic :: Software Development :: Disassemblers
12
+ Requires-Python: >=3.11
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: androguard>=4.1
15
+ Requires-Dist: numpy>=1.26
16
+ Requires-Dist: rapidfuzz>=3.14
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=8; extra == "test"
19
+ Requires-Dist: pytest-cov>=5; extra == "test"
20
+ Requires-Dist: hypothesis>=6; extra == "test"
21
+
22
+ # twinflame
23
+
24
+ DEX/APK **bytecode-level** diffing engine for Android reverse engineers. Matches classes and methods across two builds by *structure* — surviving R8/ProGuard renaming — and emits a ranked, method-localized **change report** (what was added / removed / modified) plus a ProGuard `mapping.txt` to carry recovered names into your decompiler. Implements the four-stage architecture from Quarkslab's _"Android Application Diffing: Engine Overview"_ (Czayka & Thomas, 2019).
25
+
26
+ Use cases:
27
+
28
+ - **Vulnerability patch identification** — find the class a vendor changed to fix a flaw.
29
+ - **Repackaging / mod analysis** — find injected code that replaces vendor logic with no-ops or hostile callbacks.
30
+ - **Malware triage / kinship** — find shared code across samples and flag when both implement the same permission-gated component (AccessibilityService, NotificationListener, DeviceAdmin).
31
+ - **Deobfuscation correlation** — pair classes across Proguard-renamed builds.
32
+
33
+ Inputs can be APKs, single `.dex` files, or directories of dumped `.dex` (memory dumps included). For pipelines that compare a sample against many, `twinflame prepare` fingerprints a sample once into a reusable record so later comparisons skip the expensive parse.
34
+
35
+ ## Documentation
36
+
37
+ - **[docs/using-twinflame.md](docs/using-twinflame.md)** — task-oriented guide: the three use cases, reading the change report, feeding the mapping to jadx/retrace.
38
+ - **[docs/change-report-schema.md](docs/change-report-schema.md)** — the versioned JSON output format (for downstream tooling).
39
+ - **[docs/how-it-works.md](docs/how-it-works.md)** — how the pipeline works internally, with diagrams.
40
+
41
+ ## Pipeline
42
+
43
+ 1. **Multi-DEX merge** — every `classes*.dex` is unioned into one logical view (first-wins on descriptor, matching ART). The loader also records each method's call targets + incoming-xref count and each class's string constants, which feed anchoring.
44
+ 2. **Stage 1 — clustering.** Classes group into pools keyed by package name; matching pools across the two APKs compare in parallel. Obfuscated packages (Shannon entropy + length heuristic) fall into a single fallback pool.
45
+ 3. **Stage B — anchoring.** Within each pool, R8-invariant seeds are locked in *before* structural scoring: classes sharing a rare string constant (IDF-weighted) or a distinctive set of `android/*`/`androidx/*`/`java/*`/`kotlin/*` framework calls are paired with high confidence even when SimHash can't tell them apart. Disable with `--no-anchors`.
46
+ 4. **Stage 2 — bulk SimHash + bucketed LSH.** Each remaining class gets a 128-bit signature built from four 32-bit partial hashes — `cls`, `fld`, `mth`, `code` — over **structure-only** features (no names, no strings). Nearest neighbors are found via Dullien-style bit-permutation LSH instead of all-pairs.
47
+ 5. **Stage 3 — accurate, method-level comparison.** Top-k Stage-2 neighbors are re-scored at **method granularity**: methods are assigned 1-to-1 within a class pair (abstract-opcode Levenshtein over 13 semantic categories + prototype + xref), yielding per-method `matched / modified / added / deleted` verdicts. The class score is the instruction-weighted roll-up of those verdicts blended with structural features. Greedy 1-to-1 assignment resolves class-level ties.
48
+
49
+ Optional Redex pre-pass strips junk-instruction obfuscation (`LocalDcePass` + `RegAllocPass`) before loading.
50
+
51
+ ## Installation
52
+
53
+ ### pip (recommended)
54
+
55
+ Requires Python ≥ 3.11. Runtime deps (androguard, numpy, rapidfuzz) install automatically.
56
+
57
+ ```sh
58
+ git clone https://github.com/ankorio/twinflame && cd twinflame
59
+ python -m venv .venv && . .venv/bin/activate
60
+ pip install . # or: pip install -e '.[test]' for a dev checkout
61
+ twinflame --help # console entry point is installed
62
+ ```
63
+
64
+ That's everything for the core tool. Two features need external programs that aren't Python
65
+ packages: `--normalize` needs **Redex** on `PATH` (optional; only for junk-instruction
66
+ normalization), and applying a recovered `mapping.txt` needs your own decompiler (e.g. **JADX**).
67
+
68
+ ### Nix flake (reproducible environment)
69
+
70
+ For a fully-pinned environment (Python + all deps + Redex built from source + JADX), use the
71
+ [Nix flake](https://nixos.wiki/wiki/Flakes) — everything is locked in [flake.lock](flake.lock):
72
+
73
+ ```sh
74
+ # Install Nix with flakes (Determinate Systems installer is easiest):
75
+ curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
76
+ git clone https://github.com/ankorio/twinflame && cd twinflame
77
+ nix develop # drops you in a shell with twinflame + Redex + JADX
78
+ ```
79
+
80
+ ## Running
81
+
82
+ ### One-shot CLI via `nix run`
83
+
84
+ `nix run` builds the tool on demand and invokes it. The first run compiles Redex from source (~5 min); after that everything is cached.
85
+
86
+ ```sh
87
+ # diff two APKs, scope to a vendor package, write a JSON report
88
+ nix run . -- old.apk new.apk \
89
+ --auto-package \
90
+ --threshold 0.8 \
91
+ -o report.json
92
+
93
+ # real-world example: vuln/patched pair, Redex-normalized to defeat
94
+ # junk-instruction obfuscation, with the obfuscated-package fallback
95
+ # so Proguard-renamed packages cluster together for comparison
96
+ nix run . -- vuln.apk patched.apk --normalize --find-obfuscated
97
+ ```
98
+
99
+ ### Inputs: APK, `.dex`, or dumped DEX (no APK)
100
+
101
+ Each side accepts an APK, a single `.dex`, or a directory of `.dex` files — so
102
+ you can diff **dumped/extracted DEX** (e.g. pulled from memory or an unpacked
103
+ payload) with no surrounding APK, with no flag: the input kind is detected.
104
+ Partial dumps are tolerated — an unparseable or corrupt `.dex` in a directory is
105
+ skipped (with a warning) rather than aborting the load. A DEX-only input has no
106
+ manifest, so `--auto-package` is unavailable — pass `--app-package <prefix>` for
107
+ app-vs-library ranking instead.
108
+
109
+ ```sh
110
+ # directory of dumped classes*.dex on each side
111
+ twinflame dump_old/ dump_new/ --no-cluster
112
+
113
+ # write the machine report as CSV instead of the default JSON, only the
114
+ # classes that actually differ
115
+ twinflame a/ b/ --no-cluster --app-package com.target.app -s changed -f csv -o changes.csv
116
+ ```
117
+
118
+ By default, stdout shows a ranked **change summary** (modified / added / removed /
119
+ cosmetic / unchanged, app code first) and any sensitive components both builds
120
+ share, while the full machine report is written to a file (`-f json`, default;
121
+ `csv`/`xml` also available). Pass `-m`/`--matches` for the raw per-class match
122
+ list instead:
123
+
124
+ ```
125
+ [+] com.acme.foo: Login - Login.java | com.acme.foo: Login - SourceFile -> 0.8523
126
+ ~ validateToken (0.6100) # method modified
127
+ - legacyHash # method removed
128
+ + verifyNonce # method added
129
+ [-] com.acme.foo: DeletedClass - DeletedClass.java # in lhs only
130
+ [*] com.acme.foo: AddedClass - SourceFile # in rhs only
131
+ ```
132
+
133
+ Each `[+]` paired line is followed by the methods that actually changed, so you
134
+ see *which* method moved the class score. The machine report carries the full
135
+ per-method verdict list, plus each paired class's superclass/interfaces and any
136
+ sensitive component it implements.
137
+
138
+ Timings go to stderr; the report to stdout (so you can `| less` or redirect freely).
139
+
140
+ #### Worked example
141
+
142
+ Diffing a vuln/patched APK pair (a real Android wallet app, ~8 k classes per side, the new build is Proguard'd):
143
+
144
+ ```sh
145
+ $ nix run . -- old.apk new.apk --normalize --find-obfuscated
146
+
147
+ ```
148
+
149
+ Reading the output:
150
+
151
+ - The `load:` and `diff:` lines on **stderr** report timings and class counts so you can see what was actually compared after `--normalize` (Redex strips junk instructions on both sides) and post-filtering.
152
+ - `[+]` lines are paired matches with their similarity score (`distance < 1.0`). The two `androidx.activity` examples above pair clear-text class names on the left (lhs preserved source files) against the same logical classes on the right (rhs lost source files to `SourceFile` — Proguard's default). Source-file name was dropped, but the structure + bytecode similarity is high (~0.85–0.95).
153
+ - `[-]` is a class only present in lhs; `[*]` only in rhs. Counts at the top tell you the global picture (e.g. _11 042 matches: 602 paired below 1.0, 5 027 deleted, 5 664 added_ — the bulk being unmatched is the price of heavy obfuscation; tweak `--find-obfuscated` and `--threshold` to trade quality for coverage).
154
+ - Drop the `--normalize` and re-run to see what _uncleaned_ bytecode looks like — if the average paired similarity drops from ~0.9 to ~0.95+ everywhere, that's the fingerprint of junk-instruction obfuscation that Redex defeats.
155
+
156
+ ### Build a standalone binary
157
+
158
+ ```sh
159
+ nix build # produces ./result/bin/twinflame
160
+ ./result/bin/twinflame --help
161
+ ```
162
+
163
+ ### Dev shell
164
+
165
+ For interactive use (REPL, hacking on the code, running tests):
166
+
167
+ ```sh
168
+ nix develop # enter shell with python + redex + jadx on PATH
169
+ pytest tests/ # 86 tests, ~8s
170
+ python -m twinflame.cli --help
171
+ ```
172
+
173
+ ### Python API
174
+
175
+ ```python
176
+ from twinflame import load, filter, diff
177
+
178
+ lhs_app = load("app-1.6.1.apk")
179
+ rhs_app = load("app-1.6.3.apk")
180
+
181
+ lhs = filter(lhs_app.classes, {"package_filtering": "com.vendor.app"})
182
+ rhs = filter(rhs_app.classes, {"package_filtering": "com.vendor.app"})
183
+
184
+ matches = diff(lhs, rhs, 0.8, {
185
+ "synthetic_skipping": True,
186
+ "min_inst_size_threshold": 5,
187
+ "top_match_threshold": 3,
188
+ })
189
+ for m in matches:
190
+ if m.is_paired and m.distance < 1.0:
191
+ print(f"[+] {m.lhs.info} | {m.rhs.info} -> {m.distance:.4f}")
192
+ ```
193
+
194
+ ### Cross-version deobfuscation map
195
+
196
+ `--deobfuscation-map OUT` writes a ProGuard-format `mapping.txt` for **apk2** (the obfuscated/stripped build) by propagating class names recovered from the matched classes in **apk1** (the donor build that still carries the DEX `SourceFile` attribute). Load it into JADX/Ghidra and the rename propagates to every reference automatically — twinflame never rewrites the DEX.
197
+
198
+ ```sh
199
+ # apk1 = older build that kept SourceFile; apk2 = the one you want to read
200
+ twinflame app-old.apk app-new.apk \
201
+ --find-obfuscated --deobfuscation-map app-new.map --map-min-confidence 0.8
202
+ ```
203
+
204
+ Load into JADX with `-Prename-mappings.invert=yes`:
205
+
206
+ ```sh
207
+ jadx --mappings-path app-new.map -Prename-mappings.format=PROGUARD_FILE -Prename-mappings.invert=yes \
208
+ -d out_deobf app-new.apk
209
+ ```
210
+
211
+ `invert=yes` is required: our file follows the standard ProGuard convention (`<original> -> <obfuscated>:`, what `retrace` consumes), but jadx's `PROGUARD_FILE` reader treats the left side as the name *currently in the binary* — the opposite. Without the flag it silently renames nothing (no error). Same applies in jadx-gui: check "Invert" alongside the mapping path.
212
+
213
+ - Recovers the class **simple name** (`ContextCompat.java` → `x6.q` becomes `x6.ContextCompat`); the obfuscated **package** and inner-class structure are kept (source files carry no package).
214
+ - Anchored / exact (`distance == 1.0`) matches are trusted; lower-confidence ones are still emitted but flagged with a `# low-confidence` comment. Tune the floor with `--map-min-confidence`.
215
+ - This is the cross-version superpower over single-APK source-file deobfuscation: it names classes in a build that **stripped** `SourceFile`, using the build that didn't. Method/field name propagation is a planned next step.
216
+
217
+ ### Workflow recipes
218
+
219
+ - **Patch identification.** Scope with `--package PREFIX` or `--auto-package` (drastically shrinks the comparison set), use `--threshold 0.8` since bug fixes are minor changes. Skip `distance == 1.0` matches.
220
+ - **Modded / repackaged app.** Drop `--package` (mods often live in bundled SDKs, not the vendor package). If you see a sea of ~0.95 "everything changed" matches, run with `--normalize` — that's the signature of junk-instruction obfuscation.
221
+ - **Heavily Proguard'd inputs.** Add `--find-obfuscated` so single-letter package names (`a.b.c`) collapse into a fallback pool rather than failing to pair with their clear-text counterparts.
222
+ - **Deobfuscating a stripped build.** Diff it against an older build that kept `SourceFile`, add `--find-obfuscated --deobfuscation-map out.map`, then load `out.map` in JADX.
223
+
224
+ ### Evaluation harness (`eval/`, plan M3.2)
225
+
226
+ Grades twinflame's own class matches against real ground truth instead of eyeballing them. Build an OSS Android app **twice** — R8 off, then R8 on at full optimization — the `mapping.txt` R8 writes for the R8-on build is a free, perfect oracle for the renaming-only case (directly grades M1.1 + M1.2; inlining/outlining is a future difficulty-graded slice per M3.1).
227
+
228
+ ```sh
229
+ python -m eval.cli app-r8-off.apk app-r8-on.apk app-r8-on/mapping.txt --package com.vendor.app
230
+ ```
231
+
232
+ Prints true/false positive & negative class-match counts plus precision/recall/F1. `eval/mapping.py` parses the ProGuard-format oracle (same format `deobf.py` emits); `eval/score.py` exposes `score_class_matches(matches, ground_truth) -> ScoreResult` for use as a library, independent of the CLI. Kept out of the `src/twinflame` package on purpose — it's tooling to grade the engine, not part of it.
233
+
234
+ ## Layout
235
+
236
+ | Module | Role |
237
+ | ---------------------------------------------------- | -------------------------------------------------------------------- |
238
+ | [src/twinflame/model.py](src/twinflame/model.py) | Dataclasses: `App`, `Class`, `Method`, `Field`, `Signature`, `Match`, `MethodMatch` |
239
+ | [src/twinflame/loader.py](src/twinflame/loader.py) | androguard wrapping + multi-DEX merge; captures calls/strings/xrefs |
240
+ | [src/twinflame/manifest.py](src/twinflame/manifest.py) | `AndroidManifest.xml` → `ManifestInfo`; dev-package suggestion |
241
+ | [src/twinflame/normalize.py](src/twinflame/normalize.py) | Redex subprocess wrapper |
242
+ | [src/twinflame/cluster.py](src/twinflame/cluster.py) | Package-based pools + entropy/length obfuscation fallback |
243
+ | [src/twinflame/anchor.py](src/twinflame/anchor.py) | Stage B: string-IDF + framework-call seed matches |
244
+ | [src/twinflame/signature.py](src/twinflame/signature.py) | 128-bit SimHash + LSH bucket index |
245
+ | [src/twinflame/accurate.py](src/twinflame/accurate.py) | Abstract opcodes, method-level + class scoring, greedy 1-to-1 assignment |
246
+ | [src/twinflame/opcodes.py](src/twinflame/opcodes.py) | Dalvik opcode → 13-category lookup table |
247
+ | [src/twinflame/\_hot.py](src/twinflame/_hot.py) | Hot loops (popcount, Hamming, rapidfuzz-backed Levenshtein) |
248
+ | [src/twinflame/deobf.py](src/twinflame/deobf.py) | Cross-version deobfuscation: recovered names → ProGuard mapping.txt |
249
+ | [src/twinflame/api.py](src/twinflame/api.py) | Public `load / filter / diff` entry points |
250
+ | [src/twinflame/cli.py](src/twinflame/cli.py) | `twinflame` console script |
251
+
252
+ ## Tests
253
+
254
+ ```sh
255
+ nix develop --command pytest tests/
256
+ ```
257
+
258
+ Currently: **86 tests passing**, 0 skipped. Redex is built and on PATH inside the dev shell.
259
+
260
+ Test fixtures are **pure-synthetic**: `tests/fixtures/synthetic.py` builds `Class` objects directly via Python, no binaries committed to git. The integration tests exercise the full diff pipeline (cluster → anchor → signature → accurate → api) on these objects.
261
+
262
+ The on-disk DEX/APK round-trip (loader.py end-to-end) currently has a smoke-only test; a real binary DEX emitter under [tests/fixtures/build_dex.py](tests/fixtures/build_dex.py) is stubbed for future work.
263
+
264
+ ## Known limitations
265
+
266
+ - **Identical-structure classes collide.** Pools where many classes share signatures (trivial getter/setter classes, generated stubs) may produce false pairings via greedy assignment. `--min-instr 5` filters trivial classes; raising `--neighbors` widens the candidate pool.
267
+ - **No inheritance context.** Matching is per-class (now with method-level detail *inside* a paired class), but ignores first-level parent/child signals (cf. LibPecker); these can be added later without disturbing the core.
268
+ - **No cross-boundary / optimization resilience yet.** Matching assumes a 1:1 class and method correspondence. R8 *optimizations* that break that assumption — inlining, outlining, class merging — are a planned future track (see `twinflame-next-dev-plan.md`, M3.1), not yet implemented.
269
+ - **JNI / native-code changes are invisible.** The diff is purely Dalvik-level — native library mutations require a complementary native-code diff.
270
+ - **LSH is approximate by design.** `--buckets N` tunes the accuracy/speed knob.
271
+
272
+ ## License
273
+
274
+ MIT.
@@ -0,0 +1,253 @@
1
+ # twinflame
2
+
3
+ DEX/APK **bytecode-level** diffing engine for Android reverse engineers. Matches classes and methods across two builds by *structure* — surviving R8/ProGuard renaming — and emits a ranked, method-localized **change report** (what was added / removed / modified) plus a ProGuard `mapping.txt` to carry recovered names into your decompiler. Implements the four-stage architecture from Quarkslab's _"Android Application Diffing: Engine Overview"_ (Czayka & Thomas, 2019).
4
+
5
+ Use cases:
6
+
7
+ - **Vulnerability patch identification** — find the class a vendor changed to fix a flaw.
8
+ - **Repackaging / mod analysis** — find injected code that replaces vendor logic with no-ops or hostile callbacks.
9
+ - **Malware triage / kinship** — find shared code across samples and flag when both implement the same permission-gated component (AccessibilityService, NotificationListener, DeviceAdmin).
10
+ - **Deobfuscation correlation** — pair classes across Proguard-renamed builds.
11
+
12
+ Inputs can be APKs, single `.dex` files, or directories of dumped `.dex` (memory dumps included). For pipelines that compare a sample against many, `twinflame prepare` fingerprints a sample once into a reusable record so later comparisons skip the expensive parse.
13
+
14
+ ## Documentation
15
+
16
+ - **[docs/using-twinflame.md](docs/using-twinflame.md)** — task-oriented guide: the three use cases, reading the change report, feeding the mapping to jadx/retrace.
17
+ - **[docs/change-report-schema.md](docs/change-report-schema.md)** — the versioned JSON output format (for downstream tooling).
18
+ - **[docs/how-it-works.md](docs/how-it-works.md)** — how the pipeline works internally, with diagrams.
19
+
20
+ ## Pipeline
21
+
22
+ 1. **Multi-DEX merge** — every `classes*.dex` is unioned into one logical view (first-wins on descriptor, matching ART). The loader also records each method's call targets + incoming-xref count and each class's string constants, which feed anchoring.
23
+ 2. **Stage 1 — clustering.** Classes group into pools keyed by package name; matching pools across the two APKs compare in parallel. Obfuscated packages (Shannon entropy + length heuristic) fall into a single fallback pool.
24
+ 3. **Stage B — anchoring.** Within each pool, R8-invariant seeds are locked in *before* structural scoring: classes sharing a rare string constant (IDF-weighted) or a distinctive set of `android/*`/`androidx/*`/`java/*`/`kotlin/*` framework calls are paired with high confidence even when SimHash can't tell them apart. Disable with `--no-anchors`.
25
+ 4. **Stage 2 — bulk SimHash + bucketed LSH.** Each remaining class gets a 128-bit signature built from four 32-bit partial hashes — `cls`, `fld`, `mth`, `code` — over **structure-only** features (no names, no strings). Nearest neighbors are found via Dullien-style bit-permutation LSH instead of all-pairs.
26
+ 5. **Stage 3 — accurate, method-level comparison.** Top-k Stage-2 neighbors are re-scored at **method granularity**: methods are assigned 1-to-1 within a class pair (abstract-opcode Levenshtein over 13 semantic categories + prototype + xref), yielding per-method `matched / modified / added / deleted` verdicts. The class score is the instruction-weighted roll-up of those verdicts blended with structural features. Greedy 1-to-1 assignment resolves class-level ties.
27
+
28
+ Optional Redex pre-pass strips junk-instruction obfuscation (`LocalDcePass` + `RegAllocPass`) before loading.
29
+
30
+ ## Installation
31
+
32
+ ### pip (recommended)
33
+
34
+ Requires Python ≥ 3.11. Runtime deps (androguard, numpy, rapidfuzz) install automatically.
35
+
36
+ ```sh
37
+ git clone https://github.com/ankorio/twinflame && cd twinflame
38
+ python -m venv .venv && . .venv/bin/activate
39
+ pip install . # or: pip install -e '.[test]' for a dev checkout
40
+ twinflame --help # console entry point is installed
41
+ ```
42
+
43
+ That's everything for the core tool. Two features need external programs that aren't Python
44
+ packages: `--normalize` needs **Redex** on `PATH` (optional; only for junk-instruction
45
+ normalization), and applying a recovered `mapping.txt` needs your own decompiler (e.g. **JADX**).
46
+
47
+ ### Nix flake (reproducible environment)
48
+
49
+ For a fully-pinned environment (Python + all deps + Redex built from source + JADX), use the
50
+ [Nix flake](https://nixos.wiki/wiki/Flakes) — everything is locked in [flake.lock](flake.lock):
51
+
52
+ ```sh
53
+ # Install Nix with flakes (Determinate Systems installer is easiest):
54
+ curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
55
+ git clone https://github.com/ankorio/twinflame && cd twinflame
56
+ nix develop # drops you in a shell with twinflame + Redex + JADX
57
+ ```
58
+
59
+ ## Running
60
+
61
+ ### One-shot CLI via `nix run`
62
+
63
+ `nix run` builds the tool on demand and invokes it. The first run compiles Redex from source (~5 min); after that everything is cached.
64
+
65
+ ```sh
66
+ # diff two APKs, scope to a vendor package, write a JSON report
67
+ nix run . -- old.apk new.apk \
68
+ --auto-package \
69
+ --threshold 0.8 \
70
+ -o report.json
71
+
72
+ # real-world example: vuln/patched pair, Redex-normalized to defeat
73
+ # junk-instruction obfuscation, with the obfuscated-package fallback
74
+ # so Proguard-renamed packages cluster together for comparison
75
+ nix run . -- vuln.apk patched.apk --normalize --find-obfuscated
76
+ ```
77
+
78
+ ### Inputs: APK, `.dex`, or dumped DEX (no APK)
79
+
80
+ Each side accepts an APK, a single `.dex`, or a directory of `.dex` files — so
81
+ you can diff **dumped/extracted DEX** (e.g. pulled from memory or an unpacked
82
+ payload) with no surrounding APK, with no flag: the input kind is detected.
83
+ Partial dumps are tolerated — an unparseable or corrupt `.dex` in a directory is
84
+ skipped (with a warning) rather than aborting the load. A DEX-only input has no
85
+ manifest, so `--auto-package` is unavailable — pass `--app-package <prefix>` for
86
+ app-vs-library ranking instead.
87
+
88
+ ```sh
89
+ # directory of dumped classes*.dex on each side
90
+ twinflame dump_old/ dump_new/ --no-cluster
91
+
92
+ # write the machine report as CSV instead of the default JSON, only the
93
+ # classes that actually differ
94
+ twinflame a/ b/ --no-cluster --app-package com.target.app -s changed -f csv -o changes.csv
95
+ ```
96
+
97
+ By default, stdout shows a ranked **change summary** (modified / added / removed /
98
+ cosmetic / unchanged, app code first) and any sensitive components both builds
99
+ share, while the full machine report is written to a file (`-f json`, default;
100
+ `csv`/`xml` also available). Pass `-m`/`--matches` for the raw per-class match
101
+ list instead:
102
+
103
+ ```
104
+ [+] com.acme.foo: Login - Login.java | com.acme.foo: Login - SourceFile -> 0.8523
105
+ ~ validateToken (0.6100) # method modified
106
+ - legacyHash # method removed
107
+ + verifyNonce # method added
108
+ [-] com.acme.foo: DeletedClass - DeletedClass.java # in lhs only
109
+ [*] com.acme.foo: AddedClass - SourceFile # in rhs only
110
+ ```
111
+
112
+ Each `[+]` paired line is followed by the methods that actually changed, so you
113
+ see *which* method moved the class score. The machine report carries the full
114
+ per-method verdict list, plus each paired class's superclass/interfaces and any
115
+ sensitive component it implements.
116
+
117
+ Timings go to stderr; the report to stdout (so you can `| less` or redirect freely).
118
+
119
+ #### Worked example
120
+
121
+ Diffing a vuln/patched APK pair (a real Android wallet app, ~8 k classes per side, the new build is Proguard'd):
122
+
123
+ ```sh
124
+ $ nix run . -- old.apk new.apk --normalize --find-obfuscated
125
+
126
+ ```
127
+
128
+ Reading the output:
129
+
130
+ - The `load:` and `diff:` lines on **stderr** report timings and class counts so you can see what was actually compared after `--normalize` (Redex strips junk instructions on both sides) and post-filtering.
131
+ - `[+]` lines are paired matches with their similarity score (`distance < 1.0`). The two `androidx.activity` examples above pair clear-text class names on the left (lhs preserved source files) against the same logical classes on the right (rhs lost source files to `SourceFile` — Proguard's default). Source-file name was dropped, but the structure + bytecode similarity is high (~0.85–0.95).
132
+ - `[-]` is a class only present in lhs; `[*]` only in rhs. Counts at the top tell you the global picture (e.g. _11 042 matches: 602 paired below 1.0, 5 027 deleted, 5 664 added_ — the bulk being unmatched is the price of heavy obfuscation; tweak `--find-obfuscated` and `--threshold` to trade quality for coverage).
133
+ - Drop the `--normalize` and re-run to see what _uncleaned_ bytecode looks like — if the average paired similarity drops from ~0.9 to ~0.95+ everywhere, that's the fingerprint of junk-instruction obfuscation that Redex defeats.
134
+
135
+ ### Build a standalone binary
136
+
137
+ ```sh
138
+ nix build # produces ./result/bin/twinflame
139
+ ./result/bin/twinflame --help
140
+ ```
141
+
142
+ ### Dev shell
143
+
144
+ For interactive use (REPL, hacking on the code, running tests):
145
+
146
+ ```sh
147
+ nix develop # enter shell with python + redex + jadx on PATH
148
+ pytest tests/ # 86 tests, ~8s
149
+ python -m twinflame.cli --help
150
+ ```
151
+
152
+ ### Python API
153
+
154
+ ```python
155
+ from twinflame import load, filter, diff
156
+
157
+ lhs_app = load("app-1.6.1.apk")
158
+ rhs_app = load("app-1.6.3.apk")
159
+
160
+ lhs = filter(lhs_app.classes, {"package_filtering": "com.vendor.app"})
161
+ rhs = filter(rhs_app.classes, {"package_filtering": "com.vendor.app"})
162
+
163
+ matches = diff(lhs, rhs, 0.8, {
164
+ "synthetic_skipping": True,
165
+ "min_inst_size_threshold": 5,
166
+ "top_match_threshold": 3,
167
+ })
168
+ for m in matches:
169
+ if m.is_paired and m.distance < 1.0:
170
+ print(f"[+] {m.lhs.info} | {m.rhs.info} -> {m.distance:.4f}")
171
+ ```
172
+
173
+ ### Cross-version deobfuscation map
174
+
175
+ `--deobfuscation-map OUT` writes a ProGuard-format `mapping.txt` for **apk2** (the obfuscated/stripped build) by propagating class names recovered from the matched classes in **apk1** (the donor build that still carries the DEX `SourceFile` attribute). Load it into JADX/Ghidra and the rename propagates to every reference automatically — twinflame never rewrites the DEX.
176
+
177
+ ```sh
178
+ # apk1 = older build that kept SourceFile; apk2 = the one you want to read
179
+ twinflame app-old.apk app-new.apk \
180
+ --find-obfuscated --deobfuscation-map app-new.map --map-min-confidence 0.8
181
+ ```
182
+
183
+ Load into JADX with `-Prename-mappings.invert=yes`:
184
+
185
+ ```sh
186
+ jadx --mappings-path app-new.map -Prename-mappings.format=PROGUARD_FILE -Prename-mappings.invert=yes \
187
+ -d out_deobf app-new.apk
188
+ ```
189
+
190
+ `invert=yes` is required: our file follows the standard ProGuard convention (`<original> -> <obfuscated>:`, what `retrace` consumes), but jadx's `PROGUARD_FILE` reader treats the left side as the name *currently in the binary* — the opposite. Without the flag it silently renames nothing (no error). Same applies in jadx-gui: check "Invert" alongside the mapping path.
191
+
192
+ - Recovers the class **simple name** (`ContextCompat.java` → `x6.q` becomes `x6.ContextCompat`); the obfuscated **package** and inner-class structure are kept (source files carry no package).
193
+ - Anchored / exact (`distance == 1.0`) matches are trusted; lower-confidence ones are still emitted but flagged with a `# low-confidence` comment. Tune the floor with `--map-min-confidence`.
194
+ - This is the cross-version superpower over single-APK source-file deobfuscation: it names classes in a build that **stripped** `SourceFile`, using the build that didn't. Method/field name propagation is a planned next step.
195
+
196
+ ### Workflow recipes
197
+
198
+ - **Patch identification.** Scope with `--package PREFIX` or `--auto-package` (drastically shrinks the comparison set), use `--threshold 0.8` since bug fixes are minor changes. Skip `distance == 1.0` matches.
199
+ - **Modded / repackaged app.** Drop `--package` (mods often live in bundled SDKs, not the vendor package). If you see a sea of ~0.95 "everything changed" matches, run with `--normalize` — that's the signature of junk-instruction obfuscation.
200
+ - **Heavily Proguard'd inputs.** Add `--find-obfuscated` so single-letter package names (`a.b.c`) collapse into a fallback pool rather than failing to pair with their clear-text counterparts.
201
+ - **Deobfuscating a stripped build.** Diff it against an older build that kept `SourceFile`, add `--find-obfuscated --deobfuscation-map out.map`, then load `out.map` in JADX.
202
+
203
+ ### Evaluation harness (`eval/`, plan M3.2)
204
+
205
+ Grades twinflame's own class matches against real ground truth instead of eyeballing them. Build an OSS Android app **twice** — R8 off, then R8 on at full optimization — the `mapping.txt` R8 writes for the R8-on build is a free, perfect oracle for the renaming-only case (directly grades M1.1 + M1.2; inlining/outlining is a future difficulty-graded slice per M3.1).
206
+
207
+ ```sh
208
+ python -m eval.cli app-r8-off.apk app-r8-on.apk app-r8-on/mapping.txt --package com.vendor.app
209
+ ```
210
+
211
+ Prints true/false positive & negative class-match counts plus precision/recall/F1. `eval/mapping.py` parses the ProGuard-format oracle (same format `deobf.py` emits); `eval/score.py` exposes `score_class_matches(matches, ground_truth) -> ScoreResult` for use as a library, independent of the CLI. Kept out of the `src/twinflame` package on purpose — it's tooling to grade the engine, not part of it.
212
+
213
+ ## Layout
214
+
215
+ | Module | Role |
216
+ | ---------------------------------------------------- | -------------------------------------------------------------------- |
217
+ | [src/twinflame/model.py](src/twinflame/model.py) | Dataclasses: `App`, `Class`, `Method`, `Field`, `Signature`, `Match`, `MethodMatch` |
218
+ | [src/twinflame/loader.py](src/twinflame/loader.py) | androguard wrapping + multi-DEX merge; captures calls/strings/xrefs |
219
+ | [src/twinflame/manifest.py](src/twinflame/manifest.py) | `AndroidManifest.xml` → `ManifestInfo`; dev-package suggestion |
220
+ | [src/twinflame/normalize.py](src/twinflame/normalize.py) | Redex subprocess wrapper |
221
+ | [src/twinflame/cluster.py](src/twinflame/cluster.py) | Package-based pools + entropy/length obfuscation fallback |
222
+ | [src/twinflame/anchor.py](src/twinflame/anchor.py) | Stage B: string-IDF + framework-call seed matches |
223
+ | [src/twinflame/signature.py](src/twinflame/signature.py) | 128-bit SimHash + LSH bucket index |
224
+ | [src/twinflame/accurate.py](src/twinflame/accurate.py) | Abstract opcodes, method-level + class scoring, greedy 1-to-1 assignment |
225
+ | [src/twinflame/opcodes.py](src/twinflame/opcodes.py) | Dalvik opcode → 13-category lookup table |
226
+ | [src/twinflame/\_hot.py](src/twinflame/_hot.py) | Hot loops (popcount, Hamming, rapidfuzz-backed Levenshtein) |
227
+ | [src/twinflame/deobf.py](src/twinflame/deobf.py) | Cross-version deobfuscation: recovered names → ProGuard mapping.txt |
228
+ | [src/twinflame/api.py](src/twinflame/api.py) | Public `load / filter / diff` entry points |
229
+ | [src/twinflame/cli.py](src/twinflame/cli.py) | `twinflame` console script |
230
+
231
+ ## Tests
232
+
233
+ ```sh
234
+ nix develop --command pytest tests/
235
+ ```
236
+
237
+ Currently: **86 tests passing**, 0 skipped. Redex is built and on PATH inside the dev shell.
238
+
239
+ Test fixtures are **pure-synthetic**: `tests/fixtures/synthetic.py` builds `Class` objects directly via Python, no binaries committed to git. The integration tests exercise the full diff pipeline (cluster → anchor → signature → accurate → api) on these objects.
240
+
241
+ The on-disk DEX/APK round-trip (loader.py end-to-end) currently has a smoke-only test; a real binary DEX emitter under [tests/fixtures/build_dex.py](tests/fixtures/build_dex.py) is stubbed for future work.
242
+
243
+ ## Known limitations
244
+
245
+ - **Identical-structure classes collide.** Pools where many classes share signatures (trivial getter/setter classes, generated stubs) may produce false pairings via greedy assignment. `--min-instr 5` filters trivial classes; raising `--neighbors` widens the candidate pool.
246
+ - **No inheritance context.** Matching is per-class (now with method-level detail *inside* a paired class), but ignores first-level parent/child signals (cf. LibPecker); these can be added later without disturbing the core.
247
+ - **No cross-boundary / optimization resilience yet.** Matching assumes a 1:1 class and method correspondence. R8 *optimizations* that break that assumption — inlining, outlining, class merging — are a planned future track (see `twinflame-next-dev-plan.md`, M3.1), not yet implemented.
248
+ - **JNI / native-code changes are invisible.** The diff is purely Dalvik-level — native library mutations require a complementary native-code diff.
249
+ - **LSH is approximate by design.** `--buckets N` tunes the accuracy/speed knob.
250
+
251
+ ## License
252
+
253
+ MIT.
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "twinflame"
7
+ version = "0.1.0b1"
8
+ description = "DEX-level Android APK class-diffing engine (SimHash + LSH + abstract-opcode comparison)"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "twinflame contributors" }]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Topic :: Security",
17
+ "Topic :: Software Development :: Disassemblers",
18
+ ]
19
+ dependencies = [
20
+ "androguard>=4.1",
21
+ "numpy>=1.26",
22
+ "rapidfuzz>=3.14",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ test = [
27
+ "pytest>=8",
28
+ "pytest-cov>=5",
29
+ "hypothesis>=6",
30
+ ]
31
+
32
+ [project.scripts]
33
+ twinflame = "twinflame.cli:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/ankorio/twinflame"
37
+
38
+ [tool.setuptools]
39
+ package-dir = { "" = "src" }
40
+
41
+ [tool.setuptools.packages.find]
42
+ where = ["src"]
43
+
44
+ [tool.setuptools.package-data]
45
+ twinflame = ["py.typed"]
46
+
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ addopts = "-ra"
50
+ filterwarnings = [
51
+ "ignore::DeprecationWarning:androguard.*",
52
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,46 @@
1
+ """twinflame — DEX-level Android APK class-diffing engine."""
2
+
3
+ from .api import diff, filter, load
4
+ from .model import (
5
+ App,
6
+ Class,
7
+ DiffOptions,
8
+ Field,
9
+ Match,
10
+ Method,
11
+ MethodMatch,
12
+ Pool,
13
+ Signature,
14
+ )
15
+ from .prepare import PreparedRecord, load_record, prepare, save
16
+
17
+ __all__ = [
18
+ "App",
19
+ "Class",
20
+ "DiffOptions",
21
+ "Field",
22
+ "Match",
23
+ "Method",
24
+ "MethodMatch",
25
+ "Pool",
26
+ "PreparedRecord",
27
+ "Signature",
28
+ "diff",
29
+ "filter",
30
+ "load",
31
+ "load_record",
32
+ "prepare",
33
+ "save",
34
+ ]
35
+
36
+ # Single source of truth is the [project].version in pyproject.toml; read it
37
+ # back from the installed package metadata so the two never drift. Falls back
38
+ # when running from an uninstalled source tree.
39
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version # noqa: E402
40
+
41
+ try:
42
+ __version__ = _pkg_version("twinflame")
43
+ except PackageNotFoundError: # source checkout without an install
44
+ __version__ = "0.0.0+unknown"
45
+
46
+ del _pkg_version, PackageNotFoundError