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/