Skip to content

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:

Dart source → Kernel IR → Dart AOT compiler → Native ARM instructions + Object Pool → libapp.so

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:

  1. Alloc phase -- allocates space for each serialized cluster (group of objects of the same type)
  2. 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
unzip -l target.apk | grep -E "(libflutter|libapp|flutter_assets)"

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:

strings lib/arm64-v8a/libflutter.so | grep -E "^[0-9]+\.[0-9]+\.[0-9]+"

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:

  1. Detects the Dart SDK version from libflutter.so
  2. Downloads and compiles the matching Dart SDK (cached for reuse)
  3. Parses libapp.so using the Dart VM's own deserialization code
  4. 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:

  1. Load libapp.so in Ghidra
  2. Apply blutter's output as labels/comments at function addresses
  3. Use the pool register (x27 on ARM64) to trace string and constant references
  4. Cross-reference pool entries from pp.txt with 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:

pip install reflutter
reflutter target.apk

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:

  1. Open libflutter.so in Ghidra
  2. Search for the ssl_crypto_x509_session_verify_cert_chain function (look for x509 string references)
  3. Patch the return value to always be 1 (MOV W0, #1; RET)
  4. 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:

flutter build apk --obfuscate --split-debug-info=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.so at 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