APP frida 检测绕过详解:定位 JNI 动态注册 Native 函数,Hook 核心检测函数
本文为 App frida加密检测流程的绕过思路与详细分析,纯属个人研究分享,关键对抗细节模糊化 分析过程中也遇到不少底层原理盲区,欢迎各位大佬指点。内容偏向新手向 SO 层逆向实战流程,希望能给同样入门加固对抗的朋友提供一些可落地的分析思路。
前置的一些知识和工具补充
-
脱壳与SO修复:如何获取脱壳后的防护SO文件及修复方法,篇幅较长,推荐参考东方玻璃大佬帖子:某加固新版frida检测绕过-trace一把嗦https://bbs.kanxue.com/thread-289545.htm
-
Hook clone偏移原因及原理:看雪社区有大量优质帖子可参考,因本人底层知识不够扎实,为避免误导,此处不展开赘述。
-
追踪工具的下载和使用:工具下载方式记不太清了 嘿嘿 不好意思 ,有需要的朋友可私信我获取jni函数动态注册流程和so层特征 : 这位作者的帖子讲的十分详细,大家可以去参考Android 逆向:JNI 函数地址怎么找?如何绕过防护?https://bbs.kanxue.com/thread-290351.htm
-
SO 文件加载与执行时序:b站或者论坛都有对应讲解,大家还请自行搜索,本帖也有体现和对应的讲解
本次绕过核心
检测so文件 的进程终止并非来自 SO 加载阶段(init_array/JNI_OnLoad)的子线程检测,而是 Java 层早期调用 JNI 动态注册的 native 方法,该方法通过全局检测结构体(off_E3290)调用核心检测函数 ,检测异常后触发 自杀方法杀进程;最终通过 Hook 核心检测函数 并强制返回 0 实现绕过。
开始正式绕过
检测位置的定位
先用frida注入dlopen脚本查看加载的so文件有哪些,以及确定死亡时机:

进程被杀 发现dlopen了两个so ,在第二个so执行完时进程就终止了。下面分析是哪个层面被杀的 这里的层面指的是杀死进程的时机。
先明确 SO 加载核心时机:call_constructors(SO 加载时最先执行,触发.init/.init_array 段)→JNI_OnLoad→ SO 业务函数 / 子线程阶段。
验证逻辑:
-
能跑出 检测so的 JNI 函数 → 说明 call_constructors(含.init/.init_array)和JNI_OnLoad阶段未直接杀进程(这两个阶段杀进程,JNI 函数根本跑不出来); -
因此锁定后续阶段,Hook clone 函数排查:是否是 SO 通过 call_constructors 初始化后,创建子线程执行检测并杀进程。
这里如果想了解详细机制 ,大家可以去自行找帖子,b站勇敢的小佳有一个视频比较好的介绍了这个流程 。
这里先用脚本注入验证 jni函数的偏移大家可以去so层导出表获取:
const target_so = "xxxx";var flag = false;console.log("[+] 监控启动!精准阻断加固,保留业务逻辑...");// Hook android_dlopen_ext 监控SO加载Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"), {onEnter: function (args) { this.path = args[0].readCString(); console.log("加载SO:" + this.path); if (this.path.indexOf(target_so) !== -1) { console.log("检测到目标SO加载,准备处理"); flag = true; } },onLeave: function (retval) { if (flag) { flag = false; console.log("目标SO已加载,开始精准处理加固函数"); // 获取模块基址 const moduleBase = Module.findBaseAddress(target_so); if (!moduleBase) { console.error("[-] 找不到模块基址!"); return; } console.log("[+] 模块基址:" + moduleBase); const sub_jni_Addr = moduleBase.add(""); console.log("[+] jni_onload地址:" + sub_jni_Addr); Interceptor.attach(sub_jni_Addr, {onEnter: function (args) { console.log("进来"); },onLeave: function (retval) { console.log("出去") } }) } }});// 兜底:拦截栈检查失败函数,避免崩溃Interceptor.attach(Module.findExportByName(null, "__stack_chk_fail"), {onEnter: function () { console.log("[+] 阻断栈检查失败函数__stack_chk_fail!"); this.returnValue = ptr(0); this.context.pc = this.context.lr; }});

发现成功跑出检测so的jni函数,应该不是在iniarray 或者jni_onload里面做的进程终止 既然如此,那么有可能是通过产生子线程检测函数。
捕捉线程入口函数偏移
hook clone函数,查看so文件地址上有没有创建子线程的痕迹。
clone函数是 Android 系统中创建线程的底层核心函数,监控它就能精准捕捉到so文件 创建检测子线程的源头行为,详细参考前置说明。
function hook_cer_test_and_offset() { // 只监控目标防护SO,过滤无关线程 const TARGET_SO = [ // 自行补充目标SO名称,保留原有监控配置 ]; var clone = Module.findExportByName('libc.so', 'clone'); if (!clone) { console.log("[-] 未找到clone函数"); return; } Interceptor.attach(clone, {onEnter: function (args) { // 容错:args[3]为0则跳过(无指定栈地址,非独立线程) if (args[3].isNull() || args[3] == 0) return; try { // arm64 Android 8.x 通用栈偏移96,若解析失败可调试±16/±32 var addr = args[3].add(96).readPointer(); // 容错:解析出的地址无效则跳过 if (addr.isNull() || addr == 0) return; var mod = Process.findModuleByAddress(addr); // 只打印目标防护SO的线程信息 var so_base = mod.base; var offset = addr - so_base; // 格式化输出:SO名 + 虚拟地址 + 十进制偏移 + 十六进制偏移(适配IDA) console.log("【检测线程定位】"); console.log("SO名:", mod.name); console.log("入口地址:", addr); console.log("十进制偏移:", offset); console.log("十六进制偏移:", "0x" + offset.toString(16).toUpperCase()); console.log("====================================="); } catch (e) { // 捕获解析异常,避免脚本崩溃 } },onLeave: function (retval) { } }); console.log("[+] clone函数Hook完成,仅监控目标防护SO");}// 配合原有patch+libnllvm Hook,立即执行setImmediate(() => { hook_cer_test_and_offset(); // 可自行添加原有hook_dlopen_and_patch()、hook_dlopen_libnllvm()逻辑});

检测到so层创造了两个线程 ,这里打印的偏移是clone函数执行的入口函数。大家可以理解为clone函数产生了一个工人,这里偏移就是他的任务。我们跳转到ida的入口函数看看,看看是不是有什么检测。
对偏移处进行详细分析,引入全局结构体 off_E3290
先看看0x42FE4:


可以发现这里调用了TPIDR_EL0 后续又做了校验 ,有反调试嫌疑,继续跟进。发现末尾做了标志位校验,如果监测到异常,直接跳入死亡函数分支,如果没有,函数ret0 sub_dd770是一个跳板函数,跳入一个偏移,偏移是自杀函数。


那么另外一个偏移呢 0x42F90 ,我们同样跳转看看:

这里其实我已经确定是一个检测函数了 ,为什么呢,要归于我之前的判断。首先,开头if(*off_E3290)很重要,这个极有可能是一个检测防护结构体的地址,是一个二级指针,为什么这样说呢,我们对刚刚分析的偏移0x42FE4分析看看,我们一直对这个函数链交叉引用。




最后追踪到了init_array段,我们先梳理一下,我们刚刚分析的链条 sub_432A4 -> off_E5BB0偏移 -> 431A0 -> 检测子线程入口 42FE4
开头的sub_432A4在init_array段和其他函数一起被早期执行,这里一般有大量的初始化和检测,是绕过的重点区域。

我们再聚焦于sub_432A4干了什么,就可以知道,我为什么说偏移off_E3290是一个防护结构体了,后面也可以验证我的猜想。

这里先是LDR 读取 off_E3290的所在内存页偏移(就是拿到off_E3290)的地址到x8
然后读取off_E5BB0的地址保存到x9,最后把x9的地址放入x8的0x40偏移处。
这里就类似于 *off_E3290 -> 0x40 = int * off_E5BB0 就是把这个偏移放入off_E3290结构体
所以我们现在来看看到底放了什么东西吧,先看off_E5BB0 这里也就是刚刚函数432A4加入E3290的偏移地址。

这里看到有两个函数 ,发现这不正是我们向上追溯的431A0吗。也就是子线程入口函数的调用上层,所以说这里很明确了,这个so会把偏移函数放到函数表off_E5BB0,再最终放入off_E3290。
肯定不止一个函数表off_E5BB0 ,我猜测so文件会注册很多函数表供后面检测函数隐式调用混淆攻击者。
这里后面也有验证 但是需要注意的是,也有可能其实更大可能是一个全局结构体,只是放入了很多检测函数表,同时存在一些核心的数据和标志位。
我们对这个off_E3290进行交叉引用看看:

大量函数调用off_E3290 这里肯定是有很多检测函数调用off_E3290结构体,为什么要这样多此一举呢,因为可以妨碍我们追踪调用堆栈。我们定位死亡函数,向上交叉引用是引用不到真正调者的,因为可能是通过这个调用 off_E3290结构体进行调用。
打比方说我追踪函数死亡流如果是直接,调用者 -> 检测函数,那么就很容易被攻击者追溯到。但是如果是调用者 -> 全局检测表 -> 检测函数,那就不会,因为调用者是通过结构体加偏移的方式调用,不会留下显式静态痕迹。
我们具体聚焦刚刚分析的第二个子线程入口函数偏移0x42F90,这里就很明确了。

先判断检测体是不是为空, 执行计数操作最后进入函数sub_43364:


这里先拿全局结构体off_E3290 + 18 和 + 88 的偏移所对应位数,调用函数sub_75F40做校验。
我们检查sub_75F40源码发现就是一个逐字节对比函数,检查传入的a1 和 a2 是否是相同字符,源码在上图。只要不匹配进入检测if分支,再调用sub_75F40 把一段字符串放入缓冲区v71再调用sub_75DC4进行校验,看缓冲区包不包含”:”,如果失败,跳转LABEL_52。


最后跳转死亡函数,和刚刚一样的stack_chk_fail
检测远不止这点,这里不逐个分析了,所以这里这个子线程的入口函数0x42F90也是检测函数,所以两者都为检测线程,直接试试把两个入口ret0 :
const LIB_NAME = "xxxxx.so";const TARGET_OFFSETS = [0x42F90 , 0x42FE4]; // 需要Hook的函数偏移列表// 核心Hook逻辑:拦截SO加载 + 批量Hook目标函数function hookTargetSOAndForceReturn() { // 1. 查找并拦截android_dlopen_ext(SO加载入口) const dlopen = Module.findExportByName(null, "android_dlopen_ext"); if (!dlopen) { console.log("[-] 未找到 android_dlopen_ext 函数"); return; } Interceptor.attach(dlopen, { onEnter(args) { // 标记是否为目标SO this.isTarget = args[0] ? args[0].readCString()?.includes(LIB_NAME) : false; }, onLeave(retval) { // 仅当目标SO加载成功时执行Hook if (this.isTarget && !retval.isNull()) { const soBase = Module.findBaseAddress(LIB_NAME); if (!soBase) { console.log(`[-] 未找到 ${LIB_NAME} 基址`); return; } console.log(`[+] ${LIB_NAME} 加载成功,基址: ${soBase}`); // 2. 批量Hook目标函数,强制返回0(64位用0n) TARGET_OFFSETS.forEach(offset => { const funcAddr = soBase.add(offset); Interceptor.replace(funcAddr, new NativeCallback(() => { console.log(`[+] 强制拦截 0x${funcAddr.toString(16)},直接返回0`); return 0n; // 64位函数返回long类型,用0n更稳妥 }, 'long', [])); // 匹配目标函数返回值类型(__int64对应long) }); } } });}// 立即执行setImmediate(hookTargetSOAndForceReturn);
但是我注入脚本之后,发现进程还是死了:

既然如此,我们看看死亡流 我们使用ida的外部插件 stalker trace so 来追踪一下函数的执行流,定位死亡函数。
我们注入生成后的trace流脚本,同时注入我们的线程杀函数脚本,对比两者函数执行流有无区别。
这里是同时注入的frida frida -U -f 包名 -l 杀偏移脚本 -l trace脚本
用了杀函数脚本:

没用杀函数脚本:

可以看到,没有任何区别,这就奇怪了啊,为什么呢我们杀线程没有影响一点最后的终止函数线程流,这说明并不那两个线程最终触发的函数死亡。
对函数执行流倒序分析
我们进一步分析进程死亡前的最后一个函数sub_6AAC8

可以看到直接发送死亡信号 ,杀了进程,这肯定是死亡流末尾了,也就是已经在上层触发了死亡分支,所以我们要向上交叉引用 引用到sub_66998

66998函数是一个烈性的检测杀进程,包含大量自杀逻辑,没有任何业务分支。

拿到自身pid 为后续kill进程做准备:

轮询发送死亡信号,杀进程:

末尾调用死亡函数6AAC8
没有任何活分支 ,说明也是死亡函数,已经走到了不可逆的死亡分支
我们继续向上追溯,这里肯定远远不够:

这里可以看到上层调用非常多,说明有很多检测函数调用了sub_66998死亡函数,我们看看执行流有没有相关的上层。

但是无论是sub57594,还是sub_55EB8,10个函数往上都没有命中交叉引用列表里面的函数。
这里为什么不说sub_75DC4呢,因为刚刚已经分析了,这是一个校验函数。同时没有发现66998被导入到检测函数表的行为,因为如果有,可能是调用结构体引用,隐式调用死亡函数66998 ,但是这里都没有,逻辑已经完全断了。
这里我说明为什么说一定是显式调用的,如果说是显示调用 比如说 检测函数 -> sub_66998 那么执行流一定会出现符合的上层,但是sub_66998往上10层都没有命中交叉引用的函数。
如果是隐式调用,检测函数 -> 引用函数表 -> sub_66998 那么66998的交叉引用处一定会有一个偏移 ,这个偏移就是把sub_66998导入函数表 和前面一样。
我开始思考是不是线程没杀顺利,因为我们是在dlopen onleave时期进行的函数ret0,但是如果是init_array段的话,是在dlopen进入时期,调用call_constructor这个更早的时期执行的。
关于这里执行机制的讲解,不赘述了,大家可以去看看b站视频,有关于这个时期的讲解,也就是说,我们hookcall_constructor,可以在init_array执行之前阻断子线程的加载。
但是前提是确实是init_array执行的子线程,所以我们hook一下调用clone函数的调用者,看看所在偏移,定位具体函数,明确产生时机。
function hook_cer_test_and_offset() { const TARGET_SO = [ ]; // 核心优化:从调用栈中提取目标SO的调用者 function extractTargetCallerFromStack() { try { const stack = Thread.backtrace(this.context, Backtracer.ACCURATE); // 遍历调用栈,找第一个属于目标SO的帧 for (const frame of stack) { const mod = Process.findModuleByAddress(frame); if (mod && TARGET_SO.includes(mod.name)) { const caller_offset = frame - mod.base; return {name: mod.name,base: mod.base,addr: frame,offset: caller_offset }; } } } catch (e) { } return null; } var clone = Module.findExportByName('libc.so', 'clone'); if (!clone) { console.log("[-] 未找到clone函数"); return; } Interceptor.attach(clone, {onEnter: function (args) { // 1. 从调用栈提取目标SO的调用者(核心优化) const targetCaller = extractTargetCallerFromStack.call(this); if (targetCaller) { console.log("\n========== 【目标SO调用clone定位】 =========="); console.log(`调用clone的目标SO: ${targetCaller.name}`); console.log(`目标SO基址: ${targetCaller.base}`); console.log(`调用者函数地址: ${targetCaller.addr}`); console.log(`调用者函数偏移: 0x${targetCaller.offset.toString(16).toUpperCase()}`); console.log("============================================="); } // 2. 原有逻辑:解析clone创建的线程入口(保留并优化) if (args[3].isNull() || args[3] == 0) return; try { const stackOffsets = [96, 80, 112, 64]; let addr = null; for (const offset of stackOffsets) { addr = args[3].add(offset).readPointer(); if (!addr.isNull() && addr != 0) break; } if (addr.isNull() || addr == 0) return; var mod = Process.findModuleByAddress(addr); if (!mod || !TARGET_SO.includes(mod.name)) return; var so_base = mod.base; var offset = addr - so_base; console.log("\n========== 【检测线程入口定位】 =========="); console.log(`SO名:${mod.name}`); console.log(`线程入口地址:${addr}`); console.log(`线程入口十进制偏移:${offset}`); console.log(`线程入口十六进制偏移:0x${offset.toString(16).toUpperCase()}`); console.log("==========================================="); } catch (e) { } },onLeave: function (retval) { } }); console.log(`[+] clone函数Hook完成,监控${TARGET_SO.length}个目标防护SO`);}setImmediate(() => { hook_cer_test_and_offset();});

定位核心偏移,也就是红圈处,这里就是调用clone函数的上层:

该偏移所属函数正是刚刚分析的子线程入口函数上层sub_431A0 , 它正是刚刚说的,被放入函数表的函数:

所以流程可能为,init_array段 初始化函数表,把函数表放入全局结构体 off_E3290,init_array后续函数调用off_E3290中的sub_431A0 产生子线程。
off_E3290的被引用次数非常大,一个个定位来验证猜想很花时间,所以这里决定把注入时机换成call_constructor,看看是否能成功。
call_constructor是一个比dlopen onleave阶段还早的时期
注意这里每个人不同手机的call_constructor偏移是不一样的,详细知识参考前置说明去看b站up的一个视频。
const LIB_DEX_HELPER_NAME = "xxxxx.so";const TARGET_FUNC_OFFSET ;const TARGET_FUNC_OFFSET2 = ;// 所有需要 Hook 的函数偏移列表const TARGET_OFFSETS = [ 0x42F90, 0x42FE4];// 目标防护SO列表(保留你的原有配置)const TARGET_SO = [ "libzhim_crypto_tool.so", "xxxxx.so" ""];// 全局变量:标记SO是否已Hook,避免重复操作let isSOHooked = false;// 全局变量:存储xxxxx.so基址let xxxxxBase = null;/** * 核心函数:替换目标地址为返回0 * @param {NativePointer} address - 要替换的函数地址 * @param {string} retType - 返回值类型(int/long/void) */function replace0(address, retType = 'int') { try { Interceptor.replace(address, new NativeCallback(function () { console.log(`[+] 强制拦截 ${address},直接返回0`); return retType === 'long' ? 0n : 0; }, retType, [])); console.log(`[+] 成功替换函数: ${address}`); } catch (e) { console.log(`[-] 替换函数失败 ${address}: ${e.message}`); }}/** * 你的原有逻辑:监控目标SO的线程创建 */function hook_cer_test_and_offset() { var pthread_create = Module.findExportByName('libc.so', 'pthread_create'); if (!pthread_create) { console.log("[-] 未找到pthread_create函数"); return; } Interceptor.attach(pthread_create, {onEnter: function (args) { try { if (args[2].isNull() || args[2] == 0) return; var entry_addr = args[2]; var mod = Process.findModuleByAddress(entry_addr); if (mod && TARGET_SO.includes(mod.name)) { var so_base = mod.base; var offset = entry_addr - so_base; console.log("【检测线程定位】"); console.log("SO名:", mod.name); console.log("入口地址:", entry_addr); console.log("十进制偏移:", offset); console.log("十六进制偏移:", "0x" + offset.toString(16).toUpperCase()); console.log("====================================="); } } catch (e) { } },onLeave: function (retval) { if (retval != 0) { console.log(`[-] 线程创建失败,错误码:${retval}`); } } }); console.log("[+] pthread_create函数Hook完成,仅监控目标防护SO");}/** * 核心修改:通过call_constructors提前Hook目标函数 */function hook_linker_call_constructors() { // 1. 获取linker64基址(兼容32位linker) let linker_base = Module.getBaseAddress("linker64") || Module.getBaseAddress("linker"); if (!linker_base) { console.error("[-] 未找到 linker 模块!"); // 降级方案:使用原有dlopen拦截 hook_dlopen_and_force_return(); return; } // 你的call_constructors偏移(根据实际设备调整,这里保留你的0x20AF8) let call_constructors_addr = linker_base.add(0x20AF8); console.log(`[+] linker基址: ${linker_base}`); console.log(`[+] call_constructors地址: ${call_constructors_addr}`); // 2. Hook call_constructors(SO构造函数执行前的关键节点) Interceptor.attach(call_constructors_addr, {onEnter: function (args) { console.log("\n[+] ===== call_constructors 被调用(SO构造函数即将执行)====="); // 提前查找xxxxx.so基址(此时SO已加载,init_array还未执行) xxxxxBase = Module.findBaseAddress(LIB_DEX_HELPER_NAME); if (xxxxxBase && !isSOHooked) { console.log(`[+] 提前找到xxxxx.so基址: ${xxxxxBase}`); // 批量替换目标函数(核心:此时替换,init_array执行前生效) TARGET_OFFSETS.forEach(offset => { const funcAddr = xxxxxBase.add(offset); console.log(`[+] 准备提前Hook函数: 0x${offset.toString(16)} -> ${funcAddr}`); // 根据函数返回类型调整(64位建议用long) replace0(funcAddr, 'long'); }); isSOHooked = true; // 标记已Hook,避免重复操作 } // 打印调用来源和调用栈(辅助调试) let caller_addr = this.context.pc; let caller_module = Process.findModuleByAddress(caller_addr); if (caller_module) { console.log(`[+] 调用来源模块: ${caller_module.name} (基址: ${caller_module.base})`); } console.log("[+] 调用栈:"); console.log(Thread.backtrace(this.context, Backtracer.ACCURATE) .map(DebugSymbol.fromAddress).join("\n")); },onLeave: function (retval) { console.log("[+] ===== call_constructors 执行完成 =====\n"); } }); console.log("[+] call_constructors Hook 挂载成功!");}/** * 原有dlopen拦截逻辑(降级方案) */function hook_dlopen_and_force_return() { const android_dlopen_ext = Module.findExportByName(null, "android_dlopen_ext"); if (!android_dlopen_ext) { console.log("[-] 未找到 android_dlopen_ext 函数"); return; } Interceptor.attach(android_dlopen_ext, { onEnter(args) { this.isTargetSO = false; const pathPtr = args[0]; if (pathPtr) { const path = pathPtr.readCString(); if (path && path.includes(LIB_DEX_HELPER_NAME)) { this.isTargetSO = true; console.log(`[+] 检测到加载目标SO: ${path}`); } } }, onLeave(retval) { if (this.isTargetSO && !retval.isNull() && !isSOHooked) { console.log(`[+] ${LIB_DEX_HELPER_NAME} 加载成功`); xxxxxBase = Module.findBaseAddress(LIB_DEX_HELPER_NAME); if (!xxxxxBase) { console.log(`[-] 未找到 ${LIB_DEX_HELPER_NAME} 基址`); return; } console.log(`[+] ${LIB_DEX_HELPER_NAME} 基址: ${xxxxxBase}`); // 批量Hook(降级方案,仅当call_constructors Hook失败时执行) TARGET_OFFSETS.forEach(offset => { const funcAddr = xxxxxBase.add(offset); console.log(`[+] 准备 Hook 函数: 0x${offset.toString(16)} -> ${funcAddr}`); replace0(funcAddr, 'long'); }); isSOHooked = true; } } }); console.log("[+] android_dlopen_ext Hook 挂载成功(降级方案)");}// 主执行逻辑:先启动call_constructors Hook + 线程监控setImmediate(() => { hook_linker_call_constructors(); // 核心:提前Hook hook_cer_test_and_offset(); // 保留:线程监控});

依旧死亡,说明子线程打不打,都是无法影响后续的死亡,说明还有其他核心的检测点 ,但是刚刚核心逻辑已经断了,因为66998静态引用并没有trace脚本日志打印的上层合适的函数,所以我决定直接ret 66998死亡函数为0,看看是否能过检测。

调用这个之后,又会转而调用 __stack_chk_fail函数,无论如何都是死,我决定放弃聚焦下层,因为我们刚刚分析的只是往上10层函数。
聚焦上层看看,我们再次交叉引用66998对比,发现在早期执行流中,66254有调用嫌疑。


但是这可是sub_66254在执行的第20层,sub_66998在100多层去了,中间跨度极大。但是我还是抱着试试看的心态,先hook这个sub_66254函数,看看能不能打印onleave的出去日志,看看是不是在这里面死亡。

进来出去都成功,果然不是这里死亡的,并且向上追溯发现。这是jnionload的后续调用的业务方法,只是在里面检测分支调用sub_66998 可是这里的sub_66998没有触发,因为我用的是魔改frida,当时这层检验可能通过了,但是后续并没有通过。那么还有什么原因呢。

第一,我此时杀了子线程,我hook了xxxxx的so里面的所有子线程的入口函数,并且入口函数后续其实并没有调用sub_66998的分支。
第二,66998交叉引用并没有直接被放入函数表,也就是没有被结构体引用调用的可能性。
第三66998的交叉引用调用者只有一个66254在函数执行流里面,我hook66254,并没有在里面死,我把三条可能的链路全走不通。
子线程 -> 66998 -> 6AAC8 -> 死亡 (被推倒)
主线程 -> jni_onload -> 66254 -> 66998 -> 6AAC8 -> 死亡 (被推倒)
主线程 -> 66254 -> 结构体函数表 -> 引用 66998 -> 66AC8 -> 死亡(被推倒)
主线程 -> 66254 -> 结构体函数表 -> 引用66998父亲函数 (调用者) -> 66998 -> 66AC8 -> 死亡(被推倒)
定位动态native函数创建
没有线索就创造线索 ,我们hook 66998,看看调用者是谁:

发现了一个极为重要的线索,可以看到,这里调用者是0x68480,我们跳转看看属于哪个函数。

居然是这个68060,之前执行流完全没出现:

这里显式调用了66998函数,验证了我的猜想,要想调用66998,一定是显式调用。
不可能是结构体偏移调用,因为它交叉引用没有去任何一个函数表,如果是通过上级跳转然后注册上级函数的方式放入函数表,上级也会在执行流引用。
这里上级引用就一个66254 ,已经判断了没在这个函数里面死亡,执行流完全没有680C0的痕迹,子线程入口函数偏移和这里完全不相关。这里我基本可以判断了,是结合jni函数进行调用,也就是在66259注册一个native函数,然后被java通过jni调用。这样,就和刚刚的所有流完全脱了关系,我们找到代码来验证猜想。


核心入参:a1 是全局检测结构体 /off_E3290 基址,v4/v17/v20/v25 是动态注册时传递给检测函数的局部变量入参;
关键偏移:48LL/1720LL/184LL/1884LL 是 a1 指向结构体的字节偏移,用于取检测函数指针;
函数 / 函数名:偏移解引用后是 sub_68060/sub_69880 等 IDA 自动命名函数,仅 “r”/”u” 为函数名字符片段,无完整字符串函数名。
我们看看deepseek的分析,大家遇到这种混淆重的代码,结合结合ai:

所以说,这里是jni调用的动态注册native检测方法 ,在java极早期被调用。所以,现在我们要聚焦这里。
我们要分析68060的源码,这里全局使用了大量大量的off_E3290函数,全部是隐式调用,调用这些结构体函数,发现异常,直接调用66998杀进程。



注册v9为off_E3290 , 这里直接调用v9也就是结构体的偏移处函数,让v12接收,后续使用。
我们直接对sub_68060ret0试试:

虽然还是死了,但是执行流明显改变了!!160的函数死亡层变成了60

但是这里对sub_68060 ret0之后居然还是调用了66998,我认为这里的66998不是sub_68060调用的。
我们说过,66254通过动态注册68060让jni调用,但是还记得吗 66254本身也有调用函数 66998的分支。
那么我直接ret0可能触发了检测,我们看看打印看看本次sub_66998函数的调用链:

和之前不同,跳转看看属于哪个函数:

属于69880,交叉引用发现被66254函数调用,猜想正确!是66254注册时发现了异常,调用了69880,从而再次调用66998死亡函数 ,导致进程死亡。

我想对这两个函数同时直接ret0,看看能不能杜绝检测分支。发现崩溃了,java报错:

看到了classList 这里极有可能在脱壳,sub_69880或者 sub_68060 包含业务脱壳分支 我把它业务分支一起杀了,这里我们还是动态入手。我们回退到分析68060 , 因为这里执行流很长,执行到了160层才调用的 66998。
我们看看sub_xxxx的上几层函数有没有可能被这个jni函数调用,因为sub_68060有调用检测函数确定状态后再决定要不要杀进程的分支。

由于sub_68060含有 非常多非常多的结构体调用,静态分析十分艰难,无法定位具体函数,我决定结合动态分析。
刚刚分析到了,66998是由动态注册的native函数 68060早期调用的,那么一定死亡末尾有触发的函数,我们可以找找,这里75DC4我们可以结合之前的分析,是一个字符串比较函数。这里hook一定会有大量被调用日志 我相信68060也会调用这个函数,校验字符检验异常 我们试试。

有大量调用75DC4的痕迹 ,我们跳转到最后一个,果然是68060调用的(第四个偏移) 看到是通过间接调用,我盲猜一手 68060 -> 检测函数(xxxx)所属函数-> 75DC4 -> 检测异常 68060接收,调用66998杀进程 ,我们验证一下我们的猜想 ,跳转xxxx所在函数。
是一个超长检测函数,有800行 ,包含大量检测逻辑,看返回值,最后返回是一个int64,我们交叉引用看看。

第一层:

第二层:

第三层发现被导入到函数表,放入全局结构体,供检测函数隐式调用:

所以说,我们如果静态交叉引用是追踪不到这里的核心函数的 ,这里就达到了混淆的目的。
那么,看返回值,应该是如果xxxxx检验到了异常,就直接异常(1 或 0) 我们直接ret0试试:

成功过了,frida没被杀,成功,防护so文件被彻底解决。
我们还是要验证一下刚刚的猜想,刚刚是ret0,成功了,我们ret1试试:

可以看到,死亡了,我们猜想完全正确 ,这样一来,过这层so的检测就已经结束了。
所以检测杀进程流程为 jni_onload -> sub_66254 -> 动态注册jni native方法 -> java早期调用 -> sub_68060 -> 函数表 -> xxxx检测到异常 -> sub_xxxxx 杀进程
谢谢能看到这里的朋友,这个过程可以说是山路十八弯,我们经历了很多,最后定位到了核心检测函数,这里看似很通顺,是作者经历大量试错才有的文章,但是写到这里,是真的很开心,当所有证据链齐全,能验证自己猜想的感觉,是真的很舒服,大家一定不要怕试错,试的多,就经验多。

看雪ID:reserve_zhou
https://bbs.kanxue.com/user-home-1068038.htm

# 往期推荐


球分享

球点赞

球在看

点击阅读原文查看更多
夜雨聆风