Fork Bomb for Flutter

Author

Application Security Expert

Flutter applications can be found in security analysis projects or bugbounty programs. Most often, such assets are simply overlooked due to the lack of methodologies and ways to reverse engineer them. I decided not to skip this anymore and developed the reFlutter tool. This article describes the results of my research.

Summary

The report starts with a brief overview of the Flutter SDK, followed by a look at compiling a simple mobile application. Then I’ll show you how to assemble Flutter yourself, how it is built on Google’s CI/CD, what types of builds there are, and how the versions are distinct from each other. We will:

  • Talk about a specific approach for Flutter reverse engineering
    • Write a utility
    • Analyze patches for DartVM source code
    • Create a Docker container
  • Demonstrate on the BMW app, intercepting traffic in BurpSuite and capturing function arguments via Frida
  • Recompile Engine manually using Docker
    • Figure out how to find and match the right commit
    • Create patches for dev build
    • Apply this Engine to the application

Architecture overview

Flutter is an open-source SDK from Google for developing cross-platform applications. Its goal is to deliver applications that look natural across platforms, allowing for differences in scrolling behavior and typography. Flutter is built on C, C++, Dart, and Skia.

Flutter consists of three architectural layers, but in the context of this article, we will consider only the Engine and the Framework..

Framework is a cross-platform layer written in the Dart language. It includes a rich set of platforms, layouts and foundational libraries. Many higher-level features that developers might use are implemented as packages, including platform plugins like camera, webview, and other functions like http and animation.

Engine is a portable runtime for hosting Flutter applications that contains the required SDK for Android, iOS, or Windows; it is mostly written in C++ and provides primitives to support all Flutter applications. The engine includes the package dart-sdk, which provides low-level implementation: file and network I/O, as well as Dart VM and a compiler toolchain.

Flutter app developers write code on Dart language using the Framework. This code is executed in the Dart VM, which the Engine provides. When building an application for a specific platform, the corresponding Engine compiled specifically for it will be used. Because of this architecture, where it’s possible to change the platform for already existing code, Flutter is cross-platform.

To compile a Flutter application, Engine is used to create an AOT AppSnapshot containing precompiled machine code: the Framework source code and the developers’ source code. This article focuses on AOT, because this is the Snapshot type used in release builds.

Let’s use the standard Flutter project for Android (in which all necessary libraries have been pre-placed for convenience) to see how the application is compiled.

Go to the android folder, run the build:

~/flutter_app/android$ ./gradlew -Pverbose=true -Ptarget-platform=android-arm64 -Ptarget=lib/main.dart assembleRelease

Arguments:

  • -Ptarget-platform – select the architecture we need [android-x64, android-arm, android-arm64]
  • -Ptarget – path to the file with the main function of the application

The build process is underway:

> Task :app:compileFlutterBuildRelease
[  +11 ms] dart-sdk/bin/dart artifacts/engine/linux-x64/frontend_server.dart.snapshot --sdk-root artifacts/engine/common/flutter_patched_sdk_product/ --target=flutter -Ddart.developer.causal_async_stacks=false -Ddart.vm.profile=false -Ddart.vm.product=true --bytecode-options=source-positions --aot --tfa --packages .packages --output-dill app.dill package:flutter_app/main.dart
[+7108 ms] kernel_snapshot: Complete
[   +3 ms] executing: artifacts/engine/android-arm64-release/linux-x64/gen_snapshot --deterministic --snapshot_kind=app-aot-elf --elf=libapp.so --strip --no-causal-async-stacks --lazy-async-stacks app.dill
[+3668 ms] android_aot_release_android-arm64: Complete
………….
[   +4 ms] build succeeded.
[   +6 ms] "flutter assemble" took 12,261ms.

Let’s look at the first executed command:

dart-sdk/bin/dart artifacts/engine/linux-x64/frontend_server.dart.snapshot
--sdk-root artifacts/engine/common/flutter_patched_sdk_product 
--target=flutter 
-Ddart.developer.causal_async_stacks=false 
-Ddart.vm.profile=false 
-Ddart.vm.product=true 
--bytecode-options=source-positions 
--aot --tfa 
--packages .packages 
--output-dill app.dill 
package:flutter_app/main.dart

This command starts the application frontend_server.dart.snapshot (CFE), written in Dart as part of the Engine. It compiles the Dart source code into an AST representation and saves it to a Dart Kernel Binary (.dill) file. You can find a description of this format here: https://github.com/dart-lang/sdk/wiki/Kernel-Documentation.

Arguments:

  • --packages – .packages file for compilation; has the format packageName:packageUri
  • --output-dill – output path for the generated .dill file
  • --target – target model that determines what core libraries are available [vm (default), flutter, flutter_runner, dart_runner, dartdevc]
  • --tfa – enable global type flow analysis and related transformations in AOT mode.
  • --aot – run compiler in AOT mode (enables whole-program transformations)
  • package:flutter_app/main.dart – path to the main function of the application

After saving the app.dill file, the second command is run.

artifacts/engine/android-arm64-release/linux-x64/gen_snapshot --deterministic --snapshot_kind=app-aot-elf --elf=libapp.so --strip --no-causal-async-stacks --lazy-async-stacks app.dill

Here the obtained dill file is specified and passed as an argument to gen_snapshot, which, when executed, generates an optimized FlowGraph (TFA) for the Dart code, before converting it to AOT binary machine code by writing it to a libapp.so file.

This is approximately how the compilation process looks in AOT:

Next, the obtained libapp.so is combined with resources, dex files, and the libflutter.so library into a single zip archive, which is signed and made ready-to-use release.apk

Structure of the release.apk file with comments:

├── AndroidManifest.xml
├── assets
│   └── flutter_assets
│       └── AssetManifest.json
├── classes.dex ──── //  Java (Dalvik Executable)
├── kotlin ──── //  kotlin Metadata
├── lib
│   └── arm64-v8a
│       ├── libapp.so ──── //  Dart code (App AOT Snapshot)
│       └── libflutter.so ──── //  Flutter Engine (stripped version of Dart VM)
├── META-INF
├── res
└── resources.arsc

Since the target platform is android-arm64, the lib folder contains only one architecture, arm64-v8a.

The libflutter.so file (part of the Flutter Engine) contains the required functionality for using the OS (network, file system, etc.) and a stripped version of the DartVM. This version is known as precompiled runtime, which does not contain any compiler components and is incapable of loading Dart source code dynamically. However, it handles reading of sections, deserializing, and loading instructions (binary machine code) into executable memory from the ELF file libapp.so.

~$ readelf -Ws libapp.so

Symbol table '.dynsym' contains 6 entries:
   Num:    Value          Size Type    Bind   Vis                            Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 0000000000001000     8 FUNC    GLOBAL DEFAULT    1 _kDartBSSData
     2: 0000000000002000 17792 FUNC    GLOBAL DEFAULT    2 _kDartVmSnapshotInstructions
     3: 0000000000007000 0x1f89c0 FUNC    GLOBAL DEFAULT    3 _kDartIsolateSnapshotInstructions
     4: 0000000000200000 32288 FUNC    GLOBAL DEFAULT    4 _kDartVmSnapshotData
     5: 0000000000208000 0x18c180 FUNC    GLOBAL DEFAULT    5 _kDartIsolateSnapshotData

Here we see the .text segments: _kDartVmSnapshotInstructions, _kDartIsolateSnapshotInstructions, and .rodata: _kDartVmSnapshotData, _kDartIsolateSnapshotData.

Dart has the Isolate abstraction — a structure with its own memory (heap) and usually with its own thread of control (mutator thread). All Dart code runs in an isolate. Multiple isolates can execute Dart code concurrently but cannot share any state directly and can only communicate by passing messages.

Let’s have a look at the structure of the libapp.so file:

Isolate Instructions_kDartIsolateSnapshotInstructions — Contains the AOT code that is executed by the Dart isolate. It must live in the text segment.

Isolate Snapshot_kDartIsolateSnapshotData — Represents the initial state of the Dart heap and includes isolate specific information. Along with the VM snapshot, it helps in faster launches of the specific isolate. Should live in the data segment.

Dart VM Instructions_kDartVmSnapshotInstructions — Contains AOT instructions for common routines shared between all Dart isolates in the VM. This snapshot is typically extremely small and mostly contains stubs. It must live in the text segment.

Dart VM Snapshot_kDartVmSnapshotData — Represents the initial state of the Dart heap shared between isolates. Helps launch Dart isolates faster. Does not contain any isolate specific information. Mostly predefined Dart strings used by the VM. Should live in the data segment. From the VM’s perspective, this needs to be loaded in memory with READ permissions and does not need WRITE or EXECUTE permissions. In practice, this means it should end up in .rodata when putting the snapshot in a shared library.

Each Engine (libflutter.so) stores an md5 hash (Snapshot_hash) to separate the build versions. This hash is generated on the basis of major changes in the Engine source code at compile time using the make_version.py script.

To check the compatibility of libflutter.so and libapp.so, the same Snapshot_hash is stored in them. If libflutter.so detects an invalid hash in libapp.so, the process terminates with an incompatibility error.

Flutter, like dart-sdk, is constantly under development: changes are made from version to version, performance is improved and language features are added. Therefore, when creating an application, the developer should consider which version of Flutter to use. There are three release versions in total: stable, beta and dev.

These versions (excluding dev) can be found here: docs.flutter.dev/development/tools/sdk/releases; the table lists: the Flutter version, the commit linked to this version (the Ref field), and the Dart version.

The commit makes it easy to find out which Flutter Engine version is included in the release; just substitute it here: github.com/flutter/flutter/blob/ee4e09c/bin/internal/engine.version.

Now we can study the particular Engine github.com/flutter/engine/blob/[engine.version]/DEPS. As you can see, the file DEPS contains dependencies, plus a dart-sdk commit, which is also easy to switch to github.com/dart-lang/sdk/blob/[dart_revision]/DEPS.

Each new version is developed according to the following steps (using github.com/flutter/engine as an example):

  1. Development in progress.
  2. A new-made build goes to dev.
  3. At the beginning of the month, usually, the first Monday, when many changes happen, a build goes to beta.
  4. When issues are tested and resolved, typically quarterly, a build goes to stable.

If the Engine source code is modified significantly, compilation will produce a different Snapshot_hash. Therefore, a lot of hashes will be generated for the dev version. But in the beta version, for instance, there are fewer changes than in the dev version, plus far fewer major edits, so there can be only one Snapshot_hash for beta, which may even match the hash of the stable version.

All Engine files (such as gen_snapshot, frontend_server.dart.snapshot, libflutter.so, dart-sdk) are uploaded here storage.googleapis.com after compilation:

    {
      "hash": "8f89f6505b941329a864fef1527243a72800bf4d",
      "channel": "beta",
      "version": "1.25.0-8.1.pre",
      "release_date": "2020-12-16T21:55:19.340490Z",
      "archive": "beta/linux/flutter_linux_1.25.0-8.1.pre-beta.tar.xz",
      "sha256": "8db28a4ec4dbd0e06c2c29e52560c8d9c7b0de8a94102c33764ec137ecd12e07"
    },

We use the hash (Flutter_Commit) to get the following links:

These files can be downloaded and used to compile the Flutter project on Android, which we explored with you before. This is basically the whole part of the Engine needed to generate libapp.so.

Let’s take a look at how the Flutter Engine is compiled. There is a description in the wiki: Setting-up-the-Engine-development-environment and Compiling-the-engine. To get more details, we can look at the repository and find Pull Request, where the flutter-dashboard element takes us to Google’s CI/CD https://ci.chromium.org/ui/p/flutter/builders/try/Linux Android AOT Engine/35622/overview; this just so happens to upload artifacts after compilation to storage.googleapis.com.

Essentially the build goes as follows:

We install the necessary packages.

sudo apt-get install -y git wget curl software-properties-common unzip python-pip python lsb-release sudo apt-transport-https

We clone the repository containing the required tools. Such as the Ninja build system and the gclient dependency management tool.

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

We clone the Flutter Engine repository.

git clone https://github.com/flutter/engine.git

We specify the directory for using depot_tools.

ROOT_DIR=`pwd`
export PATH=$PATH:$ROOT_DIR/depot_tools

We create a directory for using gclient.

mkdir customEngine

We create a .gclient configuration file, where url is the path to the Flutter Engine folder, and deps_file is the file’s name with dependencies to be processed by gclient.

cd customEngine
echo 'solutions = [{"managed": False,"name": "src/flutter","url": "'$ROOT_DIR/engine'","custom_deps": {},"deps_file": "DEPS","safesync_url": "",},]' > .gclient

We start gclient, after which the Flutter Engine appears in the customEngine folder with all necessary dependencies (dart-sdk, skia).

gclient sync

We install dependencies (ndk) to compile the Engine under Android.

sudo src/build/install-build-deps-android.sh --no-prompt

We use the GN and Ninja build systems, and select the desired architecture and release mode; at the output, we get the compiled Flutter Engine in the src/out/android_release_arm64 folder.

src/flutter/tools/gn --android --android-cpu=arm64 --runtime-mode=release ninja -C src/out/android_release_arm64

This folder contains the files needed to create Snapshot: artifacts, flutter_patched_sdk_product, gen_snapshot. The lib.stripped folder contains the libflutter.so engine, which will be copied into one folder with libapp.so during the compilation of the APK.

Recompilation as a reverse engineering approach

We now know that the application code is stored in the libapp.so file, which the Engine reads. And the actual instructions for the code are in the _kDartIsolateSnapshotInstructions segment.

Unfortunately, the file is divided into segments with different types of objects and has a complex structure that requires deserialization; this is confusing and hinders the reverse engineering process, making it harder to know where the required instruction begins and what function it handles.

To understand how deserialization works we can learn the source code of libflutter.so, which is available here: github.com/dart-lang/sdk. Then it will be possible to write a parser for the elf format, which should perform the same functions as the Engine.

However, examining the dart-sdk source code will still take some time. Recall that Dart is constantly under development, and the functionality of DartVM is no exception. It’s worth remembering that a parser written for a Snapshot is not universal. If the developer compiles its code using a newer version of Flutter, you will have to rewrite the parser to read the new libapp.so.

Another reading method can be used, which involves editing the source code and compiling the modified libflutter.so.

This will independently perform the necessary deserialization and read the instructions. Creating source code patches from version to version is easier, while the Engine changes are not so drastic that they are hard to keep track of.

We’ve already learned how to compile libflutter.so; now we need to come up with some source code edits. For example, add print to the Deserializer::ReadInstructions(CodePtr code, bool deferred) function; as you may have guessed, this function is executed when an instruction is read from the _kDartIsolateSnapshotInstructions segment.

The Engine is additionally responsible for the network and file system. So, it would be useful to edit the (Socket_CreateConnect)(Dart_NativeArguments args) function, specifically Socket::CreateConnect(addr) by replacing addr with the address of our own proxy. Also, don’t forget to disable certificate verification; you can introduce a patch: bool ssl_crypto_x509_session_verify_cert_chain(SSL_SESSION *session) return true;

It will now be possible to intercept the traffic of an analyzed application.

I created a utility reFlutter that modifies the source code, and configured CI/CD to compile Flutter Engine. Written in Python, it is intended for Flutter mobile applications. Supports Engine Android/iOS.

Let’s take a closer look at how my utility works. It performs two main functions:

  • Helps to compile Flutter Engine and introduce patches. (CI/CD, Docker, Local)
  • Processes IPA/APK files. Retrieving Snapshot_hash from the libapp.so file. Downloading the file from the libflutter.so repository and further actions with it: replacing the preset IP of the network patch with a custom IP. Replacing the original libflutter.so in the target APK with the processed one.

reFlutter main logic is located in the _init_.py file. The ELFF(fname, **kwargs) function searches for Snapshot_hash in the libapp.so file.