This is part 1 of a blog series.
Read part 2 here: Obstacles in Dart Decompilation & the Impact on Flutter™ App Security
Read part 3 here: How Classical Attacks Apply to Flutter™ Apps
Reverse engineering can be hard without proper tooling. Luckily for reverse engineers, there are plenty of powerful tools out there that they can rely upon. As a result, they don’t have to reinvent the wheel and create their own disassembler/decompiler each time they start reverse engineering different software.
For instance, all popular reverse engineering tools (like IDA Pro, Ghidra, JEB, Binary Ninja, …) are able to parse ELF/MachO/PE files, extract useful information from it, and they will:
Moreover, people have invested the time to develop advanced tools to deal with more complex topics like binary diffing and identification of known functions included in a binary (e.g. IDA Pro TIL or Lumina server).
But when it comes to Flutter reverse engineering, most of these tools and features are not available at the moment, and it can be hard to know where to start without them. This can lead to the misconception that writing Flutter code means that it won’t be reverse engineered and, as a result, that it doesn’t need to be protected.
In this blog post, and more blog posts coming in the future, we want to demonstrate that tools to help Flutter reverse engineering are, in fact, not very hard to develop and that more of them will emerge as Flutter becomes more popular and continues to mature.
I would like to thank CaramelDunes for letting me use his open source Flutter game called NyaNya Rocket! as an example throughout this post. Although this is an open source game, we will analyze it as if we don’t have access to the source code. If you want to follow along and experiment on your side, we have prepared a Github repo with all applications and scripts!
In this first blog, we’ll focus on the information contained in a Dart VM snapshot and explore how previously mentioned tools could easily leverage it to speed up reverse engineering of Flutter apps.
We identify three main obstacles that currently slow down Flutter reverse engineering:
Let’s look at each obstacle in a bit more detail.
The first obstacle is linked to the fact that the Dart language is still young and evolving. Because of that, the format of the Dart snapshot, which contains all the compiled machine code and data for a Flutter application, keeps changing, too. The main impact for reverse engineers is that if they write a parser to extract information contained in a Flutter app, their parser will be outdated whenever a new Flutter version gets released.
The second obstacle is caused by all Dart frameworks used by an app being statically linked into the Dart snapshot. For a reverse engineer, this has three main consequences:
The third obstacle is the dependency of Dart code on the Dart VM to be executed. In practice, it impacts the reverse engineering process in two major ways:
X27is used as the object pool pointer andX15is used as the Dart VM stack pointer.
X15accordinglyIn this blog post, we will focus on the first two obstacles and keep the last one for a future blog.
If you are interested in the internals of the Dart VM from a reverse engineering perspective, I advise reading the blogpost series from Andre Lipke.
Now that we understand the main hurdles currently complicating reverse engineering efforts of Flutter applications, let’s have a closer look at the first obstacle we identified. What information could be retrieved from a Flutter snapshot and what is the state-of-the-art for doing so?
A snapshot contains all information that the Flutter engine needs to run the Flutter application; it includes:
For a reverse engineer, class and function names are very useful information as they can be used to identify known frameworks and prevent losing time on them. Additionally, since developers generally use meaningful names while writing an application, they may get lucky and find somesuper_secret_functioneasily.
Currently, there are 3 ways this information can be extracted:
If you search for Dart snapshot parsers online, you will find several of them including darter and Doldrums. While these are great tools, the issue with them is that they have to deal with the first obstacle themselves: the Dart snapshot format keeps changing, thus they need to be modified each time a new Dart version is released. This process takes some time and therefore most of them don’t support recent versions of Flutter - yet.
The second approach is the one chosen by e.g. the reFlutter project. Rather than trying to parse the Dart snapshot, it modifies the Flutter runtime library to make the application dump information at runtime. The big advantage of this approach is that it requires far less maintenance when the Dart snapshot format changes!
When used on the NyaNya Rocket! app, it will provide you with the following type of information (full dump can be found here):
Library:'dart:io' Class: Link extends Object implements Type: FileSystemEntity {
Function 'Link.': static factory. (dynamic, String) => Link {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000008ba68
}
Function 'Link.fromRawPath': static factory. (dynamic, Uint8List) => Link {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000008b9fc
}
}
Library:'package:shared_preferences/shared_preferences.dart' Class: SharedPreferences extends Object {
Completer? _completer@1038065047 = null ;
Function 'get:_store@1038065047': static. () => SharedPreferencesStorePlatform {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee000
}
Function 'getInstance': static. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee4dc
}
Function 'getBool':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee44c
}
Function 'getInt':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002eda00
}
Function 'getString':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee3b8
}
Function 'containsKey':. (SharedPreferences, String) => bool {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002edb10
}
Function 'setBool':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee36c
}
Function 'setInt':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002eeb50
}
Function 'setString':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee31c
}
Function '_setValue@1038065047':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002ee244
}
Function '_getSharedPreferencesMap@1038065047': static. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000002edb7c
}
}
Library:'package:nyanya_rocket/screens/puzzles/widgets/local_puzzles.dart' Class: LocalPuzzles extends StatelessWidget {
Function 'build':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x00000000003200f4
}
Function '_buildPuzzleTile@1161407169':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031f574
}
Function '_buildPuzzleCard@1161407169':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031ef6c
}
Function '_verifyAndPublish@1161407169':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031e984
}
Function '_openPuzzle@1161407169':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031edc0
}
Function '_handlePublishTapped@1161407169':. String: null {
Code Offset: _kDartIsolateSnapshotInstructions + 0x000000000031e7c4
}
}
As you can see, reFlutter provides us with even more information than just the class and function names: it even shows class hierarchies and internal functions APIs.
A third approach to extract this information is to leverage the debug information that is generated when building a Flutter application with the--split-debug-infoflag. Using this flag will generate a DWARF file that can be easily parsed and which contains class/function names and their associated offset in thelibapp.so. Obviously, a reverse engineer can’t build the application they are trying to reverse engineer themself so they won't have access to debug information about it. But we will explore how this approach can be used to detect framework functions in any application at the end of this blog post.
Regardless of how the information from the last section was retrieved, let’s experiment with how it could be used to tackle the second obstacle we mentioned.
Coming back to the NyaNya Rocket! app, the first step of the analysis is to extract the metadata of the application using one of the techniques discussed in the previous section. Then we can use the extracted metadata in, for example, an IDA Pro Python script to automatically rename and sort functions.
As shown in the above video, initially the IDA database contains more than 20,000 unknown functions. After running the script with the metadata, almost all functions have been renamed and have been sorted based on their package and class name. This would be a huge gain of time for reverse engineers as it is now very easy to locate all framework functions, ignore them and focus on analyzing the application specific code.
Moreover, when inspecting application-specific code, reverse engineers can now identify the calls that it makes to different frameworks, which brings them back to the more classical scenario where they can use imported functions to quickly understand a function's behavior.
However, the decompiled code still has a lot of weird Dart artifacts. For instance, the Dart code of_handlePublishTappedshown at the end of the video is:
void _handlePublishTapped(BuildContext context, String uuid, User user) {
if (user.isConnected) {
PuzzleStore.read(uuid).then((NamedPuzzleData? puzzle) {
if (puzzle != null) {
_verifyAndPublish(context, puzzle);
}
});
} else {
final snackBar = SnackBar(
content: Text(NyaNyaLocalizations.of(context).loginPromptText));
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
}
While the associated decompiled code after renaming looks like this:
If you want to try it out yourself, you can find the script that we used here.
In the next post, we will explain how we can clean this code up to make it look closer in appearance to native code that we are used to.
Flutter has a built-in option that automatically obfuscates Dart code inside the Flutter app. When this option is enabled, most module/class/function names are replaced by random names. Thus, although the same methods of extracting metadata still work, they will only provide (obfuscated) names which are not as useful as the previously unobfuscated names.
But this is not game over, since no obfuscation is applied to the code itself, classical binary diffing tools such as BinDiff or Diaphora can be used to recover the original name of functions:
In the above video, we used BinDiff to recover the function names of an obfuscated version of the NyaNya Rocket! app using a previous (non obfuscated) build, which explains the very large number of successfully identified functions.
In real life, this attack scenario could happen if the developer previously released a build without obfuscating it and decided to enable Flutter built-in obfuscation later on. But, even if an application has always been released with the Flutter built-inobfuscateoption enabled, a reverse engineer can still use this binary diffing techniques to identify common Flutter frameworks used by the application.
For instance, they can generate several Flutter applications that use a lot of Flutter frameworks without theobfuscateoption so that the initial renaming script will identify all framework functions. Later on, when they face a new unknown application, they can use a binary diffing tool, which will identify most of the framework functions included in it. Once this is done, most of the functions that haven’t been identified will be the application specific code.
Finally, since this Dart framework code doesn’t change much, it is likely that many reverse engineering tools will include signatures that allow it to detect these framework functions directly.
In this post, we looked into the information included in Dart snapshot, how to extract it and we saw that it contains a lot of interesting metadata for a reverse engineer. We also demonstrated that with only several lines of code, this information could be used to considerably speed up the reverse engineering of a Flutter application. We showed that as Dart and Flutter further mature so will the reverse engineering tooling and any current perceived difficulties will mostly be removed.
In addition, we evaluated the built-in Flutterobfuscateoption. While it does remove some metadata, the code itself is not obfuscated, which means that it is still relatively easy to identify all framework functions used by a Flutter application built with this option. This enables reverse engineers to greatly limit the scope and use the known functions to try to understand what an unknown function is doing.
In the next blog post on this topic, we will focus on how to make decompiled code look better and how to deal with Dart VM object pool.