A Flutter APK Is More Than What JADX Shows You
The first time you open a Flutter APK in JADX, it can feel like someone has already removed half the application. You expect the usual Android experience: packages, classes, activities, API clients, business logic. Instead, you get a relatively thin Java or Kotlin surface, a Flutter embedding, some plugin code, and a lot less application logic than the app clearly has when you run it.
It is tempting to conclude that there simply isn't much to reverse. That would be the wrong conclusion. The application logic did not disappear. Flutter just moved a large part of it somewhere else.
Release Dart code is compiled ahead of time into native artifacts, while the application still lives inside Android and still has to play by Android's rules around activities, services, broadcast receivers, content providers, permissions, intents and IPC.
That means assessing a Flutter application is less about finding the one decompiler that gives you everything back and more about reconstructing the application from evidence spread across several layers.
That shift in mindset ended up being more useful to me than any particular Flutter reversing tool.
Start with what the APK can actually prove
I like to begin with the package itself, not because the package tells me everything, but because it gives me the first map. I hash the APK so every observation can be tied back to the exact artifact being tested. Then I look at what is inside it, If I see things such as:
alongside Flutter embedding metadata in the Android manifest, I have evidence that I am dealing with a Flutter application, that is all I have established.
Yh it sounds almost pedantic to say this, but this discipline matters later, finding libflutter.so does not mean I have reconstructed the Dart application and finding a URL string does not mean the app actually calls that endpoint.
does not mean I have found a vulnerability, these are clues. The problem begins when clues quietly become conclusions, so rather than keeping a loose pile of screenshots and notes, I prefer thinking in terms of an attack-surface ledger.
For every interesting thing I find, I want to know:
- What did I observe?
- Where did I observe it?
- What do I think it might allow?
- Have I tested that?
- What evidence would prove or disprove it?
That changes the workflow from:
enumerate everything and report suspicious-looking entries
to:
enumerate candidate paths and then kill or confirm them with runtime evidence.
That is a much better way to assess Flutter apps.
The Android wrapper is still valuable
Flutter complicates reverse engineering, but it does not erase Android. The manifest is still one of the most useful files in the APK.
- Activities are there.
- Services are there.
- Receivers are there.
- Providers are there.
- Permissions and intent filters are there.
And those declarations tell you something extremely important:
where another Android principal may be able to interact with the application.
Suppose I find this:
That immediately gets my attention, an external application may be able to send something to that receiver. But I still don't have a vulnerability, but rather I have a path worth investigating. That distinction is where a lot of mobile security reports get sloppy.
exported=true is a platform configuration.
A vulnerability needs consequence, so the next question is obvious:
What happens when somebody outside the application actually reaches this component?
Static analysis should give you questions
This is where I stop treating static and dynamic analysis as two separate phases.
Static analysis gives me hypotheses, runtime testing answers them.
For example, I might recover a custom broadcast action from the manifest or strings:
Then perhaps another artifact suggests that the receiver expects something like:
phone_number
Now I have a possible path:
But right now, that is just a hypothesis, maybe the component requires a signature permission or maybe Android refuses the sender, maybe the receiver accepts the message but ignores the extras, and maybe the sensitive operation only happens when some internal state is present.
Maybe the string I found is dead code, there is only one useful way forward.
Run it.
Don't use ADB as your attacker
ADB is convenient, that is also why it can weaken the quality of the result.
If I run:
and something happens, I have learned that the shell can trigger the path, that is useful reconnaissance.
But my real threat model is usually not:
attacker has ADB access to the victim's phone.
The interesting question is:
can an ordinary application installed on the device trigger the same behavior?
Those are different principals, so for the final test, I want a second APK, nothing clever.
Just a tiny Android application with:
- a different package name,
- a different signing certificate,
- no target-specific privileged permission,
- and enough code to send the intent.
Something conceptually like:
Now the experiment is much cleaner, there is the target application, another independently signed Android application, and Android itself is deciding whether one is allowed to cross into the other. If the broadcast never arrives, that is evidence, If it arrives but the operation does not happen, that is evidence. If it arrives and the target performs the sensitive operation, that is evidence too.
The important part is keeping those observations separate.
exported=true still isn't the bug
This is the part worth slowing down for, imagine the helper application successfully reaches the receiver. We still need to know what the receiver does, maybe it updates something harmless, maybe it refreshes cached data.
Maybe it starts an operation that should only be available from inside the target application, that last case is where the security boundary becomes interesting.
Suppose the helper application itself cannot perform operation OO, the target application can. But the helper can send a message to the target, and the target performs OO on its behalf.
Now we have something much closer to a real vulnerability, the issue is not Android IPC. IPC is supposed to let applications communicate, the issue is that a less-trusted principal may be asking a more-privileged principal to perform something without an authorization decision in between.
Conceptually:
This is a classic confused-deputy shape, the target has authority that the sender does not. The sender finds an exposed interface into the target, the target uses its own authority to perform the requested operation.
Now android:exported="true" matters because it helped create reachability.
But reachability alone was never the finding, the finding is the chain.
There are three things I want to prove
For this kind of component path, I reduce the question to three observations.
First:
Can the external application reach the component?
That requires actual delivery evidence.
Second:
Does the component perform the security-relevant operation?
That requires effect evidence.
And third:
Was the external application supposed to be allowed to do that?
That requires understanding the caller restriction and intended trust boundary.
If all three line up:
then the finding becomes defensible. If one disappears, the conclusion changes.
For example, suppose the receiver is exported because the developer intentionally wants a family of applications to access it, but the receiver requires a signature-level permission.
My helper app has a different signing key, Android rejects it, that isn't a failed pentest. That is evidence that the security boundary is working against the attacker I defined. Good security testing should be able to produce that conclusion too.
Flutter makes provenance more important
The same discipline applies to the rest of the application.
Suppose I run strings against a native artifact and recover:
That tells me the string exists. It does not tell me that the current build calls that endpoint. Then I proxy the application and capture a request to it, now I know that the execution I tested contacted it.
I still don't know that every user or application state can reach it. Each piece of evidence has a natural ceiling. I find this especially useful with Flutter because different parts of the application appear through different analysis paths.
The Android wrapper may expose the platform integration, resources may reveal configuration, native analysis may reveal strings and Dart structures. Runtime instrumentation may expose behavior that was awkward to recover statically, network interception may reveal backend interaction.
None of those views is the application by itself, the application is what starts to emerge when they agree.
I don't actually need the original Dart source
This was another useful realization, perfect source recovery is attractive. It is not always necessary.
If my goal is to prove that an untrusted Android application can invoke a component and cause a privileged effect, I do not need to reconstruct every Dart class that was present before compilation. I need enough evidence to establish the path.
That can come from several places:
That is already a security argument, reverse engineering is serving the investigation.
The investigation does not exist merely to celebrate successful reverse engineering.
The fix should survive the same attacker
Suppose the vulnerable receiver never needed to be externally reachable.
The obvious repair may be:
If external access is genuinely required, the answer is different. Maybe the component should require a signature-level permission so only applications signed by the same publisher can invoke it, maybe explicit caller validation is needed.
Whatever the fix is, I don't want to test it with a weaker attacker than the one that demonstrated the problem. Use the exact same helper APK, send the exact same logical request, the repaired target should block it, then test the legitimate internal flow again.
That gives two conditions:
This is much stronger than changing the manifest, seeing the exploit fail once, and declaring victory, security fixes can break features too, so a proper retest should notice both.
The bigger lesson wasn't really about Flutter
Flutter is what makes this problem obvious because the evidence is visibly fragmented. You look in JADX and don't see the application you expected, that forces you to acknowledge that one representation was never enough, but the principle applies much more broadly.
Static evidence tells you what may exist, dynamic evidence tells you what happened in one execution, configuration tells you what should be reachable.
Behavior tells you what that reachability means, and a vulnerability only emerges when those things cross a trust boundary in a way the system did not intend.
So the useful chain for me is:
If I cannot connect those steps, then I probably don't have the finding I think I have. That is the part of Flutter reverse engineering I find most useful, it is not about forcing a release APK back into perfect source code.
It is about reconstructing enough of the system to explain, with evidence, who can reach what, what happens when they do, and whether they were supposed to be able to do it.
This post is based on Grim Labs Technical Research Report GL-TR-2026-004, “Reconstructing the Attack Surface of a Flutter Android Application.” The current report defines the methodology and controlled two-APK experiment; the public empirical implementation and evidence pass remain the next stage of the work.