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:
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
:
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
.
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
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.
We do newsletters, too
Get the latest news, updates, and product innovations from Ostorlab right in your inbox.
Table of Contents