Frida Native函数Hook实战:精准获取堆栈、参数与返回值

发布时间:2026/7/2 23:36:54
Frida Native函数Hook实战:精准获取堆栈、参数与返回值 1. 项目概述为什么我们需要深入Native层在移动安全逆向和动态分析领域Frida早已成为从业者手中的“瑞士军刀”。它能让我们在运行时注入JavaScript代码动态地Hook Java层方法这解决了很多问题。但当我们面对加固应用、核心算法或高性能计算模块时真正的“硬骨头”往往藏在Native层C/C代码编译的.so库。这些函数直接操作内存和寄存器执行效率极高也是安全防护和关键逻辑的集中地。仅仅知道一个Native函数被调用了是远远不够的。我们真正需要的是洞察力这个函数在庞大的调用链中处于什么位置它接收到的参数具体是什么值尤其是复杂的结构体指针它的执行结果返回值又是什么只有拿到这些信息我们才能逆向出算法逻辑、定位漏洞点或者验证我们的Hook是否准确。网上很多教程停留在“如何Hook一个Native函数”这一步对于如何系统、精准地获取堆栈、参数和返回值往往语焉不详或代码片段零散。这正是本文要解决的问题。我将结合一个完整的、可运行的Demo手把手带你实现一个功能强大的Native函数Hook脚本让你能像调试器一样清晰地洞察Native层的执行流。无论你是想分析某个应用的加密算法还是研究系统底层机制这套方法都能直接为你所用。2. 核心思路与工具选型解析在动手之前理清思路和选择合适的工具至关重要。我们的目标是Hook目标Native函数并在其被调用时自动打印出调用堆栈、所有参数的值以及函数返回时的返回值。2.1 为什么选择Interceptor.attachFrida提供了多种Hook方式对于Native函数最常用的是Interceptor.attach。与Interceptor.replace不同attach允许我们在函数执行前后插入监听代码而不改变原函数的执行流程这非常适合我们的监控和日志记录需求。它的基本模式是定义onEnter和onLeave两个回调函数。2.2 获取堆栈的挑战与方案打印堆栈是定位函数调用上下文的关键。在Native层我们不能简单地用Java的Thread.currentThread().getStackTrace()。Frida提供了Thread.backtrace和DebugSymbol.fromAddress这对组合拳。Thread.backtrace(context) 根据给定的CPU上下文context返回一个包含返回地址returnAddress的数组。这个上下文通常在onEnter和onLeave回调中通过参数获得。DebugSymbol.fromAddress(address) 将一个内存地址转换为可读的函数名和源码位置如果调试符号可用。将backtrace得到的地址逐一传入此函数就能得到人类可读的堆栈信息。这里有一个关键细节context对象在onEnter和onLeave中代表的意义略有不同但都包含了生成堆栈所需的寄存器状态。2.3 解析参数与返回值的策略参数和返回值的解析是难点因为需要依据目标函数的原型函数签名来操作。确定函数签名 首先你需要知道目标函数的参数类型和返回值类型。这可以通过逆向工具如IDA Pro, Ghidra分析.so文件或查阅相关开发文档获得。例如一个函数可能是int encrypt(const char* input, char* output, int key)。在onEnter中读取参数 在onEnter回调中我们可以通过args数组例如args[0],args[1]来访问参数。但args元素是NativePointer类型我们需要根据参数的实际类型进行转换。例如对于int类型的参数用args[0].toInt32()对于字符串指针char*用args[0].readCString()。在onLeave中捕获返回值 在onLeave回调中返回值存储在retval对象中。同样我们需要根据函数返回类型使用retval.toInt32()、retval.readCString()或retval本身对于指针来获取值。注意 对于指针参数如结构体指针直接readCString()可能会失败或读不到完整数据。更稳健的做法是先readByteArray(length)读取原始字节再根据结构体定义进行解析。这需要更深入的逆向分析。2.4 工具与依赖准备你需要准备好以下环境一台已Root的Android设备或模拟器 用于注入Frida。推荐使用官方模拟器或Genymotion它们对Frida支持较好。Frida环境在电脑上安装Frida客户端pip install frida-tools在设备上安装对应架构的Frida-server。务必确保客户端与server版本匹配否则会出现连接错误。目标应用 一个包含你需要分析的Native库的应用。本文的Demo将创建一个简单的Android NDK应用作为目标。逆向工具可选但推荐 IDA Pro或Ghidra用于分析.so文件确定函数签名和偏移地址。3. 完整Demo构建一个可观测的Native目标为了让大家有最直观的理解我们首先构建一个非常简单的目标Android应用。它包含一个Native库库中有一个我们想要Hook的函数。1. 创建Native函数目标我们使用Android Studio创建一个Native C项目选择“Native C”模板。在自动生成的native-lib.cpp中添加我们自己的函数#include jni.h #include string #include android/log.h #define LOG_TAG NativeDemo #define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__) // 目标函数1一个简单的加法函数用于演示基本参数和返回值 extern C int native_add(int a, int b) { int result a b; LOGD(native_add called: %d %d %d, a, b, result); return result; } // 目标函数2处理字符串和指针演示复杂参数 extern C void native_process_string(const char* input, char* output, int buffer_size) { if (input nullptr || output nullptr || buffer_size 0) return; LOGD(native_process_string called with: %s, input); // 模拟一些处理比如反转字符串简单演示不处理边界 int len strnlen(input, buffer_size - 1); for (int i 0; i len; i) { output[i] input[len - 1 - i]; } output[len] \0; LOGD(native_process_string output: %s, output); } // JNI函数用于从Java层触发我们的Native函数 extern C JNIEXPORT jstring JNICALL Java_com_example_nativedemo_MainActivity_stringFromJNI( JNIEnv* env, jobject /* this */) { // 调用我们的目标函数 int sum native_add(10, 20); LOGD(Sum from native_add: %d, sum); char input[] HelloFrida; char output[32] {0}; native_process_string(input, output, sizeof(output)); std::string hello Sum is: std::to_string(sum) , Reversed string is: std::to_string(sum); return env-NewStringUTF(hello.c_str()); }编译并运行这个应用确保Native库被成功加载。你可以通过adb logcat | grep NativeDemo看到我们添加的日志。2. 定位目标函数地址Frida Hook需要函数的绝对地址。对于导出函数在我们的例子里native_add和native_process_string默认可能不导出但为了演示我们假设它们被导出我们可以使用模块名函数名的方式。更通用的方式是使用模块基地址函数偏移。首先找到目标库在内存中的基地址和函数偏移。使用Frida CLI快速查找frida -U -f com.example.nativedemo --no-pause在Frida REPL中执行Process.enumerateModules()找到名为libnative-lib.so的模块记下它的base地址。 然后如果函数是导出的可以Module.findExportByName(libnative-lib.so, native_add)如果函数未导出就需要用逆向工具打开libnative-lib.so找到native_add函数的偏移量例如0x1234。那么函数在内存中的地址就是模块基地址 0x1234。为了Demo的通用性我们假设通过逆向分析得到了以下信息libnative-lib.so基地址0x7a12c34000(每次运行可能不同需要动态获取)native_add偏移0x76c8native_process_string偏移0x77a04. Frida脚本实战实现精准监控现在我们编写核心的Frida JavaScript脚本。这个脚本将实现之前讨论的所有功能动态计算地址、Hook函数、打印堆栈、参数和返回值。// file: hook_native.js Java.perform(function () { console.log([*] Starting Native Function Hook Script); // 1. 动态获取模块基地址 var moduleName libnative-lib.so; var moduleBase Module.findBaseAddress(moduleName); if (moduleBase) { console.log([] Found module: moduleName moduleBase); } else { console.log([-] Module not found: moduleName); return; } // 2. 计算目标函数地址基址 偏移 // 这些偏移量需要你通过逆向工具如IDA提前获取 var offsetNativeAdd 0x76c8; var offsetProcessString 0x77a0; var nativeAddAddr moduleBase.add(offsetNativeAdd); var processStringAddr moduleBase.add(offsetProcessString); console.log([] native_add address: nativeAddAddr); console.log([] native_process_string address: processStringAddr); // 3. 定义一个通用的堆栈打印函数 function printStackTrace(context, prefix) { console.log(\n prefix Call Stack:); // 使用当前上下文获取堆栈回溯 var backtrace Thread.backtrace(context, Backtracer.ACCURATE); for (var i 0; i backtrace.length; i) { var address backtrace[i]; // 尝试将地址解析为符号信息 var symbol DebugSymbol.fromAddress(address); // 如果解析成功显示函数名和位置否则只显示地址 if (symbol symbol.name) { console.log( # i symbol.name ( symbol.address )); if (symbol.moduleName) { console.log( Module: symbol.moduleName); } if (symbol.fileName symbol.lineNumber) { console.log( Location: symbol.fileName : symbol.lineNumber); } } else { console.log( # i 0x address.toString(16)); } } console.log(prefix End of Stack\n); } // 4. Hook native_add 函数 Interceptor.attach(nativeAddAddr, { onEnter: function (args) { console.log(\n [ENTER] native_add ); // 打印参数 // 根据函数签名 int native_add(int a, int b) var arg0 args[0].toInt32(); // 第一个参数 a var arg1 args[1].toInt32(); // 第二个参数 b console.log( Arg0 (a): arg0); console.log( Arg1 (b): arg1); // 打印调用堆栈 printStackTrace(this.context, [Stack]); // 你可以将参数保存到 this 对象中以便在 onLeave 中使用 this.arg0 arg0; this.arg1 arg1; }, onLeave: function (retval) { console.log( [LEAVE] native_add ); // 打印返回值 var retValInt retval.toInt32(); console.log( Return value: retValInt); // 验证打印我们之前保存的参数并计算验证 console.log( Verification: this.arg0 this.arg1 (this.arg0 this.arg1)); console.log(\n); } }); // 5. Hook native_process_string 函数演示字符串和指针参数 Interceptor.attach(processStringAddr, { onEnter: function (args) { console.log(\n [ENTER] native_process_string ); // 函数签名: void native_process_string(const char* input, char* output, int buffer_size) var inputPtr args[0]; // const char* input var outputPtr args[1]; // char* output var bufferSize args[2].toInt32(); // int buffer_size console.log( Arg0 (input ptr): inputPtr); console.log( Arg1 (output ptr): outputPtr); console.log( Arg2 (buffer_size): bufferSize); // 读取输入字符串 if (!inputPtr.isNull()) { try { var inputStr inputPtr.readCString(); console.log( Input string: \ inputStr \); // 保存起来用于后续在onLeave中对比 this.inputStr inputStr; } catch (e) { console.log( Failed to read input string: e); } } else { console.log( Input string is NULL); } // 保存输出指针和缓冲区大小用于onLeave中读取结果 this.outputPtr outputPtr; this.bufferSize bufferSize; printStackTrace(this.context, [Stack]); }, onLeave: function (retval) { console.log( [LEAVE] native_process_string ); // 函数返回voidretval通常无意义 console.log( Return type: void); // 读取输出缓冲区的内容 if (this.outputPtr !this.outputPtr.isNull() this.bufferSize 0) { try { // 注意readCString会读到NULL为止对于可能包含\0的二进制数据不安全 // 更安全的方式是 readByteArray这里假设是字符串 var outputStr this.outputPtr.readCString(); console.log( Output string: \ outputStr \); if (this.inputStr) { console.log( Input was: \ this.inputStr \); } } catch (e) { console.log( Failed to read output: e); // 尝试以字节数组形式读取前N个字节 try { var bytes this.outputPtr.readByteArray(Math.min(this.bufferSize, 64)); console.log( Output bytes (hex): bytes); } catch (e2) {} } } console.log(\n); } }); console.log([*] Hooks installed. Waiting for calls...); });4.1 脚本关键点解析动态寻址 使用Module.findBaseAddress和add(offset)是处理未导出函数和ASLR地址空间布局随机化的标准方法。你需要提前通过逆向确定偏移量。printStackTrace函数 这是一个工具函数封装了堆栈打印逻辑。Backtracer.ACCURATE模式更精确但稍慢Backtracer.FUZZY更快但可能不准确。在大多数情况下ACCURATE是更好的选择。参数访问args是一个类似数组的对象索引从0开始对应第一个参数。使用.toInt32(),.toUInt32(),.readCString(),.readPointer()等方法进行类型转换是关键。上下文保存 注意在onEnter中我们将一些信息如this.arg0,this.inputStr附加到this对象上。这个this在onEnter和onLeave中是同一个对象用于在两个回调间传递数据。这是Frida提供的一个非常便利的特性。错误处理 在读取指针内容如readCString时务必进行isNull()检查并包裹在try-catch中。因为目标函数可能传入空指针或非法指针直接读取会导致脚本崩溃。5. 运行与结果分析将脚本保存为hook_native.js并在目标应用进程启动后注入frida -U -f com.example.nativedemo -l hook_native.js --no-pause启动应用触发JNI函数例如点击屏幕调用stringFromJNI你将在Frida控制台看到类似如下的输出[*] Starting Native Function Hook Script [] Found module: libnative-lib.so 0x7a12c34000 [] native_add address: 0x7a12c3b6c8 [] native_process_string address: 0x7a12c3b7a0 [*] Hooks installed. Waiting for calls... [ENTER] native_add Arg0 (a): 10 Arg1 (b): 20 [Stack] Call Stack: #0 native_add (0x7a12c3b6c8) Module: libnative-lib.so #1 Java_com_example_nativedemo_MainActivity_stringFromJNI (0x7a12c3b850) Module: libnative-lib.so Location: jni/native-lib.cpp:30 #2 art_quick_generic_jni_trampoline (0x70f3a6a8a8) Module: libart.so ... (更多系统栈帧) [Stack] End of Stack [LEAVE] native_add Return value: 30 Verification: 10 20 30 [ENTER] native_process_string Arg0 (input ptr): 0x7a15c8e000 Arg1 (output ptr): 0x7a15c8e020 Arg2 (buffer_size): 32 Input string: HelloFrida [Stack] Call Stack: #0 native_process_string (0x7a12c3b7a0) Module: libnative-lib.so #1 Java_com_example_nativedemo_MainActivity_stringFromJNI (0x7a12c3b850) Module: libnative-lib.so Location: jni/native-lib.cpp:31 #2 art_quick_generic_jni_trampoline (0x70f3a6a8a8) Module: libart.so ... (更多系统栈帧) [Stack] End of Stack [LEAVE] native_process_string Return type: void Output string: adirFolleH Input was: HelloFrida 结果解读堆栈清晰可见 我们不仅看到了native_add被调用还清晰地看到了它的调用者是Java_com_example_..._stringFromJNI甚至显示了源码行号如果so文件包含调试信息。这完美地描绘了从Java层JNI调用到具体Native函数的完整路径。参数与返回值精准捕获 我们成功读出了native_add的两个整型参数10和20以及返回值30。对于native_process_string我们读入了输入字符串“HelloFrida”并在函数执行后读出了输出缓冲区中被反转的字符串“adirFolleH”。上下文关联 通过this对象我们在onLeave中成功关联了onEnter时的输入参数并进行了对比验证使得日志信息非常完整和自洽。6. 进阶技巧与疑难排查在实际逆向更复杂的应用时你会遇到更多挑战。这里分享一些进阶技巧和常见问题的解决方法。6.1 处理复杂参数类型结构体、数组当参数是指向结构体或数组的指针时readCString()就无能为力了。你需要根据逆向分析得到的结构体布局手动解析内存。假设你Hook一个函数void process_user(User* user)并且你逆向出了User结构体typedef struct { int id; char name[32]; int age; } User;你的Hook代码可以这样写Interceptor.attach(someFunctionAddr, { onEnter: function(args) { var userPtr args[0]; if (!userPtr.isNull()) { // 手动解析结构体 var id userPtr.add(0).readInt(); // 偏移0处是id var namePtr userPtr.add(4); // 假设int是4字节偏移4是name数组起始地址 var name namePtr.readCString(); // 读取字符串 var age userPtr.add(4 32).readInt(); // 偏移43236处是age console.log(User: id${id}, name${name}, age${age}); // 保存到this方便onLeave使用 this.parsedUser {id: id, name: name, age: age}; } }, onLeave: function(retval) { // 可能根据返回值或输出参数更新结构体内容 if (this.parsedUser) { // 再次读取看看是否被修改 var newAge args[0].add(4 32).readInt(); if (newAge ! this.parsedUser.age) { console.log(User age changed from ${this.parsedUser.age} to ${newAge}); } } } });实操心得 解析结构体时内存对齐Padding是最大的坑。不同编译器、不同架构arm, arm64, x86的对齐规则可能不同。最可靠的方法是先用一个小测试程序在目标设备上编译运行打印出结构体各成员的偏移量或者直接用sizeof和offsetof宏来验证。不要完全相信逆向工具自动解析的结构体手动验证是关键。6.2 处理函数重载与名称修饰Name ManglingC函数会因为重载和命名空间而被编译器进行名称修饰。你在IDA里看到的可能是_Z10myFunctioniPc这样的名字而不是myFunction。使用Module.findExportByName时需要传入修饰后的名称。使用Frida的Module.enumerateExports 可以先枚举模块的所有导出函数搜索包含特定子串的函数名。var exports Module.enumerateExports(libtarget.so); for (var exp of exports) { if (exp.name.indexOf(myFunction) ! -1) { console.log(Found: exp.name exp.address); } }使用DebugSymbol.getFunctionsByName 如果目标文件包含符号信息这个函数可以帮我们找到所有同名函数包括重载。var funcs DebugSymbol.getFunctionsByName(myFunction); funcs.forEach(func { console.log(Function at: func.address); Interceptor.attach(func.address, {...}); });6.3 性能考量与优化在onEnter/onLeave中执行复杂的操作如深度打印堆栈、解析大结构体会显著拖慢目标进程甚至可能导致应用卡死或崩溃。选择性打印 添加条件判断只在你关心的特定调用场景下打印详细信息。例如只有当某个参数等于特定值时才打印堆栈。onEnter: function(args) { var interestingValue args[0].toInt32(); if (interestingValue 0x1234) { // 只有特定输入时才深度分析 printStackTrace(this.context, [Detail Stack]); // ... 详细解析参数 } else { console.log(Fast path: called with ${interestingValue}); } }缓存符号信息DebugSymbol.fromAddress可能比较慢。对于频繁调用的函数可以考虑缓存结果。避免在Hook中阻塞 绝对不要在回调函数中执行同步网络请求或无限循环操作。6.4 常见问题与排查清单脚本注入失败提示Permission denied或Unable to attach检查设备Root状态 确保设备已Root且Frida-server以root权限运行 (adb shell su -c /data/local/tmp/frida-server )。检查应用可调试性 对于非系统应用需要在AndroidManifest.xml中设置android:debuggabletrue或者使用frida -U -f com.package.name在应用启动时注入。关闭SELinux 在某些严格设备上临时禁用SELinux可能有帮助 (adb shell su -c setenforce 0)。Hook失败Interceptor.attach没效果地址错误 这是最常见的原因。确认模块基地址和函数偏移量是否正确。使用Module.findBaseAddress和Module.enumerateExports/DebugSymbol.getFunctionsByName交叉验证。函数未执行 你的Hook代码没问题但目标函数在这次运行中根本没被调用。检查你的触发逻辑。多线程问题 函数可能在另一个线程首次被调用而你的脚本在主线程执行。确保Java.perform已执行它会在合适的时机运行你的代码。对于非常早的初始化函数可能需要使用setImmediate或setTimeout来延迟Hook。读取参数时崩溃Invalid memory access空指针检查 在调用readCString()、readInt()等之前务必用isNull()检查指针。类型转换错误 确认你使用的转换方法与参数的实际类型匹配。一个int*应该用readPointer()再用返回的指针去读内容而不是直接用args[0].toInt32()。使用try-catch 将所有可能出错的内存读取操作包裹在try-catch中。堆栈信息不完整或全是偏移地址缺少符号 如果so文件被剥离strip了符号表DebugSymbol.fromAddress将无法解析出函数名。你只能看到模块基地址偏移。这时需要你拥有该库的带符号版本如libtarget.so和libtarget.so.dbg并使用Frida的DebugSymbol.load()加载。上下文对象错误 确保传递给Thread.backtrace()的context是正确的。在onEnter和onLeave中使用this.context。Frida脚本导致目标应用卡顿或闪退优化脚本逻辑 参考6.3节的性能优化建议减少在回调中的工作量。检查无限递归 如果你Hook的函数本身被Frida的内部调用所使用如malloc,free,pthread相关函数可能会导致递归调用和栈溢出。避免Hook这些底层系统函数。如果必须Hook使用NativeFunction来调用原函数并确保你的Hook逻辑不会再次触发自身。这套从思路到实践再到问题排查的完整流程基本覆盖了使用Frida进行Native函数深度Hook的各个层面。真正的熟练来自于对特定目标库的反复分析和尝试。开始时可以从简单的、有源码的目标如自己写的Demo练手逐步过渡到分析没有符号表的第三方库你的逆向能力会在这个过程中得到实质性的提升。记住耐心和细致的观察是成功的关键每一个崩溃和错误信息都是引导你更理解底层机制的线索。