某瓣libmsaoaidsec.so Frida检测分析与绕过实战
一、前言
学习了frida检测原理后,打算找个样本练手。起初是仿照着东方玻璃师傅的文章复现,但是在找最后一个检测点遇到了一些阻碍,导致最后一个执行流没找出来所以暂且先放放。后来在误打误撞下发现了一个新的样本,该样本有不少检测,故借此样本分析一下。最终完成检测的绕过,以及其中各类检测方法实现的分析还原。
声明
- 本文所述内容仅为技术研究与学习交流之目的, 所分析的 App 版权归其所属公司所有
- 作者未对任何 App 进行非法篡改, 破解, 数据窃取或商业利用, 亦不鼓励或支持任何违反法律法规的行为
- 读者不得将本文内容用于任何非法用途, 由此产生的一切法律责任由使用者自行承担
- 请遵守《中华人民共和国网络安全法》及相关法律法规
文章知识点
阅读这篇文章,可能能让你掌握这些知识点:
- 检测点的定位思路
pthread_create函数调用链- 一些反调试、反frida的手段
二、检测点定位
首先就是检测点定位,这里直接使用两种方式进行frida注入的测试。这里直接使用frida的纯交互模式进行测试:
frida -U -f com.douban.frodo
进入frida之后,发现frida马上就退出了,而程序正常运行: 
说明可能在某些so,或者子线程当中会有检测,接下来在app成功运行的情况下,使用attach进行附加,发现等待几秒钟后app直接退出, 退出之后app又重新启动了: 
那么已经可以推测出来,这个app在子线程当中一定有对frida的检测。接下来需要定位一下这个这个检测点到底在哪里,这里对dlopen进行hook,脚本如下:
function frida_check(){
function hook_dlopen(addr){
Interceptor.attach(addr,{
onEnter: function(args){
this.path = "";
this.soPath = args[0];
if(this.soPath !== null && this.soPath !== undefined){
this.path = this.soPath.readCString();
console.log("\x1b[96m[dlopen onEnter]\x1b[0m " + this.path);
}
}, onLeave:function(retval){
if(this.path !== null && this.path !== undefined){
console.log("\x1b[92m[dlopen onLeave]\x1b[0m " + this.path);
}
}
});
}
console.log("[+] 开始定位 frida 检测位置");
var android_dlopen_ext = Module.findExportByName("libdl.so","android_dlopen_ext");
hook_dlopen(android_dlopen_ext);
}
frida_check();
在onEnter和 onLeave的位置输出一下提示,由于这个frida检测是通过退出自己的进程来打断frida注入的话,那么这个过程中检测的so可能会有下面的行为特征:
- 只有
onEnter的输出,没有onLeave的输出,说明可能在这个so加载的过程中就检测到然后退出了。 - 有这两个位置都有输出,但是没有了其他so加载的输出提示,进程也退出了。

经过hook发现,进行检测的地方在这个libmsaoaidsec.so的so当中。而且可以看到的是在dlopen onLeave之前,这个进程就已经退出了,可以初步判断这个检测的位置大致是在init_proc,init_array执行过程当中,因为JNI_Onload的调用时机是在dlopen之后,所以需要对这个so当中的init_proc和init_array进行主要分析
三、so分析
确定了目标,就可以开始着手分析了。这里将so从apk当中取出来后,丢到IDA,接着想要通过段来找到init_array,这个结构,但是这个so有点不同,只有这三个段

这里直接在IDA View窗口搜索:能够搜到 DT_INIT,DT_INIT_ARRAY,对应的就是.init_proc,.init_array了。

这里为了快速定位到究竟是.init_proc还是.init_array当中的某个函数检测并退出进程的,就直接对call_function进行hook,将对应的函数偏移答应出来,如果遇到只进不出的就说明是在这个函数当中进行了检测行为了。对应的hook代码如下:
function frida_check(){
function hook_call_function(){
if(isHook) return;
isHook = true;
var symbols = Process.getModuleByName("linker64").enumerateSymbols();
var call_function = null;
for(let i = 0; i < symbols.length; i++ )
{
var symbol = symbols[i]; if(symbol.name.indexOf("__dl__ZL13call_functionPKcPFviPPcS2_ES0_.__uniq.331521225453620004837736674378903349473") != -1) // call_function
{
call_function = symbol.address;
break;
}
}
console.log("call function: ",call_function);
Interceptor.attach(call_function,{
onEnter: function(args){
this.realpath = args[2].readCString();
if(this.realpath.indexOf("libmsaoaidsec.so") !== -1){
this.base = Process.getModuleByName("libmsaoaidsec.so").base;
this.addr = args[1];
console.log("\x1b[96m[call Function onEnter]\x1b[0m " + ptr(this.addr).sub(this.base));
}
},onLeave: function(retval){
if(this.realpath.indexOf("libmsaoaidsec.so") !== -1){
console.log("\x1b[92m[call Function onEnter]\x1b[0m " + ptr(this.addr).sub(this.base));
}
}
});
}
function hook_dlopen(addr, soName){
Interceptor.attach(addr,{
onEnter: function(args){
this.path = "";
this.soPath = args[0];
if(this.soPath !== null && this.soPath !== undefined){
this.path = this.soPath.readCString();
if(this.path.indexOf(soName) !== -1) hook_call_function();
console.log("\x1b[96m[dlopen onEnter]\x1b[0m " + this.path);
}
}, onLeave:function(retval){
if(this.path !== null && this.path !== undefined){
console.log("\x1b[92m[dlopen onLeave]\x1b[0m " + this.path);
}
}
});
}
console.log("[+] 开始定位 frida 检测位置");
var android_dlopen_ext = Module.findExportByName("libdl.so","android_dlopen_ext");
hook_dlopen(android_dlopen_ext,"libmsaoaidsec.so");
var isHook = false;
}
frida_check();
运行之后,发现是在这个0x14400函数退出进程的,因为并没有从这个函数onLeave,也就是检测在这个.init_proc函数当中。

如果后续想要更进一步的了解这个so的检测的话就需要这个.init_proc进行深度分析了。这里先不深入进行分析,上面定位检测点的时候猜测是有通过子线程进行检测的,这里可以先尝试对pthread_create,进行hook,然后替换掉线程函数,看看能否绕过再说。
四、子线程绕过
结合上述的分析结果可以判断,也是有子线程检测的。还有一种可能是,在.init_proc当中启动的子线程检测。这里就先尝试一下,通过替换子线程的线程函数来看看能否绕过检测。而想要对子线程的线程函数进行替换,就需要先找到到底是使用了哪一个函数,而想要做到这一步,就需要对pthread_create这个函数进行hook。既然提及到了这个pthread_create,那么顺带的也来整理一下这个函数的调用链,以及是怎么调用线程函数的。
pthread_create调用链分析
Android分析有一点好处就是可以直接对照着源码,再和IDA的反编译进行比对分析。那么首先就先进行源码的分析。(源码可以使用在线网站,这里使用的是XRefAndroid - Support Android 16.0 & OpenHarmony 6.0 (AndroidXRef/AospXRef))
函数声明
int pthread_create(
pthread_t* thread_out, // 输出参数,返回新线程的线程句柄
pthread_attr_t const* attr, // 线程属性,传 NULL 表示默认属性
void* (*start_routine)(void*), // 线程函数
void* arg // 线程函数参数
);
相关结构体
pthread_attr_t
typedef struct {
uint32_t flags; // 标志位
void* stack_base; // 栈基址
size_t stack_size; // 栈大小
size_t guard_size; // 保护页大小
int32_t sched_policy; // 调度策略
int32_t sched_priority; // 调度优先级
#ifdef __LP64__
char __reserved[16]; // 64位下的保留字段,用于对齐
#endif
} pthread_attr_t;
pthread_internal_t
class pthread_internal_t {
public:
class pthread_internal_t* next; // 指向上一个线程环境块
class pthread_internal_t* prev; // 指向下一个线程环境块
pid_t tid; // 线程ID
private:
uint32_t cached_pid_ : 31; // 高31位缓存PID
uint32_t vforked_ : 1; // 最后1位标记这个线程是否通过vfork()创建的
public:
bool is_vforked() { return vforked_; }
// ==========================================================
// 3. PID/TID 缓存管理方法
// ==========================================================
pid_t invalidate_cached_pid() {
pid_t old_value;
get_cached_pid(&old_value);
set_cached_pid(0);
return old_value;
}
void set_cached_pid(pid_t value) {
cached_pid_ = value;
}
bool get_cached_pid(pid_t* cached_pid) {
*cached_pid = cached_pid_;
return (*cached_pid != 0);
}
// ==========================================================
// 4. 线程基本属性与状态
// ==========================================================
pthread_attr_t attr; // 线程基本属性
_Atomic(ThreadJoinState) join_state;
__pthread_cleanup_t* cleanup_stack;
void* (*start_routine)(void*); // 函数指针,这个线程的执行函数
void* start_routine_arg; // 函数参数指针
void* return_value; // 线程函数返回值
sigset64_t start_mask;
void* alternate_signal_stack;
// ==========================================================
// 5. 安全防护机制 (Shadow Call Stack - 针对 ARM64)
// ==========================================================
void* shadow_call_stack_guard_region;
uintptr_t stack_top;
_Atomic(bool) terminating;
// ==========================================================
// 6. 同步与内存管理
// ==========================================================
Lock startup_handshake_lock;
void* mmap_base;
size_t mmap_size;
void* mmap_base_unguarded;
size_t mmap_size_unguarded;
char vma_name_buffer[32];
// ==========================================================
// 7. 线程局部存储 (TLS) 与错误处理
// ==========================================================
thread_local_dtor* thread_local_dtors;
char* current_dlerror;
#define __BIONIC_DLERROR_BUFFER_SIZE 512
char dlerror_buffer[__BIONIC_DLERROR_BUFFER_SIZE];
bionic_tls* bionic_tls;
int errno_value;
};
在这个pthread_internal_t当中,对于找线程函数来说比较重要的就是这几个成员,他们的名称以及结构体偏移大致如下:
start_routine:线程函数地址,偏移为0x60start_routine_arg:线程函数的参数,偏移为0x68return_value:线程函数的返回值,偏移为0x70
调用链分析
有了上面的了解之后就能够来分析pthread_create的调用链了。这个函数可以在源码当中的/bionic/libc/bionic/pthread_create.cpp当中找到: 
pthread_create首先会调用__allocate_thread将新线程所需要的内存空间申请好,并且划分不同的功能区。然后返回tcb指针,后续会通过tcb来获取pthread_internal_t*指针。接着就是对thread进行初始化,包括线程回调函数,以及函数参数的初始化。
完成上述准备之后,就进入到了最关键的一步,调用clone:以当前进程为模板,克隆出来一个新的执行流。这里需要注意的这几个参数:参数一(__pthread_start),参数四(thread)。 
跟进函数,继续分析:

可以看到函数声明:fn就是__pthread_start,arg就是thread,接着往下分析:下面会判断fn是否为空,如果有值则调用__bionic_clone,来进行系统调用进行创建线程。 
到这里会发现无法跟进__bionic_clone,这里需要配合ida来分析libc.so代码。首先在libc.so的clone当中找到调用__bionic_clone的位置,标记一下参数传递所使用的寄存器。这里标记了几个比较重要的参数:

这里是对照着源码的参数进行标记的。完成后进入__bionic_clone进行分析,汇编代码分析如下 
这里系统调用之后如果返回的是子线程的空间的话,sp已经变成了子线程的栈帧。然后回进入__start_thread这个函数当中。此时x0是pthread_start的函数地址,x1是结构体地址。
接着继续看看__start_thread这个函数作用是什么。在这个函数当中,我们主要关注的是thread和phread_start相关的寄存器即可。 
最后会调用pthread_start这个函数。这个函数可以在源码当中看到是什么情况: 
可以看到传入的arg参数就是thread,接着会在后面调用thread当中的回调函数,也就是pthread_create传入的回调函数地址。综上所述,pthread_create的调用链大致如下(在其他不同版本的设备中可能会不太一样):
pthread_create --> clone --> __bionic_clone --> __start_thread ---> pthread_start
而且在这个分析过程中可以发现,有非常多的点可以通过pthread_internal_t结构体拿到对应的线程函数,例如clone,__bionic_clone,__start_thread等函数当中。有了上面的知识就可以来试试找出通过创建线程来检测frida的函数在哪里了。
获取线程函数及绕过
有了上面的知识就可以通过frida来写脚本,获取线程函数地址了:
var pthread_hooked = false;
function hook_pthread_create(soName){
if(pthread_hooked) return;
pthread_hooked = true;
var pthread_create = Module.findExportByName("libc.so","pthread_create");
console.log("pthread_create addr : ",pthread_create);
var observer = Interceptor.attach(pthread_create,{
onEnter: function(args){
this.thread_func_addr = args[2];
if(this.thread_func_addr !== null && this.thread_func_addr !== undefined){
var module = Process.findModuleByAddress(this.thread_func_addr);
if(module.name.indexOf(soName) !== -1){
console.log(`pthread_create thread func : ${module.name} +\x1b[38;5;208m0x${(this.thread_func_addr - module.base).toString(16)}\x1b[0m`);
}
}
}, onLeave:function(retval){}
});
}
hook_pthread_create("libmsaoaidsec.so");
以Spawn方式注入,输出如下,可以发现能够找到对应so当中相关的线程函数了。

接着就来试一下将这三个函数给替换掉
var pthread_hooked = false;
function hook_pthread_create(soName){
if(pthread_hooked) return;
pthread_hooked = true;
var pthread_create = Module.findExportByName("libc.so","pthread_create");
console.log("[*] pthread_create addr : ",pthread_create);
Interceptor.attach(pthread_create,{
onEnter: function(args){
this.thread_func_addr = args[2];
if(this.thread_func_addr !== null && this.thread_func_addr !== undefined){
var module = Process.findModuleByAddress(this.thread_func_addr);
if(module.name.indexOf(soName) !== -1){
let offset = this.thread_func_addr - module.base;
if (offset == 0x26E5C) {
args[2] = fake_thread_26E5C;
console.log("[replace] sub_26E5C -> fake_thread_26E5C");
} else if (offset == 0x1B8D4) {
args[2] = fake_thread_1B8D4;
console.log("[replace] sub_1B8D4 -> fake_thread_1B8D4");
} else if (offset == 0x1C544) {
args[2] = fake_thread_1C544;
console.log("[replace] sub_1C544 -> fake_thread_1C544");
}
}
}
}, onLeave:function(retval){}
});
}
var fake_thread_26E5C = new NativeCallback(function (arg) {
console.log("[fake_thread_26E5C] called, arg =", arg);
return ptr(0);
}, 'pointer', ['pointer']);
var fake_thread_1B8D4 = new NativeCallback(function (arg) {
console.log("[fake_thread_1B8D4] called, arg =", arg);
return ptr(0);
}, 'pointer', ['pointer']);
var fake_thread_1C544 = new NativeCallback(function (arg) {
console.log("[fake_thread_1C544] called, arg =", arg);
return ptr(0);
}, 'pointer', ['pointer']);
只不过替换线程函数之后就进程就直接崩溃了,命令行当中也打印了崩溃转储信息(这个可能需要多重复操作几次才会打印):

通过转储信息可以看到一个是fault addr = 0x000b0202000015b2,另一个是backtrace,当中的调用堆栈。现在就需要去IDA当中通过调用堆栈回溯一下发生这种问题的点可能在哪里。
解决崩溃问题
在IDA当中直接跳转到0x20d10的位置:

可以看到这里是将W8,这个寄存器当中的值存放到x20+0x188的位置,通过转储信息可以看到此时的 x20 = 000b02020000142a而加上0x188之后就是这个fault addr了,那么此时需要找到这个x20,到底是从哪里来的。

这里将x20高亮一下,然后再函数开头发现是从这个 [X8,#off_4B728@PAGEOFF] 中取出来的,接着找这个标签:

最后发现是调用了sub_2082C这个函数的返回值。现在得分析一下这个sub_2082C这个函数到底是做什么的:

进来看到好几个带有linker64的字符串,接着就是 .symtab,.strtab,solist


根据这些特征猜测这个函数是解析elf,根据solist这个符号获取solist的结构,用ai分析了一下确实是:通过解析 linker64 的 ELF 符号表,定位 Android linker 内部的 solist 全局变量,从而拿到当前进程加载模块链表的头指针。
那么接下来hook一下这个函数,看看返回值是什么:
function hook_sub_2082C(target, offset){
if(!target) return;
console.log(`[hooked] sub_2082C is hooked`);
Interceptor.attach(target, {
onEnter: function(args){
console.log(`\x1b[38;5;208m[sub_2082C onEnter]\x1b[0m`);
}, onLeave:function(retval){
console.log(`\x1b[93m[sub_${offset.toString(16)} onLeave]\x1b[0m retval --> ${retval.toString(16)}`);
}
})
}

通过对代码流程分析可以得出:如果这个函数返回值为0的话,就不会执行到0x20D10的位置,这里将返回值改为 0,验证 solist/soinfo 路径是否参与检测,顺便hook一下调用sub_2082C的函数,有没有正常退出(onLeave)。

这个sub_20bdc就是上层的函数,可以看到能够正常退出了,而且很惊喜的发现这个dlopen也成功的退出了,则就说明剩下的init_array没有检测函数了。可惜这里的进程还是退出了,这里hook了一下JNI_OnLoad,发现JNI_Onload能够正常退出,但是进程仍然退出,并且也没有新的so加载,那么可能就是在java层调用了检测函数了:

到这里就不再细究java层了。经过资料查找发现,有不少app都有使用了这个so来进行检测,这里找到最新版的某艺进行测试,依旧是使用一模一样的frida脚本,然后进行hook,发现是可以完成Frida的注入的:

可以发现是能够注入了。
五、思路总结
在以前旧版本的检测当中,只需要将启动线程的函数replace即可,但是在新版当中由于线程相关逻辑缺失,导致某个后续流程使用了未初始化/无效状态。在进行了nop这个线程函数,或者是替换线程函数的情况下,需要再另外对目标函数进行处理。满足以上两点才能够进行frida的检测绕过
六、混淆手段
这个so当中主要的混淆手段就是ollvm,还有就是字符串的加密,以及ShellCode的加密及使用。ollvm就不需要说了,这里就来讲讲后面两种:
字符串加密
这里找到一段反编译后的代码看看: 
大致的特征就是这种:先计算长度,然后异或解密这种。这种特征貌似不只是在这个so当中出现过,之前分析壳的时候好像也见过类似的。解决办法: 目前针对这种解密字符串没想到有什么比较好的方法,只能一个个写脚本解密。有比较好的想法的师傅可以指点一下
ShellCode使用
可能会有的师傅会想通过hook exit这类函数来定位一下进程是在哪里退出的。恐怕这个想法实现不了(可以试一下,主包这里没试过)。这里拿一个检测函数当中调用的退出函数看看:
这里会先对数据进行异或解密,解密手法就是之前字符串的解密方法,然后通过mmap将解密出来的机器码拷贝到这个 RWX 的匿名内存当中,然后通过函数指针调用这个ShellCode,而这段ShellCode解密脚本以及反汇编之后的效果如下:
enc = [ 0x91, 0xA7, 0x29, 0x4B, 0xA6, 0xA9, 0x99, 0x73, 0x69, 0x9A,
0xF8, 0x7F, 0x86, 0x87, 0xAA, 0x4C, 0xB8, 0x89, 0x9A, 0x72,
0xB6, 0xB9, 0xA4, 0x7C, 0x86, 0x87, 0xAA, 0x4C]
code = []
key = [0x99,0xa7,0xa9]
for i in range(len(enc)):
code.append(enc[i] ^ key[i % 3])
code_1 = code[0] | (code[1] << 8) | (code[2] << 16) | (code[3] << 24)
code_1 += 3008
code[0] = code_1 & 0xFF
code[1] = (code_1 >> 8) & 0xFF
code[2] = (code_1 >> 16) & 0xFF
code[3] = (code_1 >> 24) & 0xFF
from capstone import*
md = Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN)
for insn in md.disasm(bytes(code),0x1000):
print(f"0x{insn.address:x}:\t{insn.mnemonic}\t{insn.op_str}")

可以看到这个ShellCode直接通过系统调用,结束整个进程。所以hook exit这类函数的方法可能行不通
七、检测思路
那么既然已经绕过了Frida的检测,现在可以来好好看看这个so当中使用到的检测手法了。这里拿了几个出来分析,如果有兴趣的话可以自己分析一下其他的。
线程函数
那么首当其冲的肯定就是通过创建线程来进行检测的函数了。分别有以下三个:
0x1C544
0x1B8D4
0x26E5C
这里我对0x1b8d4做了一个比较详细的分析,0x26e5c,0x1c544大致粗略看了一下。
sub_1b8d4
这个函数进入之后看起来还是很清爽的,代码如下(这里是已经分析过,然后修复了不少函数名,变量名的结果): 
大致就是通过get_tracePID来获取TracerPid,来检测是否处于调试状态。另外一个就是通过check_stat()来判断线程状态是否是T (stopping),挂起状态。如果是的话就会退出循环,然后进程退出。来看看这个tracePid的获取方法: 

就是通过打开proc/self/status这个文件,然后取出TracePid,这个字段的值。这里的字符串通过反编译能看到都不是硬编码,基本上都是加密的。这个问题后面再说。check_stat这个函数原理大致也差不多,这里就不展开了。所以这个线程函数其实是通过TracePid和stat,来反调试。
sub_26E5C
接下来就是26E5C这个函数了。 
进来首先是一个小型的ollvm,重点只有这个部分,如果sub_1678c返回值最低位为0的话,就会走到这个exit_0当中,接着看看这个sub_1678c
发现是打开了/proc/self/maps这个文件,然后就是获取maps当中的start_addr等信息进行检测,这里不再赘述。分析方法就是先按照上面混淆手段提及到的字符串解密,将字符串解密之后就会比较容易分析了。
sub_1C544
进入后发现很多字符串解密的位置,解密了头几个后发现这些特征:gum-js-loop,gmain,linjector....这种很明显就是frida特征了,猜测这个线程函数主要就是进行的frida检测。



其他检测函数
sub_23AD4
这个函数是在init_proc当中的,功能就是用来检测模拟器:


通过maps,查看是否有x86这种路径的模块,这种模块路径一般是在模拟器当中
检测总结
这个so当中检测的手法远不止于此,还有类似adb检测,crc完成性检测等等,但是对这些函数进行hook之后发现并没有调用,也就没有写出来了。感兴趣的可以去看看
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)