October 25, 2026
Your Flutter App Isn’t as Obfuscated as You Think: Why Enums Leak in Production and How to Fix It
Like most Flutter developers building production apps in India, I used to believe that running:
By Aslamambiloly
3 min read
flutter build apk --release --obfuscate --split-debug-info=./symbolsflutter build apk --release --obfuscate --split-debug-info=./symbols…was the silver bullet for client-side security.
We implement SSL pinning, drop in root detection packages, hide API keys with code generation, and feel confident that our app's binary (libapp.so) is an unreadable soup of stripped ARM64 assembly instructions.
Except, it isn't.
A few weeks back, while experimenting with modern reverse-engineering pipelines specifically REA (Reverse Engineer Anything) paired with Blutter and Ghidra, I noticed something alarming.
Even with full obfuscation enabled, Dart intentionally leaks your enum names in plain text inside the compiled binary.
Let me break down why this happens, how attackers exploit it using AI, and how you can fix it with zero runtime overhead.
The Silent Vulnerability in Dart Enums
When you write a standard enum in Dart:
enum SubscriptionTier {
free,
trial,
premium,
enterprise,
}enum SubscriptionTier {
free,
trial,
premium,
enterprise,
}Dart treats enums as special first-class objects. To support built-in language features like .name, .values, and .toString(), the Dart AOT compiler preserves the string literals "SubscriptionTier", "free", "trial", and "premium" in the Dart Object Pool (which tools like Blutter extract into pp.txt).
Function and class names get stripped? Yes. Enum names? They remain in plain text.
On top of that, standard enums compile to sequential indices: 0, 1, 2, 3. In disassembly, a switch statement checking an enum compiles into a classic jump table comparing sequential values. Any junior security researcher or automated decompiler spots this within seconds.
Enter AI Reverse Engineering (REA + Blutter)
A year ago, reverse-engineering a stripped Flutter AOT binary required serious assembly reversing skills. You had to map registers and trace memory offsets by hand.
Today, tools like REA (Reverse Engineer Anything) automate this by feeding decompiled assembly to LLMs.
Here is the exact attack vector:
- The attacker dumps strings from
libapp.sousing Blutter. - They spot sensitive business enums:
"SubscriptionTier","isAdmin","PaymentStatus","AuthStep". - They look up cross-references (XREFs) in Ghidra to see which functions access those strings.
- Even though the function name is obfuscated (
f_38a9d), the LLM receives the assembly block anchored around"premium"and"trial". - The AI deduces the logic immediately: "This function checks if the user is premium. Branching occurs when register W0 equals 2."
- A quick binary patch or Frida hook later, your paywall or security check is bypassed.
The Fix: stealth_enum
To solve this gap once and for all, I built stealth_enum and its companion builder stealth_enum_generator.
The core premise is straightforward: What isn't in the binary cannot be reverse-engineered.
Instead of traditional enum objects, stealth_enum converts private enums into Dart 3.3+ extension types backed by high-entropy, pseudo-random 32-bit hex integers.
Here's all you change in your code:
// lib/subscription_status.dart
import 'package:stealth_enum/stealth_enum.dart';
part 'subscription_status.g.dart';
@StealthEnum()
enum _SubscriptionStatus {
trial,
active,
expired,
cancelled,
}// lib/subscription_status.dart
import 'package:stealth_enum/stealth_enum.dart';
part 'subscription_status.g.dart';
@StealthEnum()
enum _SubscriptionStatus {
trial,
active,
expired,
cancelled,
}Then run build_runner:
dart run build_runner build --delete-conflicting-outputsdart run build_runner build --delete-conflicting-outputsAnd use it just like a normal enum:
void checkAccess(SubscriptionStatus status) {
// Exhaustive compile-time pattern matching:
final message = status.when(
trial: () => 'Enjoy your free trial!',
active: () => 'Welcome back, subscriber!',
expired: () => 'Please renew your plan.',
cancelled: () => 'We miss you!',
);
print(message);
}void checkAccess(SubscriptionStatus status) {
// Exhaustive compile-time pattern matching:
final message = status.when(
trial: () => 'Enjoy your free trial!',
active: () => 'Welcome back, subscriber!',
expired: () => 'Please renew your plan.',
cancelled: () => 'We miss you!',
);
print(message);
}What Changes Under the Hood?
Let's look at what actually happens in the binary when you make this switch:
1. 100% Tree-Shaken String Tables
Because _SubscriptionStatus is declared with a leading underscore (_), it is private to that Dart library. Your app code never directly references _SubscriptionStatus at runtime; it only references the generated SubscriptionStatus extension type.
Because the private enum is never instantiated, the Dart AOT tree-shaker removes the original enum class and its entire string table from libapp.so. Not a single character of "SubscriptionStatus" enters the binary.
2. Entropy Over Sequential Indices
Instead of predictable indices 0, 1, 2, 3, stealth_enum assigns deterministic, high-entropy 32-bit hex constants:
// What Ghidra sees in ARM64:
CMP W0, #0x4B91E20A // Is it a memory pointer? Hash? Math constant?
CMP W0, #0x7C104F8B
CMP W0, #0x1D55A9C3// What Ghidra sees in ARM64:
CMP W0, #0x4B91E20A // Is it a memory pointer? Hash? Math constant?
CMP W0, #0x7C104F8B
CMP W0, #0x1D55A9C3To any decompiler or AI agent, these look like random mathematical constants or memory address comparisons scattered throughout the code. There is no recognizable enum jump table.
3. Zero Runtime Memory Overhead
Since Dart 3.3, extension type const Foo._(int rawValue) is a zero-cost abstraction. At runtime, SubscriptionStatus erases entirely to a primitive int. There are no wrapper objects, no heap allocations, and zero pointer indirection.
4. Inlined Control Flow
The .when() and .maybeWhen() pattern matching helpers are marked with @pragma('vm:prefer-inline'). The compiler inlines the switch logic directly into the calling function, meaning the helper methods themselves don't even exist in the symbol table.
5. No Exception String Leaks
Standard Dart switches often generate exception stubs referencing missing cases if not exhaustive. In stealth_enum, the final branch uses a wildcard _ => fallback(), ensuring no StateError strings leak into the binary.
Getting Started
Both packages are open-source and published on pub.dev:
Add to your pubspec.yaml:
dependencies:
stealth_enum: ^1.0.1
dev_dependencies:
build_runner: ^2.4.0
stealth_enum_generator: ^1.0.1dependencies:
stealth_enum: ^1.0.1
dev_dependencies:
build_runner: ^2.4.0
stealth_enum_generator: ^1.0.1Run dart pub get (or flutter pub get), annotate your private enums, and you are good to go!
Final Thoughts
Security in mobile apps isn't about making an app 100% un hackable or un breakable. With enough time and specialized hardware, any client-side binary can eventually be inspected.
Security is about raising the cost of attack. By stripping plain-text metadata and replacing predictable patterns with high-entropy constants, you neutralize the automated AI tools that make mass reverse engineering effortless.
Check out the project:
- 📦 pub.dev: https://pub.dev/packages/stealth_enum
- 💻 GitHub Repository: https://github.com/aslamambiloly/stealth_enum_workspace
If this helped you or you found the architecture interesting, please consider starring the repository on GitHub! It helps more developers in the Flutter community discover it.