Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,126 @@

It takes an APK (or `libapp.so`) and emits readable pseudo-Dart plus optional IR/ASM artifacts.

## See The Pipeline

The goal of these examples is simple: show original public source first, then show what `flutterdec` recovers from the shipped APK.

<p align="center"><strong>Original 1: Android Startup Surface</strong></p>

<p align="center">
<img src="docs/assets/readme/hivpn-mainactivity.svg" alt="hiVPN MainActivity source snippet" width="900">
</p>

<p align="center"><strong>Original 2: App-Side Flutter / Dart Surface</strong></p>

<p align="center">
<img src="docs/assets/readme/hivpn-flutter-bridge.svg" alt="hiVPN Flutter bridge source snippet" width="900">
</p>

**Compare 1: Startup Source -> Recovered Startup Path**

Source app: `hiVPN v1.0.0` released on October 29, 2025. `MainActivity` and the manifest launcher are public in the repository:
- Source repo: https://github.com/Mr-Dark-debug/hivpn
- Release APK: https://github.com/Mr-Dark-debug/hivpn/releases/tag/release

`Original`

The app enters Flutter from `MainActivity.onCreate`. The second source card shows the app-side Flutter bridge that exposes `MethodChannel('com.example.vpn/VpnChannel')` to Dart code.

<p align="center"><strong>Recovered 1: APK Startup Report</strong></p>

<p align="center">
<img src="docs/assets/readme/hivpn-startup-report.svg" alt="hiVPN startup report excerpt recovered by flutterdec" width="900">
</p>

`Recovered`

`flutterdec` parsed the APK manifest, recovered `com.example.hivpn.MainActivity` as the launcher, and correlated the startup chain from `MainActivity.onCreate` into Flutter JNI bootstrap calls such as `attachToNative` and `nativeAttach`.

**Compare 2: App Source Using Flutter APIs -> Recovered Named Selectors**

Source app: `ZedSecure v1.2.0`
- Source repo: https://github.com/CluvexStudio/ZedSecure
- Release APK: https://github.com/CluvexStudio/ZedSecure/releases/tag/v1.2.0

This is the comparison you asked for: public app source that uses Flutter APIs, then recovered APK artifacts where `flutterdec` keeps recognizable Dart/Flutter selector names instead of only anonymous control flow.

<p align="center"><strong>Original Source</strong></p>

<p align="center">
<img src="docs/assets/readme/zedsecure-minwidth-source.svg" alt="Original ZedSecure UI source using BoxConstraints(minWidth: 50)" width="900">
</p>

This source is ordinary app UI code. It builds a ping badge with `BoxConstraints(minWidth: 50)` and other Flutter widget APIs inside the shipped app.

<p align="center"><strong>Recovered 2: From The ZedSecure APK With flutterdec</strong></p>

<p align="center"><strong>↓ ARM64</strong></p>

At the machine-code layer, the APK still looks like indirect selector dispatch through pool-loaded metadata and call targets.

<p align="center">
<img src="docs/assets/readme/zedsecure-minwidth-asm.svg" alt="ARM64 snippet from the recovered ZedSecure function showing the minWidth selector pool load" width="900">
</p>

<p align="center">
<strong>↓ Function IR</strong>
</p>

<p align="center">
<img src="docs/assets/readme/zedsecure-minwidth-ir.svg" alt="IR summary for the recovered ZedSecure minWidth selector flow" width="900">
</p>

The IR stage makes the selector-bearing pool values explicit before readability passes.

<p align="center">
<strong>↓ Pseudocode</strong>
</p>

<p align="center">
<img src="docs/assets/readme/zedsecure-minwidth-pseudocode.svg" alt="Recovered pseudocode with named Flutter selectors from the ZedSecure APK" width="900">
</p>

The important part is not the anonymous function name. The important part is that `flutterdec` surfaced readable Flutter selector names from the APK itself, including `dispatch.minWidth(...)`, `dispatch.messageMap(...)`, and the framework-side `flutter.foundation.invoke(...)`.

This gives the README both views the tool is meant to show publicly:
- Android startup recovery from the APK surface
- app-side Flutter selector recovery from the AOT payload

**What this proves**

- `flutterdec` can preserve recognizable Flutter/Dart utility selectors inside app-owned recovered code
- selector-bearing pool metadata survives from asm to IR to pseudocode
- the pipeline is inspectable at every stage: asm, IR, and pseudocode

<p align="center">
<img src="docs/assets/readme/localsend-diff.svg" alt="LocalSend diff summary across two public releases" width="900">
</p>

**Case 3: Original Release A -> Original Release B -> Recovered Diff**

Source app: `LocalSend`
- `v1.16.1` released on November 5, 2024
- `v1.17.0` released on February 20, 2025
- Releases: https://github.com/localsend/localsend/releases

`Original`

Two public release APKs from the same app line.

`Recovered`

`flutterdec diff` compared the two arm64 APKs directly and emitted added/removed/common function summaries plus package-level change counts. This is useful when you care more about what changed between versions than about reconstructing a single function in isolation.

## What You Inspect

| Need | Artifact | Why it matters |
| --- | --- | --- |
| Recover readable logic | `pseudocode/*.dartpseudo` | Best first-pass view of branches, loops, returns, and named callsites |
| Validate decompiler decisions | `asm/*.s` and `ir/*.json` | Lets you confirm control-flow shape when pseudocode gets suspicious |
| Trace Flutter startup | `report.json.android_startup` | Surfaces manifest anchors, embedder stages, and recovered `DartEntrypoint` evidence |
| Audit analysis quality | `quality.json` and `report.json` | Shows coverage, compatibility, target selection, and symbol-ingestion diagnostics |

## North Star

Recover readable behavior from Flutter AOT ARM64 binaries with enough semantic structure that reverse engineering decisions can be made from pseudocode and reports.
Expand Down
5 changes: 5 additions & 0 deletions docs/assets/readme-src/arm64-loop.s
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
0x000000000000d000 b #0xd004
0x000000000000d004 add x0, x0, #1
0x000000000000d008 cbnz x0, #0xd010
0x000000000000d00c b #0xd004
0x000000000000d010 ret
15 changes: 15 additions & 0 deletions docs/assets/readme-src/dart-string-whitespace.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
static bool _isTwoByteWhitespace(int codeUnit) {
if (codeUnit <= 32) {
return (codeUnit == 32) || ((codeUnit <= 13) && (codeUnit >= 9));
}
if (codeUnit < 0x85) return false;
if ((codeUnit == 0x85) || (codeUnit == 0xA0)) return true;
return (codeUnit <= 0x200A)
? ((codeUnit == 0x1680) || (0x2000 <= codeUnit))
: ((codeUnit == 0x2028) ||
(codeUnit == 0x2029) ||
(codeUnit == 0x202F) ||
(codeUnit == 0x205F) ||
(codeUnit == 0x3000) ||
(codeUnit == 0xFEFF));
}
8 changes: 8 additions & 0 deletions docs/assets/readme-src/decompiled-loop.dartpseudo
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
dynamic goldenSimpleLoop(int receiver, dynamic param1, dynamic param2, dynamic param3, dynamic param4, dynamic param5, dynamic param6, dynamic param7) {
while (true) {
if ((receiver + 1) != 0) {
return (receiver + 1);
}
continue;
}
}
14 changes: 14 additions & 0 deletions docs/assets/readme-src/hivpn-asm-real.s
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
0x30580c: tbnz x0, #0x3f, #0x30591c
0x305810: cmp w3, #0xbc
0x305814: b.ne #0x305824
0x30582c: cmp x4, #0x5e
0x305830: b.ne #0x30586c
0x305834: cmp x2, #0x20
0x305840: b.eq #0x305910
0x30584c: cmp x2, #9
0x305850: b.ge #0x305910
0x305858: cmp x2, #0x85
0x305864: b.eq #0x305910
0x305910: sub x2, x0, #1
0x305918: b #0x305800
0x30591c: ret
15 changes: 15 additions & 0 deletions docs/assets/readme-src/hivpn-flutter-bridge.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.example.vpn/VpnChannel",
)
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"prepare" -> handlePrepare(result)
"getInstalledApps" -> result.success(fetchInstalledApps())
"elapsedRealtime" -> result.success(SystemClock.elapsedRealtime())
else -> result.notImplemented()
}
}
}
17 changes: 17 additions & 0 deletions docs/assets/readme-src/hivpn-ir-real.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
FunctionIr sub_3057dc @ 0x3057dc

bb1 -> bb2, bb35
branch b.ls #0x305920

bb2 -> bb3, bb34
branch tbnz x0, #0x3f, #0x30591c

bb6 -> bb7, bb15
branch b.ne #0x30586c

bb7 / bb15
compare codePoint against 0x20, 0x85, 0xa0
loop back to 0x305800 for whitespace-like cases

bb34
return ret
12 changes: 12 additions & 0 deletions docs/assets/readme-src/hivpn-mainactivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIntentAction(intent)
}

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.example.vpn/VpnChannel",
)
}
22 changes: 22 additions & 0 deletions docs/assets/readme-src/hivpn-pseudocode-real.dartpseudo
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
dynamic sub_3057dc(dynamic receiver, dynamic param1, dynamic param2, int value3, dynamic param4, dynamic param5, dynamic param6, dynamic param7) {
final int codePoint = (value3 - 1);
bool retryLoop1 = true;
while (retryLoop1) {
if (((codePoint >> 0x3f) & 1) != 0) {
return codePoint;
}
if ((classId(param1) << 1) == 0xbc) {
if (codePoint > 0x20) {
if ((codePoint == 0x85) || (codePoint == 0xa0)) {
continue;
}
return codePoint;
}
if ((codePoint == 0x20) || ((codePoint >= 9) && (codePoint <= 0xd))) {
continue;
}
return codePoint;
}
retryLoop1 = false;
}
}
14 changes: 14 additions & 0 deletions docs/assets/readme-src/hivpn-startup-report.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"package_name": "com.example.hivpn",
"launcher_activity": "com.example.hivpn.MainActivity",
"startup_path": [
"MainActivity.onCreate",
"R2.d.onCreate",
"S2.g.a",
"S2.c.<init>",
"FlutterJNI.attachToNative",
"FlutterJNI.performNativeAttach",
"FlutterJNI.nativeAttach"
],
"confidence": "high"
}
14 changes: 14 additions & 0 deletions docs/assets/readme-src/ir-loop.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FunctionIr goldenSimpleLoop @ 0xd000

bb0 -> bb1
jump b #0xd004

bb1 -> bb2, bb3
other add x0, x0, #1
branch cbnz x0, #0xd010

bb2 -> bb1
jump b #0xd004

bb3
return ret
11 changes: 11 additions & 0 deletions docs/assets/readme-src/localsend-diff.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"old_release": "LocalSend 1.16.1",
"new_release": "LocalSend 1.17.0",
"snapshot_hash_match": true,
"old_function_count": 5581,
"new_function_count": 5800,
"common_function_count": 19,
"added_function_count": 5781,
"removed_function_count": 5562,
"top_changed_package": "markdown"
}
8 changes: 8 additions & 0 deletions docs/assets/readme-src/reference-loop.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
int? incrementUntilNonZero(int receiver) {
while (true) {
final next = receiver + 1;
if (next != 0) {
return next;
}
}
}
15 changes: 15 additions & 0 deletions docs/assets/readme-src/zedsecure-minwidth-asm.s
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
0x460358: add x16, x3, x2, lsl #2
0x46035c: ldur w4, [x16, #0xf]
0x460360: add x4, x4, x28, lsl #32
0x460364: mov x0, x4
0x460368: ldur x2, [x29, #-8]
0x46036c: stur x4, [x29, #-0x28]
0x460370: mov x1, x22
0x460374: cmp w2, w22
0x460378: b.eq #0x460394
0x46037c: ldur w4, [x2, #0x1b]
0x460380: add x4, x4, x28, lsl #32
0x460384: ldr x8, [x27, #0x650] ; pool[1616]
0x460388: ldur x9, [x4, #7]
0x46038c: ldr x3, [x27, #0x658] ; pool[1624] = "minWidth"
0x460390: blr x9
11 changes: 11 additions & 0 deletions docs/assets/readme-src/zedsecure-minwidth-ir.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
bb2:
pool[1576] -> "_messageMap"
selector dispatch -> dispatch.messageMap(...)

bb3:
pool[1600] -> "_hasDecoration"
framework library call -> flutter.foundation.invoke(...)

bb10:
pool[1624] -> "minWidth"
selector dispatch -> dispatch.minWidth(...)
24 changes: 24 additions & 0 deletions docs/assets/readme-src/zedsecure-minwidth-pseudocode.dartpseudo
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
if ((obj1.f7) == null) {
final t2 = flutter.foundation.invoke(
objTmp1,
null,
objTmp3,
"_hasDecoration",
);

...

final t5 = dispatch.minWidth(
((((objTmp2.f15) + t4 /* lsl #2 */)).f15),
null,
objTmp3,
"minWidth",
);
} else {
final t2 = dispatch.messageMap(
param2,
null,
(obj1.f7),
"_messageMap",
);
}
16 changes: 16 additions & 0 deletions docs/assets/readme-src/zedsecure-minwidth-source.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Widget _buildPingBadge(int? ping) {
if (ping == null) return const SizedBox(width: 50);

return Container(
constraints: const BoxConstraints(minWidth: 50),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppTheme.getPingColor(ping).withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text(
ping >= 0 ? '${ping}ms' : 'Fail',
textAlign: TextAlign.center,
),
);
}
23 changes: 23 additions & 0 deletions docs/assets/readme/dart-string-whitespace.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading