От халепа... Ця сторінка ще не має українського перекладу, але ми вже над цим працюємо!

cookies

Hi! This website uses cookies. By continuing to browse or by clicking “I agree”, you accept this use. For more information, please see our Privacy Policy

bg

Reverse Engineering a Flutter App: How Secure Is Your Data, Really?

author

Maxym Radchuk

/

Flutter developer

11 min read

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.

nerdz lab | artificial intelligence software development services. AI software development services

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.

What you will learn

  • How to perform static analysis on a Flutter APK without decompilation
  • How Dart code is compiled in debug vs release mode (JIT vs AOT) and why it matters for security
  • How to decompile a Flutter release build with B(l)utter and read the AOT snapshot
  • What Flutter’s –obfuscate flag actually protects (and what it does not)
  • Practical strategies to protect API keys, assets, and local data in a Flutter app
nerdzlab

 

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

Prevention — what actually protects a Flutter app

Frequently asked questions

Static analysis of a Flutter APK without decompilation

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.

Unpacking the APK

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:

  • AndroidManifest.xml — can contain hardcoded API keys. A classic example is Google Maps:
<meta-data android:name="com.google.android.geo.API_KEY" 
android:value="key"/>

 

  • assets/ — may include animations, images, hardcoded .tflite ML models, or environment files.
  • res/strings.xml — often contains interesting strings and configuration values.

A real-world finding: the flutter_dotenv leak

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.

Local app data and rooted devices

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.

Reverse Engineering a Flutter App: How Secure Is Your Data, Really?

How Flutter compiles your app (JIT vs AOT)

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.

How it all comes together at runtime

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.

Decompiling a Flutter app with B(l)utter

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.

Setting up: extracting libapp.so and libflutter.so

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

 

Reading the object pool: every hardcoded string, exposed

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.

Reading the assembler: function structure with original names

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',
);

 

The PP register and the object pool, in practice

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"

 

What obfuscation looks like

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
}

 

Why this matters: Frida and runtime hooks

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.

Reverse Engineering a Flutter App: How Secure Is Your Data, Really?

Prevention — what actually protects a Flutter app

The defences below are ordered by impact. The first three address the data itself. The last three address the environment the app runs in.

1. API keys: do not put them in the client

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.

2. Assets: encrypt, and deliver the key from the server

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.

3. envied: useful, not bulletproof

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.

4. Always build with –obfuscate and –split-debug-info

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.

5. Detect rooted devices and runtime hooks

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:

  • freerasp — a broader runtime self-protection package covering root detection, hook detection, debugger detection, emulator detection, and tamper detection.
  • flutter_jailbreak_detection — a simpler, more focused option for root/jailbreak checks.

6. For high-risk apps: full RASP

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.

Key takeaways

  • A release Flutter APK is not a black box. Static analysis with apktool alone can expose manifests, assets, and hardcoded values.
  • flutter_dotenv is not a secrets manager. Anything in the asset bundle is effectively public.
  • AOT-compiled libapp.so can be decompiled. Tools like B(l)utter expose every hardcoded string, class structure, and function address.
  • –obfuscate renames symbols but leaves string literals and the object pool readable.
  • The only durable protection for secrets is server-side. Use a backend proxy, scoped key restrictions, and deliver content-decryption keys only after verification.
  • Defend the runtime, not just the binary. Combine –obfuscate, root/hook detection (freerasp, flutter_jailbreak_detection), and — for high-risk apps — full RASP.

Frequently asked questions:

 

Can a Flutter app be reverse-engineered?

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.

Does Flutter build –obfuscate make a Flutter app secure?

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.

Is it safe to use flutter_dotenv for production API keys?

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.

What is the difference between AOT and JIT compilation in Flutter?

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.

How do I prevent reverse engineering of a Flutter app?

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.

What is libapp.so in a Flutter Android build?

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.

 

nerdzlab

 

 

Building Flutter apps that survive real-world attack

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.

Summarize with AI