原文作者:blog.tst.sh/
发布时间:2020年3月28日
第1章:顺着兔子洞往下走
为了开始这个旅程,我将介绍一些关于Flutter堆栈的背景故事以及它是如何工作的。
你可能已经知道了什么。Flutter从底层开始建立了自己的渲染管道和部件库,使它能够真正的跨平台,无论在什么设备上运行,都有一致的设计和感觉。
与大多数平台不同的是,flutter框架的所有基本渲染组件(包括动画、布局和绘画)都在package:flutter中完全暴露给你。
你可以在wiki/The-Engine-architecture的官方架构图中看到这些组件。
从逆向工程的角度来看,最有趣的部分是Dart层,因为那是所有应用逻辑的所在。
但Dart层是什么样的呢?
Flutter将你的Dart编译成原生的汇编代码,并且使用的格式还没有公开深入的记录,更不用说完全反编译和重新编译了。
相比之下,其他平台如React Native只是捆绑了minified javascript,这对检查和修改来说是微不足道的,另外Android上Java的字节码也有很好的文档,而且有很多免费的反编译器。
尽管缺乏混淆(默认情况下)或加密,Flutter应用程序目前仍然非常难以进行逆向工程,因为它需要深入了解Dart内部的知识,甚至需要从头开始。
这使得Flutter从知识产权的角度来看非常好,你的代码几乎是安全的,不会被人窥探。
接下来我将向大家展示Flutter应用的构建过程,并详细解释如何对其产生的代码进行逆向工程。
快照
Dart SDK具有很强的通用性,你可以在许多不同的平台上将Dart代码嵌入到许多不同的配置中。
最简单的运行Dart的方式是使用dart可执行文件,它只是像脚本语言一样直接读取dart源文件。它包括主要的组件,我们称之为前端(解析Dart代码),运行时(提供代码运行的环境)和JIT编译器。
你也可以使用dart来创建和执行快照,这是Dart的一种预编译形式,通常用于加速常用的命令行工具(如pub)。
ping@debian:~/Desktop$ time dart hello.dart
Hello, World!
real 0m0.656s
user 0m0.920s
sys 0m0.084s
ping@debian:~/Desktop$ dart --snapshot=hello.snapshot hello.dart
ping@debian:~/Desktop$ time dart hello.snapshot
Hello, World!
real 0m0.105s
user 0m0.208s
sys 0m0.016s
正如你所看到的,当你使用快照时,启动时间明显降低。
默认的快照格式是kernel,相当于AST的Dart代码的中间表示。
当在调试模式下运行Flutter应用时,flutter工具会创建一个内核快照,并在你的android应用中用调试运行时+JIT运行它。这让你有能力调试你的应用,并在运行时通过热重载实时修改代码。
不幸的是,由于对RCE的担忧增加,使用自己的JIT编译器在移动行业是不受欢迎的,iOS实际上完全阻止你执行这样的动态生成代码。
不过还有两种类型的快照,app-jit和app-aot,这些快照包含了编译后的机器代码,可以比内核快照更快地初始化,但并不是跨平台的。
最后一种类型的快照,app-aot,只包含机器代码,不包含内核。这些快照是用flutter/bin/cache/artifacts/engine/<arch>/<target>/
中的gen_snapshots工具生成的,后面会详细介绍。
它们不仅仅是Dart代码的编译版本,事实上,它们是在main被调用之前的VMs堆的完整 "快照"。这是Dart的一个独特的功能,也是它与其他运行时相比初始化如此之快的原因之一。
Flutter使用这些AOT快照进行发布构建,你可以在用flutter build apk
的Android APK的文件树中看到包含它们的文件。
ping@debian:~/Desktop/app/lib$ tree .
.
├── arm64-v8a
│ ├── libapp.so
│ └── libflutter.so
└── armeabi-v7a
├── libapp.so
└── libflutter.so
这里你可以看到两个libapp.so文件,这两个文件是a64和a32快照的ELF二进制文件。
事实上,gen_snapshots在这里输出的是ELF/共享对象,这可能有点误导,它并没有将dart方法作为符号暴露出来,可以在外部调用。相反,这些文件是 "集群快照 "格式的容器,但在单独的可执行部分有编译后的代码,下面是它们的结构。
ping@debian:~/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_snapshot生成的机器代码需要加载到可执行内存中,而最好的方式是通过ELF文件来实现。
有了这个共享对象,.text部分的所有内容都会被链接器加载到可执行内存中,允许Dart运行时随时调用它。
你可能已经注意到有两个快照:VM快照和Isolate快照。
DartVM有第二个隔离快照,叫做vm隔离快照,它是app-aot快照所需要的,因为运行时不能像dart可执行文件那样动态加载它。
Dart SDK
值得庆幸的是,Dart是完全开源的,所以我们在对快照格式进行逆向工程时不必盲目飞行。
在创建用于生成和拆解快照的测试平台之前,你必须设置Dart SDK,这里有关于如何构建它的文档:github.com/dart-lang/s…
你想生成libapp.so文件通常是由flutter工具协调的,但似乎没有任何关于如何自己做的文档。
flutter sdk提供了gen_snapshot的二进制文件,它不是标准的create_sdk构建目标的一部分,你通常在构建dart时使用。
它确实作为一个单独的目标存在于SDK中,不过,你可以用这个命令为arm构建gen_snapshot工具。
./tools/build.py -m product -a simarm gen_snapshot
通常你只能为你所运行的架构生成快照,为了解决这个问题,他们创建了模拟目标,模拟目标平台的快照生成。这有一些限制,比如不能在32位系统上生成arch64或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文件其实和内核快照的格式是一样的,它们的格式在这里有规定:github.com/dart-lang/s…
这是作为包括gen_sapshot和analyzer在内的工具之间共同表示镖码的格式。
有了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的设计非常出色,我认为人们对它的创造者没有给予足够的信任。
剖析快照
AOT快照本身相当复杂,它是一种自定义的二进制格式,没有任何文档。你可能会被迫在调试器中手动完成序列化过程,以实现一个能够读取格式的工具。
与快照生成相关的源文件可以在这里找到。
- 集群序列化/反序列化 vm/clustered_snapshot.h vm/clustered_snapshot.cc
- ROData序列化 vm/image_snapshot.h vm/image_snapshot.cc。
- 读取流/写入流 vm/datastream.h
- 对象定义 vm/object.h
- ClassId enum vm/class_id.h
我花了大约两周的时间实现了一个能够解析快照的命令行实用程序,使我们能够完整地访问编译后的应用程序的堆。
作为一个概述,这里是集群快照数据的布局。
Isolate中的每一个RawObject*
都会被一个相应的SerializationCluster实例序列化,这取决于它的类ID。这些对象可以包含从代码、实例、类型、基元、闭包、常量等任何内容。后面会详细介绍。
在反序列化虚拟机隔离快照后,其堆中的每个对象都会被添加到隔离快照对象池中,允许它们在同一上下文中被引用。
集群的序列化分为三个阶段。跟踪、分配和填充。
在跟踪阶段,根对象与它们在广度第一搜索中引用的对象一起被添加到队列中。同时,每个类类型对应创建一个SerializationCluster实例。
根对象是vm在isolate的ObjectStore中使用的一组静态对象,我们将在后面使用它来定位库和类。vm快照包括StubCode基础对象,它是所有隔离体之间共享的。
Stubs基本上是dart代码调用的手工编写的汇编部分,允许它与运行时安全通信。
追踪之后,集群信息被写入,包含集群的基本信息,最重要的是要分配的对象数量。
在分配阶段,每个集群WriteAlloc方法被调用,它写入分配原始对象所需的任何信息。大多数情况下,这个方法所做的就是写出这个集群的类id和对象数量。
作为每个簇的一部分的对象也会按照它们被分配的顺序分配一个递增的对象id,这在以后的填充阶段解析对象引用时使用。
你可能已经注意到缺乏任何索引和集群大小信息,整个快照必须被完全读取,才能从中获得任何有意义的数据。因此,要想真正进行任何逆向工程,你必须为31种以上的集群类型实现反序列化例程(我已经做了),或者通过将其加载到修改后的运行时中来提取信息(这很难做到跨架构)。
下面是一个简化的例子,说明对于一个数组[123,42]来说,簇的结构会是什么。
如果一个对象引用了另一个对象,比如一个数组元素,序列化器会写下最初在 alloc 阶段分配的对象 id,如上图所示。
在像Mints和Smis这样的简单对象的情况下,它们完全是在alloc阶段构造的,因为它们不引用任何其他对象。
之后,约107个根 refs被写入,包括核心类型、库、类、缓存、静态异常和其他一些杂项对象的对象 id。
最后,ROData对象被写入,它直接映射到内存中的RawObject*
s,以避免额外的反序列化步骤。
ROData最重要的类型是RawOneByteString,它用于库/类/函数名称。ROData也是由offset作为快照数据中唯一可选择解码的地方来引用的。
与ROData类似,RawInstruction对象是快照数据的直接指针,但存储在可执行指令符号中,而不是主快照数据中。
下面是编译应用程序时通常会写入的序列化簇的转储。
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
还有一些集群有可能出现在快照中,但这是我目前在Flutter应用中看到的唯一的集群。
在DartVM中,有一组静态的预定义的类ID定义在ClassId枚举中,准确的说是Dart 2.4.0的142个ID。在这之外的ID(或者没有关联的簇)则用单独的InstanceSerializationClusters来编写。
最后把解析器整合在一起,我可以从头开始查看快照的结构,从根对象表中的库列表开始。
使用对象树,你可以找到一个顶层函数,在本例中是包:ftest/main.darts main。
正如你所看到的,库,类,和函数的名字都包含在发布快照中。
如果不同时混淆堆栈痕迹,Dart就无法真正删除它们,参见:github.com/flutter/flu…
Obfuscation可能不值得努力,但这在未来很可能会改变,变得更加精简,类似于Android上的proguard或网络上的sourcemaps。
实际的机器代码存储在由代码对象指向的指令对象中,从偏移量到指令数据的开始。
原始对象
在DartVM中,所有的管理对象都被称为RawObjects,在真正的DartVM风格中,这些类都被定义在一个3000行的文件中,可以在vm/raw_object.h找到。
在生成的代码中,你可以访问和移动RawObject*
s,无论你想要什么,只要你根据增量写屏障掩码屈服,GC似乎能够通过被动扫描跟踪引用。
下面是类树。
RawInstances是传统的Objects,你可以在Dart代码中传递并调用方法,所有这些对象在Dart土地上都有一个等价的类型。然而,非实例对象是内部的,只是为了利用引用跟踪和垃圾收集而存在,它们没有等价的镖地类型。
每个对象都以一个uint32_t开头,包含以下标签。
这里的类ID和之前的集群序列化一样,它们是在vm/class_id.h中定义的,但也包括从kNumPredefinedCids开始的用户定义。
Size和GC数据标签是用于垃圾收集的,大多数时候可以忽略。
如果canonical位被设置,意味着这个对象是唯一的,没有其他对象与之相等,就像符号和类型一样。
对象非常轻,RawInstance的大小通常只有4个字节,它们竟然也完全不使用虚拟方法。
所有这些都意味着分配一个对象和填写它的字段可以在虚拟中免费完成,这一点我们在Flutter中做了不少。
Hello, World!
酷,我们可以通过名称定位函数,但我们如何弄清楚它们到底是做什么的?
正如预期的那样,从这里开始的逆向工程是有点困难的,因为我们正在挖掘指令对象中包含的汇编代码。
Dart没有使用像clang这样的现代编译器后端,而是使用JIT编译器来生成代码,但是进行了一些AOT特定的优化。
如果你从来没有使用过JIT代码,它在某些地方比同等的C代码产生的代码要臃肿一些。不过并不是说Dart做的不好,它的设计是为了在运行时快速生成,而且常用指令的手写汇编往往在性能上胜过clang/gcc。
生成的代码不太经过微优化,其实对我们的优势很大,因为它更接近用于生成代码的更高级别的IR。
大多数相关的代码生成可以在。
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
这个ABI遵循标准的AArch64调用惯例,但有一些全局寄存器。
- R26 / THR: 指向正在运行的vm线程的指针,参见vm/thread.h。
- R27 / PP:指向当前上下文的ObjectPool的指针,见vm/object.h。
- R28 / BRM:屏障掩码,用于增量式垃圾收集。
同样,这也是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
虽然A64是一个更常见的目标,但我将主要介绍A32,因为它更容易读取和反汇编。
你可以通过传递--disassemble-optimized到gen_snapshot来查看IR和反汇编,但是请注意这只对debug/release目标有效,而不是产品。
举个例子,当编译hello world时。
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
}
这里打印的内容与快照内置的产品略有不同,但重要的是我们可以在组装的同时看到红外指示。
将它分解开来。
;; Enter frame
0xf6a6ce60 e92d4800 stmdb sp!, {fp, lr}
0xf6a6ce64 e28db000 add fp, sp, #0
这是一个标准的函数序幕,调用者的帧指针和链接寄存器被推送到堆栈后,帧指针被设置到函数堆栈帧的底部。
与标准的ARM ABI一样,这使用了一个全下降栈,意味着它在内存中向后生长。
;; 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,告诉我们访问的字段是线程栈极限。
对栈指针进行比较后,如果栈指针已经溢出,就会调用stackOverflowStubWithoutFpuRegsStub stub。拆解中的分支目标似乎没有打补丁,但我们仍然可以在之后检查二进制文件来确认。
;; PushArgument(v3)
0xf6a6ce74 e285ca01 add ip, pp, #4096
0xf6a6ce78 e59ccfa7 ldr ip, [ip, #+4007]
0xf6a6ce7c e52dc004 str ip, [sp, #-4]!
这里从对象池中推送一个对象到栈中。由于偏移量太大,无法放入ldr偏移编码中,所以使用了额外的add指令。
这个对象实际上是我们的 "Hello, World!"字符串,作为一个RawOneByteString*,存储在偏移量为8103的隔离区GlobalObjectPool中。
你可能已经注意到偏移量是错位的,这是因为对象指针是用vm/pointer_tagging.h中的kHeapObjectTag标记的,在这种情况下,编译代码中所有指向RawObjects的指针都偏移了1。
;; StaticCall:12( print<0> v3)
0xf6a6ce80 ebfffffe bl +0 ; 0xf6a6ce80
0xf6a6ce84 e28dd004 add sp, sp, #4
这里调用print后,字符串参数从栈中弹出。
就像之前的分支没有被解析,它是一个相对分支,指向dart:core中print的入口点。
;; ParallelMove r0 <- C
0xf69ace88 e59a0060 ldr r0, [thr, #+96]
Null被加载到返回寄存器中,96是线程中null对象字段的偏移量。
;; Return:16(v0)
0xf69ace8c e24bd000 sub sp, fp, #0
0xf69ace90 e8bd8800 ldmia sp!, {fp, pc}
0xf69ace94 e1200070 bkpt #0x0
最后是函数的尾声,堆栈框架与任何调用保存的寄存器一起被恢复。由于lr是最后推送的,所以把它弹到pc中会导致函数返回。
从现在开始,我将使用我自己的反汇编器的片段,它的问题比内置的少。
通过www.DeepL.com/Translator(免费版)翻译