乐趣区

如何逆向Flutter应用

Flutter 简介

Flutter 作为⾕歌推出的⼀个跨平台移动应⽤开发框架,可以帮助开发者快速在移动 iOS、Android 上构建⾼质量的原⽣⽤户界⾯,同时还支持开发 Web 和桌面应用。自 2018 年 12 ⽉ Flutter 1.0 版本发布以来,Flutter 受到越累越多的开发者的追捧。截⾄⽬前,Flutter 在 GitHub 上已经获得了 93.1K 的 Star 和 12.6K 的 Fork,发展速度相当惊⼈。

目前,使用 Flutter 进行工程化开发的有阿⾥、腾讯、字节跳动、美团等知名⼤⼚,当然,除此之外,还有一些个人个中小企业,详细可以查看 Flutter 开发现状。众所周知,不同意 React Native 和 Weex 等跨平台技术方案,Flutter 是一款自带渲染引擎的跨平台开发框架,它有自己的渲染管线和 Widget 库,因而开发出的应用体验更好。如下图所示,是 Flutter 官方给出的架构示意图。

可以看到,Flutter 框架主要分为 Framework、Engine 和 Embedder 三层。
其中,Framework 使用 Dart 语言实现,包括 UI、文本、图片、按钮等 Widgets,渲染,动画,手势等,与开发者直接交互的就是这一层。Engine 使用 C ++ 实现,主要包括 Skia、Dart 和 Text。

  • Skia 是开源的二维图形库,提供了适用于多种软硬件平台的通用 API。其已作为 Google Chrome,Chrome OS,Android, Mozilla Firefox, Firefox OS 等其他众多产品的图形引擎,支持平台还包括 Windows, macOS, iOS,Android,Ubuntu 等。
  • Dart 部分主要包括:Dart Runtime,Garbage Collection(GC),如果是 Debug 模式的话,还包括 JIT(Just In Time)支持。Release 和 Profile 模式下,是 AOT(Ahead Of Time)编译成了原生的 arm 代码,并不存在 JIT 部分。
  • Text 即文本渲染,其渲染层次如下,衍生自 Minikin 的 libtxt 库(用于字体选择,分隔行);HartBuzz 用于字形选择和成型;Skia 作为渲染 /GPU 后端,在 Android 和 Fuchsia 上使用 FreeType 渲染,在 iOS 上使用 CoreGraphics 来渲染字体。

Embedder 则是一个嵌入层,该层的主要作用是把 Flutter 嵌入到各个平台上去,它的主要工作包括渲染 Surface 设置, 线程设置,以及插件等。平台 (如 iOS) 只是提供一个画布,剩余的所有渲染相关的逻辑都在 Flutter 内部,这就使得它具有了很好的跨端一致性。

由于平时进行应用开发时和我们打交道最多的就是 Framework 层,并且该层主要使用 Dart 语言进行编写,也是应用程序所有业务逻辑所在的位置,因此,逆向 Flutter 应用主要的工作就在这一层。

逆向基础

由于 Flutter 将 Dart 编译为本机汇编代码使用的格式尚未公开,因此尽管没有混淆或加密,但 Flutter 应用程序目前仍然很难逆向,因为需要深入了解 Dart 内部知识才能了解到皮毛。而比较其他应用而言,React Native 使用的是容易检查和修改的 Javascript,而 Android 使用的 Java 有详细的字节码说明,并且有许多免费的反编译器,因此逆向要容易许多。

接下来,我们通过 Flutter 应用程序的构建过程,来详细说明如何对它产生的代码进行逆向工程。首先需要说明的就是【快照】

快照

Dart SDK 具有高度的通用性,我们可以在许多不同的平台上以不同的配置嵌入 Dart 代码。运行 Dart 的最简单方法是使用 dart 可执行文件,该可执行文件可以像读取脚本语言一样直接读取 dart 源文件。它包括我们称为前端的主要组件(解析 Dart 代码),运行时(提供在其中运行代码的环境)以及 JIT 编译器。

您还可以使用 dart 创建和执行快照,这是 Dart 的预编译形式,通常用于加速常用的命令行工具(如 pub)。例如,我们新建一个 main.dart 文件,然后添加如下源码。

void main() {print('Hello, World!');     // 输出 Hello, World!
}

然后,我们在控制台输入如下的“time dart main.dart”命令,会得到如下打印信息。

Flutter xiangzhihong$ time dart main.dart
Hello, World!

real    0m0.775s
user    0m0.691s
sys    0m0.191s

xiangzhihong:Flutter xiangzhihong$  dart --snapshot=main.snapshot main.dart
xiangzhihong:Flutter xiangzhihong$ time dart main.snapshot
Hello, World!

real    0m0.100s
user    0m0.093s
sys    0m0.028s

可以发现,使用快照后启动时间大大缩短。默认的快照格式是 kernel,它是等效于 AST 的 Dart 代码的中间表示形式。

在调试模式下运行 Flutter 应用程序时,Flutter 工具会创建 kernal 快照,并使用调试运行时 +JIT 在您的 Android 应用程序中运行该快照。这让你能够在运行时使用热重载实时调试应用程序和修改代码。不幸的是,由于对 RCE 的关注日益增加,在移动行业中,使用自己的 JIT 编译器已不受欢迎,并且 iOS 已经开始阻止执行这样的动态生成的代码。

除了 kernel 快照外,还有两种快照类型,即 app-jit 和 app-aot,它们包含编译后的机器代码,这些代码可以比 kernel 快照更快地初始化,但它们不是跨平台的,是平台编译后的产物。
在 Flutter 开发中,快照的最终类型为 app-aot,仅包含机器代码,且没有内核。这些快照使用的是flutter/bin/cache/artifacts/engine/<arch>/<target>/ 中的 gen_snapshots 工具生成的。app-jit 不仅仅是 Dart 代码的编译版本,实际上,它们是在调用 main 之前 VM 堆栈的完整【快照】。这是 Dart 的一项独特功能,也是与其他运行时相比,其初始化速度如此之快的原因之一。

Flutter 使用这些 AOT 快照构建发布版本,您可以在文件树中查看包含它们的文件,该文件树包含使用 flutter build apk 构建的 Android APK。需要说明的是,使用下面的命令都需要在 linux 环境下进行。

Flutter xiangzhihong$ ~/Desktop/app/lib$ tree .
.
├── arm64-v8a
│   ├── libapp.so
│   └── libflutter.so
└── armeabi-v7a
    ├── libapp.so
    └── libflutter.so

可以看到,Android apk 包中两个 libapp.so 文件,它们分别是作为 ELF 二进制文件的 a64 和 a32 快照。gen_snapshots 在此处输出 ELF/ 共享对象可能会引起误解,它不会将 dart 方法公开为可以在外部调用的符号。相反,这些文件是“cluster 化快照”格式的容器,但在单独的可执行部分中包含编译的代码,以下是它们的结构:

Flutter xiangzhihong$~/Desktop/app/lib/arm64-v8a$ aarch64-linux-gnu-objdump -T libapp.so
 
libapp.so:     file format elf64-littleaarch64
 
DYNAMIC SYMBOL TABLE:
0000000000001000 g    DF .text  0000000000004ba0 _kDartVmSnapshotInstructions
0000000000006000 g    DF .text  00000000002d0de0 _kDartIsolateSnapshotInstructions
00000000002d7000 g    DO .rodata        0000000000007f10 _kDartVmSnapshotData
00000000002df000 g    DO .rodata        000000000021ad10 _kDartIsolateSnapshotData

AOT 快照采用共享对象形式而不是常规快照文件的原因是因为 gen_snapshots 生成的机器代码需要在应用程序启动时加载到可执行内存中,而最好的方法是通过 ELF 文件。使用此共享对象,链接器会将 .text 部分中的所有内容加载到可执行内存中,从而允许 Dart 运行时随时调用它。

或许您可能已经注意到有两个快照,即 VM 快照和 Isolate 快照。其中,Dart VM 有一个执行后台任务的 isolate,称为 vm isolate,它是 app-aot 快照所必需的,因为运行时无法像 dart 可执行文件那样动态加载它。

Dart SDK

幸运的是,Dart 是完全开源的,因此在对快照格式进行逆向工程时,我们不是两眼摸黑。在创建用于生成和分解快照的测试平台之前,您必须设置 Dart SDK,这里有有关如何构建它的文档:https://github.com/dart-lang/sdk/wiki/Building。

您想生成通常由 flutter 工具编排的 libapp.so 文件,但是似乎没有任何有关如何执行此操作的文档。flutter sdk 附带了 gen_snapshot 的二进制文件,该文件不属于构建 dart 时通常使用的标准 create_sdk 构建目标。尽管 gen_snapshot 确实是作为 SDK 中的一个单独目标存在,但是你可以使用以下命令为构建 arm 版本的 gen_snapshot:

./tools/build.py -m product -a simarm gen_snapshot

通常,您只能根据架构来生成快照,以解决它们已经创建了模拟目标的情况,该模拟目标可模拟目标平台的快照生成。不管,需要说明的是,您无法在 32 位系统上制作 aarch64 或 x86_64 快照。在制作共享库之前,您必须使用前端编译一个 dill 文件:

~/flutter/bin/cache/dart-sdk/bin/dart ~/flutter/bin/cache/artifacts/engine/linux-x64/frontend_server.dart.snapshot --sdk-root ~/flutter/bin/cache/artifacts/engine/common/flutter_patched_sdk_product/ --strong --target=flutter --aot --tfa -Ddart.vm.product=true --packages .packages --output-dill app.dill package:foo/main.dart

Dill 文件实际上与 kernel 快照的格式相同,其格式可以参考:https://github.com/dart-lang/sdk/blob/master/pkg/kernel/binary.md

这是用作 gen_snapshot 和 analyzer 之类的工具之间的 Dart 代码的通用表示形式的格式。有了 app.dill,我们最终可以使用以下命令生成 libapp.so 文件了。

gen_snapshot --causal_async_stacks --deterministic --snapshot_kind=app-aot-elf --elf=libapp.so --strip app.dill

一旦能够手动生成 libapp.so,就可以轻松修改 SDK,以打印出对 AOT 快照格式进行逆向工程所需的所有调试信息。

附带说明一下,Dart 实际上是由创建 JavaScript 的 V8 的一些人设计的,V8 可以说是有史以来最先进的解释器。DartVM 的设计令人难以置信,我认为人们没有给予 DartVM 创造者足够的荣誉。

快照剖析

AOT 快照非常复杂,文件格式是自定义的且没有文档,需要先在调试器里手动走过它的序列化过程,然后才能实现文件格式解析。和快照生成相关的源文件:

Cluster serialization / deserialization

  • vm/clustered_snapshot.h
  • vm/clustered_snapshot.cc

ROData serialization

  • vm/image_snapshot.h
  • vm/image_snapshot.cc

ReadStream / WriteStream

  • vm/datastream.h

Object definitions

  • vm/object.h

ClassId enum

  • vm/class_id.h

我花了两周时间实现了一个能解析快照的命令行工具,可以帮助我们查看应用的数据构成。下面是快照数据块的布局总览:

Isolate 中的每个 RawObject* 对象的序列化由对应的 SerializationCluster 完成,索引是其 class id。这些对象囊括了代码、实例、类型、原语、闭包、常量等等。Isolate 序列化完成后,每个对象被加入 Isolate 对象池里,便于在同一上下文中引用。

Clusters 序列化分三个步骤:Trace、Alloc 和 Fill。

在 trace 阶段,根节点们和在广度优先搜索时它们引用的对象被添加到一个队列里,同时生成每个对象的 SerializationCluster。根节点是虚拟机用到的对象的集合,位于 isolate 的 ObjectStore 中,我们用它定位库和类。VM 快照中的 StubCode 基对象在 isolates 中是共享的。Stubs 基本都是手写的汇编代码,dart 代码可以调用进去,实现和运行时的安全通信。

tracing 完成后,cluster 的基本信息就写入完成了,最重要的是知道了待分配对象的数量。在 alloc 阶段,会调用每个 cluster 的 WriteAlloc 函数来写入分配原始对象需要的所有信息,大部分是该 cluster 的 class id 和对象的数量。每个 cluster 中的对象的 object id 是按照分配顺序递增赋值的,之后在 fill 阶段解析对象引用时会用到。

可能你注意到缺了索引和 cluster 大小相关的信息,要得到我们需要的信息必须完整读取整个快照。现在要进行逆向有两条路可选:一是给 31+ cluster 类型实现反序列化,二是把快照加载到修改过的运行时里提取信息。例如,下面是一个 cluster 中数组的例子 [123, 42],它的数据快照块如下:

如果一个对象引用了另一个对象(比如数组元素),serializer 在 alloc 阶段把 object id 初始化(如上图所示)。

简单对象如 Mint 和 Smi 类型的对象创建在 alloc 阶段就已经完成,因为它们不需要引用其他对象。之后写入根引用的值,包括核心类型的对象 id、库、类、缓存、静态异常和其他对象。

最后是 ROData 的写入,ROData 直接映射进 RawObject* 的内存,这样反序列化过程就能少一步。ROData 最重要的类型是 RawOneByteString,作为库 / 类 / 函数名称的类型。ROData 是以偏移引用的,也是快照数据中唯一可以不用解码的地方。

和 ROData 类似,RawInstruction 对象是指向快照数据的指针,存储在可执行指令区而不是快照主数据区。下面是编译 app 时常见的 SerializationCluster:

idx | cid | ClassId enum        | Cluster name
----|-----|---------------------|----------------------------------------
  0 |   5 | Class               | ClassSerializationCluster
  1 |   6 | PatchClass          | PatchClassSerializationCluster
  2 |   7 | Function            | FunctionSerializationCluster
  3 |   8 | ClosureData         | ClosureDataSerializationCluster
  4 |   9 | SignatureData       | SignatureDataSerializationCluster
  5 |  12 | Field               | FieldSerializationCluster
  6 |  13 | Script              | ScriptSerializationCluster
  7 |  14 | Library             | LibrarySerializationCluster
  8 |  17 | Code                | CodeSerializationCluster
  9 |  20 | ObjectPool          | ObjectPoolSerializationCluster
 10 |  21 | PcDescriptors       | RODataSerializationCluster
 11 |  22 | CodeSourceMap       | RODataSerializationCluster
 12 |  23 | StackMap            | RODataSerializationCluster
 13 |  25 | ExceptionHandlers   | ExceptionHandlersSerializationCluster
 14 |  29 | UnlinkedCall        | UnlinkedCallSerializationCluster
 15 |  31 | MegamorphicCache    | MegamorphicCacheSerializationCluster
 16 |  32 | SubtypeTestCache    | SubtypeTestCacheSerializationCluster
 17 |  36 | UnhandledException  | UnhandledExceptionSerializationCluster
 18 |  40 | TypeArguments       | TypeArgumentsSerializationCluster
 19 |  42 | Type                | TypeSerializationCluster
 20 |  43 | TypeRef             | TypeRefSerializationCluster
 21 |  44 | TypeParameter       | TypeParameterSerializationCluster
 22 |  45 | Closure             | ClosureSerializationCluster
 23 |  49 | Mint                | MintSerializationCluster
 24 |  50 | Double              | DoubleSerializationCluster
 25 |  52 | GrowableObjectArray | GrowableObjectArraySerializationCluster
 26 |  65 | StackTrace          | StackTraceSerializationCluster
 27 |  72 | Array               | ArraySerializationCluster
 28 |  73 | ImmutableArray      | ArraySerializationCluster
 29 |  75 | OneByteString       | RODataSerializationCluster
 30 |  95 | TypedDataInt8Array  | TypedDataSerializationCluster
 31 | 143 | <instance>          | InstanceSerializationCluster
...
 54 | 463 | <instance>          | InstanceSerializationCluster

快照里还有些其他的 cluster,但目前为止我只在一个 Flutter 应用里见过,就不再列举。ClassId 枚举对象里预定义了 class ID 集合,在 Dart 2.4.0 版本中有 142 个 ID,此范围之外或没有相关联 cluster 的 ID 单独写在 InstanceSerializationCluster 中。

终于到了可以能彻底地查看快照结构的解析器部分了,从根对象表中的库开始。通过对象树可以定位函数,以 package:ftest/main.dart 的 main 函数为例:

如你所见,release 版本的快照是包含库名、类名和函数名的。如果不混淆 Dart 是没办法移除这些符号的,见 https://github.com/flutter/flutter/wiki/Obfuscating-Dart-Code。

目前这种混淆可能不值得,但未来这种情况很可能会改善,变得更合理易用,就像 Android 的 proguard 和 web 的 sourcemaps。机器码以 Instruction 对象存储,Code 对象以指定数据起始偏移指向 Instruction 对象。

RawObject

Dart 虚拟机中所有的对象都是 RawObject,这些类的定义可以在 vm/raw_object.h 中找到。

根据递增的写屏障标志,只要你在生成的代码中声明,就可以随意读取、移动 RawObject*,GC 能通过标志被动地扫描追踪引用。下面是类的树形图:

RawInstance 在 dart 世界中的类型都是 Object,在 dart 代码和方法调用时都能看到。非实例对象是内部的,只存在于引用跟踪、垃圾回收时,它们没有相同的 dart 类型。并且,每个对象都以一个 uint32_t 类型的标志位开头,结构如下所示。

这里的 Class ID 和之前 cluster 序列化的 class id 一样,定义在 vm/class_id.h,也包括用户定义的开头,在 kNumPredefinedCids。

Size 和 GC data 垃圾回收时使用,基本可以忽略。如果 canonical 位有值,代表这个对象是唯一的,没有对象和它相等,如 Symbol 和 Type 的实例。一般来说,对象都很小,RawInstance 通常只有 4 字节,也不需要使用虚拟方法,这些都意味着分配一个对象并填充字段基本没有消耗。

Hello, World!

Dart 并没有用流行的编译后端(比如 Clang),而是用针对 AOT 优化了的 JIT 编译器进行代码生成。

如果你没有研究过 JIT 代码,那么你可以看看 C 代码的等比产物,相比 C 代码的产物,JIT 的产物在某些地方有些庞大。并不是说 Dart 做得不好,而是设计的目的在于在运行时能够快速地生成代码,所以性能上可能比不上 C 代码,硬说性能的话手写的汇编指令速度可是完胜 clang/gcc。实际上生成代码优化越少我们的优势越大,和生成它的高级中间语言更接近。

其中,代码生成相关的源码,都可以在以下文件中找到,文件路径为 vm/compiler/:

vm/compiler/backend/il_<arch>.cc
vm/compiler/assembler/assembler_<arch>.cc
vm/compiler/asm_intrinsifier_<arch>.cc
vm/compiler/graph_intrinsifier_<arch>.cc

下面是 dart A64 汇编程序的寄存器和调用约定,如下所示。

      r0 |     | Returns
r0  -  r7 |     | Arguments
r0  - r14 |     | General purpose
      r15 | sp  | Dart stack pointer
      r16 | ip0 | Scratch register
      r17 | ip1 | Scratch register
      r18 |     | Platform register
r19 - r25 |     | General purpose
r19 - r28 |     | Callee saved registers
      r26 | thr | Current thread
      r27 | pp  | Object pool
      r28 | brm | Barrier mask
      r29 | fp  | Frame pointer
      r30 | lr  | Link register
      r31 | zr  | Zero / CSP

A64 采用了 AArch64 的调用约定 但多了几个全局寄存器:

  • R26 / THR:指向当前虚拟机 Thread,见 vm/thread.h
  • R27 / PP:指向当前上下文的 ObjectPool,见 vm/object.h
  • R28 / BRM:barrier mask,用于递增型垃圾回收

类似的,A32 的寄存器如下:

r0 -  r1 |     | Returns
r0 -  r9 |     | General purpose
r4 - r10 |     | Callee saved registers
      r5 | pp  | Object pool
     r10 | thr | Current thread
     r11 | fp  | Frame pointer
     r12 | ip  | Scratch register
     r13 | sp  | Stack pointer
     r14 | lr  | Link register
     r15 | pc  | Program counter

例如,下面是一个简单的 Hello Word 的例子。

void hello() {print("Hello, World!");
}

生成的汇编代码如下所示:

Code for optimized function 'package:dectest/hello_world.dart_::_hello' {
        ;; B0
        ;; B1
        ;; Enter frame
0xf69ace60    e92d4800               stmdb sp!, {fp, lr}
0xf69ace64    e28db000               add fp, sp, #0
        ;; CheckStackOverflow:8(stack=0, loop=0)
0xf69ace68    e59ac024               ldr ip, [thr, #+36]
0xf69ace6c    e15d000c               cmp sp, ip
0xf69ace70    9bfffffe               blls +0 ; 0xf69ace70
        ;; PushArgument(v3)
0xf69ace74    e285ca01               add ip, pp, #4096
0xf69ace78    e59ccfa7               ldr ip, [ip, #+4007]
0xf69ace7c    e52dc004               str ip, [sp, #-4]!
        ;; StaticCall:12(print<0> v3)
0xf69ace80    ebfffffe               bl +0 ; 0xf69ace80
0xf69ace84    e28dd004               add sp, sp, #4
        ;; ParallelMove r0 <- C
0xf69ace88    e59a0060               ldr r0, [thr, #+96]
        ;; Return:16(v0)
0xf69ace8c    e24bd000               sub sp, fp, #0
0xf69ace90    e8bd8800               ldmia sp!, {fp, pc}
0xf69ace94    e1200070               bkpt #0x0
}

可以发现,上面的汇编代码和生成的快照文件大不一样,这样可以对照汇编看 IR 指令。接下来,我们来依次查看这些生成的汇编代码。

        ;; Enter frame
0xf6a6ce60    e92d4800               stmdb sp!, {fp, lr}
0xf6a6ce64    e28db000               add fp, sp, #0

上面的代码是一个标准的函数序言,帧指针指向函数栈帧底部后,将调用者的帧指针、链接寄存器入栈。通常,标准 ARM 架构是递减栈,倒序增长。

        ;; CheckStackOverflow:8(stack=0, loop=0)
0xf6a6ce68    e59ac024               ldr ip, [thr, #+36]
0xf6a6ce6c    e15d000c               cmp sp, ip
0xf6a6ce70    9bfffffe               blls +0 ; 0xf6a6ce70

上面的代码主要用于检查栈溢。自带反汇编器既不提供线程字段的注解,也不提供分支的注解,需要花点功夫才能理解。字段偏移表可以在 vm/compiler/runtime_offsets_extracted.h 找到,Thread_stack_limit_offset = 36 表明线程栈可访问的字段个数限制在 36 个。如果检测到栈溢出,调用 stackOverflowStubWithoutFpuRegsStub 处理。汇编中的分支不能打补丁,但可以通过观察二进制来进行确认。接下来,看下一段:

        ;; PushArgument(v3)
0xf6a6ce74    e285ca01               add ip, pp, #4096
0xf6a6ce78    e59ccfa7               ldr ip, [ip, #+4007]
0xf6a6ce7c    e52dc004               str ip, [sp, #-4]!

上面的代码用于将对象入栈,如果对象的偏移太大,ldr 就处理不了,需要使用基址寻址。这个对象实际上是 RawOneByteString 类型的“Hello, World!”,位于 isolate 偏移 8103 处的 globalObjectPool 中。

注意到这里的偏移没有对齐,这是因为对象指针都被 `vm/pointer_tagging.h 定义的 kHeapObjectTag 标记了,本例中所有的 RawObject 指针以 1 对齐。

 ;; StaticCall:12(print<0> v3)
0xf6a6ce80    ebfffffe               bl +0 ; 0xf6a6ce80
0xf6a6ce84    e28dd004               add sp, sp, #4

上面的代码表示字符串参数出栈之后的调用,最后调用 dart:core 中 print 函数进行打印操作。

  ;; ParallelMove r0 <- C
0xf69ace88    e59a0060               ldr r0, [thr, #+96]

返回值是 Null,96 是 Thread 中 null 对象的偏移。

 ;; Return:16(v0)
0xf69ace8c    e24bd000               sub sp, fp, #0
0xf69ace90    e8bd8800               ldmia sp!, {fp, pc}
0xf69ace94    e1200070               bkpt #0x0

最后是函数结语,写回调用者保存的寄存器,恢复栈帧。lr 是最后入栈的,把它 pop 给 pc 后函数返回。

原文链接:https://blog.tst.sh/reverse-engineering-flutter-apps-part-1/

退出移动版