От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!
От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!
Maxym Radchuk
/
Flutter developer
11 min read
A hands-on walkthrough of Flutter APK static analysis, AOT snapshot decompilation with B(l)utter, and the prevention techniques that actually hold up in production.
Have you ever asked yourself whether the .env file, API keys, or premium content inside your Flutter app are actually safe — or just assumed to be? Most teams treat the compiled APK as a black box. It is not. With a laptop and a couple of open-source tools, a reverse engineer can pull strings, function addresses, class layouts, and hardcoded secrets out of a release Flutter build in minutes.
This article walks through what a Flutter app looks like from a reverse engineering perspective: what an attacker can read straight out of an APK without decompiling anything, what they pull out of libapp.so once they do, and — most importantly — which prevention techniques actually work. The findings below come from third-party production apps we reverse-engineered as part of independent security research — not apps we built. One of them is a banking app with more than one million downloads that we discovered was leaking sensitive keys in plain text.
Article content
Static analysis of a Flutter APK without decompilation
How Flutter compiles your app (JIT vs AOT)
Decompiling a Flutter app with B(l)utter
Static analysis is the process of inspecting an app’s files without running the code. For a Flutter app, you do not need to understand the Flutter internals to start. A free tool called apktool unpacks an APK into its raw components — resources, manifests, and native libraries — and that alone can reveal a surprising amount of data that developers assume is safely hidden inside the binary.
Once you have an APK from a build server or extracted from a device, the command is:
apktool d suspect.apk
In our case, for the purposes of this walkthrough, we are working with a sample APK we built from source ourselves, so no extraction step is needed. If you want to pull an APK from an installed app on a real device, you can extract it first with adb. Unpacking produces a folder of resources and code. The main areas of interest for static analysis:
<meta-data android:name="com.google.android.geo.API_KEY"
android:value="key"/>
One of the most striking finds from our reverse engineering research was a .env file shipped inside the assets of a third-party banking app with more than one million downloads. We did not build this app — we only analysed it from the outside, the same way any attacker could. It contained sensitive keys that were never meant to be public, and the cause was a single mistake by the app’s original developers: they used flutter_dotenv and ignored its own documentation warning:
Values loaded by this package should be treated as public client-side configuration.
In other words: anything that ends up in an asset bundle is public, period. The package can be useful for non-secret runtime configuration, but it should never be used to hide credentials.
Another common assumption is that data stored in an app’s private folder (/data/data/<package>/) is safe. That is true — on a non-rooted device. On a rooted device, Android’s sandbox restrictions do not apply the same way, and anyone with root access can pull the entire data directory using standard adb tools:
adb root
adb pull /data/data/com.example.app/ ./app_data/
This captures cached files, SQLite databases, shared preferences — including any “locked” or premium content you assumed was protected by the file system.
There is a whole catalogue of static analysis techniques and prevention strategies in the OWASP Mobile Application Security project, which is the de facto reference for what to test and how to defend against it.
Before decompiling anything, it helps to understand how Dart code becomes the binary that ships in your APK. Flutter has two fundamentally different compile pipelines depending on the build mode.
|
Build mode |
Compiler |
Output |
Reverse engineering risk |
|
Debug |
JIT (Just-In-Time) |
Kernel AST — high-level Dart IR compiled on-device at runtime |
High — near-source recovery is possible |
|
Release |
AOT (Ahead-Of-Time) |
libapp.so — native machine code plus a pre-built heap snapshot |
Lower than debug, but still high with the right tooling (B(l)utter, reFlutter) |
In debug mode, the Dart frontend compiler transforms your source into a Kernel AST — a high-level intermediate representation that gets JIT-compiled on the device at runtime. Recovering near-original source from a debug APK is straightforward.
In release mode, your code goes through AOT compilation: the compiler performs program analysis, tree-shakes dead code, and produces an AOT snapshot — native machine code plus a pre-built heap image containing all the objects your program needs (string literals, constants, type metadata). This snapshot is packaged as libapp.so.
The other native library, libflutter.so, is the Flutter engine itself — a prebuilt C/C++ shared library containing the Dart VM runtime, the rendering engine, and the platform embedding layer.
When the app launches, the system loads libflutter.so (the engine), which then loads libapp.so (your compiled Dart code). The engine maps the snapshot’s data — all the pre-built objects like strings, constants, and type info — directly into memory as part of the Dart heap. From that point on, the compiled machine code in libapp.so executes and references those heap objects as needed.
The practical consequence: every string literal, constant, and type identifier in your release app exists in a structured, addressable region of memory — and on disk. That region is exactly what reverse engineering tools target.
With a debug APK, you can recover something very close to the original source code thanks to the Kernel AST. But that is rarely what you find in the wild — most apps ship release builds compiled through AOT, and the picture there is very different.
Flutter is constantly evolving, and there is no single universal decompiler because the snapshot format changes between Dart SDK versions. Many older tools are now abandoned. The two that currently work with recent Flutter versions are reFlutter and B(l)utter — the latter is the one we use here.
B(l)utter currently supports the arm64-v8a architecture only. The two libraries you need are inside the unpacked APK:
lib/
└── arm64-v8a/
├── libapp.so
└── libflutter.so
Then run the tool:
python3 blutter.py lib/arm64-v8a output_directory
B(l)utter will download the necessary Dart SDK components on its own and start decompilation. The output looks like this:
output_directory/
├── asm/ - app code in assembler
├── objs.txt - dart objects
└── pp.txt - object pool
pp.txt contains everything that was hardcoded — API URLs, keys, system data. You can run any secrets-scanning tool against this file and find anything that was not properly handled. objs.txt contains a different representation: a raw dump of Dart objects rather than pool references.
If you are interested in the actual code, look inside asm/. Every library, the Flutter framework itself, and the full source code structure — including original naming from development — is there, in assembler. If the app is not obfuscated, a class containing a terms link looks like this in the decompiled output:
static Uri termsLink() {
// ** addr: 0x8fcc2c, size: 0x38
// 0x8fcc2c: EnterFrame
// 0x8fcc2c: stp fp, lr, [SP, #-0x10]!
// 0x8fcc30: mov fp, SP
// 0x8fcc34: CheckStackOverflow
// 0x8fcc34: ldr x16, [THR, #0x48] ;
THR::stack_limit
// 0x8fcc38: cmp SP, x16
// 0x8fcc3c: b.ls #0x8fcc5c
// 0x8fcc40: r1 = "https://app.com/terms"
// 0x8fcc40: add x1, PP, #0x25, lsl #12 ;
[pp+0x25980] "https://app.com/terms"
// 0x8fcc44: ldr x1, [x1, #0x980]
// 0x8fcc48: r4 = const [0, 0x1, 0, 0x1, null]
// 0x8fcc48: ldr x4, [PP, #0x358] ;
[pp+0x358] List(5) [0, 0x1, 0, 0x1, Null]
// 0x8fcc4c: r0 = parse()
// 0x8fcc4c: bl #0x4cb1c4 ;
[dart:core] Uri::parse
// 0x8fcc50: LeaveFrame
// 0x8fcc50: mov SP, fp
// 0x8fcc54: ldp fp, lr, [SP], #0x10
// 0x8fcc58: ret
// 0x8fcc58: ret
// 0x8fcc5c: r0 = StackOverflowSharedWithoutFPURegs()
// 0x8fcc5c: bl #0xbea960 ;
StackOverflowSharedWithoutFPURegsStub
// 0x8fcc60: b #0x8fcc40
}
That is a lot of assembler. The corresponding Dart source is just four lines:
static final Uri termsLink = Uri.parse(
'https://app.com/terms',
);
This example also shows how compiled Dart accesses the object pool. It uses the PP register — a dedicated CPU register that points to the current function’s object pool — and loads data from a specific offset:
// 0x8fcc40: r1 = "https://app.com/terms"
// 0x8fcc40: add x1, PP, #0x25, lsl #12 ;
[pp+0x25980]
// 0x8fcc44: ldr x1, [x1, #0x980]
And in pp.txt the same string appears at exactly that offset:
[pp+0x25980] String: "https://app.com/terms"
If the app is built with obfuscation enabled, class and field names are replaced with meaningless identifiers. The code still references objects from the object pool the same way:
// class id: 251, size: 0x18, field offset: 0x8
class Ooa extends Object {
late int _uEd; // offset: 0x14
late double _rEd; // offset: 0x8
late double _sEd; // offset: 0xc
late double _tEd; // offset: 0x10
}
With the structure laid out — function addresses, class layouts, field offsets — the next step for an attacker is dynamic instrumentation. Tools like Frida attach to a running process and modify behaviour at runtime. The decompiled output gives them every address they need.
This is why hardcoding secrets, relying on client-side checks, or assuming obfuscation is enough are all dangerous assumptions. Once someone has libapp.so, the object pool gives them every string, and the assembly gives them every function address. Obfuscation raises the effort but does not change the outcome — the structure is still there, and tools like B(l)utter make it accessible.
The defences below are ordered by impact. The first three address the data itself. The last three address the environment the app runs in.
API keys can be injected at build time through build.gradle (Android) or Info.plist (iOS) using secrets files. This avoids checking keys into source control — but the keys still end up as strings inside the binary, and the object pool still exposes them.
The truly secure approach is to never put sensitive keys in the client at all. Use a backend proxy that holds the credentials server-side and exposes only authenticated, scoped endpoints to your app. Where a key absolutely has to ship with the client (some third-party SDKs require it), use the provider’s key-restriction features: bind the key to your app’s signing certificate, package name, and bundle ID.
Protecting bundled assets — premium content, ML models, media — is harder because there is no equivalent of a backend proxy. The only real option is encryption. But the decryption key itself becomes the new problem: if it is hardcoded in the app, you are back to square one.
The stronger approach is to deliver decryption keys from the server only after the user has been authenticated and a purchase has been verified. The encrypted asset can ship with the app; the key never does.
The envied package adds a layer of obfuscation to compiled-in values. It makes secrets harder to spot in a casual scan of the binary, but it does not encrypt them and does not change the underlying fact that the values live in the object pool. Treat it as friction, not as a security control.
For release builds, always pass the obfuscation flags:
flutter build apk --obfuscate --split-debug-info=debug-info/
This is a baseline hardening step that costs nothing. But — and this is critical — –obfuscate only renames Dart symbols. String literals and the object pool are untouched. As the pp.txt example above showed, every hardcoded string remains fully readable regardless of obfuscation.
If your app handles sensitive data, detect the environment it is running in and refuse to operate when that environment is hostile. You can detect known root management apps (Magisk, SuperSU, etc.) and instrumentation frameworks like Frida. Two Flutter packages worth knowing:
The most comprehensive protection is RASP (Runtime Application Self-Protection) — tools that continuously verify app integrity at runtime, detect tampering, and respond to attacks while the app is running. For finance, healthcare, and other regulated domains, RASP is increasingly an expectation, not an option.
Yes. Both debug and release Flutter apps can be reverse-engineered. Debug builds use JIT compilation and produce a high-level Kernel AST from which near-original source can be recovered. Release builds use AOT compilation and produce libapp.so, which can be decompiled with tools like B(l)utter and reFlutter to recover function structure, class layouts, and every hardcoded string in the object pool.
No. The –obfuscate flag only renames Dart symbols (class and field names). String literals, API keys, and the entire object pool remain readable in the compiled binary. Obfuscation increases the effort required to read the code, but it does not encrypt or protect any embedded data.
No. The flutter_dotenv package itself warns that its values should be treated as public client-side configuration. Anything in the asset bundle ships unencrypted inside the APK and can be extracted with apktool in seconds. For production secrets, use a backend proxy or use the third-party provider’s key restrictions.
JIT (Just-In-Time) compilation is used in Flutter debug builds. The Dart source becomes a high-level Kernel AST that is compiled to machine code on-device at runtime, enabling features like hot reload. AOT (Ahead-Of-Time) compilation is used in Flutter release builds. The Dart source is compiled in advance to native ARM machine code plus a heap snapshot, packaged as libapp.so. AOT is faster at runtime but produces a binary that is closer to native code and more compact.
You cannot fully prevent reverse engineering of any client-side app — but you can make it expensive enough not to be worth the attacker’s time. The most effective combination: (1) keep secrets server-side and proxy sensitive requests, (2) encrypt bundled assets and deliver decryption keys only after server-side verification, (3) build releases with –obfuscate and –split-debug-info, (4) add root/jailbreak and hook detection with freerasp or flutter_jailbreak_detection, and (5) for high-risk domains, add a full RASP solution.
libapp.so is the native shared library produced by Flutter’s AOT compiler. It contains the machine code compiled from your Dart source plus a pre-built heap snapshot — a memory image of all the objects the app needs at startup, including string literals, constants, and type metadata. At launch, libflutter.so (the Flutter engine) loads libapp.so and maps the snapshot directly into the Dart heap.
At NERDZ LAB, we have been shipping production Flutter apps across healthcare, fintech, and consumer markets since 2017. Security is not a feature you bolt on at the end — it is an architectural decision that starts on day one. On the Yakaboo project (Ukraine’s largest online bookstore, with 14,000+ e-books and audiobooks), we built a custom Flutter e-book reader from scratch with at-rest encryption, DRM, jailbreak/root detection, secure display, and offline DRM with a 30-day grace period. The reader runs on iOS and Android with no backend — every protection lives on the device.
If you are building a Flutter app that handles sensitive data — patient records, financial transactions, paid content, regulated information — and you want a partner who treats mobile security as a first-class engineering concern, we would be glad to talk. Start with our Flutter and mobile development services, or book a call directly with our team.