如何逆向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。linux

  • 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内部,这就使得它具备了很好的跨端一致性。git

因为平时进行应用开发时和咱们打交道最多的就是Framework层,而且该层主要使用 Dart 语言进行编写,也是应用程序全部业务逻辑所在的位置,所以,逆向Flutter应用主要的工做就在这一层。github

逆向基础

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

接下来,咱们经过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

ROData serialization

ReadStream / WriteStream

Object definitions

ClassId enum

我花了两周时间实现了一个能解析快照的命令行工具,能够帮助咱们查看应用的数据构成。下面是快照数据块的布局总览:
在这里插入图片描述

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/

相关文章
相关标签/搜索