June 10, 2022

    The Current State & Future of Reverse Engineering Flutter™ Apps

    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:

    • Use the symbol table and perform automatic renaming of defined functions
    • Locate cross references between strings definition and where they are used
    • Use known processor ABIs to identify function parameters

    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.

    Why Reverse Engineering Flutter is Currently Hard

    We identify three main obstacles that currently slow down Flutter reverse engineering:

    1. The Dart AOT snapshot format is changing a lot with each update.
    2. All Dart frameworks are statically linked in the application binary.
    3. The Dart code relies on the Dart VM to be executed.

    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:

    1. The size of the Dart snapshot is way bigger than similar native libraries embedded in an Android application, which means more things need to be reverse engineered.
    2. It can be hard to distinguish application code from framework code, which means that the reverse engineer can lose time reverse engineering code of open source frameworks.
    3. It is not directly possible to guess what a function is doing by looking at framework function calls since such calls are not external.

    The third obstacle is the dependency of Dart code on the Dart VM to be executed. In practice, it impacts the Flutter reverse engineering process in two major ways:

    1. There are no direct references from a static data definition to the places it is used. All of that is hidden through an indirection via the Dart VM object pool. Thus, our reverse engineering tools are not able to locate uses of Dart Objects.
    2. The Dart VM uses a custom register layout and ABI. For instance on arm64,X27is used as the object pool pointer andX15is used as the Dart VM stack pointer.
      • The caller will push function parameters on the Dart VM stack (which is not the regular program stack) and updateX15accordingly
      • Similarly, local variables will be stored on the Dart VM stack
      • This custom stack and stack pointer are not properly handled by our traditional tools which causes the Dart decompiled code to look very weird (more on this in a future blog post).

    In 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.

    Extracting Information from a Flutter Snapshot

    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:

    • The compiled code that will be used to run the application. It includes not only application-specific code but also the code of all frameworks used by the application.
    • All strings or static data used by the Flutter app.
    • A lot of metadata that is used by the Flutter engine to make a Flutter app run:
      • Some is mandatory, like the definition of Dart objects and the Dart VM object pool.
      • Some is optional, like the class/function names, but can be useful to, for instance, print symbolicated stack traces when a crash occurs.

    For a Flutter 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.

    State-of-the-art Approaches for Flutter Metadata Extraction

    Currently, there are 3 ways this information can be extracted:

    • Using a Dart snapshot parser
    • Using a modified version of the Flutter runtime library
    • Using debug information

    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.

    Experimenting With Using Flutter Metadata for Easier Reverse Engineering

    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:

    after renaming

    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.

    What about Flutter’s Built-in Obfuscation?

    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.

    Conclusion

    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.

    Read part 2 here.

    Boris Batteux - Security Researcher

    Discover how Guardsquare provides industry-leading protection for mobile apps.

    Request Pricing

    Other posts you might be interested in