乐于分享
好东西不私藏

APP frida 检测绕过详解:定位 JNI 动态注册 Native 函数,Hook 核心检测函数

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"), {onEnterfunction (args) {        this.path = args[0].readCString();        console.log("加载SO:" + this.path);        if (this.path.indexOf(target_so) !== -1) {            console.log("检测到目标SO加载,准备处理");            flag = true;        }    },onLeavefunction (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, {onEnterfunction (args) {                    console.log("进来");                },onLeavefunction (retval) {                    console.log("出去")                }            })        }    }});// 兜底:拦截栈检查失败函数,避免崩溃Interceptor.attach(Module.findExportByName(null"__stack_chk_fail"), {onEnterfunction () {        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, {onEnterfunction (args) {            // 容错:args[3]为0则跳过(无指定栈地址,非独立线程)            if (args[3].isNull() || args[3] == 0return;            try {                // arm64 Android 8.x 通用栈偏移96,若解析失败可调试±16/±32                var addr = args[3].add(96).readPointer();                // 容错:解析出的地址无效则跳过                if (addr.isNull() || addr == 0return;                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) {                // 捕获解析异常,避免脚本崩溃            }        },onLeavefunction (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.contextBacktracer.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, {onEnterfunction (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] == 0return;            try {                const stackOffsets = [968011264];                let addr = null;                for (const offset of stackOffsets) {                    addr = args[3].add(offset).readPointer();                    if (!addr.isNull() && addr != 0break;                }                if (addr.isNull() || addr == 0return;                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) { }        },onLeavefunction (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 {NativePointeraddress - 要替换的函数地址 * @param {stringretType - 返回值类型(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, {onEnterfunction (args) {            try {                if (args[2].isNull() || args[2] == 0return;                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) { }        },onLeavefunction (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, {onEnterfunction (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.contextBacktracer.ACCURATE)                .map(DebugSymbol.fromAddress).join("\n"));        },onLeavefunction (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

*本文为看雪论坛精华文章,由 reserve_zhou原创,转载请注明来自看雪社区

# 往期推荐

安卓逆向基础知识之frida Hook

2025 强网杯和强网拟态部分题解

在逆向分析方面-unidbg真的适合 MCP 吗?

AI静态分析,内核模块隐藏 Frida 特征,绕过linker私有结构遍历崩溃链

某安全so库深度解析

球分享

球点赞

球在看

点击阅读原文查看更多