ankorio/twinflame
GitHub: ankorio/twinflame
twinflame 是一个 DEX 字节码级别的 Android APK 差异比对引擎,通过结构化的 SimHash 匹配和精确的方法级比较来识别两个 APK 版本之间的代码变化。
Stars: 2 | Forks: 0
# twinflame
DEX 级别的 Android APK 类差异比对引擎。给定两个 APK 版本,它会生成一个排序后的类匹配列表,并附带标准化的相似度得分,让你能够准确找出在版本发布之间究竟哪些类发生了变异。它实现了 Quarkslab 在《Android Application Diffing: Engine Overview》(Czayka & Thomas, 2019)中提出的四阶段架构。
使用场景:
- **漏洞补丁识别** — 找出厂商为了修复缺陷而修改的类。
- **重打包 / 模组分析** — 找出那些用空操作(no-ops)或恶意回调替换了厂商逻辑的注入代码。
- **去混淆关联** — 将不同版本间经过 Proguard 重命名的构建中的类进行配对。
## 文档
- **[docs/using-twinflame.md](docs/using-twinflame.md)** — 面向任务的指南:三大使用场景、阅读变更报告、将映射关系提供给 jadx/retrace。
- **[docs/change-report-schema.md](docs/change-report-schema.md)** — 版本化的 JSON 输出格式(用于下游工具)。
- **[docs/how-it-works.md](docs/how-it-works.md)** — pipeline 的内部工作原理及图表。
## Pipeline
1. **多 DEX 合并** — 将每个 `classes*.dex` 合并为一个逻辑视图(在描述符冲突时采用先到先得原则,以匹配 ART)。加载器还会记录每个方法的调用目标 + 传入的交叉引用(xref)数量,以及每个类的字符串常量,这些将作为锚定的数据来源。
2. **阶段 1 — 聚类。** 类根据包名被分组到不同的池中;在两个 APK 之间匹配池的操作会并行进行。被混淆的包(基于 Shannon 熵 + 长度启发式算法判断)会落入一个统一的后备池中。
3. **阶段 B — 锚定。** 在每个池内部,R8 不变种子会在结构化评分*之前*被锁定:那些共享稀有字符串常量(经过 IDF 加权)或拥有一组独特的 `android/*`/`androidx/*`/`java/*`/`kotlin/*` 框架调用的类,即使在 SimHash 无法区分它们的情况下,也会被高置信度地配对。使用 `--no-anchors` 可禁用此功能。
4. **阶段 2 — 批量 SimHash + 桶式 LSH。** 每个剩余的类都会获得一个由四个 32 位部分哈希(`cls`、`fld`、`mth`、`code`)构建的 128 位签名,这些哈希**仅基于结构**特征(不包含名称或字符串)。通过 Dullien 风格的位排列 LSH 来寻找最近邻,而不是进行全对计算。
5. **阶段 3 — 精确的方法级比较。** Top-k 阶段 2 的邻居会在**方法粒度**上被重新评分:在一个类对中以 1 对 1 的方式分配方法(基于 13 个语义类别的抽象操作码 Levenshtein 距离 + 原型 + 交叉引用),从而得出每个方法 `matched / modified / added / deleted`(匹配 / 修改 / 新增 / 删除)的判定。类得分是这些判定结果的指令加权汇总,并与结构特征混合。贪心 1 对 1 分配用于解决类级别的平局。
可选的 Redex 预处理会在加载前剥离垃圾指令混淆(`LocalDcePass` + `RegAllocPass`)。
## 安装说明
### pip(推荐)
需要 Python ≥ 3.11。运行时依赖(androguard, numpy, rapidfuzz)会自动安装。
```
git clone https://github.com/ankorio/twinflame && cd twinflame
python -m venv .venv && . .venv/bin/activate
pip install . # or: pip install -e '.[test]' for a dev checkout
twinflame --help # console entry point is installed
```
这就是核心工具所需的全部内容。有两个功能需要非 Python
包的外部程序:`--normalize` 需要在 `PATH` 中配置 **Redex**(可选;仅用于垃圾
指令标准化),而应用恢复的 `mapping.txt` 需要你自己的反编译器(例如 **JADX**)。
### Nix flake(可复现环境)
如果需要完全锁定的环境(Python + 所有依赖 + 从源码构建的 Redex + JADX),请使用
[Nix flake](https://nixos.wiki/wiki/Flakes) — 所有内容都锁定在 [flake.lock](flake.lock) 中:
```
# 使用 flakes 安装 Nix(使用 Determinate Systems installer 最为简单):
curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
git clone https://github.com/ankorio/twinflame && cd twinflame
nix develop # drops you in a shell with twinflame + Redex + JADX
```
## 运行说明
### 通过 `nix run` 进行一次性 CLI 执行
`nix run` 会按需构建并调用该工具。首次运行时会从源码编译 Redex(约 5 分钟);之后所有内容都会被缓存。
```
# 对比两个 APK,范围限定到 vendor package,编写 JSON 报告
nix run . -- old.apk new.apk \
--auto-package \
--threshold 0.8 \
-o report.json
# 实际案例:vuln/patched 对,经过 Redex-normalized 以击败
# junk-instruction 混淆,并带有 obfuscated-package fallback
# 从而使 Proguard-renamed packages 聚集在一起以便对比
nix run . -- vuln.apk patched.apk --normalize --find-obfuscated
```
### 输入:APK、`.dex` 或导出的 DEX(无 APK)
每一侧都接受一个 APK、单个 `.dex` 文件,或一个包含 `.dex` 文件的目录 —— 因此
你可以在没有 APK 包裹的情况下比对 **导出/提取的 DEX**(例如从内存或解包的
载荷中提取的),无需任何标志:输入类型会被自动检测。仅包含
DEX 的输入没有 manifest,因此 `--auto-package` 不可用 —— 请传递
`--app-package ` 来进行应用与库的优先级排序。
```
# 对比双方各自 dumped 的 classes*.dex 目录
twinflame dump_old/ dump_new/ --no-cluster
# 将机器报告写入为 CSV 格式而非默认的 JSON,仅包含
# 实际存在差异的 classes
twinflame a/ b/ --no-cluster --app-package com.target.app -s changed -f csv -o changes.csv
```
输出格式(每个非完美匹配占一行):
```
[+] com.acme.foo: Login - Login.java | com.acme.foo: Login - SourceFile -> 0.8523
~ validateToken (0.6100) # method modified
- legacyHash # method removed
+ verifyNonce # method added
[-] com.acme.foo: DeletedClass - DeletedClass.java # in lhs only
[*] com.acme.foo: AddedClass - SourceFile # in rhs only
```
每行 `[+]` 配对记录后面都会跟着实际发生变化的方法,因此你
可以看到是*哪个*方法影响了类的得分,而不仅仅是知道这个类发生了变化。机器
报告(默认输出;`-f json`)包含完整的方法级判定列表,
以及每个配对类的超类/接口和它实现的任何敏感
组件(见下文)。
计时信息输出到 stderr;差异本身输出到 stdout(因此你可以自由地使用 `| less` 或重定向)。
#### 实战示例
对比漏洞版本/已修复版本的 APK 对(一个真实的 Android 钱包应用,每侧约 8k 个类,新构建版本经过了 Proguard 处理):
```
$ nix run . -- old.apk new.apk --normalize --find-obfuscated
```
解读输出:
- **stderr** 上的 `load:` 和 `diff:` 行报告了计时和类数量,因此你可以看到在经过 `--normalize`(Redex 会剥离两侧的垃圾指令)和后过滤后实际比较了什么。
- `[+]` 行是带有相似度得分(`distance < 1.0`)的配对匹配。上面的两个 `androidx.activity` 示例将左侧的明文类名(lhs 保留了源文件)与右侧相同的逻辑类(rhs 的源文件因为 `SourceFile` 而 丢失了 — 这是 Proguard 的默认行为)进行了配对。源文件名被丢弃了,但结构和字节码的相似度很高(~0.85–0.95)。
- `[-]` 表示仅在 lhs 中存在的类;`[*]` 表示仅在 rhs 中存在的类。顶部的计数告诉你全局情况(例如 _11 042 个匹配项:602 个配对得分低于 1.0,5 027 个被删除,5 664 个被新增_ — 大部分无法匹配是重度混淆的代价;可以通过调整 `--find-obfuscated` 和 `--threshold` 来权衡质量和覆盖率)。
- 去掉 `--normalize` 并重新运行,看看*未清理的*字节码长什么样 — 如果平均配对相似度从 ~0.9 降到了到处都是 ~0.95+,那就是 Redex 所能击败的垃圾指令混淆的指纹特征。
### 构建独立二进制文件
```
nix build # produces ./result/bin/twinflame
./result/bin/twinflame --help
```
### 开发 Shell
用于交互式使用(REPL、修改代码、运行测试):
```
nix develop # enter shell with python + redex + jadx on PATH
pytest tests/ # 86 tests, ~8s
python -m twinflame.cli --help
```
### Python API
```
from twinflame import load, filter, diff
lhs_app = load("app-1.6.1.apk")
rhs_app = load("app-1.6.3.apk")
lhs = filter(lhs_app.classes, {"package_filtering": "com.vendor.app"})
rhs = filter(rhs_app.classes, {"package_filtering": "com.vendor.app"})
matches = diff(lhs, rhs, 0.8, {
"synthetic_skipping": True,
"min_inst_size_threshold": 5,
"top_match_threshold": 3,
})
for m in matches:
if m.is_paired and m.distance < 1.0:
print(f"[+] {m.lhs.info} | {m.rhs.info} -> {m.distance:.4f}")
```
### 跨版本去混淆映射
`--deobfuscation-map OUT` 通过传播从 **apk1**(仍然带有 DEX `SourceFile` 属性的供体构建版本)中匹配类恢复出的类名,为 **apk2**(被混淆/剥离的构建版本)写入 ProGuard 格式的 `mapping.txt`。将其加载到 JADX/Ghidra 中,重命名操作会自动传播到每一个引用 —— twinflame 永远不会重写 DEX。
```
# apk1 = 保留了 SourceFile 的 older build;apk2 = 你想要读取的版本
twinflame app-old.apk app-new.apk \
--find-obfuscated --deobfuscation-map app-new.map --map-min-confidence 0.8
```
使用 `-Prename-mappings.invert=yes` 加载到 JADX 中:
```
jadx --mappings-path app-new.map -Prename-mappings.format=PROGUARD_FILE -Prename-mappings.invert=yes \
-d out_deobf app-new.apk
```
必须使用 `invert=yes`:我们的文件遵循标准的 ProGuard 约定(` -> :`,即 `retrace` 使用的格式),但 jadx 的 `PROGUARD_FILE` 读取器将左侧视为*当前在二进制文件中*的名称 —— 正好相反。如果不加这个标志,它什么也不会重命名(也不会报错)。在 jadx-gui 中也是如此:在映射路径旁勾选“Invert”。
- 恢复类的**简单名称**(`ContextCompat.java` → `x6.q` 会变成 `x6.ContextCompat`);混淆的**包**和内部类结构将被保留(源文件不携带包信息)。
- 锚定 / 完全匹配(`distance == 1.0`)是受信任的;较低置信度的匹配仍然会被输出,但会被加上 `# low-confidence` 的注释标志。可以通过 `--map-min-confidence` 调整下限。
- 相比于单 APK 的源文件去混淆,这是跨版本的超级武器:它利用保留了 `SourceFile` 的构建版本,为**剥离了** `SourceFile` 的构建版本中的类命名。方法/字段名的传播是计划中的下一步。
### 工作流秘诀
- **补丁识别。** 使用 `--package PREFIX` 或 `--auto-package` 限定范围(可大幅缩小比较集),由于 Bug 修复是微小的改动,可使用 `--threshold 0.8`。跳过 `distance == 1.0` 的匹配。
- **被模改 / 重打包的应用。** 去掉 `--package`(模组通常存在于捆绑的 SDK 中,而不是厂商包里)。如果你看到大量“一切都变了”的 ~0.95 相似度的匹配,请使用 `--normalize` 运行 — 那就是垃圾指令混淆的标志。
- **经过重度 Proguard 处理的输入。** 添加 `--find-obfuscated`,这样单字母包名(`a.b.c`)就会折叠进后备池,而不会导致与其明文对应项配对失败。
- **对被剥离的构建版本进行去混淆。** 将其与保留了 `SourceFile` 的旧版本进行比对,添加 `--find-obfuscated --deobfuscation-map out.map`,然后在 JADX 中加载 `out.map`。
### 评估测试工具(`eval/`,计划 M3.2)
根据真实的 ground truth 对 twinflame 自身的类匹配进行评分,而不是凭肉眼观察。构建一个开源 Android 应用**两次** — 一次关闭 R8,另一次在完全优化下开启 R8 — R8 为开启 R8 的构建版本写入的 `mapping.txt` 是针对仅重命名情况的免费、完美的 oracle(直接评估 M1.1 + M1.2;根据 M3.1,内联/ outlining 是未来难度分级的切片)。
```
python -m eval.cli app-r8-off.apk app-r8-on.apk app-r8-on/mapping.txt --package com.vendor.app
```
打印真/假阳性与阴性类匹配计数,以及精确率/召回率/F1。`eval/mapping.py` 解析 ProGuard 格式的 oracle(与 `deobf.py` 输出的格式相同);`eval/score.py` 暴露了 `score_class_matches(matches, ground_truth) -> ScoreResult`,可作为库独立于 CLI 使用。它被特意排除在 `src/twinflame` 包之外 — 它是用来评估引擎的工具,而不是引擎的一部分。
## 布局
| 模块 | 角色 |
| ---------------------------------------------------- | -------------------------------------------------------------------- |
| [src/twinflame/model.py](src/twinflame/model.py) | 数据类:`App`, `Class`, `Method`, `Field`, `Signature`, `Match`, `MethodMatch` |
| [src/twinflame/loader.py](src/twinflame/loader.py) | 封装 androguard + 多 DEX 合并;捕获调用/字符串/交叉引用 |
| [src/twinflame/manifest.py](src/twinflame/manifest.py) | `AndroidManifest.xml` → `ManifestInfo`;开发包建议 |
| [src/twinflame/normalize.py](src/twinflame/normalize.py) | Redex 子进程封装 |
| [src/twinflame/cluster.py](src/twinflame/cluster.py) | 基于包的池 + 熵/长度混淆后备机制 |
| [src/twinflame/anchor.py](src/twinflame/anchor.py) | 阶段 B:字符串 IDF + 框架调用种子匹配 |
| [src/twinflame/signature.py](src/twinflame/signature.py) | 128 位 SimHash + LSH 桶索引 |
| [src/twinflame/accurate.py](src/twinflame/accurate.py) | 抽象操作码、方法级 + 类评分、贪心 1 对 1 分配 |
| [src/twinflame/opcodes.py](src/twinflame/opcodes.py) | Dalvik 操作码 → 13 类别查找表 |
| [src/twiname/\_hot.py](src/twinflame/_hot.py) | 热点循环(popcount, Hamming, 基于 rapidfuzz 的 Levenshtein) |
| [src/twinflame/deobf.py](src/twinflame/deobf.py) | 跨版本去混淆:恢复的名称 → ProGuard mapping.txt |
| [src/twinflame/api.py](src/twinflame/api.py) | 公开的 `load / filter / diff` 入口点 |
| [src/twinflame/cli.py](src/twinflame/cli.py) | `twinflame` 控制台脚本 |
## 测试
```
nix develop --command pytest tests/
```
目前:**86 个测试通过**,0 个跳过。Redex 已在开发 shell 中构建并配置在 PATH 中。
测试夹具是**纯合成的**:`tests/fixtures/synthetic.py` 直接通过 Python 构建 `Class` 对象,不需要将二进制文件提交到 git。集成测试在这些对象上执行完整的差异比对 pipeline(cluster → anchor → signature → accurate → api)。
目前针对磁盘上的 DEX/APK 往返测试(loader.py 端到端)仅有一个冒烟测试;位于 [tests/fixtures/build_dex.py](tests/fixtures/build_dex.py) 的真实二进制 DEX 生成器已作为占位符留待未来工作实现。
## 已知局限性
- **相同结构的类会发生冲突。** 如果在池中有许多类共享相同的签名(简单的 getter/setter 类、生成的存根),则可能会通过贪心分配产生错误的配对。`--min-instr 5` 可过滤掉无关紧要的类;提高 `--neighbors` 可扩大候选池。
- **无继承上下文。** 匹配是按类进行的(现在在配对类内部提供了方法级的细节),但忽略了第一级的父/子信号(参见 LibPecker);这些可以在以后添加,而不会干扰核心逻辑。
- **尚无跨边界 / 优化弹性。** 匹配假设类和方法存在 1:1 的对应关系。破坏这一假设的 R8 *优化* — 内联、 outlining、类合并 — 是计划中的未来方向(参见 `twinflame-next-dev-plan.md`,M3.1),目前尚未实现。
- **JNI / native 代码的更改是不可见的。** 差异比对纯粹处于 Dalvik 层 — 原生库的变异需要互补的 native 代码差异比对工具。
- **LSH 在设计上就是近似的。** `--buckets N` 可用于调节准确度/速度的旋钮。
## 许可证
MIT。
标签:DEX分析, 云资产清单, 代码比对, 去混淆, 安卓应用分析, 目录枚举, 移动安全, 逆向工具, 逆向工程