Frida Hook JNI动态注册函数:Android Native层逆向分析实战
1. 项目概述为什么需要Hook JNI动态注册函数在Android逆向与安全分析的日常工作中我们经常会遇到一个棘手的问题应用的核心逻辑被编译进了原生Native层也就是那些用C/C写的.so库文件。这些库通过JNIJava Native Interface与Java层交互。其中JNI函数的注册方式有两种——静态注册和动态注册。静态注册的函数名遵循特定的命名规则如Java_com_example_MyClass_myMethod相对容易被定位。而动态注册则是开发者在Native代码中通过一个JNINativeMethod结构体数组手动将Java方法与C函数指针进行绑定。这种方式隐蔽性极强函数名在编译后可能被混淆或完全无关传统的基于字符串搜索的方法基本失效。这就引出了我们今天的实战主题如何用Frida的Hook技术精准监控Android应用中的JNI动态注册函数。这不仅仅是逆向分析中的一个“高级技巧”更是深入理解应用底层行为、分析加密算法、追踪敏感数据流如密钥生成、网络请求签名的必经之路。想象一下一个金融类App的登录密码加密函数或者一个游戏的核心校验逻辑很可能就藏在这些动态注册的JNI函数里。如果你无法定位和监控它们整个分析工作就可能止步于Java层触及不到真正的核心。我遇到过不少案例应用在Java层只是做简单的参数组装真正的加密、签名、协议封包全部在动态注册的Native函数里完成。不搞定它们你连数据包都构造不出来。因此掌握这套方法相当于拿到了一把打开Native层黑盒的钥匙。接下来我将从一个实战者的角度带你一步步拆解原理并附上我打磨过无数次的完整脚本让你能直接上手复现整个监控过程。2. 核心原理与前置知识拆解2.1 JNI动态注册机制深度剖析要Hook必须先理解它是怎么“长”出来的。动态注册的核心发生在JNI_OnLoad函数或某个初始化函数中。我们来看一个典型的代码片段// 假设这是Native层实现的函数 jstring native_hello(JNIEnv* env, jobject thiz) { return (*env)-NewStringUTF(env, Hello from Dynamic JNI!); } // 定义方法映射表 static JNINativeMethod gMethods[] { {helloFromJNI, ()Ljava/lang/String;, (void*)native_hello} }; // 在JNI_OnLoad中注册 jint JNI_OnLoad(JavaVM* vm, void* reserved) { JNIEnv* env NULL; if ((*vm)-GetEnv(vm, (void**)env, JNI_VERSION_1_6) ! JNI_OK) { return JNI_ERR; } jclass clazz (*env)-FindClass(env, com/example/myapp/NativeHelper); (*env)-RegisterNatives(env, clazz, gMethods, 1); // 关键注册调用 return JNI_VERSION_1_6; }这里的关键是RegisterNatives这个JNI函数。它接受一个Java类引用、一个JNINativeMethod结构体数组及其长度作为参数。JNINativeMethod结构体包含三个字段name: Java方法名如helloFromJNI。signature: JNI方法签名描述参数和返回值类型如()Ljava/lang/String;。fnPtr: 指向实际Native函数实现的指针如(void*)native_hello。我们的Hook目标就是这个fnPtr指向的函数地址。但问题在于这个地址是在运行时由RegisterNatives调用时才确定并绑定到Java方法上的。我们无法像Hook Java方法那样直接通过类名和方法名去拦截。2.2 Frida的介入策略从源头拦截既然动态注册的过程发生在Native层我们的思路就很明确了在RegisterNatives函数被调用时进行拦截从而“捕获”到那个关键的JNINativeMethod数组进而拿到每个Native函数的指针地址。Frida提供了强大的Interceptor.attach功能允许我们附加到任意Native函数地址上。因此整个方案的核心步骤就清晰了定位RegisterNatives函数它在libart.soAndroid运行时库或目标应用自身的Native库中导出。HookRegisterNatives函数当它被调用时我们就能获取到其参数特别是那个包含函数指针的JNINativeMethod数组。解析并二次Hook从数组中提取出每个fnPtr然后使用Frida对这些具体的Native函数实现进行Hook。注意不同Android版本、不同厂商ROM的libart.so中RegisterNatives的函数签名参数顺序、类型可能略有差异。我们的脚本需要具备一定的兼容性不能写死偏移量或假设参数顺序。2.3 工具与环境准备工欲善其事必先利其器。开始实战前请确保你的环境已经就绪一部已Root的Android设备或模拟器这是使用Frida进行深入Native Hook的前提。推荐使用官方Android Studio自带的x86_64镜像模拟器兼容性好调试方便。Frida环境PC端通过pip安装pip install frida-tools。设备端根据设备CPU架构arm,arm64,x86_64下载对应的frida-server推送到设备并以后台进程运行。这是Frida的核心服务。目标应用选择一个你拥有测试权限的应用。对于学习可以自己写一个包含动态注册JNI函数的Demo App。这样你能完全掌控内部逻辑方便验证Hook是否成功。代码编辑器任意你喜欢的即可用于编写和修改Frida JavaScript脚本。3. 实战分步构建监控脚本理论讲完我们进入最核心的实操环节。我将把完整的脚本拆解开逐一讲解每个部分的意图和关键代码。3.1 第一步定位并Hook RegisterNatives首先我们需要找到RegisterNatives函数。在Android系统中它通常由libart.so导出。我们可以使用Frida的Module.findExportByName来获取其地址。// 1. 定义我们需要的关键函数指针和结构体根据JNI头文件 const RegisterNativesAddr Module.findExportByName(libart.so, _ZN3art3JNI15RegisterNativesEP7_JNIEnvP7_jclassPK15JNINativeMethodi); if (RegisterNativesAddr) { console.log([] Found RegisterNatives at ${RegisterNativesAddr}); } else { // 某些版本或ROM可能在别处尝试其他常见名称或模块 console.log([-] Failed to find RegisterNatives in libart.so, trying alternative...); // 可以尝试遍历所有模块查找 Process.enumerateModules().forEach(m { let addr m.findExportByName(RegisterNatives); if (addr) { console.log([] Found in ${m.name} at ${addr}); RegisterNativesAddr addr; } }); }找到地址后我们使用Interceptor.attach进行挂钩。这里最大的难点是参数解析。RegisterNatives的原型是jint RegisterNatives(JNIEnv* env, jclass clazz, const JNINativeMethod* methods, jint nMethods)我们需要读取第三个参数methods它是一个指向JNINativeMethod数组的指针。Interceptor.attach(RegisterNativesAddr, { onEnter: function(args) { // args[0] 是 JNIEnv* // args[1] 是 jclass clazz this.clazz args[1]; // args[2] 是 const JNINativeMethod* methods this.methodsPtr args[2]; // args[3] 是 jint nMethods this.nMethods args[3].toInt32(); console.log(\n[RegisterNatives Called]); console.log( Class: ${this.clazz}); console.log( Methods Ptr: ${this.methodsPtr}); console.log( Number of Methods: ${this.nMethods}); // 临时保存用于onLeave时或直接在此处解析 this._nativeMethods []; }, onLeave: function(retval) { // 注册完成后我们可以解析方法数组 // 注意onEnter时数组内容可能还未完全准备好有时在onLeave解析更稳妥 if (this.methodsPtr this.nMethods 0) { parseJNINativeMethods(this.methodsPtr, this.nMethods, this.clazz); } } });3.2 第二步解析JNINativeMethod结构体这是脚本中最精细的部分。我们需要在内存中正确地“爬取”这个结构体数组。JNINativeMethod在内存中的布局通常是三个连续指针在32位下是3个4字节64位下是3个8字节分别对应name,signature,fnPtr。function parseJNINativeMethods(methodsPtr, nMethods, clazz) { const ptrSize Process.pointerSize; // 获取当前进程指针大小兼容32/64位 const methodSize ptrSize * 3; // 每个JNINativeMethod结构体的大小 for (let i 0; i nMethods; i) { let methodEntry methodsPtr.add(i * methodSize); // 读取三个指针 let namePtr methodEntry.readPointer(); let sigPtr methodEntry.add(ptrSize).readPointer(); let fnPtr methodEntry.add(ptrSize * 2).readPointer(); // 将指针转换为字符串C字符串 let name namePtr.readCString(); let signature sigPtr.readCString(); console.log( [${i}] ${name} ${signature} -- NativePtr: ${fnPtr}); // 记录到全局对象以便后续Hook let methodInfo { javaName: name, javaSig: signature, nativePtr: fnPtr, owningClass: clazz }; gHookedMethods.push(methodInfo); // 立即对这个Native函数进行Hook hookNativeFunction(methodInfo); } }实操心得readCString()可能会因为指针无效而抛出异常。在实际对抗中应用可能会故意传入假指针或已释放的内存来干扰分析。因此在生产脚本中这部分代码需要放在try-catch块中并增加对指针有效性的基础判断例如检查是否在可读的内存页内。3.3 第三步对捕获的Native函数实现进行Hook现在我们拿到了梦寐以求的Native函数指针fnPtr。接下来就可以像Hook普通Native函数一样Hook它了。但是我们不知道这个函数的参数个数和类型签名只对Java层有意义。这里有两种策略通用参数打印适用于快速监控函数是否被调用、获取大致参数值。我们可以读取前几个参数通常是JNIEnv*和jclass/jobject以及可能的后续参数。function hookNativeFunction(methodInfo) { let address methodInfo.nativePtr; console.log([*] Attempting to hook native function at ${address} for ${methodInfo.javaName}); try { Interceptor.attach(address, { onEnter: function(args) { console.log(\n [Native Call: ${methodInfo.javaName}] ); // args[0] 通常是 JNIEnv* // args[1] 通常是 jclass (静态方法) 或 jobject (实例方法) console.log( JNIEnv*: ${args[0]}); console.log( this/class: ${args[1]}); // 尝试打印可能的参数从第三个开始具体取决于函数签名 // 这是一个示例实际参数解析极其复杂需要根据签名来 for (let i 2; i 6; i) { // 假设最多看前4个可能参数 if (i args.length) { // 防止越界 let val args[i]; // 简单判断如果是指针可能是字符串或对象引用 if (val.isNull()) { console.log( arg${i}: NULL); } else { // 尝试当作C字符串读取如果不是会抛异常我们捕获即可 try { let str val.readCString(); console.log( arg${i}: ${str} (string)); } catch (e) { // 不是字符串直接打印指针值 console.log( arg${i}: ${val} (pointer)); // 还可以尝试读取为整数等这里省略 } } } } // 记录调用栈对于分析调用链非常有用 // console.log(Thread.backtrace(this.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join(\n)); }, onLeave: function(retval) { // 尝试打印返回值 if (!retval.isNull()) { // 根据方法签名猜测返回值类型这里简化处理 if (methodInfo.javaSig.includes(String)) { try { // 对于jstring需要通过JNIEnv函数转换这里直接打印指针 console.log( retval (jstring): ${retval}); } catch(e) {} } else if (methodInfo.javaSig.includes(I) || methodInfo.javaSig.includes(J)) { console.log( retval (int/long): ${retval.toInt32()}); } else { console.log( retval: ${retval}); } } else { console.log( retval: NULL); } console.log( [End: ${methodInfo.javaName}] \n); } }); console.log([] Successfully hooked ${methodInfo.javaName}); } catch (e) { console.log([-] Failed to hook ${methodInfo.javaName}: ${e}); } }基于签名的精确参数解析这是终极方案但实现极其复杂。你需要完整解析JNI类型签名如(ILjava/lang/String;[B)J然后在onEnter中根据签名使用Frida的Memory.readByteArray、Memory.readInt等API结合JNIEnv的函数如GetStringUTFChars来正确读取参数值。这通常需要为每种JNI类型编写专门的解析函数工作量巨大一般只在针对特定关键函数进行深度分析时使用。3.4 第四步脚本整合与优化将上述所有步骤整合并增加一些健壮性和实用性功能。// frida_hook_jni_dynamic.js // 全局存储已Hook的方法信息 var gHookedMethods []; // 主逻辑 function hookDynamicJNIRegistration() { console.log([*] Starting JNI Dynamic Registration Hook...); let targetModules [libart.so, libandroid_runtime.so]; let registerNativesAddr null; // 1. 寻找RegisterNatives for (let modName of targetModules) { let addr Module.findExportByName(modName, RegisterNatives); if (addr) { registerNativesAddr addr; console.log([] Found RegisterNatives in ${modName} at ${addr}); break; } } if (!registerNativesAddr) { console.log([-] Could not find RegisterNatives. Trying broader search...); // 更暴力的搜索方式遍历所有模块 Process.enumerateModules().forEach(m { let exports m.enumerateExports(); for (let exp of exports) { if (exp.name.includes(RegisterNatives)) { // 模糊匹配 registerNativesAddr exp.address; console.log([] Found via search: ${exp.name} in ${m.name} at ${exp.address}); return false; // 跳出forEach循环 } } }); } if (!registerNativesAddr) { console.log([-] Fatal: RegisterNatives not found. Exiting.); return; } // 2. Hook RegisterNatives Interceptor.attach(registerNativesAddr, { onEnter: function(args) { this.methodsPtr args[2]; this.nMethods args[3].toInt32(); this.clazz args[1]; // 可以在这里记录但解析放在onLeave更安全 }, onLeave: function(retval) { if (this.methodsPtr this.nMethods 0) { console.log(\n[] Intercepted RegisterNatives for class ${this.clazz}, registering ${this.nMethods} method(s).); parseAndHookMethods(this.methodsPtr, this.nMethods, this.clazz); } } }); console.log([] RegisterNatives hook installed. Waiting for calls...\n); } // 解析并Hook方法的函数 function parseAndHookMethods(methodsPtr, nMethods, clazz) { // ... 同上一节的 parseJNINativeMethods 函数 ... } // 对单个Native函数进行Hook的函数 function hookNativeFunction(methodInfo) { // ... 同上一节的 hookNativeFunction 函数 ... // 可以增加一个过滤只Hook我们感兴趣的函数名 let targetNames [encrypt, decrypt, sign, check, init]; // 示例关键词 for (let kw of targetNames) { if (methodInfo.javaName.toLowerCase().includes(kw)) { console.log([!] Key function ${methodInfo.javaName} hooked.); // 这里可以触发更详细的Hook逻辑 break; } } } // 延迟执行确保目标库已加载 setTimeout(hookDynamicJNIRegistration, 1000); // 导出一些实用函数方便在REPL中调用 rpc.exports { listHookedMethods: function() { return gHookedMethods.map(m ${m.javaName} ${m.nativePtr}); }, // 可以添加手动Hook指定地址的函数 };4. 运行脚本与结果分析将上述脚本保存为hook_jni.js。在确保frida-server已在设备上运行后在电脑终端执行frida -U -f com.example.targetapp -l hook_jni.js --no-pause-U: 连接到USB设备。-f com.example.targetapp: 启动目标应用。-l hook_jni.js: 加载我们的脚本。--no-pause: 立即启动主线程。如果应用已经运行你可以使用其进程名或PID来附加frida -U com.example.targetapp -l hook_jni.js当应用启动并执行到JNI_OnLoad或任何调用RegisterNatives的地方时你的控制台就会输出捕获到的信息。示例输出可能如下[*] Starting JNI Dynamic Registration Hook... [] Found RegisterNatives in libart.so at 0x7a12c3d4a0 [] RegisterNatives hook installed. Waiting for calls... [] Intercepted RegisterNatives for class 0xdf2a, registering 3 method(s). [0] nativeEncrypt (Ljava/lang/String;)[B -- NativePtr: 0x7a8f1b2c [1] nativeGetKey ()Ljava/lang/String; -- NativePtr: 0x7a8f1b8 [2] nativeVerify (I[B)Z -- NativePtr: 0x7a8f1c04 [*] Attempting to hook native function at 0x7a8f1b2c for nativeEncrypt [] Successfully hooked nativeEncrypt [*] Attempting to hook native function at 0x7a8f1b8 for nativeGetKey [] Successfully hooked nativeGetKey [*] Attempting to hook native function at 0x7a8f1c04 for nativeVerify [] Successfully hooked nativeVerify [Native Call: nativeEncrypt] JNIEnv*: 0x7a8e4000 this/class: 0xdf2a arg2: HelloWorld (string) arg3: 0x16 (pointer) [End: nativeEncrypt] retval: 0x7a8f4a00 (pointer) // 这是一个jbyteArray的指针从输出中你可以清晰地看到动态注册发生时捕获了3个方法。成功Hook了这三个方法对应的Native函数地址。当Java层调用nativeEncrypt(HelloWorld)时我们的Hook被触发打印出了参数。5. 高级技巧与疑难问题排查5.1 对抗反调试与Frida检测在实际分析中尤其是安全要求较高的应用可能会检测Frida或反调试。我们的Hook行为本身也可能触发这些机制。Frida检测应用可能通过检查进程内存中是否存在frida-agent字符串、特定端口如27042是否被监听、或/proc/self/maps中是否存在frida相关库来检测。应对使用Frida的frida-compile将脚本编译成二进制或使用frida-gum的Memory.protectAPI修改相关特征字符串。也可以尝试使用frida的--debug模式配合spawn方式启动有时能绕过简单检测。反调试Native层可能使用ptrace、检查TracerPid、或利用定时器检查执行时间差等方式进行反调试。应对Hook这些反调试函数如ptrace,fork,syscall并修改其返回值。这需要更深入的Native逆向知识。5.2 处理多线程与并发注册应用可能在多个线程中并发调用RegisterNatives。我们的脚本需要保证gHookedMethods数组的线程安全虽然Frida JavaScript运行在主线程但回调可能来自不同线程。简单的做法是在修改全局数组时使用锁或原子操作但在JS中较难实现。一个更实用的方法是允许重复Hook并在hookNativeFunction函数开始时检查该地址是否已被Hook过。var gHookedAddresses {}; // 使用对象作为简单Set function hookNativeFunction(methodInfo) { if (gHookedAddresses[methodInfo.nativePtr]) { return; // 已Hook跳过 } gHookedAddresses[methodInfo.nativePtr] true; // ... 原有的Hook逻辑 ... }5.3 参数与返回值的深度解析难题如前所述通用地解析所有JNI函数参数几乎是不可能的。对于关键函数你需要进行手动逆向分析。使用IDA Pro/Ghidra反编译目标so库找到fnPtr对应的函数分析其参数和返回值类型。编写针对性的Hook脚本。例如如果你知道某个函数接收一个jstring和一个jint你可以这样精确读取onEnter: function(args) { let jniEnv args[0]; let jstringArg args[2]; // 假设是第三个参数 // 调用JNIEnv函数转换jstring到C字符串 let getStringUTFChars new NativeFunction(Module.findExportByName(libart.so, _ZN3art3JNI12GetStringUTFCharsEP7_JNIEnvP8_jstringPh), pointer, [pointer, pointer, pointer]); let cStrPtr getStringUTFChars(jniEnv, jstringArg, NULL); let inputStr cStrPtr.readCString(); console.log(Input String: ${inputStr}); // 记得后续要调用ReleaseStringUTFChars这里省略 }这需要对JNI API非常熟悉并且小心处理内存管理。5.4 脚本性能优化如果注册的函数非常多几十上百个全部Hook可能会对应用性能产生明显影响甚至导致崩溃。选择性Hook在parseAndHookMethods函数中根据方法名javaName或签名javaSig进行过滤只Hook你关心的函数如包含crypt,sign,key,token等关键词的。精简日志在onEnter/onLeave中减少console.log的输出尤其是在高频调用的函数上。可以将日志写入文件或仅在某些条件触发时输出。6. 完整脚本与使用指南以下是我在实际工作中使用的增强版脚本框架它包含了基本的健壮性处理和过滤功能。你可以以此为起点根据你的具体目标进行修改。// frida_hook_jni_dynamic_enhanced.js var gHookedMethods []; var gHookedAddressMap {}; function main() { console.log([*] JNI Dynamic Registration Hooking Script v1.2 ); let resolvedAddr resolveRegisterNatives(); if (!resolvedAddr) { console.log([-] Critical: Failed to resolve RegisterNatives. Exiting.); return; } installHook(resolvedAddr); console.log([] Hook installed successfully. Monitoring for JNI registrations...\n); } function resolveRegisterNatives() { let commonPaths [ libart.so, libandroid_runtime.so, libnativehelper.so ]; for (let lib of commonPaths) { let addr Module.findExportByName(lib, RegisterNatives); if (addr) { console.log([] Resolved RegisterNatives in ${lib} ${addr}); return addr; } } console.log([-] Not found in common libs, performing broad search...); // 遍历模块搜索略见前文 return null; } function installHook(registerNativesAddr) { Interceptor.attach(registerNativesAddr, { onEnter: function(args) { this.registrationArgs { clazz: args[1], methodsPtr: args[2], nMethods: args[3].toInt32() }; }, onLeave: function(retval) { let ra this.registrationArgs; if (ra.methodsPtr ra.nMethods 0) { console.log(\n[] RegisterNatives Invoked: Class${ra.clazz}, Count${ra.nMethods}); processMethodsArray(ra.methodsPtr, ra.nMethods, ra.clazz); } } }); } function processMethodsArray(ptr, count, clazz) { const PTR_SIZE Process.pointerSize; const STRUCT_SIZE PTR_SIZE * 3; for (let i 0; i count; i) { let entry ptr.add(i * STRUCT_SIZE); try { let namePtr entry.readPointer(); let sigPtr entry.add(PTR_SIZE).readPointer(); let fnPtr entry.add(PTR_SIZE * 2).readPointer(); if (namePtr.isNull() || sigPtr.isNull() || fnPtr.isNull()) { console.log( [${i}] Invalid Entry); continue; } let name namePtr.readCString() || unnamed; let sig sigPtr.readCString() || no sig; let methodInfo { name: name, signature: sig, nativePtr: fnPtr, classPtr: clazz, id: ${name}${fnPtr} }; // 过滤只Hook感兴趣的函数 if (shouldHookMethod(methodInfo)) { console.log( [${i}] HOOKING: ${name} ${sig} - ${fnPtr}); safeAttachHook(methodInfo); } else { console.log( [${i}] Skipped: ${name} ${sig}); } } catch (e) { console.log( [${i}] Error parsing entry: ${e}); } } } function shouldHookMethod(info) { // 自定义过滤逻辑 let keywords [encrypt, decrypt, sign, verify, key, secret, token, auth, init, check]; let lowerName info.name.toLowerCase(); for (let kw of keywords) { if (lowerName.includes(kw)) { return true; } } // 或者根据签名过滤例如只Hook返回String或byte[]的函数 // if (info.signature.includes(String) || info.signature.includes([)) { // return true; // } return false; // 默认全部Hook生产环境建议设为false并配置白名单 } function safeAttachHook(info) { if (gHookedAddressMap[info.nativePtr]) { return; // 避免重复Hook } try { Interceptor.attach(info.nativePtr, { onEnter: function(args) { console.log(\n[Call] ${info.name}); // 基础参数日志 // 可以在这里添加更精细的参数解析 logBasicArgs(args, info.signature); }, onLeave: function(retval) { // 基础返回值日志 logBasicRetval(retval, info.signature); console.log([End] ${info.name}\n); } }); gHookedAddressMap[info.nativePtr] true; gHookedMethods.push(info); } catch (e) { console.log([!] Failed to hook ${info.name}: ${e}); } } function logBasicArgs(args, signature) { // 简化版只打印前几个参数的指针值 for (let i 0; i Math.min(args.length, 4); i) { console.log( arg[${i}]: ${args[i]}); } } function logBasicRetval(retval, signature) { if (!retval.isNull()) { console.log( retval: ${retval}); } else { console.log( retval: null); } } // 延迟启动确保目标库加载 setTimeout(main, 800); // RPC接口方便交互 rpc.exports { get_hooked_list: () gHookedMethods.map(m m.id), get_method_info: (ptr) gHookedMethods.find(m m.nativePtr.equals(ptr)) };使用指南将脚本保存为jni_hook.js。启动目标应用frida -U -f com.target.app -l jni_hook.js --no-pause。观察控制台输出动态注册发生时符合过滤条件的方法会被自动Hook并打印调用信息。你可以在Frida的REPL中调用rpc.exports.get_hooked_list()来查看已Hook的函数列表。这个脚本提供了一个坚实的起点。真正的战场在于如何根据具体的应用调整过滤策略、深化参数解析、并应对各种保护措施。Hook动态注册的JNI函数就像在程序的启动阶段埋下了一颗颗监听器让你能洞察那些最深层的秘密。