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 | # ARM32 |
快照
libapp.so 是 Flutter 的一种快照,Flutter 中有 三种快照:
kernel
: 实际上只是生成了 AST,运行时:AST -> IR -> ASMapp-jit
: parsed/compiled 所有的函数和类(train run),到时仅运行 user codeapp-aot
: 从 main 开始完整的编译所有代码
平时打包到 apk 中的就是 app-aot 格式的快照,相当于 vm heap snapshot。
对这三种快照的介绍:Snapshots,更多关于快照的内容 Anatomy of a snapshot。
确定版本
可通过快照 Hash 来确定,比如 𝘚 使用的是:8ee4ef7a67df9845fba331734198a953,如果抹除了快照 Hash,暂时无法确定版本。
准备工作
在开始逆向之前,应该做一些准备工作,来降低逆向的难度,主要是按照下面两篇文章:
- The Current State & Future of Reversing Flutter™ Apps
- 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 默认自带混淆插件,会混淆函数的符号信息,如果此时恰好有旧版本没有混淆的,那么可以使用:BinDiff 或 Diaphora,通过对比来确定函数名,具体参考第一篇文章。
Rename Functions
拥有了类和函数的符号信息,下面就可以将 IDA 中对应偏移的函数重命名,首先要做的就是把 reFlutter 获取到的 dump.dart 解析以下,转为 json,方便重命名,具体参考 parse_info.py。
解析完成后,可用 rename_flutter_functions.py 这个 IDAPython 脚本批量将 json 中的信息 全部重命名到 IDA 数据库中。
完成函数的重命名之后,对 𝘚 而言就可以使用 IDA 进行动态调试了,下面的步骤也非常 重要/有趣,尽管对 𝘚 的动态调试动态调试影响不大。
反汇编很奇怪
伪代码非常不可读,甚至不如 反汇编,这是为什么呢?
- Flutter 中所有 object 都是通过 object pool 间接引用
- Flutter 使用了自定义的栈 r13/x15,并且通过栈来传递参数
- Flutter 使用自定义的二进制接口 (ABI)
下面就根据第二篇文章的内容,来完成修复。
Object Pool
Flutter 中所有类都是通过 Object Pool (R5/X27)
来间接访问的,这就导致 IDA 反汇编几乎找不到任何类。
保存到快照中的 object pool 是序列化之后的,这些内容无法直接使用。想要反序列化之后的 object pool 需要 dump 运行中的内存。
那么如果想要恢复出 Object,要将 app 跑起来,具体步骤为:
- dump 出运行时 r5/x27 指向的附近的所有内存
- rebase libapp.so 使其和 dump 出来的内存对齐
- 手动读取 object pool,导入其中 object 指向的内存块
- 使用 ida python 脚本恢复 object
具体的方法和脚本,参见左侧仓库:dump_flutter_memory.js 或原文。
object
以下对结构的讨论仅对 𝘚 起效,其他版本的快照,需要根据 app_snapshot.cc 做些修改。
object 结构
1 | struct DartObjectTag // 4B |
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 | void __usercall LocalPuzzles___handlePublishTapped(void *context@<^16.8>, void *uuid@<^8.8>, void *user@<^0.8>) |
一个个的修改函数的签名过于麻烦,这里仅需要修改用到的几个关键函数足够了。
问题
完成上述步骤后,IDA 中的代码其实还没到初步可读的程度,主因是 r10(thr),很多的函数访问都是通过该寄存器来完成的,其指向的函数大都无法静态追踪。
效果
执行完如下脚本之后,就完成了原文中的各种静态恢复:
恢复完的函数名和结构体:
恢复完的伪代码,效果不佳,其中黄色的 v5 就是 r10:
抓包
BoringSSL
因为 Flutter 使用的并非系统提供的 ssl 库,而是其自带的 BoringSSL 库,所以,通用的系统 vpn 和 proxy 方式不能起效,需要转发全部流量才行,可用 ProxyDroid 或直接用 iptables 来抓包,此外可以尝试基于 ebpf 的流量控制工具。
针对抓不到包的问题已经有不少文章讨论过了,如:
- Intercepting traffic from Android Flutter applications
- Intercepting Flutter traffic on Android (ARMv8)
- 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 interception 和 Bypass SSL Pinning for flutter apps using reFlutter Framework。
抓包结果
frida
因为 r10(thr) 的存在,导致静态分析几乎无法解决任何问题,仅仅能够通过重命名后的函数名来猜测关键位置,hook 判断流程。
1 | Java.perform(hookdecData) |
经过不断尝试,初步判断调用流程为:decryptData -> Encrypter_decrypt -> AES__decrypt
。
但很快发现 key 是每小时修改的,但并没有在抓包中发现下发的 key,那么初步推测是本地生成的,此时再用 frida 分析就显得非常无力,本来想使用一些 trace 工具,从中找查找加密方式,但苦于没有顺手的 tracer,最终还是拾起了 IDA,啊,真香!
IDA 动态调试与分析
decryptData
通过上面 frida 的分析,decryptData
函数应该是整个解密的入口,然后再在 Encrypter_decrypt
和 AES__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 解密可以解决抓包中的大部分加密了,但还有两个特殊情况:
- Host/Info : 其 post 的 data 是上面的 key 和 iv 加密的
- Host/loadMore : 其 post 的 data 无法正确加密,这是本节内容
既然是加密,那么把所有 *encry*
都打上断点,然后启动调试器,点开一个视频后,下滑,成功断在 encryptDataWithR
,如下图:
传入参数是 idid_xxxx
。
然后 load 了 Public Key
然后 parse 转为 Uint8List,以 C7 73 10 开头,后面直接就是 rsa 加密。
源码对照
通过上面的观察,AES 解密 / RSA 加密 应该是调用了外部包,经过一番搜索,是如下两个:
比如 RSA 其对应的关键函数:
小结
整个过程中最关键的是这三个步骤:
- 了解 Object 的结构和 ABI,这样才能在内存中找到需要的内容
- 导入函数名至关重要,否则就像无头苍蝇
- 适时找到外部包,这将节省巨量时间