当前位置: 首页 > news >正文

Frida Hook JNI动态注册函数的三大实战路径

1. 为什么JNI动态注册是Frida Hook的“硬骨头”——从一次崩溃说起去年在做某款金融类App的兼容性加固分析时我遇到一个典型场景所有Java层的Hook都稳如老狗但只要一碰Native层的关键校验逻辑App就直接闪退。Logcat里只有一行模糊的A/libc: Fatal signal 11 (SIGSEGV)连堆栈都残缺不全。反复排查后发现目标函数根本没走Java_com_xxx_yyy_zzz这种标准命名导出而是通过RegisterNatives在JNI_OnLoad里动态注册进JVM的。这意味着——你用Java.use(xxx).method.overload(...).implementation根本找不到它它压根不在Java反射的符号表里。这就是JNI动态注册函数的特殊性它绕过了编译期的符号绑定把函数地址和Java方法名的映射关系推迟到运行时才建立。对Frida而言这相当于你要在一辆高速行驶的列车上精准跳上一节没有车门编号、只靠内部广播临时报站的车厢。而市面上大多数教程要么只讲静态注册JNIEXPORT那种要么一笔带过“动态注册也能Hook”却从不告诉你在哪一刻Hook最稳、用什么方式定位最准、为什么某种写法必崩。这篇笔记就是为了解决这三个“为什么”。它不讲Frida基础语法不堆砌API文档只聚焦于真实逆向现场中你必须立刻做出的三个关键决策点是在JNI_OnLoad入口处埋点抢注还是在dlopen加载so后扫描内存找RegisterNatives调用点抑或直接在libart.so的JNIMethodTable里动手术每条路径背后是内存布局差异、ART版本演进、以及Android系统底层机制的硬约束。如果你正卡在某个Native函数Hook失败的深夜这篇就是为你写的实战手记。2. 路径一劫持JNI_OnLoad——在注册行为发生前完成“预埋伏击”2.1 为什么这是最直观却最容易翻车的起点很多初学者第一反应是“既然函数在JNI_OnLoad里注册那我就Hook这个函数在它调用RegisterNatives之前把我的代理函数塞进去不就行了”逻辑没错但实操中90%的失败都源于一个被忽略的前提JNI_OnLoad本身可能被混淆、重命名甚至被拆分成多个子函数。更致命的是某些加固方案会检测JNI_OnLoad的函数指针是否被篡改一旦发现异常立即触发自毁逻辑。我实测过某款游戏加固SDK它在JNI_OnLoad开头插入了一段汇编指令读取当前函数的__TEXT段起始地址再与预存的CRC32值比对。如果你用Interceptor.attach(Module.findExportByName(libxxx.so, JNI_OnLoad))直接HookFrida会在该函数入口处插入jmp跳转导致实际执行地址偏移CRC校验直接失败。这不是Frida的问题而是你没理解加固方的检测逻辑。2.2 真正可行的“无感劫持”三步法要绕过这类检测核心思路是不修改函数入口只修改其内部关键跳转点。以ARM64为例RegisterNatives调用通常表现为一条blbranch with link指令。我们不Hook整个JNI_OnLoad而是定位bl RegisterNatives指令的真实地址const so Module.load(libxxx.so); const onLoadAddr so.findExportByName(JNI_OnLoad); // 扫描JNI_OnLoad函数体查找bl指令opcode: 0x94000000 offset const instructions Instruction.parse(onLoadAddr, 256); // 解析256字节 let regNativesCallAddr null; for (let i 0; i instructions.length; i) { if (instructions[i].mnemonic bl) { const target instructions[i].operands[0].value; if (target target.toString().includes(RegisterNatives)) { regNativesCallAddr instructions[i].address; break; } } }在bl指令前插入我们的Hook逻辑这里不能用Interceptor.attach因为它是全局Hook。我们要用Memory.patchCode直接修改内存Memory.patchCode(regNativesCallAddr, 16, function (code) { const cw new Arm64Writer(code, { pc: regNativesCallAddr }); // 保存x0-x3寄存器RegisterNatives参数env, clazz, methods, numMethods cw.writeInstruction(0xa9bf7bfd); // stp x29, x30, [sp, #-16]! cw.writeInstruction(0x910003fd); // mov x29, sp // 调用我们的JS函数需提前用NativeCallback定义 cw.writeInstruction(0x58000000 | ((myCallbackPtr.toInt32() 2) 0x3ffffff)); // adr x0, myCallbackPtr cw.writeInstruction(0xd61f0000); // br x0 // 恢复寄存器并继续执行原bl指令 cw.writeInstruction(0xa8c17bfd); // ldp x29, x30, [sp], #16 cw.writeInstruction(0x14000000 | ((regNativesAddr.sub(regNativesCallAddr).toInt32() 2) 0x3ffffff)); // bl original_RegisterNatives cw.flush(); });在JS回调中完成“狸猫换太子”myCallbackPtr指向的NativeCallback函数里我们拿到methods数组指针遍历每个JNINativeMethod结构体将fnPtr字段替换成我们自己的代理函数地址typedef struct { const char* name; const char* signature; void* fnPtr; } JNINativeMethod; void my_register_hook(JNIEnv* env, jclass clazz, JNINativeMethod* methods, jint numMethods) { for (int i 0; i numMethods; i) { if (strcmp(methods[i].name, verifyLicense) 0) { // 保存原始函数指针供后续调用 original_verify methods[i].fnPtr; // 替换为我们的代理 methods[i].fnPtr (void*)my_verify_proxy; } } // 必须调用原RegisterNatives否则注册不生效 original_RegisterNatives(env, clazz, methods, numMethods); }提示此方案最大的风险在于methods数组可能位于只读内存页。实测中Android 8.0 的ART默认将JNINativeMethod数组放在.data段可写但某些定制ROM会将其映射为PROT_READ。此时需先调用mprotect修改内存权限否则memcpy会触发SIGSEGV。这是很多教程不提但你一定会踩的坑。2.3 实战经验如何快速验证你的劫持是否成功别等App启动完再看日志。在my_register_hook里加一句__android_log_print(ANDROID_LOG_DEBUG, FRIDA, Hooked %s - %p, methods[i].name, methods[i].fnPtr);然后用adb logcat -s FRIDA实时监控。如果看到verifyLicense - 0x7f8a123456说明替换成功如果日志根本没出现大概率是bl指令定位错了或者加固代码把RegisterNatives调用藏在了JNI_OnLoad之外的其他函数里比如initCrypto()。这时就得转向第二条路径。3. 路径二内存扫描法——在so加载后主动“狩猎”RegisterNatives调用点3.1 为什么需要放弃“守株待兔”转为主动出击当JNI_OnLoad被深度混淆或者RegisterNatives根本不在其中调用时例如某些SDK会在网络请求回调后动态加载第二个so并注册新函数路径一就失效了。此时我们必须像猎人一样在目标so加载进内存的瞬间扫描其所有代码段寻找RegisterNatives的调用痕迹。这听起来很暴力但却是最通用、最可靠的方案。关键洞察在于RegisterNatives是一个系统级JNI函数它的地址在进程内是固定的位于libart.so中。因此任何对它的调用必然是一条bl指令且目标地址指向libart.so的某个偏移。我们不需要知道RegisterNatives的具体地址只需要在目标so的代码段里搜索所有bl指令并检查其跳转目标是否落在libart.so的内存范围内。3.2 扫描算法的工程化实现细节Frida的Module.enumerateExports只能找到导出符号对内部调用无能为力。我们必须手动解析ELF文件头定位.text段再逐条解码指令。以下是经过千次实测优化的扫描流程精准定位目标so的代码段Module.load(libxxx.so).enumerateSections()返回的section信息有时不准尤其加固后。更可靠的方式是读取/proc/self/mapsfunction getSoBaseAddress(soName) { const maps Memory.readUtf8String(Process.getModuleByName(libc.so).base); const content Memory.readUtf8String(Process.getModuleByName(libc.so).base.add(0x1000)); // 实际应读取 /proc/self/maps此处简化 const mapsFile new File(/proc/self/maps, r); let line; while ((line mapsFile.readLine()) ! null) { if (line.includes(soName) line.includes(r-xp)) { return ptr(line.split(-)[0].trim()); } } return null; }ARM64指令扫描的避坑要点bl指令的编码格式是0x94000000 | (imm26 0)其中imm26是带符号的26位立即数表示跳转偏移单位4字节。计算目标地址的公式是target current_address (signExtend(imm26) 2)很多人在这里翻车因为忘了signExtend——imm26最高位是符号位。例如imm26 0x2000000二进制0010 0000 0000 0000 0000 0000 0000符号扩展后是0x0000000020000000而imm26 0x4000000二进制0100 0000 0000 0000 0000 0000 0000符号扩展后是0xffffffff40000000负数。Frida的Instruction.parse会自动处理但自己写解析器时必须注意。高效过滤libart.so调用的技巧直接比较target是否在libart.so基址范围内太慢。更优策略是先获取libart.so的基址const artBase Module.load(libart.so).base;计算libart.so的大小通过/proc/self/maps或Module.load(libart.so).size对每个bl指令计算出的target执行if (target.compare(artBase) 0 target.compare(artBase.add(artSize)) 0) { /* 可能是RegisterNatives */ }但仍有误报比如调用FindClass。终极过滤是检查bl指令前几条指令是否在加载JNIEnv*和jclass。典型的RegisterNatives调用序列是ldr x0, [x19] // 加载env指针通常存于x19 ldr x1, [x20] // 加载clazz ldr x2, [x21] // 加载methods数组 ldr w3, [x22] // 加载numMethods bl 0x7f8a123456 // RegisterNatives我们只需匹配ldr x0, [...]ldr x1, [...]bl ...这个模式准确率可达99.8%。3.3 扫描时机的黄金窗口dlopen之后JNI_OnLoad之前很多人在Java.perform里启动扫描结果一无所获。正确时机是监听dlopen调用当参数filename包含目标so名时立即对刚加载的so进行扫描。这是因为dlopen返回后so的代码段已映射进内存但JNI_OnLoad可能尚未执行此时RegisterNatives调用点已存在却还未被触发正是Hook的最佳时刻。Interceptor.attach(Module.findExportByName(null, dlopen), { onEnter: function (args) { const soName Memory.readUtf8String(args[0]); if (soName soName.includes(libxxx.so)) { this.targetSoName soName; } }, onLeave: function (retval) { if (this.targetSoName) { const loadedSo Module.load(this.targetSoName); console.log([] ${this.targetSoName} loaded at ${loadedSo.base}); scanForRegisterNatives(loadedSo); } } });注意dlopen可能被plt劫持或内联某些加固会hook它。更稳妥的方式是Module.load(libdl.so).findExportByName(dlopen)但需确保libdl.so未被隐藏。这是逆向中的“套娃”问题没有银弹只能根据目标环境灵活调整。4. 路径三ART虚拟机层Hook——直击JNIMethodTable的“心脏手术”4.1 当前两条路径都失效时你必须理解ART的真相假设你已尝试路径一JNI_OnLoad劫持和路径二内存扫描但目标函数依然无法Hook。此时不是你的代码有问题而是你面对的是ART虚拟机的深度优化机制。从Android 7.0Nougat开始ART引入了JNIMethodTable它是一个全局哈希表存储所有已注册的Native方法映射。RegisterNatives的最终目的就是往这个表里插入键值对Java方法签名 - Native函数指针。如果我们能直接修改这个表就能绕过所有上层调用点的干扰。这听起来像黑魔法但原理极其清晰JNIMethodTable的地址在libart.so中是固定的可通过符号gJNIMethodTable获得其数据结构是一个std::unordered_map。在Android 9.0它的定义是// art/runtime/jni/jni_internal.h extern std::unordered_mapstd::string, void* gJNIMethodTable;但直接访问gJNIMethodTable在Frida中不可行因为C STL容器的内存布局复杂。真正的突破口是art::JNI::RegisterNativeMethod这个函数——它是RegisterNatives的ART内部实现所有注册行为最终都会流经这里。4.2 定位RegisterNativeMethod的三种实战技巧gJNIMethodTable符号在发行版libart.so中通常被strip掉。我们必须通过特征码扫描来定位art::JNI::RegisterNativeMethod。以下是三种成功率最高的方法按推荐顺序排列字符串锚点法首选该函数内部必定包含对JNI RegisterNativeMethod或类似调试字符串的引用。用Module.load(libart.so).enumerateSymbols()查找含RegisterNativeMethod的符号若无则搜索字符串const artSo Module.load(libart.so); const strAddr artSo.findBaseAddress().add(0x1000); // 从.data段开始搜 const scanner new Scanner(artSo); scanner.scan(strAddr, JNI RegisterNativeMethod, { onMatch: function (address, size) { console.log(Found string at ${address}, scanning backwards for function...); // 向前扫描找函数入口通常是sub sp, sp, #0xXX } });调用图回溯法针对高版本在Android 10RegisterNatives的JNI层实现是art::JNI::RegisterNatives它会调用art::JNI::RegisterNativeMethod。我们可以先定位RegisterNatives它有导出符号然后反汇编其代码找bl指令的目标const regNatives Module.load(libart.so).findExportByName(RegisterNatives); const insns Instruction.parse(regNatives, 128); for (let insn of insns) { if (insn.mnemonic bl) { const target insn.address.add(insn.operands[0].value); if (target.compare(artSo.base) 0 target.compare(artSo.base.add(artSo.size)) 0) { console.log(Potential RegisterNativeMethod at ${target}); // 验证该地址附近是否有对gJNIMethodTable的引用 } } }内存特征码法终极保底art::JNI::RegisterNativeMethod函数体有一个稳定特征它一定会对gJNIMethodTable执行insert()操作而std::unordered_map::insert在ARM64上会调用std::unordered_map::_M_insert_unique_node其入口指令是sub sp, sp, #0x40分配栈帧。搜索这个指令序列命中率极高。4.3 修改JNIMethodTable的原子操作一旦定位到art::JNI::RegisterNativeMethod我们就可以在其函数末尾ret指令前注入我们的逻辑。但注意不能直接修改gJNIMethodTable而要Hook它的insert调用。因为std::unordered_map::insert是模板函数地址不固定但art::JNI::RegisterNativeMethod是唯一的入口。我们的Hook逻辑是Interceptor.attach(registerNativeMethodAddr, { onEnter: function (args) { // args[0] JNIEnv*, args[1] jclass, args[2] methodName, args[3] signature, args[4] fnPtr const methodName Memory.readUtf8String(args[2]); const signature Memory.readUtf8String(args[3]); const fullSig ${methodName}${signature}; // 如 verifyLicense(Ljava/lang/String;)Z if (fullSig.includes(verifyLicense)) { console.log([] Intercepted registration: ${fullSig} - ${args[4]}); // 保存原始指针 originalFn args[4]; // 将第4个参数fnPtr改为我们的代理 args[4] my_verify_proxy_ptr; } } });关键经验此方案在Android 12上需额外处理ScopedObjectAccess。ART 12引入了更严格的线程状态检查RegisterNativeMethod内部会调用Thread::Current()-GetJniEnv()如果我们的Hook中调用Java API如Java.use会因线程状态不一致而崩溃。解决方案是所有Java层操作必须在Java.perform回调中异步执行Native Hook里只做指针替换。这是高版本ART的硬性约束绕不过去。5. 三条路径的对比决策树何时该选哪一条面对一个未知的加固App你不可能同时尝试所有路径。必须有一套快速决策的逻辑。这是我整理的实战决策树基于200个真实样本的统计决策节点选项A路径一JNI_OnLoad劫持选项B路径二内存扫描选项C路径三ART层Hook第一步检查JNI_OnLoad是否存在且可读✅ 存在且未混淆nm -D libxxx.so | grep JNI_OnLoad❌ 符号被strip或nm无输出⚠️nm有输出但Hook后App立即崩溃加固检测第二步确认RegisterNatives调用位置✅objdump -d libxxx.so | grep -A5 -B5 RegisterNatives显示在JNI_OnLoad内✅objdump显示在initCrypto等其他函数内⚠️objdump完全找不到RegisterNatives动态生成第三步验证ART版本与加固强度✅ Android 8.0或加固较弱无CRC校验✅ Android 8.0-10加固中等仅检测入口✅ Android 11或加固极强检测所有JNI调用第四步实测Hook稳定性 Hook后logcat无崩溃但目标函数未被调用说明注册点不在JNI_OnLoad 扫描到调用点但替换后VerifyError方法签名不匹配 其他路径均失败且logcat显示JNI ERROR (app bug): local reference table overflow说明ART层拦截失败决策树使用口诀先看符号再看调用最后看系统。如果nm能看到JNI_OnLoad永远先试路径一因为它最轻量、最安全。如果nm看不到或objdump显示RegisterNatives在JNI_OnLoad外立刻切到路径二并开启dlopen监听。如果前两者都失败且目标App是Android 11金融/游戏类应用闭眼选路径三并准备好处理ScopedObjectAccess异常。我在某款银行App的逆向中就是按此流程nm无输出 → 切路径二 →dlopen监听成功 → 扫描到RegisterNatives在loadSecurityModule里 → Hook成功。整个过程从开始到拿到密钥耗时17分钟。而如果一开始就想当然用路径三光是定位RegisterNativeMethod就得花2小时。6. 终极避坑指南那些文档里绝不会写的“血泪教训”6.1 “Hook了但没完全Hook”——签名匹配的魔鬼细节JNINativeMethod结构体里的signature字段是JNI类型签名不是Java方法签名。例如Java方法public static String verify(String token)其JNI签名是(Ljava/lang/String;)Ljava/lang/String;而非(String)String。很多新手在路径一中用strcmp(methods[i].name, verify) 0匹配成功却忘了检查signature导致Hook了错误的重载方法。更隐蔽的坑是某些加固会修改signature字符串本身。例如把Ljava/lang/String;改成Lcom/xxx/ObfuscatedString;。此时仅靠name匹配会失败。正确做法是在onEnter回调中用GetMethodSignature动态获取真实签名Java.perform(function () { const env Java.vm.getEnv(); const clazz env.findClass(com/xxx/TargetClass); const method env.getMethodID(clazz, verifyLicense, (Ljava/lang/String;)Z); const sig env.getMethodSignature(method); // 动态获取100%准确 console.log(Real signature: ${sig}); });6.2 “Hook成功但调用崩溃”——线程模型的无声杀手Native函数可能在任意线程被调用包括Binder线程、RenderThread、甚至Signal Catcher线程。你的代理函数my_verify_proxy如果调用了JNIEnv*的任何方法如NewStringUTF而当前线程并未attach到JVM就会崩溃。解决方案只有两个在代理函数开头强制attach当前线程JNIEnv* env; jint res (*g_jvm)-GetEnv(g_jvm, (void**)env, JNI_VERSION_1_6); if (res JNI_EDETACHED) { (*g_jvm)-AttachCurrentThread(g_jvm, env, NULL); // 执行你的逻辑 (*g_jvm)-DetachCurrentThread(g_jvm); }彻底避免在Native层调用JNIEnv所有Java交互通过frida-java-bridge在JS层完成Native层只做纯计算。6.3 “一切正常但数据不对”——内存可见性的量子纠缠在多核CPU上methods[i].fnPtr my_proxy这条赋值可能因CPU缓存不一致导致其他核心看到的仍是旧值。这不是Bug而是现代CPU的内存模型特性。解决方案是在修改fnPtr后执行内存屏障__atomic_store_n(methods[i].fnPtr, my_proxy, __ATOMIC_SEQ_CST); // 或者 ARM64 汇编 __asm__ volatile (dsb sy ::: memory);没有这行你在A核心上看到Hook成功B核心调用的仍是原始函数。这是最让人抓狂的“薛定谔的Hook”。最后分享一个小技巧在所有路径的Hook代码里加上时间戳日志console.log([${new Date().toISOString()}] Hook triggered for verifyLicense);当App崩溃时logcat里的时间戳能帮你精确到毫秒级定位是哪一行代码触发了崩溃。这比任何调试器都快。逆向不是玄学是精密的工程。每一个bl指令每一行dsb sy都是你和系统底层对话的语言。当你能读懂这些语言所谓的“加固”不过是纸老虎。
http://www.zskr.cn/news/1398306.html

相关文章:

  • 07.Day 7:植入顶级大脑 —— PEAK 框架与多维 ABLE 假设工程
  • SQL去重不是删数据,而是数据治理决策链
  • O4-Mini轻量大模型API实战:边缘部署与工业诊断落地指南
  • GNURadio实战:一台电脑插两个RTL-SDR电视棒,同时收听不同FM电台的完整配置流程
  • AI集成实战指南:从战略规划到持续运维的避坑与落地
  • 工业机器人少样本故障诊断:PTFM时频混合与原型学习实战
  • 数据管道静默失败监控:从数据质量到业务价值的全方位防御体系
  • 探索型与执行型AI智能体:设计哲学、技术实现与协同工作流
  • 从iris数据集实战出发:手把手教你用Python+sklearn玩转KMeans聚类与t-SNE可视化
  • 跨模态Transformer模型:成像测井图像与常规测井曲线的特征融合及岩性分类
  • 保姆级教程:用yum downloadonly搞定Docker离线包,一份包适配麒麟V10/CentOS 8
  • PlayIntegrityFix终极指南:简单三步解决Android设备认证难题
  • EEG微状态序列分析新范式:用NLP词嵌入技术解码大脑动态语法
  • 从地理空间数据云到可游玩地图:一份给独立开发者的真实世界地形创建全流程指南
  • 观察使用Taotoken后API调用的成功率和响应时间变化
  • NVIDIA Profile Inspector技术深度解析:驱动程序配置管理架构与实践指南
  • 情感分析实战:用Python和jieba给你的微博评论自动‘打标签’(附完整代码与词典)
  • 揭秘进程管理:从PID到PCB全解析
  • AzurLaneAutoScript:5步实现碧蓝航线全自动化的终极解决方案
  • TransCAD 6.0 闪退别慌!手把手教你打补丁并搞定波士顿交通网络的最短路径分析
  • [吐槽] outlook 新版本
  • 别再只拿Amazon Review Dataset做推荐了!用Python玩转商品评论的情感分析与销量预测
  • 告别Transformer?手把手带你用Mamba搭建首个图像分类模型(附PyTorch代码)
  • Anthropic开源11个企业级插件,我全试了一遍——这是值得装的4个
  • AI Agent 认知模型与推理模式综述
  • 别再只会点按钮了!SPSS聚类分析实战:用31省产业数据手把手教你选对方法(附数据集)
  • 在银河麒麟V10上装VirtualBox增强工具,卡在SELinux policy.29错误?试试这个临时关闭SELinux的完整流程
  • Windows系统itss.dll文件丢失找不到问题解决
  • 多Agent虚拟开发:构造功能设想与开发方案(一)
  • A51汇编器行号偏移问题解析与调试优化