发现一篇很好的vmp分析,360加固dex解密流程分析 | oacia = oaciaのBbBlog~ = DEVIL or SWEET跟着复现学习
init_array
JNI_onLoad
反调试
自定义linker加载第二个so
手工修复elf_header
动态注册函数
native化java函数分发器分析
根据注册vmp方法时的描述信息执行分支
分析指令映射表
java层:
从 AndroidManifest.xml
中可以得知,360 加固的入口是 com.stub.StubApp
, 所以我们就先进到 apk 的入口进行分析
寻找两个重要的系统回调函数onCreate
和 attachBaseContext
,attachBaseContext
在onCreate
之前执行
我这里没有出现混淆,显示JEB 在反编译时识别到某个字段a,并且它是一个
java.lang.String,找到加密函数,一个16的异或
顺便学习一下jeb的python脚本
https://www.anquanke.com/post/id/228981
1 | # coding=utf-8 |
attachBaseContext
的核心是根据系统架构、是否需要桥接等条件进行加载不同的动态库文件(libjiagu.so、
libjiagu_x86.so、
libjiagu_x64.so)
so 在 assets
目录下
在加载完 libjiagu_xxx.so
之后,还调用了 DtcLoader
类进行初始化(jeb没有,gdae看)
找到
目的是加载一个名为 libjgdtc.so
的.so 文件,分析重点是在 libjiagu.so
中
壳 ELF 导入导出表修复
使用 ida 分析 libjiagu_a64.so
,导入表和导出表为空,在 so 装载进内存时导入导出表才会去进行相应的链接操作
frida dump下来so用yang神的项目 lasting-yang/frida_dump: frida dump dex, frida dump so
开启 frida
在当前源码路劲 : python dump_so.py 然后 python dump_so.py libjiagu_64.so
打开修复后的so
加固壳反调试初步分析
frida常见过检测思路:
1.自编译frida server 魔改一些常见特征,然后过检测,例如hluda-server
2.通过hook strstr、strcmp等系统函数,将一些比较frida的情况给hook
3.通过hook readlink将一些maps等文件进行重定向
4.通过hook so加载和pthread创建,定位frida检测线程,然后将线程给hook
5.通过逆向分析,定位frida检测函数,然后hook
【安卓逆向】libmsaoaidsec.so反调试及算法逆向案例(爱库存)
[原创]绕过bilibili frida反调试-Android安全-看雪-安全社区|安全招聘|kanxue.com
hook dlopen看一下so的加载流程
由于命名空间的变化 Android 7.0以上 对android_dlopen_ext 进行hook Android7.0及以下 对dlopen进行hook
1 | function hook_dlopen() { |
输出日志:反调试是在 libjiagu_64.so
中
1 | load /data/app/com.oacia.apk_protect-5j4R5Rk3FA7UwMimKR6EHQ==/oat/arm64/base.odex |
先看看strstr的hook
1 | function antiAntiFrida() { |
- gmain:Frida 使用 Glib 库,其中的主事件循环被称为 GMainLoop。在 Frida 中,gmain 表示 GMainLoop 的线程。
- gdbus:GDBus 是 Glib 提供的一个用于 D-Bus 通信的库。在 Frida 中,gdbus 表示 GDBus 相关的线程。
- gum-js-loop:Gum 是 Frida 的运行时引擎,用于执行注入的 JavaScript 代码。gum-js-loop 表示 Gum 引擎执行 JavaScript 代码的线程。
- linjector 是一种用于 Android 设备的开源工具,它允许用户在运行时向 Android 应用程序注入动态链接库(DLL)文件。通过注入 DLL 文件,用户可以修改应用程序的行为、调试应用程序、监视函数调用等,这在逆向工程、安全研究和动态分析中是非常有用的。
输出日志
1 | strstr(s1="/data/local/tmp/re.frida.server/frida-agent-64.so", s2="/base.apk" ) |
可以知道它们来自maps,而Frida的一大特征就是在注入到app中后,app的maps中会有frida-agent.so的内存分布
或者使用:
1 |
|
也能看出是maps检测;
我们正常打开一次加壳的 apk, 然后使用下列命令备份 maps(status有时也会检测,同理)
1 | cp /proc/self/maps /sdcard |
1 | function my_hook_dlopen(soName = '') { |
进程由于非法内存访问而退出了,这说明 360 加固不仅读取 maps 文件,并且会尝试访问 maps 文件中所记录的文件或内存映射。这里由于 frida 注入后重启 apk, 但是备份的 maps 文件中记录的是先前的映射起始地址 (这块内存在关闭 apk 后就被抹去了), 所以当壳尝试访问其中的映射时产生了非法内存访问从而让进程崩溃
解决方式是将上述 frida 代码中的 fakePath
赋值为一个不存在的文件例如 /data/data/com.oacia.apk_protect/maps_nonexistent
, 来让壳没有内容可以访问
修改完 fakePath
后重新注入代码,
打印一下堆栈 假如我们使用常规的 frida 打印堆栈代码,即使用 DebugSymbol.fromAddress 函数来判断地址所在的 so 的位置,那么进程是会报错退出的
1 | console.log('RegisterNatives called from:\\n' + Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join('\\n') + '\\n'); |
所以这里 DebugSymbol.fromAddress
所实现的逻辑需要自己编写,即下方的 addr_in_so
函数
1 | function addr_in_so(addr){ |
classes.dex
与 classes2.dex
的堆栈回溯完全相同,并且 classes3.dex
的前半部分和前两个 dex 的堆栈一样,随后进程便又退出了
或者另外一种思路是hook readlink将一些maps等文件进行重定向 ,也是先把maps status文件等保存
脚本(但是无效)
1 | function LogPrint(log) { |
用 010editor 打开 classes.dex , 发现前几位并不是 dex 的魔术头,说明这个 dex 还没有被解密,不过现在我们只需要分析 dex 如何被壳从内存中释放出来的过程就可以了
主elf文件解密流程分析
继续分析之前dump下来的libjiagu_64.so
alt+T 搜索ELF,存在ELF文件头
写个 python 脚本,把这个 ELF 从 0x0e7000
开始后面的所有字节都复制到新的文件里面
1 | with open('libjiagu_64.so_0x77f8acb000_2572288_fix.so','rb') as f: |
把这个 elf 提取出来之后拿 010editor
看却发现 program header table
被加密了,且ida无法正常解析。
在elf文件中加载elf,是自实现 linker 加固 so
自定义linker加固so - 吾爱破解 - 52pojie.cn
[原创]自實現Linker加載so-Android安全-看雪-安全社区|安全招聘|kanxue.com
对于这种加固方式,壳 elf 在代码中自己实现了解析 ELF 文件的函数,并将解析结果赋值到 soinfo 结构体中,随后调用 dlopen 进行手动加载
看到第二个交叉引用,来到 sub_3C94 函数,这个 for 循环看起来像是在用符号表通过 dlopen 加载依赖项
看到这个 switch 就知道找对地方了,这里应该就是自实现 linker 来加载 so 的,这和 AOSP 源码 ( android-platform\bionic\linker\linker.cpp
) 中的预链接 ( soinfo::prelink_image
) 这部分的操作极为的相似
在 ida 中导入 soinfo 相关的符号
在 ida 中依次点击 View->Open subviews->Local Types
, 然后按下键盘上的 Insert
将下面的结构体添加到对话框中
1 | //IMPORTANT |
导入完成后按下 Y
键,将 a1
定义为 soinfo*
这里不应该出现 a1[1]
或者 a1[2]
, 猜测这个 soinfo 有被魔改的痕迹
是从 sub_3C94
这个预链接相关函数入手好了,交叉引用发现 sub_3C94
是被 sub_49F0
调用
随后我们来到 sub_49F0
内调用 sub_3C94
函数的位置,向下看,进入 sub_4918
函数中
sub_4918
中调用了 sub_5E6C
, 我们进入 sub_5E6C
把刚刚提取出来的 elf 用 010editor
打开,看到 elf_header
的 phentsize
这个字段,这个字段的含义是一个 Program header table
的长度,它正正好好也是 0x38
该字段表示 Program Header Table的大小(以字节为单位)。
所以说在 sub_5E6C
中变量 v5
的类型应该是 Elf64_Phdr *
, 我们直接重定义类型
既然知道了真正的 program header table
就是在这个位置的,那我们直接在这个地方把 program header table
整个给 dump 下来
所以我们直接去 hook sub_5E6C
的三个传入的值
1 | function my_hook_dlopen(soName = '') { |
上面的第一个 hexdump 就是 program header table
, 我们可以用 cyberchef
将 hexdump
转成数组的形式
输出:
1 | 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF |
第一个参数 就是 program header table
, 我们可以用 cyberchef
将 hexdump
转成数组的形式 ,然后ctrl+shift+v粘贴进program header table段
1 | 0x01,0x00,0x00,0x00,0x05,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xe8,0xb8,0x16,0x00,0x00,0x00,0x00,0x00,0xe8,0xb8,0x16,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x06,0x00,0x00,0x00,0xf0,0xc3,0x16,0x00,0x00,0x00,0x00,0x00,0xf0,0xc3,0x17,0x00,0x00,0x00,0x00,0x00,0xf0,0xc3,0x17,0x00,0x00,0x00,0x00,0x00,0x80,0xd9,0x00,0x00,0x00,0x00,0x00,0x00,0xc8,0xeb,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x02,0x00,0x00,0x00,0x06,0x00,0x00,0x00,0xc0,0xe3,0x16,0x00,0x00,0x00,0x00,0x00,0xc0,0xe3,0x17,0x00,0x00,0x00,0x00,0x00,0xc0,0xe3,0x17,0x00,0x00,0x00,0x00,0x00,0xf0,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0xf0,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x50,0xe5,0x74,0x64,0x04,0x00,0x00,0x00,0x84,0x20,0x15,0x00,0x00,0x00,0x00,0x00,0x84,0x20,0x15,0x00,0x00,0x00,0x00,0x00,0x84,0x20,0x15,0x00,0x00,0x00,0x00,0x00,0x44,0x3e,0x00,0x00,0x00,0x00,0x00,0x00,0x44,0x3e,0x00,0x00,0x00,0x00,0x00,0x00,0x04,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x51,0xe5,0x74,0x64,0x06,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x52,0xe5,0x74,0x64,0x04,0x00,0x00,0x00,0xf0,0xc3,0x16,0x00,0x00,0x00,0x00,0x00,0xf0,0xc3,0x17,0x00,0x00,0x00,0x00,0x00,0xf0,0xc3,0x17,0x00,0x00,0x00,0x00,0x00,0x10,0x2c,0x00,0x00,0x00,0x00,0x00,0x00,0x10,0x2c,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00 |
0x6
则对应着 phnum
, 这表示共有 6 个 program header table
0x7e74ac5000
表示这个主 ELF 的基址,因为这个主 ELF 的位置在壳 ELF 基址的偏移 0xe7000
处,而最下面这行也已经打印出了壳 ELF 的基址为 0x7e74bac000
, 0x7e74bac000==0x7e74ac5000+0xe7000
等式成立
phnum
是 ELF 文件头中的一个字段,表示 程序头表(Program Header Table)中的条目数量。base
是指 模块的基地址。它表示该 ELF 文件加载到内存中的起始位置。
我们拿到了解密之后的 program header table
, 同时我们也知道了 sub_5E6C
传入的三个参数分别是 phdr
, phnum
以及 base
但是 phdr
成员命名是在 soinfo 偏移的 0x0
的位置
那假如 a1
的类型就是 soinfo*
, 为什么在 sub_4918
里面调用 sub_5E6C
传入的不应该是偏移是 232
所以 soinfo* 必定有被魔改,同时我们也可以在 soinfo 前填充一个大小为 232 的 char 类型数组看看是什么情况
完整的,验证了我们对于 soinfo*
被魔改的猜测
一个 ida 插件来实现主 ELF 的函数调用链是什么样子 stalker_trace_so
在 IDA 中使用 Edit->Plugins->stalker_trace_so
后,在 so 所在的目录下会生成一个 js 脚本,我们用 frida 注入到 apk 中即可,需要注意的是 so_name
需要改成 libjiagu_64.so
其他:https://github.com/SeeFlowerX/stackplz
frida/frida-itrace: Instruction tracer powered by Frida
chame1eon/jnitrace: A Frida based tool that traces usage of the JNI API in Android apps.
以 sub_3C94 为起点开始分析,因为这是我们通过 dlopen 交叉引用找到的自实现 linker 加固 so 的一个功能函数
对 sub_3C94
不断按下 X
查看交叉引用,得到如下的调用关系 sub_4B54->sub_49F0->sub_3C94
sub_4B54
可能被 sub_8000
或 sub_8C74
调用
1 | call43:sub_8000 <-- |
sub_8000
的函数长这个样子,第 25 行 0xB8010
后面会派上用场的
跟着函数调用在IDA中跳转到相应的地址进行查看,在 call62:sub_5F20 我们发现rc4的代码
RC4 在初始化的过程中,密钥的主要功能是将S盒搅乱,对S盒一系列操作
识别重点:
2个长度为256的For循环
S盒乱序时的数据交换
以及最后的异或加解密
用 frida 去 hook 一下这个函数看看 RC4 的密钥是什么
1 | function hook_5f20_guess_rc4(){// 像是 RC4 的样子,hook 看看 |
密钥就是,0xa就是密钥长度
1 | key = b"vUV4#\x91#SVt" |
继续跟着函数调用链走,在 call63:sub_6044
我们发现了 RC4 的解密函数
hook 一下 call63:sub_6044
看看到底给什么数据解密了
1 | var rc4_enc_text_addr,rc4_enc_size; |
结果:这个函数的第二个参数是 0xb8010
,在前面 sub_8000
中出现过
而 v5[0]
的值是 qword_2E270
, 这个数组也是 01 18 25 e7
开头的
继续跟着调用链走,接下来是调用 call65:uncompress
, 进行解压缩操作,hook看看
1 | function hook_uncompress_res(){ |
发现解压缩的数据,前面四个字节 b9 0e 1a 00
没有包含在解压缩的字节之内
已经知道了主 ELF 在壳 ELF 中的位置,以及解密的算法,那我们直接从解压 apk, 找到里面的 assets/libjiagu_a64.so
, 就能直接把壳 ELF 解密出来
我们发现的揭秘数据在.load段,用010editor打开这个段,最后有个p_align,表示这个段要对齐到虚拟地址0x10000的倍数的位置,所以现在LOAD段的起始地址是0x1e270+0x10000 ,要对应到相对于文件的地址的话减掉0x10000就可以
1 | import zlib |
解密完成后,我们发现 0x1a0eb9
应该表示解压缩之后数据的大小,wrap_elf 的前半部分是一大堆莫名其妙有很多 D3 的东西,但是看到中间还是发现了壳 ELF 的身影
以 .ELF
为标志将这两部分分离一下
1 | with open('wrap_elf', 'rb') as f: |
跟着函数调用链来到 call69:sub_5B08
, 这里又出现了 0x38
, 并且 word_38
跳转过去的值为 6
这正好和 phentsize
和 phnum
的值相对应
phentsize
是一个字段,表示 程序头表 中每个条目的大小(以字节为单位)。每个程序头表条目(即每个 程序段)都有一个固定的大小。该大小由 ELF 文件头中的phentsize
字段指定。phnum
是 程序头表 中条目的数量,表示该 ELF 文件包含多少个程序头(即程序段)。这个值告诉加载器在 程序头表 中有多少个条目,加载器需要根据这些条目的描述来加载 ELF 文件的不同段。
这又是一个关键点了,往下看一下代码,发现了循环异或,用 frida 把 v4 的值 hook 下来看看是什么
1 | function hook_5B08(){ |
v4 的值出现了那么多的 d3
,而这就是 wrap_elf
的前半部分那一大堆我们看不懂的字节
接下来用来解密的循环就是一个 arm64 的 neon 运算,NEON 是 ARM 架构中的一个 SIMD(Single Instruction, Multiple Data,单指令多数据)指令集,旨在加速多媒体和数字信号处理(DSP)任务。
官网可以找到 vdupq_n_s8 和 veorq_s8, 根据函数描述可以知道这里用向量运算,把向量中的每一个元素都异或了 0xd3
对 sub_5B08 进行分析之后,我们便可以知道 wrap_elf_part1 的读取方式是第一个字节表示被异或的数字,这里是 0xD3 , 后面的四个字节表示一个段的长度,随后读取指定长度的字节并异或,之后再读取四个字节获取到下一个段的长度,以此类推,直到读取到文件末尾
在 sub_5B08 的最后,因为n0x20xx 代表对应的数据组的长度,所以这里共有四个数据组,而为了表示每一个数据组的长度共需占用 4*4=16 字节,并且文件开头还有 1 位的异或值,于是这些长度加起来, a1[19]就来到了主 ELF 的魔术头 .ELF 的位置了
我们可以在 sub_5B08 中为变量 a1 定义一个结构体,成员分别表示数据组的 1,2,3,4 这四个部分,这样我们就知道这四个部分分别被用到什么地方了
1 | struct deal_extra |
接下来再捋一下函数的调用链 sub_49F0->sub_5478(&v16, a1, v4)->sub_5B08(a1, a2, a3)
, 在 sub_5B08
中,我们把 a1
的类型定义成了 deal_extra
, 所以理所应当的,我们也把 sub_49F0
中的变量 v16
的类型定义为 deal_extra
在
sub_49F0
中我们发现成员 extra_part
赋值给了变量 filename
, 所以我们也为 filename
建立一个结构体让 filename 的偏移可以对应这些变量
1 | struct deal_extra_B |
我们发现变量 filename
分别被传入到了 sub_3C94
和 sub_4918
中,我们分别进去看看
在 sub_3C94
中,这个 switch
是用来处理动态链接库的,即 extra_part4
对应 .dynamic
段
在 sub_4918
中, extra_part2 和 extra_part3
被传入到 sub_4000
中:
而这个函数中的 switch 是用来处理重定位的,因为重定位主要有基址重定位和符号重定位,这两个的值分别是 0x403 (1027)和 0x402(1026)
所以 extra_part2
和 extra_part3
分别对应着 .rela.dyn
(403 重定位) 和 .rela.plt
(402 重定位)
而之后 part1
被传入到了 sub_5E6C
中
而来到 sub_5E6C
也来到了我们最开始分析的起点, 所以 extra_part1
表示 program header table
数据组1
表示program header table
数据组2
表示.rela.plt
数据组3
表示.rela.dyn
数据组4
表示.dynamic
写个脚本把这四个数据组给分离成单独的文件
1 | import copy |
主 ELF 导入导出表修复
需要被修复的主 ELF 是我们在从 assets/libjiagu_a64.so
利用 RC4
和 decompress
解密出来的文件的后半部分那个 ELF
拿到了主 ELF 的四个重要的数据段,分别是 phdr
, .rela.plt
, .rela.dyn
, .dynamic
, 那么接下来需要做的工作就是修复主 ELF 的导入导出表了,
在使用自实现 linker 加固 so 时, phdr , .rela.plt , .rela.dyn , .dynamic 这四个段是从待加固的 so 中提取出来,然后加密存储到其他位置, 原来的位置会使用无关字节直接覆盖 等到需要为加固的 so 进行预链接和重定位的工作时,才将这些段解密并通过自己实现的预链接和重定位代码,让待加固的 so 可以正确的被壳 so 加载出来
原来的位置会使用无关字节直接覆盖 ,我们可以将分离出来的这四个段再塞回到原来的位置 自实现linker加固so 的加固方案既然都把那四个段加密存到其他地方了,那怎么不直接把原来的四个段直接删除而是用无关字节覆盖呢? 因为直接把段删除掉的话,会影响了一整个 ELF 文件的布局,偏移就会变得和原先不一样,然后产生各种奇奇怪怪的问题
在 010editor 中,按下 ctrl+shift+C
可以复制整块内存,按下 ctrl+shift+V
可以粘贴整块内存
修复 program header table
复制 libjiagu.so_0x150_phdr
的所有字节,然后来到 libjiagu_0xe7000.so
中选中 struct program_header_table
粘贴
随后按下 F5
刷新模板
修复 .dynamic
program header table
的 (RW_) Dynamic Segment
的 p_offset
指向 .dynamic
段的位置
跳转到该位置,复制 libjiagu.so_0x1b0_.dynamic
的内容并粘贴到这个位置
修复重定位表
我们需要通过 .dynamic 段的 d_tag 字段来直到重定位表的位置,下面是 AOSP 中 d_tag 的宏定义
所有的 d_tag 标志对应的含义可以在 ORACLE 链接程序和库指南 中找到
对于我们修复主 ELF 比较重要的 tag
有
d_tag | 值 | 含义 |
---|---|---|
DT_JMPREL | 0x17 | .rela.plt 在文件中的偏移 |
DT_PLTRELSZ | 0x2 | .rela.plt 的大小 |
DT_RELA | 0x7 | .rela.dyn 在文件中的偏移 |
DT_RELASZ | 0x8 | .rela.dyn 的大小 |
我们可以在 .dynamic
中发现这些 tag
以及对应的值
看看这两个大小分别是 0x1650 和 0x25188 , 和我们刚刚分离出来的文件大小一模一样
然后就是和之前一样,跳转到 .rela.plt 和 .rela.dyn 的对应地址,然后把这些段本来的数据粘贴进去
修复完成
为了方便起见,我们可以将主 ELF 的基址定义成在其在壳 ELF 的偏移 0xe7000 方便后续的分析
Edit–>Segments–>Rebase Program改变基址
主 DEX 解密流程初步分析
加固壳反调试初步分析中,我们拿到了未解密的 dex。
接下来有个问题就是,这个未解密的 dex 究竟藏在了 apk 的什么地方
经过 360 加固之后的 apk 解压出来,按大小对文件进行排序之后发现,最大的文件就只有这个壳 classes.dex , 而别的文件甚至连 1MB 都没到
在这个 classes.dex
的末尾,果然藏着一大堆的数据
末尾的数据是由 71 68 00 01
和我们之前看到的加密的 dex 一模一样
继续用 stalker_trace_so 去看看补充上主 ELF 的函数地址以及名称之后的函数调用链是什么样子的,首先在主 ELF 中运行插件 Edit->Plugins->stalker_trace_so
同样的,我们需要将 so_name
改成 libjiagu_64.so
, 特别注意的是,这里我们需要把壳 ELF 的 func_addr
和 func_name
给复制过来,同时使用 concat
方法将主 ELF 和壳 ELF 的函数地址和函数名拼接成一个新的数组
之前替换 /proc/self/maps
来实现初步反调试的 js 函数 hook_proc_self_maps
也需要同时执行
输出结果如下, KEkeELF
标志表示壳 ELF, mainELF
表示主 ELF
要判断调用的函数在哪个 ELF 里面,在 trace_so()
里面稍作修改判断一下范围可以了
1 | function (iterator) : void { |
打印
1 | (KEkeELF)call1:JNI_OnLoad |
分析输出的结果,发现三个有趣的函数 inflateInit2_
, inflate
, inflateEnd
, zlib 用来解压缩的函数
1 | (mainELF)call230:inflateInit2_ |
对 inflateInit2_
交叉引用,发现有两个函数调用了它
向上看看函数调用链,发现是 sub_1B6270
调用了 inflateInit2_
1 | (mainELF)call227:sub_1B6270 <-- |
我们来到 sub_1B6270
, 先到 https://github.com/madler/zlib 把 zlib.h
中的 z_stream_s
, 导入的方法和之前一样
1 | # define z_const const |
重定义 s
的类型为 z_stream
, 这四个字段的含义如下
s.next_in
: 压缩数据s.avail_in
: 压缩数据的长度s.next_out
: 解压后的数据s.avail_out
: 解压后数据的长度
各个成员的偏移如图所示
用 frida 去 hook 一下 inflate 函数看看解压缩之后的数据是什么
这里有个技巧,就是如何可以 hook 到主 ELF 中的函数,因为在壳 ELF 加载进内存时,主 ELF 还没有被加载,所以假如在壳 ELF 通过 android_dlopen_ext 打开时我们进行 hook, 是会 hook 失败的 那么如何才能获取到主 ELF 的 hook 时机呢?我们可以通过统计外部函数的调用次数来判断是否已经加载了主 ELF,
例如这里,我通过 zlib_count 统计外部函数 inflate 调用次数,因为在壳 ELF 会使用 uncompress 调用一次 inflate , 所以当第二次调用 inflate , 我们就知道这肯定是主 ELF 调用的,所以我们也可以在这个位置放心大胆的 hook 了
1 | function dump_memory(start,size,filename) { |
解压缩之后的输出如下,在输出的文件头发现了 dex035
, 所以我们把这块内存 dump 下来看看,使用上方的 dump_memory(start,size,filename)
函数
通过校验哈希发现 dump 下来的 dex 和壳 dex 其实是同一个文件
之前的分析中知道壳 dex 的末尾附带了一大串的加密数据,所以通过将这个解压缩得到了这个 dex, 就说明马上要进行加密主 DEX 的解密操作
解压缩的函数是 sub_1B6270 , 通过 stalker_trace_so 打印出来的内容,并利用交叉引用来追踪该函数的调用链
通过 stalker_trace_so
打印出来的函数调用链,我们发现是 sub_1A0C88
在 sub_1B6270
之前调用,所以函数的调用关系就是 sub_1A0C88->sub_1B6270
, 以此类推
一路跟过来之后,函数的调用链为 sub_1332B8->sub_124FA0->sub_1A0C88->sub_1B6270->inflate
, sub_1332B8
函数之后就没有交叉引用了
1 | (mainELF)call189:sub_1332B8 |
在这个函数中,发现了apk@classes.dex
, 而它的作用,正是为了找到已加载到内存且优化后的壳 dex
在加固壳反调试初步分析的后半部分,我们打印出了加固壳打开 dex 的堆栈回溯,现在我们直接跳转到相对应的地方看看
如何可以定位到是什么位置调用了 open 函数来打开 classes.dex
在加固壳反调试初步分析的后半部分,我们打印出了加固壳打开 dex 的堆栈回溯,现在我们直接跳转到相对应的地方看看
(这里没有hook到0x19b780,恼
到 0x19b780
,似乎是一个标准的打开并写入文件的函数
对该函数进行交叉引用,sub_1332B8调用了它
我们对这两处调用都 hook 一下看看是什么情况,打印的结果如下,说明这两处调用都打开了 dex,
sub_1332B8
中的前一个调用打开了 classes.dex
, 后一个调用打开了 classes2.dex
和 classes3.dex
, 而 classes.dex
文件中的内容就是加密的主 dex
在创建完 classes2.dex
和 classes3.dex
, 通过 hook 发现调用在调用 sub_128D44
之后进程就退出了
hook 一下 sub_128D44
函数,传入的参数 v8
正是加密的主 DEX
并且在壳 ELF 加载时启动 stalker_trace_so
的 trace_so()
函数所打印出的结果中,并没有sub_128D44
的调用被打印出来
在调用 sub_128D44 的位置再去调用一次 trace_so() 函数从现在的位置开始打印函数的调用链
1 | function possible_place_call() { |
找到函数调用关系, mainELF 调用完 sub_128D44 之后,通过一系列操作又回到了壳 ELF 中,最终调用 raise 导致进程退出
待完成
加固壳反调试深入分析
pthread_create
反调试(pthread_create
是一个用于创建新的线程的函数)
1 | int pthread_create(pthread_t *thread, |
**pthread_t \*thread
**:
- 这是一个指向
pthread_t
类型的指针。pthread_t
是一个线程的标识符,它用于唯一标识一个线程。 - 当
pthread_create
成功执行时,线程标识符会保存在thread
参数中。你可以使用这个标识符来引用线程,进行后续操作(例如pthread_join
等)。
**const pthread_attr_t \*attr
**:
- 这是一个指向
pthread_attr_t
结构体的指针,它用于设置线程的属性(例如线程的栈大小、调度策略等)。 - 如果传递
NULL
,则表示使用默认的线程属性,通常会创建一个默认的、可分配的线程。
**void \*(\*start_routine)(void\*)
**:
这是一个函数指针,指向新线程要执行的函数。该函数必须具有以下签名:
1
void *start_routine(void *arg);
也就是说,线程开始执行时会调用这个函数,且该函数接受一个
void *
类型的参数,并返回一个void *
类型的值。
**void \*arg
**:
- 这是传递给
start_routine
的参数。在start_routine
中,arg
会被用作输入参数,可以是任何类型的数据(需要通过指针来传递)。你可以通过它向线程传递数据。
返回:
如果线程创建成功,pthread_create
会返回 0
,并且新线程会在后台开始执行指定的 start_routine
函数。
如果发生错误,则会返回一个错误码,常见的错误码包括 ENOMEM
(内存不足)或 EAGAIN
(系统资源暂时不可用)等。
针对创建线程的反调试pthread_create,有两种解决办法,一种是hook,一种是硬改opcode,比如把返回值nop掉,或者把相等改成不等。
通过hook dlsym函数来看是否有通过dlsym来获取pthread_create地址来进行调用, 发现确实调用了创建线程的函数,只不过不是直接调用,而是采用通过dlsym获取地址再调
1 | function check_pthread_create() { |
pthread_create
的调用都指向了同一个地址 0x17710
跳转到这个地址之后却发现没有 pthread_create
所在函数的名称是ffi_call_SYSV
libffi:动态调用和定义 C 函数 | iOS Development Guidelines
直接到 libffi 的 github 仓库看 ffi_call_SYSV 的源码
1 |
|
利用注释就可以知道每行汇编都代表什么了,所以 BLR X24 表示去动态调用函数,而前面的 X0 , X2 , X4 , X6 是用来传参的
**stack
(x0)**:
- 这个参数指向调用栈的位置。
- 在很多函数调用约定中,函数参数通过栈来传递。
stack
参数指定了参数传递的位置或者栈的起始位置。
**frame
(x1)**:
- 这个参数指向帧数据结构,它可能包含关于当前调用的上下文信息。在某些体系结构中,栈帧存储了局部变量、返回地址和其他必要的控制信息。
- 该参数可以用来保存有关函数调用的其他信息,如局部变量、函数调用的上下文等。
**fn
(x2)**:
- 这是一个函数指针,指向我们要调用的实际函数。该函数的签名是
void (*fn)(void)
,意味着它不接受任何参数并且没有返回值。 - 通过这个参数,
ffi_call_SYSV
可以执行不同的目标函数。
**rvalue
(x3)**:
- 这是一个指针,用于存储函数调用的返回值。通常,当外部函数有返回值时,我们将其保存在
rvalue
指向的内存位置。 - 这使得
ffi_call_SYSV
能够处理具有返回值的函数。
**flags
(x4)**:
- 这个整数值可能用于指定额外的标志或选项。这些标志可能控制函数调用的行为,如是否使用某些优化、是否采用特定的调用约定等。
**closure
(x5)**:
- 这个参数通常用于传递给回调函数的数据。在调用外部函数时,有时候需要传递附加的数据(例如回调函数的上下文)。
closure
可以存储这些数据,在执行回调时传递给目标函数。
我们 hook 一下 x0
看看有没有什么敏感的字符串,但是会直接奔溃
1 | function anti_frida_check(){ |
筛选一下看看有没有什么敏感的字符串
1 | function anti_frida_check(){ |
两个关键文件检测,,那就把这些字符串全部替换成无意义的字符串,进程终于不再崩溃成功的进入了 apk
1 | function anti_frida_check(){ |
主 DEX 加载流程分析
继续回来分析sub_1332B8,来到sub_18FEA8
出现了加密后的字符串
字符串解密后发现了 DexFileLoader 相关的字符串,说明这个函数肯定和加载 dex 有某种关联
1 | def create_name(): |
hook一下sub_18FEA8,发现这个函数共调用了三次,而且传入的值都是已经解密了的dex, classes.dex
, classes2.dex
, classes3.dex
重点
如何可以 hook 到主 ELF 中的函数,因为在壳 ELF 加载进内存时,主 ELF 还没有被加载,所以假如在壳 ELF 通过 android_dlopen_ext 打开时我们进行 hook, 是会 hook 失败的 我们可以通过统计外部函数的调用次数来判断是否已经加载了主 ELF,通过 zlib_count 统计外部函数 inflate 调用次数,因为在壳 ELF 会使用 uncompress 调用一次 inflate , 所以当第二次调用 inflate , 我们就知道这肯定是主 ELF 调用的
1 | var so_name = "libjiagu_64.so"; |
classes.dex
classes2.dex
classes3.dex
参数2就是我们的dex文件,把这三个 dex 给 dump 下来,直接使用lasting-yang/frida_dump: frida dump dex, frida dump so
dump 下来8个文件,经过分析,class3.dex就是我们要找的主dex
唯独 onCreate
函数变成了 native
声明,而除此之外的别的类和直接反编译未加固的 apk 的类是一样的
主 DEX 解密算法分析
回到 sub_1332B8
中的 sub_128D44
(interpreter_wrap_int64_t_), 来进行后续的解密算法的分析
以这个函数为起点进行解密算法的分析,因为参数 v8 传入的值是加密之后的主 DEX
在这个内存用 frida 打一个内存读写断点来看看是究竟是什么函数读取了主 DEX, 同时需要注意加上我们的反调试函数
1 | var so_name = "libjiagu_64.so"; |
打印日志如下,在 0xd364
处读取了这个主 DEX
跳转到 0xd364 之后发现这个地址在函数 sub_C918 中
应该是加了ollvm混淆
接下来继续使用 staker_trace_so 生成的 trace_so()
函数去看看函数的调用链是什么样子的,trace 的起点就是在 sub_1332B8
中的 sub_128D44
, 同时注意加上反调试函数
调用链:
1 | start Stalker! |
关键函数sub_18FEA8,此处加载了dex
根据调用链,sub_143008似乎是一个解密的函数,先加上 0x70 再异或 0x36
我们 hook 一下传入的参数看看是什么情况
发现这个函数传入的就是加密的主 dex
解密,发现是 APPKEY
, activityName
等等像是一些配置信息的样子
1 | with open('classes.dex', 'rb') as f: |
sub_142ABC
中以 pk
为标志读取各个配置信息
1 | (mainELF)call61:sub_143848 |
根据调用栈pthread_mutex_lock
意味着要用互斥锁来切换到另外一个线程
hook sub_143848
, 并同时打印出 pid 来看看是什么情况
1 | var threadId = Process.getCurrentThreadId(); |
在这里我们发现除了主线程外,还有三个不同的线程调用了这个函数
在 pid 和主线程不同的时候,用 stalker_trace_so
的 trace_so
函数在去看看究竟调用了什么函数吧
1 | var stalker_trace_once = false; |
调用链
1 | (mainELF)call1:sub_1B66A8 |
分析调用链
1 | (mainELF)call45:sub_1A1B7C |
似乎是 rc4
hook ,发现密钥是 b"\x68\x76\x99\x72\x96\x60\x9f\x63\x96\x2c\x98\x30\xc2\x36\x51\x42"
再去 hook sub_1A1E74
看看传入了什么数据,我们发现前三部分都是 f7 4f e8 0e
开头的
这些数据就是我们读取完壳 DEX 尾部的前 0x41E 个加密数据并解密出配置信息之后的那部分加密数据,同时我们也可以在 010editor 中通过搜索壳 DEX 找到它们,这三部分加密数据段所在的位置分别为 0x35CE,
0x3A93AD,
0x417064
我们先来到第一个数据段的位置来分析数据段的结构,这个位置是在 0x31A4+0x41E , 即 0x35c2 之后
dex 的第一个解密算法如下
1 | def RC4(data, key): |
rc4 解密出来的数据仍然不是 DEX 格式
继续跟着函数调用链走,找到这些函数
1 | (mainELF)call60:sub_18DCC0 |
在 sub_18F6AC
中的代码感觉很像是算法相关
hook 其中的 sub_18DDB8
函数发现 a3 是 rc4 解密之后的从 0xc 开始的数据段
而前面的 0xc 位额外数据的读取方式为 5+4+4, 在 sub_18DCC0
中有读取操作,通过这样的读取操作我们可以依次得到 0x010000005D
, 0x400000
, 0x109492
这里的 0x109492
表示的是第二次解密的数据段的大小
加密数据段是有前后两部分,前半部分用 rc4 解密,那么后半部分我们来看看长什么样
在主 DEX 加载流程分析中,我们成功的 dump 出了主 DEX, 那么我们不妨把这个这里的数据到主 DEX 中搜索
它的位置正好就是 0x400000
,经过 RC4 解密之后的这部分的数据结构我们也就可以知道了
再回到分析的起点 sub_128D44
函数
hook sub_128D44
的返回值 v150 之后发现,三级指针指向着解密之后的 dex
1 | console.log(hexdump(ptr(ptr(ptr(this.context.x0.readS64()).add(0x0).readS64()).readS64()), { |
我们观察这个解密数据所在的内存 703bc75000 , 它正好是 0x1000 的倍数,而 0x1000 正好就是一页的大小
这说明 dex 所在的内存极有可能是通过 mmap 函数分配.stalker_trace_so
打印的数据中,正好就有这个函数被调用
于是我们来到 mmap 被调用的函数
使用 frida hook mmap 返回的内存指针,然后打上内存读写断点,就可以帮助我们快速定位到最终解密算法完成之后赋值的地址
1 | function hook_mmap(){ |
得到
在 0x18ebd4
中将值写入最终的目标内存,这个地址在 sub_18E8D0
中
在这个函数的开头,有对参数 a1 的读取操作
hook 一下值来看看 a1 各个部分都有什么含义,给 a1 写一个结构体
1 | struct dec2_struct{ |
后面就是漫长的算法分析。待完成