第1章:掉进兔子洞
首先,我将介绍Flutter堆栈的一些背景知识及其工作原理。
您可能已经知道:Flutter是从头开始构建的,具有自己的渲染管道和小部件库,从而使其真正跨平台,并具有一致的设计,无论在什么设备上运行都可以感觉到。
与大多数平台不同,flutter框架的所有基本渲染组件(包括动画,布局和绘画)都在中完全向您公开package:flutter
。
您可以从Wiki / The-Engine-architecture的官方架构图中看到这些组件
从逆向工程的角度来看,最有趣的部分是Dart层,因为这是所有应用程序逻辑所在的位置。
但是Dart层是什么样的?
Flutter将Dart编译为本机汇编代码,并使用尚未公开深入记录的格式,更不用说完全反编译和重新编译了。
为了进行比较,其他平台(例如React Native)仅捆绑了缩小的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工具会创建内核快照,并使用调试运行时+ JIT在您的android应用中运行该快照。这使您能够通过热重载在运行时调试应用程序并实时修改代码。
对于我们来说不幸的是,由于对RCE的关注日益增加,在移动行业中,使用您自己的JIT编译器已不受欢迎。iOS实际上阻止您完全执行像这样的动态生成的代码。
但是,还有两种类型的快照,app-jit
和app-aot
,它们包含编译后的机器代码,它们可以比内核快照更快地初始化,但它们不是跨平台的。
快照的最终类型app-aot
,仅包含机器代码,不包含内核。这些快照是使用中提供的gen_snapshots
工具生成的flutter/bin/cache/artifacts/engine/<arch>/<target>/
,稍后再介绍。
但是,它们不仅仅是Dart代码的编译版本,实际上,它们是在调用main之前VM堆的完整“快照”。这是Dart的一项独特功能,也是与其他运行时相比,其初始化速度如此之快的原因之一。
Flutter将这些AOT快照用于发行版本,您可以在使用以下内容构建的Android APK的文件树中看到包含它们的文件flutter build apk
:
ping@debian:~/Desktop/app/lib$ tree .
.
├── arm64-v8a
│ ├── libapp.so
│ └── libflutter.so
└── armeabi-v7a
├── libapp.so
└── libflutter.so
在这里,您可以看到两个libapp.so文件,它们是作为ELF二进制文件的a64和a32快照。
在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,这里有有关如何构建它的文档:https : //github.com/dart-lang/sdk/wiki/Building。
您想生成通常由flutter工具编排的libapp.so文件,但是似乎没有任何有关如何执行此操作的文档。
flutter sdk附带的二进制文件gen_snapshot
不是create_sdk
构建飞镖时通常使用的标准构建目标的一部分。
虽然它确实作为SDK中的一个单独目标存在,但是您可以gen_snapshot
使用以下命令为arm 构建工具:
./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文件实际上与内核快照的格式相同,其格式在此处指定:https : //github.com/dart-lang/sdk/blob/master/pkg/kernel/binary.md
这是用作工具(包括gen_snapshot
和)之间的飞镖代码的通用表示形式的格式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的一些人设计的,它可以说是有史以来最先进的解释器。DartVM的设计令人难以置信,我认为人们没有给予DartVM足够的信誉。
快照剖析
AOT快照本身非常复杂,它是没有文档的自定义二进制格式。您可能被迫在调试器中手动完成序列化过程,以实现可以读取格式的工具。
与快照生成相关的源文件可以在这里找到:
- 集群序列化/反序列化
vm/clustered_snapshot.h
vm/clustered_snapshot.cc
- ROData序列化
vm/image_snapshot.h
vm/image_snapshot.cc
- ReadStream / WriteStream
vm/datastream.h
- 对象定义
vm/object.h
- ClassId枚举
vm/class_id.h
我花了大约两周的时间来实现一个命令行工具,该工具能够解析快照,使我们能够完全访问已编译应用程序的堆。
作为概述,这是群集快照数据的布局:
RawObject*
隔离中的每个对象都SerializationCluster
根据其类ID 由相应的实例序列化。这些对象可以包含代码,实例,类型,原语,闭包,常量等中的任何内容。稍后将对此进行介绍。
对VM隔离快照进行反序列化之后,其堆中的每个对象都会添加到隔离快照对象池中,从而可以在同一上下文中对其进行引用。
群集分三个阶段进行序列化:跟踪,分配和填充。
在跟踪阶段,根对象与它们在广度优先搜索中引用的对象一起添加到队列中。同时SerializationCluster
创建与每种类类型相对应的实例。
根对象是隔离中的vm使用的一组静态对象,ObjectStore
稍后我们将使用它们来查找库和类。VM快照包括StubCode
在所有隔离之间共享的基础对象。
存根基本上是飞镖代码所调用的汇编的手写部分,从而使它可以与运行时安全地通信。
跟踪之后,将写入集群信息,其中包含有关集群的基本信息,最重要的是要分配的对象数。
在WriteAlloc
分配阶段,将调用每个簇方法,该方法将写入分配原始对象所需的任何信息。大多数情况下,此方法所做的全部工作是编写类ID和属于该群集的对象的数量。
每个群集中的对象也按分配顺序分配了一个递增的对象ID,稍后在填充阶段解析对象引用时使用此ID。
您可能已经注意到缺少任何索引和群集大小信息,必须完全读取整个快照才能从中获取任何有意义的数据。因此,实际上要进行任何逆向工程,必须为31种以上的集群类型实现反序列化例程(我已经完成),或者通过将其加载到经过修改的运行时中来提取信息(这很难进行跨体系结构)。
这是一个数组的簇结构的简化示例[123, 42]
:
如果一个对象引用了另一个对象(例如数组元素),则序列化器将在分配阶段写入最初分配的对象ID,如上所示。
对于像Mints和Smis这样的简单对象,它们完全是在alloc阶段构造的,因为它们没有引用任何其他对象。
之后,将写入约107个根引用,包括核心类型,库,类,缓存,静态异常和其他几个其他对象的对象ID。
最后,写入ROData对象,将其直接映射到RawObject*
s内存中,以避免额外的反序列化步骤。
ROData最重要的类型RawOneByteString
是用于库/类/函数名称。ROData也通过偏移量引用,偏移量是快照数据中唯一可选解码的位置。
与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中,ClassId
枚举中定义了一组静态的预定义类ID ,确切地说,从Dart 2.4.0起,有142个ID。之外的ID(或没有关联的集群)用单独的InstanceSerializationCluster
s 编写。
最后,将解析器组合在一起,我可以从根对象表中的库列表开始,从头开始查看快照的结构。
使用对象树,这里是找到顶级函数的方法,在这种情况下为package:ftest/main.dart
s main
:
如您所见,版本快照中包含库,类和函数的名称。
Dart在不混淆堆栈跟踪的情况下也无法真正删除它们,请参阅:https : //github.com/flutter/flutter/wiki/Obfuscating-Dart-Code
混淆可能不值得付出努力,但是这种情况将来很有可能会改变,并且变得更加简化,类似于Android上的proguard或网络上的源地图。
实际的机器代码存储在Instructions
对象指向的Code
对象中,从偏移量到指令数据的开头。
原始对象
DartVM中的所有托管对象都被称为RawObject
s,以真正的DartVM方式,这些类都在位于的3,000行文件中定义vm/raw_object.h
。
在生成的代码中,您可以访问RawObject*
s 并在其中移动,但是只要您根据增量写屏障屏蔽进行操作,GC似乎就能够仅通过被动扫描来跟踪引用。
这是类树:
RawInstance
s是Object
您在Dart代码中传递并调用方法的传统方法,它们在dart领域都具有等效的类型。但是,非实例对象是内部的,仅存在于利用引用跟踪和垃圾回收的情况下,它们没有等效的dart类型。
每个对象均以包含以下标记的uint32_t开头:
此处的类ID与集群序列化之前的类ID相同,它们在中定义,vm/class_id.h
但也包含用户定义的开头kNumPredefinedCids
。
Size和GC数据标签用于垃圾回收,大多数时候它们可以忽略。
如果设置了规范位,则意味着该对象是唯一的,并且没有其他对象等于它,例如Symbol
s和Type
s。
对象非常轻巧,RawInstance
通常只有4个字节,令人惊讶的是它们根本都不使用虚拟方法。
所有这些意味着分配一个对象并填充其字段几乎可以免费完成,这在Flutter中可以做很多。
你好,世界!
很酷,我们可以按名称查找函数,但是如何确定它们的实际作用呢?
正如预期的那样,从现在开始进行逆向工程要困难一些,因为我们正在挖掘Instructions
对象中包含的汇编代码。
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的指针
Thread
,请参阅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和反汇编,但请注意,这仅适用于调试/发布目标,不适用于产品。
例如,在编译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
}
此处打印的内容与内置产品的快照略有不同,但重要的是,我们可以在装配旁看到IR指令。
分解:
;; 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
如果溢出,它将调用存根。反汇编中的分支目标似乎未打补丁,但之后我们仍然可以检查二进制文件进行确认。
;; PushArgument(v3)
0xf6a6ce74 e285ca01 add ip, pp, #4096
0xf6a6ce78 e59ccfa7 ldr ip, [ip, #+4007]
0xf6a6ce7c e52dc004 str ip, [sp, #-4]!
在这里,来自对象池的对象被压入堆栈。由于偏移量太大而无法适合ldr偏移量编码,因此它使用了额外的添加指令。
这个对象实际上就是我们的“世界你好!” 字符串作为RawOneByteString*
存储在globalObjectPool
我们的隔离中的偏移量8103处。
您可能已经注意到偏移量未对齐,这是因为对象指针被标记为kHeapObjectTag
from vm/pointer_tagging.h
,在这种情况下,已RawObject
编译代码中指向s的所有指针都偏移了1。
;; StaticCall:12( print<0> v3)
0xf6a6ce80 ebfffffe bl +0 ; 0xf6a6ce80
0xf6a6ce84 e28dd004 add sp, sp, #4
在这里,先调用print,然后再从堆栈中弹出字符串参数。
就像分支尚未解析之前一样,它是print
dart:core 的入口点的相对分支。
;; ParallelMove r0 <- C
0xf69ace88 e59a0060 ldr r0, [thr, #+96]
空值被加载到返回寄存器中,96是a中空对象字段的偏移量Thread
。
;; Return:16(v0)
0xf69ace8c e24bd000 sub sp, fp, #0
0xf69ace90 e8bd8800 ldmia sp!, {fp, pc}
0xf69ace94 e1200070 bkpt #0x0
最后是函数结尾,堆栈帧与所有被调用者保存的寄存器一起恢复。由于lr被最后推入,将其弹出到pc中将导致该函数返回。
从现在开始,我将使用自己的反汇编程序中的代码片段,该代码片段的问题少于内置的问题。