抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

Flutter 是 Google 发布的一个跨平台的框架,其基于 Dart 语言,运行于 Dart VM 中,其中的寄存器有自定义的使用规则、还有自定义 ABI、自定义的 Stack 等,加上其目前处于快速发展阶段,变更频繁,导致逆向非常困难。

𝘚 是当前直播盒子行业的领头羊,支持国内外近乎所有平台;其用 老版本 Flutter 开发,尽管 Java 层加了 360 壳,但并不影响使用 frida 和 IDA 等工具 hook,下面一窥其内部协议。

Flutter 逆向基础

Reverse engineering Flutter apps (Part 1) 这篇文章提供了关于 快照、寄存器、Object Pool 等关键内容的信息,推荐首先阅读。

Dart VM

目前来看,下面这篇应该是最权威的介绍 Dart VM 的文章:
https://mrale.ph/dartvm/

Register

关于寄存器定义源码在:ARM32, AARCH64 中,其中 Arm 指令集变化并不大,但 aarch64 变化较大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# ARM32
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
# AARCH64
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

快照

libapp.so 是 Flutter 的一种快照,Flutter 中有 三种快照:

  1. kernel: 实际上只是生成了 AST,运行时:AST -> IR -> ASM
  2. app-jit: parsed/compiled 所有的函数和类(train run),到时仅运行 user code
  3. app-aot: 从 main 开始完整的编译所有代码

平时打包到 apk 中的就是 app-aot 格式的快照,相当于 vm heap snapshot。

对这三种快照的介绍:Snapshots,更多关于快照的内容 Anatomy of a snapshot

确定版本

可通过快照 Hash 来确定,比如 𝘚 使用的是:8ee4ef7a67df9845fba331734198a953,如果抹除了快照 Hash,暂时无法确定版本。

准备工作

在开始逆向之前,应该做一些准备工作,来降低逆向的难度,主要是按照下面两篇文章:

  1. The Current State & Future of Reversing Flutter™ Apps
  2. Obstacles in Dart Decompilation & the Impact on Flutter™ App Security

但因为文章中是 64 位的 app,所以需要做一点点修改,具体参考左侧仓库中的 arm32 branch。

提取信息

Flutter 快照中存在符号信息,但无法被 IDA 正确识别,若要提取信息,有两种方法,第一种是解析 Dart 快照,比如:darter, Doldrums,但因为其快照格式更新频繁,所以维护成本高;第二种方式方法是:魔改 Flutter runtime library,使程序可以在运行的时侯 dump 出有用信息,其最知名的就是 reFlutter。

reFlutter

使用 reFlutter 需要重新打包 apk,也可以使用 flutter 逆向助手 基于 reFlutter 但不需要重新打包即可 dump。

Obfuscation

Flutter 默认自带混淆插件,会混淆函数的符号信息,如果此时恰好有旧版本没有混淆的,那么可以使用:BinDiffDiaphora,通过对比来确定函数名,具体参考第一篇文章。

Rename Functions

拥有了类和函数的符号信息,下面就可以将 IDA 中对应偏移的函数重命名,首先要做的就是把 reFlutter 获取到的 dump.dart 解析以下,转为 json,方便重命名,具体参考 parse_info.py

解析完成后,可用 rename_flutter_functions.py 这个 IDAPython 脚本批量将 json 中的信息 全部重命名到 IDA 数据库中。

完成函数的重命名之后,对 𝘚 而言就可以使用 IDA 进行动态调试了,下面的步骤也非常 重要/有趣,尽管对 𝘚 的动态调试动态调试影响不大。

反汇编很奇怪

伪代码非常不可读,甚至不如 反汇编,这是为什么呢?

  1. Flutter 中所有 object 都是通过 object pool 间接引用
  2. Flutter 使用了自定义的栈 r13/x15,并且通过栈来传递参数
  3. Flutter 使用自定义的二进制接口 (ABI)

下面就根据第二篇文章的内容,来完成修复。

Object Pool

Flutter 中所有类都是通过 Object Pool (R5/X27) 来间接访问的,这就导致 IDA 反汇编几乎找不到任何类。

保存到快照中的 object pool 是序列化之后的,这些内容无法直接使用。想要反序列化之后的 object pool 需要 dump 运行中的内存。

那么如果想要恢复出 Object,要将 app 跑起来,具体步骤为:

  1. dump 出运行时 r5/x27 指向的附近的所有内存
  2. rebase libapp.so 使其和 dump 出来的内存对齐
  3. 手动读取 object pool,导入其中 object 指向的内存块
  4. 使用 ida python 脚本恢复 object

具体的方法和脚本,参见左侧仓库:dump_flutter_memory.js 或原文。

object

以下对结构的讨论仅对 𝘚 起效,其他版本的快照,需要根据 app_snapshot.cc 做些修改。

object 结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
struct DartObjectTag  // 4B
{
char is_canonical_and_gc; // 1B 表示引用数量?
char size_tag;
__int16 cid; // object 类型 ID
};

struct DartUnkObject // 未知对象
{
char is_canonical_and_gc;
char size_tag;
__int16 cid;
<int padding> // int: arm32 = __int32, aarch64 = __int64

// 常常是一个指针列表,依次指向该对象的各种属性
// DartObject *attribute_objects[]
<unknown class specific data>
<int padding>
};

struct DartString // 字符串
{
char is_canonical_and_gc;
char size_tag;
__int16 cid; // arm32 = 0x51, aarch64 = 0x55
int s_len; // int: arm32 = __int32, aarch64 = __int64
<int padding>
char s[];
};

struct DartBytes // Bytes 串
{
char is_canonical_and_gc;
char size_tag;
__int16 cid; // arm32 = 0x6F
int b_len; // int: arm32 = __int32, aarch64 = __int64
<int padding>
__int8 b[];
};

struct DartObjectPool
{
DartObjectTag tag;

int nb_dart_objects_in_object_pool; // int: arm32 = __int32, aarch64 = __int64
DartObject *object_pool_array[];
};

odd pointer

指向 object 的指针都是奇数,这是为了区分对象和数字,数字是偶数,这就代表:如果一个指针是奇数,那它指向的内存是从其指向内存的上一个字节开始的,比如:0xBF965CC1,它指向的其实是 0xBF965CC0 并且这块内存对应的是一个 object;另如:0xBF967750,它指向一个数字。

创建 IDA struct

通过上面的步骤,导入 r5/x27 指向的内存之后,就可用 create_dart_objects.py 脚本来解析 object pool,解析完成后,可能会有些报红,按照缺少的内存位置导入相应 dump 出来的内存段。

映射

将创建的 struct 与反汇编相关联:add_xref_to_dart_objects.py

将创建的 struct 与伪代码相关联:在反汇编时 根据 microcode r5.4/x27.8 来 Hook,替换为 内存中 Object 位置,相当于把原来的间接访问改为直接访问。脚本在:add_dart_objects_in_decompiled_code.py 中。

Stack

arm 指令集无需修改,但 aarch64 指令集需要,方法简单粗暴:遍历所有指令找到 x15 然后替换为 sp,patch_dart_stack_pointer.py

传参和返回(ABI)

Flutter 的参数传递全部都是通过 sp(r13/x15)/fp 完成的,返回值和通常一样都是 r0;也因为都是在栈上传参的,所以 IDA 无法正确识别参数,可以适当修改函数签名,如:

1
2
void __usercall LocalPuzzles___handlePublishTapped(void *context@<^16.8>, void *uuid@<^8.8>, void *user@<^0.8>)
int __usercall LiveTool__decryptData@<R0>(DartString1 *str@<^0.4>)

一个个的修改函数的签名过于麻烦,这里仅需要修改用到的几个关键函数足够了。

问题

完成上述步骤后,IDA 中的代码其实还没到初步可读的程度,主因是 r10(thr),很多的函数访问都是通过该寄存器来完成的,其指向的函数大都无法静态追踪。

效果

执行完如下脚本之后,就完成了原文中的各种静态恢复:

恢复完的函数名和结构体:

恢复完的伪代码,效果不佳,其中黄色的 v5 就是 r10:

抓包

BoringSSL

因为 Flutter 使用的并非系统提供的 ssl 库,而是其自带的 BoringSSL 库,所以,通用的系统 vpn 和 proxy 方式不能起效,需要转发全部流量才行,可用 ProxyDroid 或直接用 iptables 来抓包,此外可以尝试基于 ebpf 的流量控制工具。

针对抓不到包的问题已经有不少文章讨论过了,如:

  1. Intercepting traffic from Android Flutter applications
  2. Intercepting Flutter traffic on Android (ARMv8)
  3. Intercept Flutter traffic on iOS and Android (HTTP/HTTPS/Dio Pinning)

这位大佬详细分析了 Android 和 IOS 平台的 Flutter 应用无法抓包的原因以及 hook 位置和抓包方法。

其对应的源码位于:ssl_crypto_x509_session_verify_cert_chain

有两种方式干掉 ssl pinning,一种是想办法识别该函数的特征,然后 hook 掉,另外一种是直接使用魔改的 flutter 库替换原版的 flutter,两种方法分别对应如下:

frida

上文作者提供了一个 frida 脚本 disable-flutter-tls-verification(原版有个小 bug: fixed version),提供了相对通用的匹配规则来 hook 该函数。

reFlutter

除了上述方法外,还可以用 reFlutter 重打包,替换掉 libflutter.so,然后配合 BurpSuite 抓包,具体参考:Traffic interceptionBypass SSL Pinning for flutter apps using reFlutter Framework

抓包结果

frida

因为 r10(thr) 的存在,导致静态分析几乎无法解决任何问题,仅仅能够通过重命名后的函数名来猜测关键位置,hook 判断流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
Java.perform(hookdecData)

// 调用该函数极易导致崩溃
function Caller(baseAddr, funAddr, context, funame) {
console.log(
funame + " Base address: " + baseAddr + " funAddr: " + funAddr + " ==> " +
Thread.backtrace(context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join('\n')
);
}

function sp_info(sp) {
return `sp: ${sp}: ${sp.readPointer()}, ${sp.add(4)}: ${sp.add(4).readPointer()}, ${sp.add(8)}: ${sp.add(8).readPointer()}`
}

function dec_info(sp) {
// cid == 0bxx: 获取数据位置 首先 -1 得到 object 起点,然后 +4 得到数据在内存中位置
console.log(sp.readPointer() // 首先是一个 cid == 0b12
.add(-1 + 4).readPointer() // 然后 cid == 00bf 存储了 bytes 的指针
.add(-1 + 4).readPointer() // +4 -1 得到 iv 在内存中位置
.readByteArray(16)); // 从该位置获取长度 16b 的 iv
console.log(sp.add(8).readPointer() // 首先从 栈上读取一个指针,其指向 cid == 0b18
.add(-1 + 4).readPointer() // cid == 0b11
.add(-1 + 4).readPointer() // cid == 00bf 存储的是 bytes 的指针
.add(-1 + 4).readPointer() // +4 -1 得到 key 在内存中位置
.readByteArray(32)); // 从该位置获取长度 32b 的 key
}

function hookdecData() {
let baseAddr = Module.findBaseAddress("libapp.so");
var snapAddr = Module.findExportByName('libapp.so', '_kDartIsolateSnapshotInstructions')
console.log(baseAddr, snapAddr)

Interceptor.attach(snapAddr.add(0x35fda0), {
onEnter: function (args) { // 传入一个参数:需要解密的 data 字符串,cid == 0x51
console.log(this.context.r10.readByteArray(1024));
console.log("enter decryptData: ", snapAddr.add(0x35fda0), sp_info(this.context.sp));
// let s_len = this.context.sp.readPointer().add(-1 + 4).readU32() / 2
// console.log(this.context.sp.readPointer().add(-1).readByteArray(12 + s_len));
},
onLeave: function (ret) {
// console.log("leave: decryptData, r0: ", this.context.r0);
// console.log(this.context.r0.readByteArray(32));
}
})

Interceptor.attach(baseAddr.add(0x1e3d88), {
onEnter: function (args) {
// Caller(baseAddr, baseAddr.add(0x1e3d88), this.context, "Encrypter_decrypt")
console.log(this.context.r10.readByteArray(1024));
console.log("enter Encrypter_decrypt: ", baseAddr.add(0x1e3d88), sp_info(this.context.sp));
// dec_info(this.context.sp)
}
})

Interceptor.attach(baseAddr.add(0x4f16a4), {
onEnter: function (args) {
// Caller(baseAddr, baseAddr.add(0x4f16a4), this.context, "AES__decrypt")
console.log("enter AES__decrypt: ", baseAddr.add(0x4f16a4), this.context.r12, sp_info(this.context.sp));
dec_info(this.context.sp)
},
onLeave: function (ret) {
// console.log("leave: AES__decrypt, r0: ", this.context.r0);
// let r_len = this.context.r0.add(8-1).readU32() / 2
// console.log(this.context.r0.add(-1).readByteArray(12 + r_len));
}
})
}

经过不断尝试,初步判断调用流程为:decryptData -> Encrypter_decrypt -> AES__decrypt

但很快发现 key 是每小时修改的,但并没有在抓包中发现下发的 key,那么初步推测是本地生成的,此时再用 frida 分析就显得非常无力,本来想使用一些 trace 工具,从中找查找加密方式,但苦于没有顺手的 tracer,最终还是拾起了 IDA,啊,真香!

IDA 动态调试与分析

decryptData

通过上面 frida 的分析,decryptData 函数应该是整个解密的入口,然后再在 Encrypter_decryptAES__decrypt 加上断点,使用 32 位的 server,attach 上,随便在 App 中点击一下:

成功断下了,然后一路 f8,执行到 下面的位置:

发现之后就进入到 Encrypter_decrypt 中了,那重启调试器,进去看看,很顺利,进入到一个 C47 开头的函数中:

通过查看其传入参数,不包含 key,但在其中调用了 Encrypter_decrypt 其中需要 key 参数,所以 C47 及其调用中必然存在 key 的生成规则。

下面需要详细分析 C47 这个函数:

其首先获取当前 UTC 时间,格式为 %Y%b%#d%#H,然后转为大写,接下来:

转为 Uint8List 后每个 byte + 4,这部分在函数内部完成。

这是拼接上 Shinkansen188,然后获取 UserInfo

上图中是 将拼接后的字符做 MD5。

这里注意 hash 函数包括两种算法,这里用的是 MD5,后面还会用 SHA256。

如上,其结果经过三层 object 可见。

接下来把刚才获取到的 UserInfo 中的 token 和 md5 拼接起来。

做 sha256(token + md5);然后是些无用操作。

其结果保存到了 0xBEF89224 中,以 AB B1 开头,这就是需要的 key

IV 是固定的,直接以 base64 的方式存储在 so 中。

后面就是 AES 初始化和 解密 操作了,没有密码学上的魔改,参考 frida 部分,不再赘述。

encryptDataWithR

使用上面的 AES 解密可以解决抓包中的大部分加密了,但还有两个特殊情况:

  1. Host/Info : 其 post 的 data 是上面的 key 和 iv 加密的
  2. Host/loadMore : 其 post 的 data 无法正确加密,这是本节内容

既然是加密,那么把所有 *encry* 都打上断点,然后启动调试器,点开一个视频后,下滑,成功断在 encryptDataWithR,如下图:

传入参数是 idid_xxxx

然后 load 了 Public Key

然后 parse 转为 Uint8List,以 C7 73 10 开头,后面直接就是 rsa 加密。

源码对照

通过上面的观察,AES 解密 / RSA 加密 应该是调用了外部包,经过一番搜索,是如下两个:

  1. https://github.com/leocavalcante/encrypt
  2. https://github.com/bcgit/pc-dart

比如 RSA 其对应的关键函数:

小结

整个过程中最关键的是这三个步骤:

  1. 了解 Object 的结构和 ABI,这样才能在内存中找到需要的内容
  2. 导入函数名至关重要,否则就像无头苍蝇
  3. 适时找到外部包,这将节省巨量时间

评论