Mas0n
to be reverse engineer🐧
翻车鱼

[译]Android环境下的Flutter逆向工程

[译]Android环境下的Flutter逆向工程

本文引自rloura,仅翻译,以下译文。

免责声明
keyboard_arrow_down

本文的内容是经过无数小时的个人分析,尝试的结果。我从未联系过Flutter或Dart开发团队的成员,也从未使用过Flutter或Dart作为开发工具。因此,虽然我已经尽了最大的努力,但我不能保证以下所有信息是100%准确的。

注意:通常来讲,斜体用于标记尚未定义的术语,而标红加粗用于定义它们。

介绍

Flutter是谷歌最近从头开始开发的一个UI工具包,它旨在高效构建跨平台精美应用。为此,Flutter构建在开源的Dart VM之上,并且使用Dart编程语言进行开发。当使用Flutter构建一个Android应用程序时,会为每个支持的架构生成两个特殊的库,并存储在APK中的lib/目录中。

flutter-app.apk
├──
 ...
└── lib/
    ├── arm64-v8a
    │   ├── libapp.so
    │   └── libflutter.so
    ├── armeabi-v7a
    │   ├── libapp.so
    │   └── libflutter.so
    └── x86_64
        ├── libapp.so
        └── libflutter.so

从逆向工程的角度来看,libapp.so是最重要的文件,因为它包含开发期间编写的所有已编译的Dart代码,而libflutter.so只是简单地打包运行时引擎。本文的其余部分将致力于理解libapp.so的布局以及其中的snapshots。

一些关键概念

Dart 基本原理

在我们开始之前,让我们先定义一些概念。

每一段Dart代码都运行在一个独立的isolate中,isolate由heap组成,每个isolate管理着代码运行所需的所有对象(内存分配,垃圾收集,…),并且可能不会引用另一个isolate中的对象。只有一个例外:VM isolate。VM isolate是一种特殊的isolate,有时被称为pseudo-isolate,它的内存堆可以被驻留在另一个isolate中的对象引用。

因为VM isolate只管理不可变对象,如空对象或空数组,所以它并没有打破其原有的隔离机制。因此,VM isolate相比于其他isolate具有相对优势。尽管Dart通常可以同时使用多个isolate,但Flutter并没有利用这一点,除去始终存在的VM isolate外,它只使用了一个isolate。
Android下的Flutter可以用两种方式编译:AOT或JIT。后者用于调试,它的动态编译是热加载功能的基础。前者用于发布。在正式生产生活中不太可能遇到JIT编译的应用程序,因此AOT将是本文的重点。
JIT编译的Android应用中的Dart代码直接从源代码运行,而AOT编译的应用中的Dart代码是从snapshots运行。snapshots是Dart VM在执行时的序列化状态,包含所有本地编译的代码。在Flutter中,isolate snapshots对应于调用main之前的Dart VM的状态。这种 snapshots 格式是本文的重点,因为它包含isolate中的内存堆和代码。

整型的编码

为了理解格式,还需要了解一些知识。

Dart中的一些整数使用变体LEB128(对变长编码稍微修改的LEB128)进行编码。解码unsigned整数很容易:读取字节,直到包含其最高有效位(这里称为标记位)的字节为止(设置为相等,其值大于0x7f)。现在,反转字节顺序。 对于每个读取的字节,只需将标记位删除即可。剩下的是这个整数的值。

例如,考虑以下字节序列:

0x7900 7487 4e46 8301 826d 9900

并假设我们知道在偏移量 4 处有一个已编码的无符号整数。要对其进行解码。

首先读取字节,直到其中一个字节的值大于0x7f,这样得出 (从偏移4开始):

0x 4e 4683

现在,反转字节顺序,结果是:

0x83 46 4e

或者以二进制形式:0b10000011 0100011001001110

最后,删除每个字节的标记:

0b0000011 1000110 1001110

十进制为58109

要读取带符号的整数,请执行完全相同的操作,但是在最后一步中,当读取实际值时,将最高有效位解释为符号位。 之所以将其作为LEB128的变体,是因为“真” LEB128设置了除最后一个字节以外的所有标记位,而不是相反。

布局

Flutter生产版本的libapp.so文件包含两个snapshots:一个snapshots用于无处不在的VM隔离,另一个snapshots用于实际内容的隔离。 这些每个都分为数据部分和指令部分。 数据部分包含isolate的heap,而指令部分包含本机编译的代码。 由于已编译的代码需要加载到可执行内存中,因此将其放置在ELF文件的.text部分中。 isolate的其余heap(包含VM对象描述)放置在不可执行的.rodata节中。

libapp.so
├── ...
│
├── .text
│   └── _kDartVmSnapshotInstructions
├── .rodata
│   └── _kDartVmSnapshotData
├── .text.2
│   └── _kDartIsolateSnapshotInstructions
└── .rodata.2
    └── _kDartIsolateSnapshotData

snapshots 格式

Header

有了ELF文件内容的完整概述,现在该深入了解Dart snapshots的结构了。

Dart snapshots的第一个字节是标头,其格式相当简单,这里使用表格来描述。 并对每个条目的含义以及几个重要的定义进行了描述。

OffsetSize or typeNameDescription
04标志头snapshots的标志头,常量 0xf5f5dcdc.
48大小snapshots的大小,以字节为单位。
128类型VM snapshots的种类。
2032版本使用的编译器版本。
52string特征以Null结尾的功能字符串。
unsigned基本对象基本对象的数量。
unsigned对象对象的数量。
unsignedClustersClusters的数量。
unsigned字段表长度字段表的长度。
Dart VM snapshots标头
  • 标志头:常量值0xf5f5dcdc,用于识别Dart snapshots文件。
  • 大小:ELF文件中snapshots的大小,以字节为单位(不包括上一个字段的4个字节)。
  • 类型:一个数字,用来标识snapshots的种类。 Flutter仅使用AOT或JIT类型,但还存在其他类型。它们是:full, message, none and invalid(请参见snapshot.h中的Kind枚举)。如前所述,生产发行版是AOT类型(常数 2),而调试版则是JIT类型(常数 1)。
  • 版本:负责描述格式的源代码文件的十六进制MD5哈希的ASCII编码(我知道是复杂的 译者注:简洁的说就是打包文件的哈希值)。特定的文件集以及计算版本的公式可以在make_version.py中找到。使用这种方式计算版本可以轻松检测兼容性问题,并确保(如果有任何直接处理格式更改的文件)版本将自动更新。因此在给定版本的情况下,找到与之相对应的Dart发行版并不容易。作为参考,所有Dart 2.10版本(撰写本文时为最新生产版本)的版本均为3865 6534 6566 3761 3637 6466 3938 3435 6662 6133 3331 3733 3431 3938 6139 3533,或者在ASCII解码后为8ee4ef7a67df9845fba331734198a953
  • 特征:以空格分隔为特征的以NULL终止的ASCII字符串。它们用于标识snapshots是生产版本还是调试版本(尽管Flutter使用JIT进行调试,但Dart通常也支持调试AOT编译的应用程序)以及目标体系结构和其他功能。下文的例子中显示了一个典型的字符串。
  • 基础对象:某些对象(例如不言自明的null和空数组对象)始终被隐式假定为存在,因此不会写入ELF文件中。这些对象是在clustered_snapshot.cc中的VMSerializationRoots :: AddBaseObjects下定义的(查找AddBaseObjects,这是第一个匹配项)。对于_kDartVmSnapshotData部分,此标头字段定义此类对象的数量。对于_kDartIsolateSnapshotData,此标头字段对应于_kDartVmSnapshotData中定义的对象总数(请参阅下一字段),并且可用于隔离对象(请参见上一节)。
  • 对象:snapshots使用的对象总数(基本对象和其他对象)。
  • Clusters:snapshots中存在的不同Dart类型的数量(有关更多详细信息,请参见下面的“群集”小节)。
  • 字段表长度:本文未使用。

例如,这是一个简单的Flutter应用的Dart VM snapshots标头,对其进行分析。

f5f5 dcdc 
0e79 0b00 0000 0000 
0200 0000 0000 0000 
3865 6534 6566 3761 3637 6466 3938 3435 6662 6133 3331 3733 3431 3938 6139 3533 
7072 6f64 7563 7420 6e6f 2d64 7761 7266 5f73 7461 636b 5f74 7261 6365 735f 6d6f 6465 206e 6f2d 6361 7573 616c 5f61 7379 6e63 5f73 7461 636b 7320 6c61 7a79 5f61 7379 6e63 5f73 7461 636b 7320 6e6f 2d6c 617a 795f 6469 7370 6174 6368 6572 7320 7573 655f 6261 7265 5f69 6e73 7472 7563 7469 6f6e 7320 6465 6475 705f 696e 7374 7275 6374 696f 6e73 206e 6f2d 2261 7373 6572 7473 2220 7836 342d 7379 7376 206e 6f2d 6e75 6c6c 2d73 6166 6574 7900 
7487 
4e46 83
01 82
6d 99
  • 标志头:0xf5f5dcdc。
  • 大小:751890字节(小尾数整数,未编码)。
  • 类型:2(AOT)。
  • 版本:8ee4ef7a67df9845fba331734198a953。
  • 特征:product no-dwarf_stack_traces_mode no-causal_async_stacks lazy_async_stacks no-lazy_dispatchers use_bare_instructions dedup_instructions no-“asserts” x64-sysv no-null-safety.
  • 基础对象:1012。
  • 对象:58190。
  • Clusters:257。
  • 字段表长度:3309。

对象序列化

每个给定类型的Dart对象都可以分解为一组固定的字段。 每个字段都有一个确定的值,或对另一个Dart对象的reference 。 例如,Dart中的整数最多具有64位,并且类型为Mint。 每个Mint类型的对象都可以用两个字段来描述:指示其是否为规范对象的标志canonical以及Mint的实际值。 用通用高级OOP语言(译者注,OOP:面向对象)可视化Mint可能会有所帮助。

class Mint {
    bool isCanonical;
    long value;
}

Mint的序列化版本可以通过将布尔值的编码(false为0x00,true为0x01)与的带符号编码相结合来获得。 因此,值为 7 的非规范Mint的序列化为:0x00 87

稍微复杂一点的类型是Array类型。 可以用四个字段来描述此类型:数组的长度l,指示其是否为规范对象的标志,对类型为TypeArguments的对象的引用,该对象确定数组所持有的对象的类型,以及l引用 ,每个数组元素对象一个。 再次,以高级OOP语言可视化Array类型将产生类似以下内容。

class Array {
    int length;
    bool isCanonical;
    TypeArguments typeArguments; // 确切地说,是对类型为TypeArguments对象的引用
    Object[length] data; // 确切地说,是对象引用的数组
}

就像以前一样,长度被序列化为无符号,而isCanonical字段被序列化为布尔值。 每个reference也是无符号的,其值是目标对象的reference ID。 在序列化过程中,每个序列化的对象都有一个reference ID。 第n个序列化对象的reference ID是n。

在上文的示例中,字段typeArguments的值可以为1,这意味着要进行(反)序列化,第一个对象是数组引用的TypeArguments对象。 类似地,数据数组可以保存例如两个引用:2和3,分别引用两个Mints,值分别为7和12。 这样的非规范数组的序列化为:0x 82 00 81 82 83

Clusters

Clusters是snapshot中共享通用Dart类型的一组对象。 在序列化过程中,Clusters按顺序序列化,并且每个簇都负责序列化其中的每个对象。 Dart中有150多种不同的类型,每种类型都有一个称为class ID的数字,每种都有其自己的Clusters序列化过程。 这些class ID在classId枚举中的class_id.h中定义。 总体上,所有Clusters的序列化过程都在两个单独的阶段中进行(准确地说是三个,但是trace阶段在反序列化过程中不起作用,在这里可以安全地忽略):alloc阶段和fill阶段 。

alloc阶段包括遍历所有Clusters,并将reference ID分配给它们包含的每个对象,以及序列化有关的每个Clusters的基本信息。 通常,此基本信息只是Clusters的class ID,以及该Clusters包含的对象数或类似的信息。 在某些情况下,例如在Mint对象下,它实际上可能已经包含序列化的对象。 可以在clustered_snapshot.cc中找到每个Clusters的alloc阶段的具体定义(每个SerializationCluster对于150多种类型中的每一个类型都有不同的alloc阶段)。

一旦alloc阶段完成,并且已经对某些Clusters的信息进行了序列化,那么将会再次启动fill阶段,该过程包括遍历所有Clusters,序列化其中的每个对象(除去一些例外,例如Mint 类型的情况下,这已经在alloc阶段得到了解决),如上一节中所示。

如果要序列化的snapshots,仅包含具有两个Mints的Array类型,则标题后将如下所示。

.... b582 0087 008c ce81 82

.... 8200 8182 83
  • ….:实参类型的alloc阶段(此处忽略)。
  • b582 0087 008c:mint类型的alloc阶段(class ID:b5,count:82,然后是非规范(non-canonicalMints 7和12的序列化)。
  • ce81 82:array类型的alloc阶段(class ID:ce,count:81,length(s):82 –仅一个长度为2的array)。
  • ….:实参类型的fill阶段(此处忽略)。 
  • Mint类型的fill阶段为空(这两个Mints都已在alloc阶段被序列化)。
  • 8200 8182 83:array fill阶段(Array的序列化–长度:82,canonical:false,类型参数 reference ID:81,Mint #1 reference ID:82,Mint#2 reference ID:83)。

上面的示例是一个类似snapshots的简化版本,只有3个clusters和4个对象(TypeArguments,两个MintsArray)。 在实际的snapshots中,此类序列化将丢失引用,因为TypeArguments还需要引用其自身的其他一些对象。 然而,过程是相同的,只是更长且太麻烦,难以阅读。

Code对象

Dart拥有的类型中有一个Code类型。 这种类型的对象特别重要,因为它们可以精确指出开发过程中创建的编译方法的地址。 尽管它们具有相当复杂的结构,但可以说它们在ELF的_kDartIsolateSnapshotInstructions区段中包含一个偏移量,可以在其中找到函数。 这个函数的代码是虚拟化的代码。但是,尽管不能够立竿见影,对其进行逆向工程并不是很困难。

Dart VM 追踪资源,例如专用寄存器中的strings,这些资源在执行期间保持不变。枚举支持的体系结构中的资源和寄存器目前超出了本文的范围。 因此,这里的重点将放在可以用来逆向虚拟化代码片段的通用技术上。 下面的代码片段摘自一个基于ARM64-v8的真实Flutter应用程序:

002238d4 60  2b  40  91    add        x0, x27, #0xa, LSL #12
002238d8 00  0c  44  f9    ldr        x0, [x0, #0x818]
002238dc ef  03  1d  aa    mov        x15, x29
002238e0 fd  79  c1  a8    ldp        x29, x30, [x15], #0x10
002238e4 c0  03  5f  d6    ret

使用Ghidra反编译

return *(undefined8 *)(unaff_x27 + 0xa818);

其中unaff_x27代表寄存器x27的内容。 在ARM64-v8中,x27是前面提到的一个不可变寄存器。 它包含一个指向资源的指针数组的指针。 使用Frida和Medusa,可以Hook此代码片段所在的函数,并读取寄存器的内容。 在给定的应用程序中运行,x27的值为0x74f0138f40。 这样就可以读取这个数组中偏移量为0xa818的成员,以获得一个新地址:0x74fdea4c41。 最后,读取地址0x74fdea4c41,得到以下内容:

74fdea4c41 38 02 51 00 67 1c f2 1f 8.Q.g...
74fdea4c49 0a 00 00 00 00 00 00 00 ........
74fdea4c51 54 69 74 6c 65 00 00 00 Title...
74fdea4c59 00 00 00 00 00 00 00 00 ........

可以识别出字符串“Title”的序列化版本。同样的技术也可以应用于其他资源。

Doldrums

Doldrums是Dart 2.10版的snapshot解析器,可以在GitHub上找到。

使用时,它将恢复所有Dart类和方法,并写出libapp.so文件中的所有函数的偏移量, 然后可以使用上一节所示的技术来进行逆向。

鸣谢

尽管这项工作大部分是我自己研究的结果,但如果没有以下内容,我可能不会成功:

补充

本文链接:https://blog.shi1011.cn/rev/android/689
本文采用 CC BY-NC-SA 4.0 Unported 协议进行许可

Mas0n

文章作者

发表回复

textsms
account_circle
email

  • 那啥怪

    看不看的懂,先占个沙发 :wink:

    3年前 回复

翻车鱼

[译]Android环境下的Flutter逆向工程
本文引自rloura,仅翻译,以下译文。 免责声明keyboard_arrow_down本文的内容是经过无数小时的个人分析,尝试的结果。我从未联系过Flutter或Dart开发团队的成员,也从未使用过Flutter…
扫描二维码继续阅读
2021-02-02