前言
最近在分析绕过frida检测的姿势,看到libmsaoaidsec.so 的使用还是挺多的,比如某站、某书和某艺。
既然有这么多样本,那就来分析一下检测逻辑,顺便感受一下不同版本之间该.so有哪些变化。
注意!
样本一是比较早的经典版本,想看较新的绕过思路可直接看样本二。
【环境】:Redmi(android 8)、v8a、Frida16
样本一
【某app v7.26.1版本】
先定位so,hook_dlopen
可见加载libmsaoaidsec.so
后,frida进程就被杀掉了,因此监测点在libmsaoaidsec.so
中。
function hook_dlopen(){
Interceptor.attach(Module.findExportByName(null,"android_dlopen_ext"),
{
onEnter:function(args){
var pathptr=args[0];
if(pathptr!==undefined&& pathptr!=null){
var path=ptr(pathptr).readCString();
console.log("load"+path);
}
}
});
}
setImmediate(hook_dlopen)
接下来要定位函数
ida打开libmsaoaidsec.so
找到JNI_Onload函数的偏移地址,尝试hook JNI_Onload
函数,如果能成功hook将打印日志“call JNI_Onload”,没有就说明frida检测在hook JNI_Onload
前。
运行后没有打印日志,检测在init_xxx。
需要hook .init_xxx的函数,但这里有一个问题,dlopen函数调用完成之后.init_xxx函数已经执行完成了,这个时候不容易使用frida进行hook,这个问题其实很麻烦的,因为想要hook linker的call_function并不容易,这里面涉及到linker的自举。
这里学到的思路是在.init_proc
函数中找一个调用了外部函数的位置,时机越早越好。
导出表搜索只搜索到.init_proc
f5看到.init_proc被混淆了,很明显的虚假控制流。不过这里在最开始调用了一个外部函数sub_B1B4,这个时候也就是.init_proc函数刚刚调用的时候,在这个时机点是个注入的好时机。
int sub_B1B4()
{
int v1[8]; // [sp+0h] [bp-20h] BYREF
v1[3] = *(_DWORD *)off_1FC04;
v1[1] = 0;
v1[0] = 0;
_system_property_get("ro.build.version.sdk", v1);
return atoi((const char *)v1);
}
在获取了一个非常早的注入时机之后,就可以定位具体的frida检测点了。
这里对pthread_create
函数进行hook,可以看到这里有两个线程地址和其他的不一样,说明这两个线程是libmsaoaidsec.so
创建的。
这里需要计算一下偏移地址:
0xb54d4129 - 0xb54c3000 = 0x11129
0xb54d3975 - 0xb54c3000 = 0x10975
0x11129和0x10975是线程需要执行的函数的地址,也就是pthread_create函数的第三个参数。
而我们需要通过交叉引用找到pthread_create函数被调用的地址。
G键搜索11129
找到sub_11128
,然后通过x键交叉引用找到目标函数sub_113E0
,同理,搜索10975
找到sub_10974
,然后交叉引用找到目标函数sub_109A8
。
然后直接把pthread_create
函数nop
掉即可。
样本二
v8.31.0
【另一款也使用libmsaoaidsec.so的app】
这里用上面定位frida检测的hook脚本直接定位到libmsaoaidsec.so
,
这里为了避免遇到字符串加密,而是将注意力放在分析frida检测逻辑上,就直接从内存中dump出libmsaoaidsec.so
然后修复得到libmsaoaidesc_fixed.so
ida打开,由于前面的分析可以直接定位到.init_proc
,反编译看到有很多while一看便知是虚假控制流,然后发现代码与样本一的代码有些细微的变化,比如此处就没有之前较明显的外部函数可以hook了。那我们就来分析下具体逻辑。
这里直接使用D810去除混淆,然后来分析代码逻辑看看做了哪些检测的操作。
void init_proc()
{
const char *v0; // x23
__int64 v1; // x0
__int64 v2; // x0
unsigned __int64 StatusReg; // [xsp+0h] [xbp-870h] BYREF
__int64 *v4; // [xsp+8h] [xbp-868h]
int v6; // [xsp+18h] [xbp-858h]
int v7; // [xsp+1Ch] [xbp-854h]
__int64 *v8; // [xsp+20h] [xbp-850h]
char *v10; // [xsp+30h] [xbp-840h]
FILE *v11; // [xsp+38h] [xbp-838h]
char v12[2000]; // [xsp+40h] [xbp-830h] BYREF
__int64 v13; // [xsp+810h] [xbp-60h]
StatusReg = _ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));// 获取寄存器状态
v13 = *(_QWORD *)(StatusReg + 40);
v4 = (__int64 *)(&StatusReg - 250);
*(_DWORD *)off_47FB8 = sub_123F0(); // 获取SDK版本
sub_12550(); // 判断art/devm
sub_12440(); // 获取版本信息
if ( *(_DWORD *)off_47FB8 > 23 )
*(_BYTE *)off_47ED8 = 1;
if ( (sub_25A48() & 1) == 0 )
{
v10 = v12;
memset(v10, 0, 0x7D0u);
getpid();
_cxa_finalize(v12);
v11 = fopen(v12, "r");
if ( v11 )
{
v8 = v4;
memset(v4, 0, 0x7D0u);
v0 = (const char *)v4;
strdup((const char *)v11);
fclose(v11);
if ( !strchr(v0, 58) )
sub_1BEC4(); // anti-frida
}
v1 = sub_13728();
sub_23AD4(v1);
v6 = sub_C830();
if ( v6 != 1 || (v7 = sub_95C8()) != 0 )
sub_9150();
}
if ( *(_QWORD *)(StatusReg + 40) != v13 )
{
v2 = _strlcpy_chk(); // check标志位
sub_148A0(v2);
}
}
可以定位到关键函数sub_1BEC4,来分析一下:
__int64 sub_1BEC4()
{
unsigned __int64 StatusReg; // x19
__int64 v1; // x0
__int64 v3; // x0
__int64 v4; // [xsp+8h] [xbp-48h]
StatusReg = _ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
v4 = *(_QWORD *)(StatusReg + 40);
HIDWORD(qword_49010) = getpid();
v1 = sub_1B924(); // check2
if ( *(_QWORD *)(StatusReg + 40) == v4 )
return 0LL;
v3 = _strlcpy_chk(v1);
return sub_1BFAC(v3); // check1 检测/proc/pid/tast下线程:gum-js-loop和gmain
}
这里介绍下task和fd目录下的具体检测特征。
检测/proc/pid/tast下线程
/proc/pid/task目录下可以查看不同线程的子目录,获取每个线程运行时信息,这些信息包括线程的状态、线程的寄存器内容、线程占用的CPU时间、线程的堆栈信息等。
开启frida调试后这个task目录下会多出几个线程,检测点在查看这些多出来的线程是否和frida调试相关。
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 代码的线程。
pool-frida
:Frida 中的某些功能可能会使用线程池来处理任务,pool-frida 表示 Frida 中的线程池。
/proc/pid/fd
该目录的作用在于提供了一种方便的方式来查看进程的文件描述符信息,通过查看文件描述符信息,可以了解进程打开了哪些文件、网络连接等。
linjector
是一种用于 Android 设备的开源工具,它允许用户在运行时向 Android 应用程序注入动态链接库(DLL)文件。通过注入 DLL 文件,用户可以修改应用程序的行为、调试应用程序、监视函数调用等,这在逆向工程、安全研究和动态分析中是非常有用的。
chek2:sub_1B924->sub_1CEF8->sub_1C544
chek1比较简单一眼就看到,下面详细介绍check2。
sub_1B924中一眼看到定位到_strlcpy_chk()
:
result = (__int64)dlopen(v19, 2);
v21 = (char *)result;
if ( result )
{
v24 = atoi(v21);
v25 = (void (__fastcall *)(__int64 *, _QWORD, void *, _QWORD))v24;
if ( (unsigned int)sub_CAA8() == 248 && (sub_12D9C(0LL) & 1) == 0 )
sub_1CEF8(v25);
if ( (unsigned int)sub_CAE8() == 249 )
v25(&qword_49650, 0LL, &loc_1B8D4, 0LL);
else
sub_1B380(v21, v25);
v23 = sub_CA28();
if ( v23 == 167 )
v25(&qword_49658, 0LL, &loc_19E0C, 0LL);
result = dlclose(v21);
}
if ( *(_QWORD *)(StatusReg + 40) != v28 )
{
_strlcpy_chk(result);
return sub_1BEC4();
}
交叉引用定位到关键变量v25,进而定位到sub_1CEF8。
sub_1CEF8一开头就看到加载了“libart.so”,然后一堆异或和取余操作,应该是在加解密(这里就不还原算法了),根据经验合理猜测这是通过libc.so的pthread_create创建线程实例来anti-frida。
继续往下分析发现关键代码块为LABEL__35:
LABEL_35:
if ( (sub_25B30(v15) & 1) != 0 )
sub_234E0(0LL);
result = a1(&v42, 0LL, sub_1C544, v15);
if ( *(_QWORD *)(StatusReg + 40) != v44 )
{
v41 = _strlcpy_chk(result);
return std::_Rb_tree<std::string,std::string,std::_Identity<std::string>,std::less<std::string>,std::allocator<std::string>>::_M_erase(v41);
}
看到老朋友_strlcpy_chk
同理,定位到sub_1C544,一路分析定位到LABEL_81,最终的检测写在这个while循环里:
while ( 1 )
{
LABEL_81:
v103 = sub_1BFAC();// check1,gum-js-loop和gmain
v104 = sub_1C158(v103);// chek3,检测linjector
sub_1C26C(v104);// check4,检测/data/local/tmp和frida-agent
sub_26334(a1);// check5,mmap
sleep(4u);
}
}
}
分析结束,下面是hook
这里插入关于linker的讨论
我们已经知道dlopen(android_dlopen_ext)
将动态库加载到内存中,加载库后,系统会处理库中的依赖关系,并在库的地址空间中映射所有符号。
然后执行.init_array
和 .init_proc
段。
ELF 文件中 .init_proc
段的地址存储在 .init_array
表中。
在加载库后,系统会调用 call_constructors
来执行 .init_array
中的所有初始化函数,.init_proc
中的代码一般由开发者定义,它可能会调用一些重要的逻辑函数,比如初始化全局变量,设置反调试、密钥初始化等。
由此可知, 如果库中有重要的保护逻辑或初始化算法,它们通常会出现在 .init_proc
或由 .init_proc
调用的函数中。
这里有个关键的地方在于, call_constructors
的执行过程,即:linker64中dlopen通过do_dlopen->call_constructors->call_array
。
整个流程借用正己大佬给的流程图,更清晰:
所以我们从call_constructors
入手。sub_1BEC4
是一个位于 libmsaoaidsec.so
中的关键函数,但在动态库完成加载前,它的地址并未映射到内存中。而call_constructors
是库完成加载后,首次执行初始化逻辑的地方,此时目标库的基地址已经确定,所有符号地址也已经可以解析。
使用objdump查看android_dlopen_ext
和call_constructors
相对linker64的偏移地址,然后hook:
function hook_android_dlopen_ext() {
var linker64_base_addr = Module.getBaseAddress("linker64")
var android_dlopen_ext_func_off = 0x8f74
var android_dlopen_ext_func_addr = linker64_base_addr.add(android_dlopen_ext_func_off)
Interceptor.attach(android_dlopen_ext_func_addr, {
onEnter: function (args) {
if (args[0].readCString() != null && args[0].readCString().indexOf("libmsaoaidsec.so") >= 0) {
hook_call_constructors()
}
},
onLeave: function (ret) {
}
})
}
function hook_call_constructors() {
var linker64_base_addr = Module.getBaseAddress("linker64")
var call_constructors_func_off = 0x20b78
var call_constructors_func_addr = linker64_base_addr.add(call_constructors_func_off)
var listener = Interceptor.attach(call_constructors_func_addr, {
onEnter: function (args) {
var module = Process.findModuleByName("libmsaoaidsec.so")
if (module != null) {
Interceptor.replace(module.base.add(0x1BEC4), new NativeCallback(function () {
console.log("replace sub_1BEC4")
}, "void", []))
listener.detach()
}
},
})
}
hook_android_dlopen_ext()
成功绕过!
v8.63.0
关于样本二的最新版(24年11月)我也分析了下,关键函数没变,具体检测的函数调用有点变化,不过一样可以bypass<(^-^)>
__int64 sub_1BEC4()
{
unsigned __int64 StatusReg; // x19
__int64 v1; // x0
__int64 v3; // x0
__int64 v4; // [xsp+8h] [xbp-48h]
StatusReg = _ReadStatusReg(ARM64_SYSREG(3, 3, 13, 0, 2));
v4 = *(_QWORD *)(StatusReg + 40);
HIDWORD(qword_49010) = getpid();
v1 = sub_1B924();
if ( *(_QWORD *)(StatusReg + 40) == v4 )
return 0LL;
v3 = _strlcpy_chk(v1);
return sub_1BFAC(v3);
}
成功!
【参考】:
[原创]绕过bilibili frida反调试-Android安全-看雪-安全社区|安全招聘|kanxue.com
某书Frida检测绕过记录_libmsaoaidsec.so-CSDN博客
后记
第一次在吾爱发文有些小忐忑,笨人此前备受完美主义折磨,羞愧于自己没有写出有价值的文章,认为不值得分享。
最近笨人的想法有些转变,决定大胆迈出第一步。欢迎大家留言交流!