Security

Flutter Reverse Engineering and Security Analysis

Article on Static and Dynamic analysis techniques for Reverse engineering Flutter Applications. The article goes over runtime patching, custom ABI interception and traffic interception.

Thu 15 June 2023

Introduction

Flutter, developed by Google, is a widely-used cross-platform framework for mobile app development that also supports web and desktop applications. It has seen significant growth, with a 340% and 270% increase in the Android and iOS marketplaces respectively.

The framework is recognized for its high performance, attributed to the Skia rendering engine. It also provides a flexible UI system for complex UI designs.

Flutter is able to integrate with existing applications and design applications from scratch.

The platform boasts a robust and active community, contributing to its rapid growth. Due to its novelty, Flutter is currently under-studied in terms of security, with limited work done on mobile application security analysis.

Even though Flutter is popular, it's not immune from security risks, which highlights the need for better tools and knowledge of the framework. This includes learning how to break down and study Flutter applications to find and understand potential security issues.

Tools and Techniques

To analyze a Flutter application, two categories of tools are available: static analysis tools and dynamic analysis tools.

Static analysis tools, such as Doldrums, parse the file by re-implementing the file format. Doldrums only partially supports older versions of Flutter (2.10 and 2.12, the latest release at the time of writing of this article is 3.10), and is no longer maintained due to the challenges faced with the rapid and continuous runtime evolution.

Dynamic analysis tools, on the other hand, like reFlutter patch the Flutter runtime to collect information during execution. These tools often require supplementary utilities like Frida or a debugger like LLDB to instrument the dynamically identified functions.

Despite its powerful approach to analyzing Flutter applications, reFlutter no longer supports the latest versions of the runtime. Changes to the Flutter runtime have resulted in non-working patches.

Structure Flutter Application

Flutter applications, on all platforms, consist of a wrapper application, the Flutter runtime, and the Flutter app.

Below is an example of a Linux application layout. libapp.so is the application code, counter is the wrapper, and libflutter_linux_gtk.so is the runtime.

├── counter
├── data
│   ├── flutter_assets
│   │   ├── AssetManifest.json
│   │   ├── FontManifest.json
│   │   ├── fonts
│   │   │   └── MaterialIcons-Regular.otf
│   │   ├── NOTICES.Z
│   │   ├── packages
│   │   │   └── cupertino_icons
│   │   │       └── assets
│   │   │           └── CupertinoIcons.ttf
│   │   ├── shaders
│   │   │   └── ink_sparkle.frag
│   │   └── version.json
│   └── icudtl.dat
└── lib
    ├── libapp.so
    └── libflutter_linux_gtk.so

The wrapper application provides an entry point that coordinates with the underlying operating system for access to services like rendering surfaces, accessibility, and input, and manages the message event loop.

The Flutter runtime is responsible for loading the Flutter app and offers a set of functionalities to the application. It comprises the Dart Virtual Machine (VM) and the Flutter engine, with the latter mainly written in C++ and supporting the necessary primitives for all Flutter applications.

Below a list of functions exposed by the Flutter runtime:

000000000041a860 g    DF .text  00000000000001e8  Base        fl_binary_messenger_send_response
000000000041f740 g    DF .text  0000000000000043  Base        fl_json_message_codec_new
000000000042a1f0 g    DF .text  000000000000015c  Base        fl_method_channel_invoke_method
000000000042a390 g    DF .text  000000000000012b  Base        fl_method_channel_invoke_method_finish
000000000042b410 g    DF .text  000000000000007c  Base        fl_method_success_response_get_result
0000000000437a00 g    DF .text  0000000000000050  Base        fl_view_new
000000000041b890 g    DF .text  000000000000007c  Base        fl_dart_project_get_icu_data_path
0000000000e8f8d8 g    D  .bss   0000000000000000  Base        _end
000000000041b990 g    DF .text  00000000000000a6  Base        fl_dart_project_set_dart_entrypoint_arguments
00000000004364c0 g    DF .text  000000000000003d  Base        fl_value_get_int
00000000004365f0 g    DF .text  000000000000003d  Base        fl_value_get_int32_list
00000000004133d0  w   DF .text  0000000000000005  Base        operator delete(void*, std::align_val_t)
00000000004192c0 g    DF .text  00000000000001c1  Base        fl_basic_message_channel_new
00000000004198d0 g    DF .text  000000000000015d  Base        fl_basic_message_channel_send
0000000000419a70 g    DF .text  000000000000012b  Base        fl_basic_message_channel_send_finish
000000000042d980 g    DF .text  0000000000000080  Base        fl_plugin_registry_get_type
000000000041be60 g    DF .text  0000000000000045  Base        fl_engine_new_headless
0000000000436480 g    DF .text  000000000000003f  Base        fl_value_get_bool
00000000004297b0 g    DF .text  000000000000007c  Base        fl_method_call_get_args
000000000042ae40 g    DF .text  000000000000003b  Base        fl_method_success_response_get_type
0000000000434f70 g    DF .text  0000000000000160  Base        fl_texture_registrar_mark_texture_frame_available
000000000041f790 g    DF .text  0000000000000176  Base        fl_json_message_codec_encode
000000000042d320 g    DF .text  0000000000000154  Base        fl_plugin_registrar_get_messenger
00000000004368c0 g    DF .text  0000000000000073  Base        fl_value_set
00000000004293f0 g    DF .text  00000000000000be  Base        fl_message_codec_decode_message

The engine is responsible for tasks such as rasterizing composited scenes, providing the low-level implementation of Flutter’s core API, handling file and network I/O, accessibility support, the plugin architecture, and a Dart runtime and compile toolchain. The runtime is fixed, and It doesn't include any application-specific code.

The Flutter app is a standalone library but does not load as a standard one. Instead, it follows a custom format containing the application layout, code, and objects.

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: 00000000001ec000 19904 OBJECT  GLOBAL DEFAULT    7 _kDartVmSnapshotInstructions
     2: 00000000001f0dc0 0x2e0420 OBJECT  GLOBAL DEFAULT    7 _kDartIsolateSnapshotInstructions
     3: 0000000000000200 33728 OBJECT  GLOBAL DEFAULT    2 _kDartVmSnapshotData
     4: 00000000000085c0 0x1dfff0 OBJECT  GLOBAL DEFAULT    2 _kDartIsolateSnapshotData
     5: 00000000000001c8    32 OBJECT  GLOBAL DEFAULT    1 _kDartSnapshotBuildId

The app file contains two snapshots: one for the VM isolate and another for the isolate with actual substance. Each of these isolates is split into a data section (containing the isolate’s heap) and an instructions section (containing the natively compiled code).

Flutter uses Dart's isolate model, where each piece of Dart code runs within an isolate, a structure composed of a chunk of memory called its heap. In Flutter, multiple isolates aren't exploited, with only one isolate in use apart from the ever-present VM isolate.

The VM isolate is a special one, managing only immutable objects and accessible to other isolates.

A Dart snapshot, in the context of a Flutter app, represents the serialized state of the Dart VM at a specific point in its execution, including all natively compiled code. In Flutter, the isolate snapshot corresponds to the state of the Dart VM right before main is called.

Setup and Runtime Patching

To analyze a Flutter application, our goal is to rely on a robust and maintainable approach. Instead of reimplementing the file format, we will modify the Dart SDK to extract the necessary information from the Flutter application at runtime.

When you compile a Flutter application, two files are generated within your application directory:

alt text

  • libFlutter.so: This file serves as a shared library containing the Flutter engine, responsible for rendering the UI, handling input events, and managing the application lifecycle.
  • libapp.so: This file contains the actual application code and logic that make up the Flutter application.

Both files include a snapshot_hash to distinguish build versions. The snapshot_hash serves as a unique identifier for the build versions. If the snapshot_hash differs between the libFlutter.so and libapp.so files, a version mismatch error occurs, preventing the application from starting.

Patching and building a specific version of Flutter can be done by following these steps:

Creating a patch for a specific Flutter release.

To create a patch for a Flutter release, we must determine the specific version of the Dart SDK used in that release. You can list all Flutter official releases using the link: https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json

For example, Flutter v3.10.4 is using Dart SDK v3.0.3:

alt text

The first step is to create a patch file for that specific version of Dart SDK:

git clone https://github.com/dart-lang/sdk
cd sdk
git checkout 2.13.4

Now we edit dart SDK source code to dump information from the Flutter application during runtime, in the next section, we will discuss those changes in detail. Once all the changes are done create a patch file with your changes by running:

git diff > patch_2_13_4.patch

And keep the patch file for later use.

2 - Build libFlutter.so with our patched dart SDK.

Clone the repository containing the required tools. Such as the Ninja build system and the gclient dependency management tool and add it to your PATH.

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
cd depot_tools
export PATH=$PATH:$(pwd)/depot_tools

To ensure that we have the correct version of the Flutter Engine for a specific Flutter release, we locate the file /bin/internal/engine.version within the Flutter codebase at the same commit hash as the release. Open the file and identify the commit hash mentioned inside. This commit hash represents the specific version of the Flutter Engine used by that Flutter release.

For instance, let's consider Flutter version v3.10.4 and its associated commit hash is 682aa387cfe4fbd71ccd5418b2c2a075729a1c66.

Visit the following URL Engine.version to obtain the engine used with Flutter version 3.10.4 which is 2a3401c9bbb5a9a9aec74d4f735d18a9dd3ebf2d.

alt text

Clone the Flutter Engine repository and checkout to the same commit hash used in the desired Flutter version.

git clone https://github.com/flutter/engine.git
cd engine
git fetch origin 2a3401c9bbb5a9a9aec74d4f735d18a9dd3ebf2d
git reset --hard

Prepare a directory for using gclient and create a .gclient configuration file.

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

Run gclient and wait for it to finish:

cd customEngine
gclient sync

gclient will clone all dependencies from DEPS file inside the flutter engine, including the dart SDK from the Dart SDK repository, into our costum_engine/src/third_party/dart/ folder.

Any changes to the dart SDK source code, compilation will produce a different snapshot_hash, Which may lead to a broken libflutter.so that can't be used with any libapp.so.

The snapshot_hash is generated at compile time using the method MakeSnapshotHashString() inside the make_version.py script.

Before making any changes to the source code we need to make sure that the make_version.py will always generate the correct snapshot_hash even if we changed the source code.

Generate the original snapshot_hash using the make_version.py script.

cd costum_engine/src/third_party/dart/tool
ipython
In [1]: import make_version
In [2]: make_version.MakeSnapshotHashString()
Out[2]: 'e4a09dbf2bb120fe4674e0576617a0dc'

Open the make_version.py script in a text editor and change it to return the original snapshot_hash.

code make_version.py

alt text

Now we can change the source code without worrying about the snapshot_hash mismatch. Copy your patch file inside the dart SDK folder and apply it:

git apply patch_2_13_4.patch

You can now build Flutter for any supported operating system/architecture:

costum_engine/src/flutter/tools/gn --no-goma --android --android-cpu=arm64 --runtime-mode=release 

ninja -C costum_engine/src/out/android_release_arm64

Once done, you can find the patched libFlutter.so inside the folder custom_engine/src/out/android_release_arm64/lib.stripped/libflutter.so.

Extracting functions, classes and objects

To dump information about the Flutter application structure, list of libraries, function, and their location, reFlutter is used to patch a class table object to list them. This object can no longer be used as the function list is pruned in newer releases.

While the information is pruned in that class, the information is still present in the application during load time. An asterisk on that, but we will get back to that in a different article.

In normal cases, we can still list Flutter functions, classes, and their offset from the Flutter parser. To do so we will have to patch the PostLoad function in the FunctionDeserializationCluster class.

Below is a sample of the patch to apply to dump the function offsets in a JSON format.

   void PostLoad(Deserializer* d, const Array& refs, bool primary) {
+    OS::Print("Patch: Function List START\n");
     if (d->kind() == Snapshot::kFullAOT) {
       Function& func = Function::Handle(d->zone());
       for (intptr_t i = start_index_, n = stop_index_; i < n; i++) {
         func ^= refs.At(i);
         auto const code = func.ptr()->untag()->code();
         ASSERT(code->IsCode());
+
+        auto& rCode = Code::Handle(code);
+        auto& rClass = Class::Handle(func.Owner());
+        auto& rLib = Library::Handle(rClass.library());
+        auto& rlibName = String::Handle(rLib.url());
+
+        JSONWriter js;
+        // Open empty object so output is valid/parsable JSON.
+        js.OpenObject();
+
+        js.PrintProperty("method_name", func.UserVisibleNameCString());
+        js.PrintProperty("offset", offset);
+
+        auto& sig = String::Handle(func.InternalSignature());
+        js.PrintProperty("library_url", rlibName.ToCString());
+        js.PrintProperty("class_name", rClass.UserVisibleNameCString());
+        js.CloseObject();
+
+        char* buffer = nullptr;
+        intptr_t buffer_length = 0;
+        js.Steal(&buffer, &buffer_length);

The patch reads the library URL, the class name, and the method name. Other information can also be extracted, like method signature, but it is not always present.

The offset returned will be an absolute address that varies depending on the loaded address of the library.

To show only the related offset in the binary, the following patch can be applied:

   code->untag()->monomorphic_entry_point_ = monomorphic_entry_point;
-  code->untag()->monomorphic_unchecked_entry_point_ =
-      monomorphic_entry_point + unchecked_offset;
+
+  auto& offset =
+      instructions_table_.rodata()
+          ->entries()[instructions_table_.rodata()->first_entry_with_code +
+                      instructions_index_ - 1]
+          .pc_offset;
+  code->untag()->monomorphic_unchecked_entry_point_ = offset;
+  //  OS::Print("Patch: Offset 0x%016lx\n", monomorphic_entry_point + unchecked_offset);

Once offsets are extracted at the start time, it is possible to intercept them. Offsets can be printed in the logs, this can however be problematic since the logs have a size limitation which will often lead to data being truncated.

Writing the files to disk is a more stable solution. To write to an external file, you can append the following patch that tries to dump at different file locations to accommodate different phones' filesystem layouts.

+        for (const auto& path : PATHS) {
+          OS::Print("Using Path %s\n", path);
+          std::FILE* file;
+          // Write to the file
+          file = std::fopen(path, "a");
+          if (file != NULL) {
+            std::fwrite(buffer, sizeof(char), buffer_length, file);
+            std::fwrite("\n", sizeof(char), 1, file);
+            std::fclose(file);
+            OS::Print("Successfully wrote to the file '%s'.\n", path);
+          } else {
+            OS::Print("Failed to open the file '%s' for writing.\n", path);
+          }
+        }
+

Once the list of functions and their offset is collected, we can start intercepting them.

Dynamic Analysis and Flutter Custom ABI

To intercept function calls on Flutter, a major hurdle is its use of a custom ABI implementation.

ABI dictates binary compatibility and covers calling convention, data type, size and alignment, system call interface, name mangling, exception handling, and file format.

Dart uses a custom calling convention. The calling convention for arm64 for instance is defined at the sdk/runtime/vm/constants_arm64.h file.

// Register aliases.
const Register TMP = R16;  // Used as scratch register by assembler.
const Register TMP2 = R17;
const Register PP = R27;  // Caches object pool pointer in generated code.
const Register DISPATCH_TABLE_REG = R21;  // Dispatch table register.
const Register CODE_REG = R24;
const Register FPREG = FP;          // Frame pointer register.
const Register SPREG = R15;         // Stack pointer register.
const Register ARGS_DESC_REG = R4;  // Arguments descriptor register.
const Register THR = R26;           // Caches current thread in generated code.
const Register CALLEE_SAVED_TEMP = R19;
const Register CALLEE_SAVED_TEMP2 = R20;
const Register BARRIER_MASK = R28;
const Register NULL_REG = R22;  // Caches NullObject() value.
const Register HEAP_BASE = R23;

To intercept a function and read its arguments with either Frida or a debugger like LLDB, we must first get the argument pointer, and based on the argument type, which we must know beforehand or sometimes can extract from the function signature metadata, we would determine the memory layout of the argument object to read.

Below is Frida's JS code that supports extracting the arguments, and reading the string value. Bool and Int are simple values and can easily be retrieved in the same fashion:

...
                    const argPointer = dartGetArguments(this.context, index)
                    const stringValue = getDartStringData(argPointer)

...

/**
 * Get Dart Arguments.
 *
 * Arguments are passed to the custom function stack pointed at by register X15 on ARM.
 *
 * @param context Frida context giving access to register values.
 * @param argIndex Argument index to determine which offset is the arg pointer.
 * @returns {*} Argument pointer.
 */
function dartGetArguments(context, argIndex) {
    // RSP on x64, see constants_x64.h at Dart VM repo SPREG value.
    const x15 = context.x15;
    return x15.add(8 * argIndex).readPointer();
}

/**
 * Read SMI (Small Integer) from pointer.
 * @param smiPtr SMI Pointer,
 * @returns {*|null} Value.
 */
function readSMI(smiPtr) {
    let smi_data = smiPtr.readU64();
    if (parseInt(smi_data & 0x1, 10) === 0) {
        return smi_data >> 1;
    }
    console.log(
        `Invalid SMI pointer ${smiPtr} -> 0x${smi_data.toString(16)}: Smi LSB should be 0`)
    return null
}

/**
 * Parse String Dart object to extract value.
 * @param dartStringPtr Dart String pointer.
 * @returns {null|*[]} Tuple of parsed data.
 */
function parseDartString(dartStringPtr) {
    if (dartStringPtr.and(0x1).toInt32() === 1) {
        dartStringPtr = dartStringPtr.sub(1)
    }
    const tag = dartStringPtr.readU32();
    const classId = (tag >> 16) & 0xffff;
    if (classId === 0x5 || classId === 0x55) {
        let stringLength = readSMI(dartStringPtr.add(8));
        let stringDataStr = dartStringPtr.add(16)
        let stringData = stringDataStr.readCString(stringLength);
        return [stringDataStr, stringLength, stringData]
    }
    return null
}

/**
 * Read Dart string at pointer.
 * @param dartStringPtr Dart string pointer.
 * @returns {*|null} String value.
 */
function getDartStringData(dartStringPtr) {
    let dartStringInfo = parseDartString(dartStringPtr);
    if (dartStringInfo != null) {
        return dartStringInfo[2];
    }
    return null;
}

Now that we have created a patch, built a patched version of the runtime and have a script that can read the function and their arguments, the final step is to repackage our target application, be it Android or iOS, start it and attach our favorite instrumentation tool.

Knowing what functions to instrument is a large topic and will be covered in a later article. We will then discuss the Dart SDK with its over 120k methods and some of the risks seen its top 1000 popular packages.

Traffic Interception

Flutter traffic is an oddball that requires custom care.

While in vanilla Android or iOS applications, it is possible to intercept traffic and bypass TLS pinning using a variety of methods, like changing the network security policy to accept the custom certificate, hooking the SSL_read and SSL_write to intercept traffic before encryption or dumping the TLS session key to decrypt traffic.

Flutter does not use the native TLS stack, and therefore it is not possible to set a proxy or intercept traffic. The TLS library is compiled statically in the Flutter runtime, and it is therefore difficult to identify it and patch it dynamically.

To intercept traffic on Flutter, the most robust solution is to again patch the Flutter runtime to disable TLS certificate validation and set a custom proxy.

This approach is already seen in the reFlutter tool and continues to work in the current versions of the Flutter runtime.

In Socket.cc, we force the IP address and the port to the one used to intercept the traffic:

        void FUNCTION_NAME(Socket_CreateConnect)(Dart_NativeArguments args) {
            RawAddr addr;
            SocketAddress::GetSockAddr(Dart_GetNativeArgument(args, 1), &addr);
            Dart_Handle port_arg = Dart_GetNativeArgument(args, 2);
            int64_t port = DartUtils::GetInt64ValueCheckRange(port_arg, 0, 65535);
+ if (port > 50) {
+     port = 8083;
+     addr.addr.sa_family = AF_INET;
+     addr.in.sin_family = AF_INET;
+     inet_aton("192.168.10.5", &addr.in.sin_addr);
+ }
+ ...

Then we disable the certificate checks in ssl_crypto_x509_session_verify_cert_chain:

static bool ssl_crypto_x509_session_verify_cert_chain(SSL_SESSION *session,
                                          SSL_HANDSHAKE *hs,
                                          uint8_t *out_alert) {
+ return true;

After those changes, we use the proxy to intercept the traffic without installing any certificate on the device. However, we need to make sure the invisible proxy mode is used.

Conclusion

In this first part of the article, we went over the Flutter application layout, how to patch the runtime, how to intercept function calls and read their arguments, and finally how to patch the runtime to intercept traffic.

The next 2 articles will tackle security issues to evaluate in the Dart application, notable issues are memory corruption vulnerabilities using dart:ffi, interoperability issues using dart:jni or dart:js, reflection, and serialization issues either on native code or Dart code using Reflectables.

We will also discuss known security issues in popular dart packages that should be taken into account when developing your application.