Flutter 安全:逆向工程与分析
本文介绍用于逆向工程 Flutter 应用的静态与动态分析技术,涵盖运行时修补、自定义 ABI 拦截以及流量拦截。
引言
Flutter 由 Google 开发,是一个广泛使用的跨平台移动应用开发框架,同时也支持 Web 和桌面应用。它增长迅猛,在 Android 和 iOS 应用市场中分别增长了 340% 和 270%。
该框架以高性能著称,这得益于 Skia 渲染引擎。它还提供了灵活的 UI 系统,可用于实现复杂的 UI 设计。
Flutter 既能与现有应用集成,也能用于从零开始设计应用。
该平台拥有一个强大而活跃的社区,推动了它的快速发展。由于 Flutter 较新,目前在安全方面的研究还不充分,针对其移动应用安全分析的工作也很有限。
尽管 Flutter 很受欢迎,但它并非不存在安全风险,这凸显了对更好的工具以及对该框架更深入了解的需求。这包括学习如何拆解和研究 Flutter 应用,以发现并理解潜在的安全问题。
工具与技术
要分析 Flutter 应用,可用的工具分为两类:静态分析工具和动态分析 工具。
静态分析工具,例如 Doldrums,通过重新实现文件格式来解析文件。Doldrums 只部分支持较旧的 Flutter 版本(2.10 和 2.12,而在撰写本文时最新版本为 3.10),并且由于运行时快速、持续演进带来的挑战,它已不再维护。
另一方面,动态分析工具,例如 reFlutter,会修补 Flutter 运行时,以便在执行期间收集信息。这些工具通常需要借助 Frida 等辅助工具或 LLDB 等调试器,对动态识别出的函数进行插桩。
尽管 reFlutter 分析 Flutter 应用的方法很强大,但它已不再支持最新版本的运行时。Flutter 运行时的变化导致了补丁失效。
Flutter 应用的结构
在所有平台上,Flutter 应用都由一个包装应用、Flutter 运行时和 Flutter app 组成。
下面是一个 Linux 应用的目录结构示例。libapp.so 是应用代码,counter 是包装应用,libflutter_linux_gtk.so 是运行时。
├── 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
包装应用提供一个入口点,负责与底层操作系统协调,以访问渲染表面、无障碍功能和输入等服务,并管理消息事件循环。
Flutter 运行时负责加载 Flutter app,并为应用提供一组功能。它由 Dart 虚拟机(VM)和 Flutter 引擎组成,后者主要用 C++ 编写,为所有 Flutter 应用提供必要的基础原语。
下面是 Flutter 运行时导出的部分函数列表:
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
引擎负责的任务包括:对合成后的场景进行光栅化、提供 Flutter 核心 API 的底层实现、处理文件和网络 I/O、无障碍支持、插件架构,以及 Dart 运行时和编译工具链。运行时是固定的,不包含任何特定于应用的代码。
Flutter app 是一个独立的库,但它并不像标准库那样加载。相反,它遵循一种自定义格式,其中包含应用的布局、代码和对象。
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
app 文件包含两个快照:一个用于 VM isolate,另一个用于承载实际内容的 isolate。每个 isolate 又分为数据段(包含该 isolate 的堆)和指令段(包含原生编译后的代码)。
Flutter 使用 Dart 的 isolate 模型,每段 Dart 代码都在一个 isolate 中运行,isolate 是一种由一块称为堆的内存组成的结构。在 Flutter 中并不会使用多个 isolate,除了始终存在的 VM isolate 之外,只会使用一个 isolate。
VM isolate 是一个特殊的 isolate,只管理不可变对象,并且可被其他 isolate 访问。
在 Flutter app 的语境中,Dart 快照表示 Dart VM 在其执行过程中某一特定时刻的序列化状态,包括所有原生编译后的代码。在 Flutter 中,isolate 快照对应的是 Dart VM 在调用 main 之前那一刻的状态。
环境搭建与运行时修补
为了分析 Flutter 应用,我们的目标是采用一种稳健且易于维护的方法。我们不去重新实现文件格式,而是修改 Dart SDK,在运行时从 Flutter 应用中提取所需的信息。
编译 Flutter 应用时,会在应用目录中生成两个文件:

libFlutter.so:该文件是一个包含 Flutter 引擎的共享库,负责渲染 UI、处理输入事件以及管理应用生命周期。libapp.so:该文件包含构成 Flutter 应用的实际应用代码和逻辑。
两个文件都包含一个 snapshot_hash,用于区分构建版本。snapshot_hash 是构建版本的唯一标识符。如果 libFlutter.so 和 libapp.so 文件中的 snapshot_hash 不同,就会出现版本不匹配错误,导致应用无法启动。
按照以下步骤,即可修补并构建特定版本的 Flutter:
为特定的 Flutter 版本创建补丁。
要为某个 Flutter 版本创建补丁,必须先确定该版本所使用的 Dart SDK 的具体版本。您可以通过以下链接列出所有 Flutter 官方版本:https://storage.googleapis.com/flutter_infra_release/releases/releases_linux.json
例如,Flutter v3.10.4 使用的是 Dart SDK v3.0.3:

第一步是为该特定版本的 Dart SDK 创建补丁文件:
git clone https://github.com/dart-lang/sdk
cd sdk
git checkout 2.13.4
现在我们编辑 dart SDK 源代码,以便在运行时从 Flutter 应用中导出信息,我们将在下一节详细讨论这些修改。完成所有修改后,运行以下命令,用您的修改创建一个补丁文件:
git diff > patch_2_13_4.patch
并保留该补丁文件以备后用。
2 - 使用修补后的 dart SDK 构建 libFlutter.so。
克隆包含所需工具的仓库,例如 Ninja 构建系统和 gclient 依赖管理工具,并将其添加到您的 PATH 中。
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
cd depot_tools
export PATH=$PATH:$(pwd)/depot_tools
为了确保针对特定 Flutter 版本使用正确版本的 Flutter Engine,我们在 Flutter 代码库中与该版本相同的提交哈希下找到 /bin/internal/engine.version 文件。打开该文件,找出其中注明的提交哈希。该提交哈希代表该 Flutter 版本所使用的 Flutter Engine 的具体版本。
例如,以 Flutter 版本 v3.10.4 为例,其对应的提交哈希为 682aa387cfe4fbd71ccd5418b2c2a075729a1c66。
访问以下 URL Engine.version,即可获取 Flutter 版本 3.10.4 所使用的引擎,即 2a3401c9bbb5a9a9aec74d4f735d18a9dd3ebf2d。

克隆 Flutter Engine 仓库,并检出到目标 Flutter 版本所使用的同一提交哈希。
git clone https://github.com/flutter/engine.git
cd engine
git fetch origin 2a3401c9bbb5a9a9aec74d4f735d18a9dd3ebf2d
git reset --hard
准备一个用于 gclient 的目录,并创建 .gclient 配置文件。
mkdir costum_engine
cd customEngine
echo 'solutions = [{"managed": False,"name": "src/flutter","url": "PATH_TO_ENGINE'","custom_deps": {},"deps_file": "DEPS","safesync_url": "",},]' > .gclient
运行 gclient 并等待其完成:
cd customEngine
gclient sync
gclient 会克隆 flutter engine 中 DEPS 文件列出的所有依赖,包括来自 Dart SDK 仓库的 dart SDK,并放入我们的 costum_engine/src/third_party/dart/ 文件夹中。
只要对 dart SDK 源代码做了任何修改,编译就会生成不同的 snapshot_hash,这可能导致生成一个无法与任何 libapp.so 配合使用的损坏的 libflutter.so。
snapshot_hash 是在编译时通过 make_version.py 脚本中的 MakeSnapshotHashString() 方法生成的。
在修改源代码之前,我们需要确保即使修改了源代码,make_version.py 也始终能生成正确的 snapshot_hash。
使用 make_version.py 脚本生成原始的 snapshot_hash。
cd costum_engine/src/third_party/dart/tool
ipython
In [1]: import make_version
In [2]: make_version.MakeSnapshotHashString()
Out[2]: 'e4a09dbf2bb120fe4674e0576617a0dc'
在文本编辑器中打开 make_version.py 脚本,将其修改为返回原始的 snapshot_hash。
code make_version.py

现在,我们可以修改源代码,而无需担心 snapshot_hash 不匹配的问题。将补丁文件复制到 dart SDK 文件夹中并应用:
git apply patch_2_13_4.patch
现在,您可以为任何受支持的操作系统/架构构建 Flutter:
costum_engine/src/flutter/tools/gn --no-goma --android --android-cpu=arm64 --runtime-mode=release
ninja -C costum_engine/src/out/android_release_arm64
完成后,您可以在以下文件夹中找到修补后的 libFlutter.so:custom_engine/src/out/android_release_arm64/lib.stripped/libflutter.so.
提取函数、类和对象
为了导出有关 Flutter 应用结构、库列表、函数及其位置的信息,reFlutter 会修补一个类表(class table)对象来列出它们。由于在较新的版本中函数列表已被裁剪,这个对象已无法再使用。
虽然该类中的信息已被裁剪,但这些信息在应用加载时仍然存在。这一点还有些需要补充的地方,我们会在另一篇文章中再回头讨论。
在一般情况下,我们仍然可以从 Flutter 解析器中列出 Flutter 函数、类及其偏移量。为此,我们需要修补 FunctionDeserializationCluster 类中的 PostLoad 函数。
下面是一个补丁示例,用于以 JSON 格式导出函数偏移量。
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);
该补丁读取库 URL、类名和方法名。也可以提取其他信息,例如方法签名,但这些信息并不总是存在。
返回的偏移量是一个绝对地址,会随库的加载地址而变化。
如果只想显示二进制文件中的相对偏移量,可以应用以下补丁:
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);
在启动时提取出偏移量后,就可以对其进行拦截。偏移量可以打印到日志中,但这样可能会有问题,因为日志有大小限制,经常会导致数据被截断。
将文件写入磁盘是一种更稳定的方案。要写入外部文件,您可以追加以下补丁,它会尝试导出到不同的文件位置,以适配不同手机的文件系统布局。
+ 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);
+ }
+ }
+
收集到函数列表及其偏移量后,就可以开始拦截它们了。
动态分析与 Flutter 自定义 ABI
在 Flutter 上拦截函数调用,一个主要障碍是它使用了自定义的 ABI 实现。
ABI 决定了二进制兼容性,涵盖调用约定、数据类型、大小与对齐、系统调用接口、名称修饰(name mangling)、异常处理以及文件格式。
Dart 使用自定义的调用约定。例如,arm64 的调用约定定义在 sdk/runtime/vm/constants_arm64.h 文件中。
// 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;
要使用 Frida 或 LLDB 等调试器拦截函数并读取其参数,我们必须先获取参数指针,然后根据参数类型(必须事先知道,有时也可以从函数签名元数据中提取)确定要读取的参数对象的内存布局。
下面是支持提取参数并读取字符串值的 Frida JS 代码。Bool 和 Int 是简单的值,可以用同样的方式轻松获取:
...
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;
}
现在,我们已经创建了补丁、构建了修补后的运行时,并拥有了一个可以读取函数及其参数的脚本,最后一步是重新打包目标应用(无论是 Android 还是 iOS),启动它并附加我们喜欢的插桩工具。
确定要对哪些函数进行插桩是一个很大的话题,我们将在后续文章中介绍。届时我们将讨论拥有超过 120k 个方法的 Dart SDK,以及在其最受欢迎的前 1000 个软件包中发现的一些风险。
流量拦截
Flutter 的流量是个异类,需要特别处理。
在普通的 Android 或 iOS 应用中,可以通过多种方法拦截流量并绕过 TLS 证书锁定,例如修改网络安全策略以接受自定义证书、Hook SSL_read 和 SSL_write 以在加密之前拦截流量,或者导出 TLS 会话密钥来解密流量。
Flutter 不使用原生的 TLS 协议栈,因此无法设置代理或拦截流量。TLS 库被静态编译进 Flutter 运行时,因此很难识别它并对其进行动态修补。
要在 Flutter 上拦截流量,最稳健的方案仍然是修补 Flutter 运行时,以禁用 TLS 证书校验并设置自定义代理。
reFlutter 工具中已经采用了这种方法,并且它在当前版本的 Flutter 运行时中仍然有效。
在 Socket.cc 中,我们将 IP 地址和端口强制设置为用于拦截流量的地址和端口:
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);
+ }
+ ...
然后,我们在 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;
完成这些修改后,我们就可以使用代理拦截流量,而无需在设备上安装任何证书。不过,我们需要确保使用的是隐形代理(invisible proxy)模式。
结论
在本文的第一部分中,我们介绍了 Flutter 应用的结构、如何修补运行时、如何拦截函数调用并读取其参数,最后介绍了如何修补运行时以拦截流量。
接下来的 2 篇文章将探讨 Dart 应用中需要评估的安全问题,值得关注的问题包括:使用 dart:ffi 导致的内存破坏漏洞、使用 dart:jni 或 dart:js 带来的互操作性问题、反射,以及原生代码或使用 Reflectables 的 Dart 代码中的序列化问题。
我们还将讨论热门 dart 软件包中已知的安全问题,您在开发应用时应将这些问题考虑在内。