我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

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 软件包中已知的安全问题,您在开发应用时应将这些问题考虑在内。