Flutter¶
Flutter apps compile Dart source code ahead-of-time (AOT) into native ARM instructions, packaged as libapp.so. The standard Android reverse engineering pipeline -- jadx, apktool, smali -- produces nothing useful because the malicious logic never touches Dalvik bytecode. The entire app runs inside Flutter's custom engine (libflutter.so), which embeds the Dart VM, a Skia-based rendering engine, and BoringSSL for networking. Flutter is Google's open-source UI toolkit, and its AOT compilation model has made it increasingly attractive to malware authors seeking a "free" obfuscation layer.
Architecture¶
A release-mode Flutter APK contains three layers:
| Layer | Component | Contents |
|---|---|---|
| Java shell | io.flutter.embedding.* |
Minimal bootstrap -- initializes the Flutter engine, nothing else |
| Flutter engine | libflutter.so |
Dart VM, Skia renderer, BoringSSL, text/input handling (~10-15 MB) |
| App payload | libapp.so |
AOT-compiled Dart code -- all business logic, UI, networking |
The Java/Kotlin layer is a thin wrapper. Decompiling it with jadx reveals only Flutter engine initialization code. All meaningful analysis targets libapp.so.
Dart AOT Compilation¶
In release builds, the Dart compiler performs ahead-of-time compilation through this pipeline:
The output is not a standard native shared library with exported symbols. Instead, libapp.so is an ELF file containing a Dart snapshot -- a serialized representation of compiled code and data designed to be loaded by the Dart VM at startup.
libapp.so ELF Structure¶
libapp.so exports exactly four symbols:
| Symbol | Purpose |
|---|---|
_kDartVmSnapshotData |
VM-level type system metadata, core library structures |
_kDartVmSnapshotInstructions |
Compiled machine code for VM internals |
_kDartIsolateSnapshotData |
Application object pool -- strings, constants, class metadata |
_kDartIsolateSnapshotInstructions |
Compiled machine code for application Dart functions |
The SnapshotInstructions sections contain native ARM64 code. The SnapshotData sections contain serialized Dart objects: strings, integer arrays, type descriptors, and references linking code to data.
Pool Register Convention
Dart AOT code accesses the object pool through a dedicated register. On ARM64, register x27 holds the pool pointer (PP). On ARM32, it is r5. On x64, r15. Recognizing pool-relative loads is essential for resolving string references and constant values in Ghidra or IDA.
Snapshot Format¶
The Dart snapshot uses a two-phase serialization model:
- Alloc phase -- allocates space for each serialized cluster (group of objects of the same type)
- Fill phase -- populates field values: string bytes, reference IDs, integer scalars
The format is identified by a snapshot version string, generated by hashing the source code of snapshot-related files in the Dart SDK. This means the format changes whenever the serialization code changes -- not on a predictable schedule.
No Random Access
The snapshot must be parsed sequentially from the beginning. There is no index or offset table for individual classes. Locating a specific class requires walking every cluster before it. This is a deliberate design choice for serialization speed, not an anti-RE measure, but it has the same practical effect.
Identification¶
| Indicator | Location |
|---|---|
libflutter.so |
lib/<arch>/libflutter.so -- Flutter engine |
libapp.so |
lib/<arch>/libapp.so -- compiled Dart payload |
flutter_assets/ |
assets/flutter_assets/ -- fonts, images, asset manifests |
kernel_blob.bin |
assets/flutter_assets/kernel_blob.bin -- debug builds only (contains Dart kernel IR) |
io.flutter.* |
Package prefix in DEX (thin bootstrap only) |
io.flutter.embedding.engine.FlutterEngine |
Engine initialization class |
Dart SDK Version Detection¶
The Dart SDK version determines the snapshot format and dictates which tools can parse it. Extract the version from libflutter.so:
This typically returns a version string like 3.3.10 or 3.5.4. The flutter-versions repository maps Flutter versions to Dart SDK snapshot hashes and engine commits.
Code Location & Extraction¶
Extracting libapp.so¶
unzip target.apk lib/arm64-v8a/libapp.so -d extracted/
unzip target.apk lib/arm64-v8a/libflutter.so -d extracted/
Both files are needed: libflutter.so for version detection and libapp.so for analysis.
Debug Builds¶
Debug builds include kernel_blob.bin in assets/flutter_assets/. This file contains the Dart Kernel IR (an intermediate representation) and can be decompiled back to near-source Dart using the Dart SDK's gen_snapshot tools. Debug builds are rare in production but occasionally appear in early-stage malware samples.
Analysis Tools & Workflow¶
| Tool | Purpose | Notes |
|---|---|---|
| blutter | Dart AOT snapshot analysis, symbol recovery, annotated disassembly | ARM64 only, auto-detects Dart version |
| reFlutter | SSL bypass via patched libflutter.so, snapshot extraction |
Version-dependent patched engines |
| darter | Python snapshot parser, 100% data parsing | Research tool, less practical output |
| unflutter | Static analyzer for AOT snapshots | ELF parsing + cluster extraction |
| Ghidra | Native ARM64 disassembly of libapp.so |
Requires blutter output for symbol import |
| IDA Pro | Alternative native disassembler | JEB Dart AOT plugin available |
| JEB | Dart AOT snapshot helper plugin | Commercial, integrates snapshot metadata |
| Frida | Runtime hooking of Dart functions | Hook at addresses from blutter output |
blutter Workflow¶
blutter is the primary tool for Flutter reverse engineering. It works by compiling a Dart AOT snapshot dumper against the exact Dart SDK version used to build the target app, then using the Dart VM's internal APIs to parse the snapshot.
git clone https://github.com/worawit/blutter.git
cd blutter
python3 blutter.py path/to/lib/arm64-v8a/
blutter automatically:
- Detects the Dart SDK version from
libflutter.so - Downloads and compiles the matching Dart SDK (cached for reuse)
- Parses
libapp.sousing the Dart VM's own deserialization code - Outputs annotated assembly with recovered symbols
Output structure:
blutter_output/
├── asm/ # Annotated ARM64 assembly per library
│ ├── dart:core/
│ ├── package:flutter/
│ └── package:app_name/
├── pp.txt # Object pool dump (strings, constants)
├── objs.txt # Serialized object listing
└── blutter_frida.js # Auto-generated Frida hook stubs
The asm/ directory contains per-library disassembly with class names, method names, and pool references resolved. The pp.txt file dumps all object pool entries -- search it for API endpoints, encryption keys, and C2 URLs.
Dart SDK Version Matching
blutter must compile against the exact Dart SDK version. If the version detection fails (custom Flutter engine builds, stripped version strings), use --dart-version X.X.X_android_arm64 to specify manually. The flutter-versions repository maps snapshot hashes to SDK versions.
Ghidra Integration¶
After running blutter, import the annotated data into Ghidra:
- Load
libapp.soin Ghidra - Apply blutter's output as labels/comments at function addresses
- Use the pool register (
x27on ARM64) to trace string and constant references - Cross-reference pool entries from
pp.txtwith Ghidra's disassembly
The Phrack issue 71 article "Reversing Dart AOT snapshots" covers the snapshot format internals, pool register conventions, and analysis techniques in depth.
SSL Pinning Bypass¶
Flutter does not use Android's Java-layer TLS stack. It bundles BoringSSL (Google's OpenSSL fork) directly into libflutter.so, meaning standard Java-layer Frida hooks (OkHttp, TrustManager) do not work.
reFlutter Method¶
reFlutter provides pre-patched libflutter.so binaries with the certificate verification function neutralized:
reFlutter patches ssl_crypto_x509_session_verify_cert_chain in BoringSSL to always return true, then repackages the APK with the modified engine. The app accepts any certificate, allowing proxy interception.
Version Matching Required
reFlutter must have a pre-built patched engine matching the target's Flutter version. If the exact version is unavailable, the patched binary may crash or produce ABI mismatches. Check reFlutter releases for supported versions.
Frida Native Hook Method¶
When reFlutter lacks a matching version, hook the BoringSSL verification function directly in libflutter.so:
function findVerifyFunction() {
var dominated = Process.findModuleByName("libflutter.so");
var pattern = "FF 03 05 D1 FD 7B 0F A9 F4 4F 0E A9";
var matches = Memory.scanSync(dominated.base, dominated.size, pattern);
if (matches.length > 0) {
return matches[0].address;
}
return null;
}
var verifyAddr = findVerifyFunction();
if (verifyAddr) {
Interceptor.attach(verifyAddr, {
onLeave: function(retval) {
retval.replace(0x1);
}
});
console.log("[Flutter] SSL verification bypassed at " + verifyAddr);
}
The byte pattern targets the prologue of ssl_crypto_x509_session_verify_cert_chain. This pattern varies across Flutter engine versions -- the SensePost guide documents the approach for locating the function when patterns change.
BoringSSL Binary Patching¶
Permanently patch libflutter.so in the APK:
- Open
libflutter.soin Ghidra - Search for the
ssl_crypto_x509_session_verify_cert_chainfunction (look for x509 string references) - Patch the return value to always be 1 (MOV W0, #1; RET)
- Save, repackage APK, re-sign
This avoids runtime hooking entirely but requires re-signing the APK.
Hooking Strategy¶
Flutter hooking targets native function addresses in libapp.so, not Java methods. blutter provides the addresses.
Using blutter-Generated Frida Stubs¶
blutter outputs blutter_frida.js with pre-generated hooks for discovered functions:
var libapp = Module.findBaseAddress("libapp.so");
Interceptor.attach(libapp.add(0x1A2B3C), {
onEnter: function(args) {
console.log("[Dart] LoginService.authenticate called");
console.log(" arg0 (this): " + args[0]);
},
onLeave: function(retval) {
console.log(" return: " + retval);
}
});
Replace 0x1A2B3C with the actual offset from blutter's output for the target function.
Intercepting HTTP Requests¶
Dart's HttpClient ultimately calls through dart:io functions compiled into libapp.so. Hook the HTTP connection setup:
var libapp = Module.findBaseAddress("libapp.so");
var symbols = blutterOutput.httpClientSymbols;
symbols.forEach(function(sym) {
Interceptor.attach(libapp.add(sym.offset), {
onEnter: function(args) {
console.log("[HTTP] " + sym.name + " called");
}
});
});
String Pool Monitoring¶
Monitor object pool accesses to capture runtime string usage:
var libapp = Module.findBaseAddress("libapp.so");
var poolBase = libapp.add(0xPOOL_OFFSET);
Interceptor.attach(libapp.add(0xTARGET_FUNC), {
onEnter: function(args) {
var poolPtr = this.context.x27;
console.log("[Pool] PP register: " + poolPtr);
}
});
Obfuscation & Anti-Analysis¶
Inherent Obfuscation¶
Flutter's AOT compilation provides significant obfuscation by default:
| Property | Effect on RE |
|---|---|
| No DEX code | jadx, apktool, smali produce nothing useful |
| Stripped symbols | Release builds strip function names from libapp.so |
| Custom snapshot format | Standard ELF analysis tools cannot parse the data sections |
| Version-coupled format | Snapshot format changes with each Dart SDK version |
| Sequential parsing | No random access to classes within the snapshot |
Dart Obfuscation Flag¶
Flutter's build system supports the --obfuscate flag combined with --split-debug-info:
This replaces Dart class and method names with short random identifiers. blutter will still recover the structure (class hierarchy, method signatures) but not the original names.
Commercial Protection¶
- Guardsquare DexGuard -- supports Flutter apps, adds native code encryption and integrity checks
- Promon SHIELD -- runtime application self-protection wrapping Flutter APKs
- Custom native wrappers -- some apps add an additional native layer that decrypts
libapp.soat runtime
Anti-Debugging¶
Flutter apps can detect debugging through the Dart assert mechanism (stripped in release) and native anti-debug checks in custom libraries. Standard Frida anti-detection bypasses apply.
Malware Context¶
Flutter's AOT compilation has drawn malware authors who want to evade DEX-based scanning without investing in custom packers.
FluHorse¶
FluHorse is the most documented Flutter malware family. First reported by Check Point Research in May 2023, it targets East Asian users (Taiwan, Vietnam) with fake toll collection and banking apps.
| Aspect | Details |
|---|---|
| Business logic | Entirely in Dart, compiled to libapp.so |
| Capability | Credential theft, credit card harvesting, SMS interception |
| C2 protocol | HTTP POST to hardcoded endpoints (e.g., /addcontent3) |
| Evolution | Unpacked samples in May 2023, packed samples by June 2023 |
| Analysis reference | Fortinet reverse engineering analysis, Virus Bulletin 2024 presentation |
FluHorse Analysis Approach
Run blutter on FluHorse's libapp.so to recover Dart class names. Search the object pool (pp.txt) for HTTP endpoints and credential-related strings. The malware's Dart code is straightforward -- no Dart-level obfuscation was applied in early samples.
Why Flutter Appeals to Malware Authors¶
- Free obfuscation -- jadx and standard scanners produce nothing from
libapp.so - Cross-platform -- single Dart codebase targets Android and iOS
- Rapid UI cloning -- Flutter's widget system makes it easy to replicate legitimate banking app interfaces for phishing
- Low DEX footprint -- Android manifest and DEX contain only Flutter bootstrap code, evading signature-based detection focused on Dalvik
RE Difficulty Assessment¶
| Aspect | Rating |
|---|---|
| Code format | Native ARM64 + Dart snapshot (no DEX) |
| Tool maturity | Moderate -- blutter is effective but requires exact SDK matching |
| Symbol recovery | Good with blutter (class names, method names, pool strings) |
| Control flow | Readable after blutter annotation; raw Ghidra analysis is painful |
| String extraction | Easy from object pool dump |
| SSL bypass | Moderate -- requires reFlutter or native patching (Java hooks useless) |
| Patching | Difficult -- requires binary patching of ARM64 code |
| Overall difficulty | Hard (rank 24/28) |
The primary bottleneck is the Dart SDK version matching requirement. If blutter successfully compiles against the target's SDK version, analysis proceeds smoothly with recovered symbols and annotated assembly. Without blutter, the analyst faces a stripped ARM64 binary with custom calling conventions, pool-relative data access, and no standard symbol table -- effectively the same difficulty as any stripped native binary, but with less documentation.
References¶
- blutter -- Flutter RE Tool
- reFlutter -- SSL bypass and snapshot extraction
- Phrack #71: Reversing Dart AOT snapshots
- OWASP MASTG: Reverse Engineering Flutter Applications
- Guardsquare: Current State and Future of Reversing Flutter Apps
- SensePost: Intercepting HTTPS Communication in Flutter
- Minded Security: Bypassing Certificate Pinning on Flutter-based Android Apps
- JEB Dart AOT Snapshot Helper Plugin
- ping's cookbook: Dart SDK and Snapshots
- B(l)utter -- HITB 2023 Presentation
- flutter-versions: SDK to snapshot hash mapping