深入解析安卓SO层HOOK技术:从函数定位到动态调用

📅 发布时间:2026/7/10 20:45:22 👁️ 浏览次数:
深入解析安卓SO层HOOK技术:从函数定位到动态调用
1. 为什么SO层HOOK是安卓逆向的“硬核”技能大家好我是老张在安卓安全领域摸爬滚打了十多年。今天想和大家聊聊安卓逆向里一个既基础又核心的技术点SO层HOOK。很多刚入门的朋友可能会觉得Java层的HOOK用Xposed或者Frida的Java.use()已经能解决大部分问题了为什么还要折腾SO层呢我举个实际的例子。去年我分析一个金融类App它的核心加密逻辑全部用C实现并编译成了SO文件。你用Java层HOOK去拦截只能看到一些参数传入但完全看不到内部的加密过程。这就好比你想知道一个黑匣子怎么工作但只能看到输入和输出中间的过程完全是盲区。这时候SO层HOOK就成了你唯一的“透视镜”。SO文件也就是共享对象文件里面是编译好的本地代码通常是C/C。相比Java代码它的执行效率更高也更难被逆向。很多App会把关键算法、协议加密、反调试逻辑都放在SO层。如果你只会Java层HOOK那遇到这种“硬骨头”就只能干瞪眼了。SO层HOOK的核心目标很简单定位到目标函数在内存中的地址然后拦截它的执行。听起来容易但实际操作中你会遇到各种问题函数没有导出符号怎么办地址每次运行都变化怎么处理参数和返回值怎么正确解析别急下面我就结合自己踩过的坑一步步带你掌握这门技术。2. 准备工作环境搭建与工具选择工欲善其事必先利其器。在开始SO层HOOK之前你得先把环境准备好。我这里推荐一套我用了很多年的“黄金组合”。首先是Frida这是我们的主力武器。我建议直接用Python pip安装最新版pip install frida-tools安装完后在电脑上运行frida --version确认安装成功。然后需要在你的测试手机上也安装Frida服务端。这里有个小技巧去Frida的GitHub releases页面下载对应你手机架构的frida-server文件。怎么知道手机架构用adb shell getprop ro.product.cpu.abi命令查看。比如大部分现代手机是arm64-v8a。然后是测试环境。我强烈建议你先自己写一个简单的Demo App来练手而不是一上来就搞复杂的商业App。创建一个包含Native代码的Android工程很简单Android Studio的“Native C”模板就行。关键是在native-lib.cpp里写几个测试函数比如extern C JNIEXPORT jstring JNICALL Java_com_example_test_MainActivity_stringFromJNI(JNIEnv* env, jobject thiz) { std::string hello Hello from C; return env-NewStringUTF(hello.c_str()); } extern C JNIEXPORT jint JNICALL Java_com_example_test_MainActivity_add(JNIEnv* env, jobject thiz, jint a, jint b) { return a b; }编译出APK后用adb install安装到手机。这样你就有了一个完全可控的测试目标。第三个工具是IDA Pro或者Ghidra用于静态分析SO文件。虽然Frida能动态HOOK但你要知道HOOK哪里就得先静态分析。IDA的F5反编译功能对阅读C代码帮助很大不过它价格不菲。如果预算有限开源的Ghidra也是不错的选择它的反编译器效果相当不错。最后是调试环境。有时候光靠HOOK还不够你需要动态调试。配置adb端口转发adb forward tcp:23946 tcp:23946然后在IDA里选择Remote ARM Linux/Android debugger。这个配置过程稍微有点繁琐但一旦配好你就能像调试本地程序一样调试手机里的SO代码了。准备好这些工具后我们还需要了解一些基础知识。SO文件在内存中加载时会有个基地址函数地址 基地址 函数偏移。这个偏移在SO文件里是固定的但基地址每次运行都可能变化因为ASLR。所以我们的第一个任务就是找到SO的基地址。3. 定位目标函数的四种实战方法找到了SO基地址接下来就要定位具体的函数地址。根据函数是否“有符号”出现在导出表、导入表或符号表中我们有不同的定位策略。3.1 方法一直接使用Frida API针对有符号函数如果函数出现在SO的导出表里那定位起来最简单。Frida提供了Module.findExportByName()这个API你只需要知道SO文件名和函数名就行。比如我们要HOOK上面Demo里的stringFromJNI函数// 这是最直接的方式 var funcAddr Module.findExportByName(libnative-lib.so, Java_com_example_test_MainActivity_stringFromJNI); console.log(函数地址:, funcAddr); Interceptor.attach(funcAddr, { onEnter: function(args) { console.log(stringFromJNI被调用了!); // args[0]是JNIEnv*, args[1]是jobject thiz }, onLeave: function(retval) { // 返回值是jstring需要转换成可读字符串 var result Java.vm.getEnv().getStringUtfChars(retval, null).readCString(); console.log(函数返回值:, result); } });但现实往往没这么美好。很多关键函数根本不会导出开发者会有意隐藏它们。这时候你就需要枚举各种表来寻找线索。3.2 方法二枚举三大表寻找函数踪迹Frida提供了三个重要的枚举APIModule.enumerateImports()、Module.enumerateExports()和Module.enumerateSymbols()。我习惯写一个通用的枚举脚本来快速扫描function scanModule(moduleName) { console.log( 扫描模块:, moduleName, ); // 1. 枚举导入表 console.log(\n[导入表]); var imports Module.enumerateImports(moduleName); for (var i 0; i imports.length; i) { if (imports[i].name.indexOf(malloc) ! -1 || imports[i].name.indexOf(free) ! -1 || imports[i].name.indexOf(mem) ! -1) { console.log(发现内存相关函数:, imports[i].name, 地址:, imports[i].address); } } // 2. 枚举导出表 console.log(\n[导出表]); var exports Module.enumerateExports(moduleName); for (var i 0; i exports.length; i) { // 过滤出可能感兴趣的函数 if (exports[i].name.indexOf(encrypt) ! -1 || exports[i].name.indexOf(decrypt) ! -1 || exports[i].name.indexOf(sign) ! -1) { console.log(发现加解密相关函数:, exports[i].name, 地址:, exports[i].address); } } // 3. 枚举符号表信息最全 console.log(\n[符号表]); var symbols Module.enumerateSymbols(moduleName); for (var i 0; i symbols.length; i) { var sym symbols[i]; // 通过函数名特征筛选 if (sym.name.indexOf(AES) ! -1 || sym.name.indexOf(MD5) ! -1 || sym.name.indexOf(SHA) ! -1 || sym.name.indexOf(CRC) ! -1) { console.log(发现算法函数:, sym.name, 地址:, sym.address, 类型:, sym.type, 大小:, sym.size); } } } // 使用示例 scanModule(libencrypt.so);在实际项目中我经常用这个脚本快速了解一个SO文件里有哪些“可疑”函数。符号表特别有用因为它包含了调试信息即使函数没有导出只要编译时没完全strip掉符号这里就能看到。3.3 方法三通过模块枚举间接定位有时候你连目标在哪个SO文件里都不知道。这时候可以枚举进程中的所有模块// 枚举所有已加载的模块 var modules Process.enumerateModules(); for (var i 0; i modules.length; i) { var module modules[i]; console.log(模块名:, module.name, 基地址:, module.base, 大小:, module.size, 路径:, module.path); // 如果发现感兴趣的模块再深入枚举 if (module.name.indexOf(crypto) ! -1 || module.name.indexOf(secure) ! -1) { console.log(发现安全相关模块开始详细扫描...); var exports module.enumerateExports(); // ... 进一步分析 } }这个方法在分析大型App时特别有用。很多App会加载十几个甚至几十个SO文件你需要快速定位到包含关键逻辑的那个。3.4 方法四计算地址针对无符号函数如果函数既没有导出符号表里也找不到那就只能用最“原始”的方法基地址 偏移量。这个偏移量需要从IDA等静态分析工具中获得。假设我们用IDA打开SO文件发现目标函数的偏移量是0x1234在IDA里显示为.text:00001234。那么动态计算地址的代码如下// 先获取SO基地址 var soBase Module.findBaseAddress(libtarget.so); console.log(SO基地址:, soBase); // 计算函数地址 var functionOffset 0x1234; var funcAddr soBase.add(functionOffset); // 注意64位系统通常不需要1 console.log(计算得到的函数地址:, funcAddr); // 对于32位ARM Thumb模式需要地址1 // var funcAddr soBase.add(functionOffset 1); // 验证地址是否有效 try { // 尝试读取地址处的指令 var firstInstruction Instruction.parse(funcAddr); console.log(地址有效第一条指令:, firstInstruction.toString()); } catch(e) { console.log(地址可能无效:, e); }这里有个关键点ARM架构的Thumb模式。在32位ARM上如果函数是用Thumb指令集编译的实际执行地址需要偏移量1。怎么判断是不是Thumb模式在IDA里看函数地址的最低bit如果是1就是Thumb模式。不过现在的手机基本都是64位了这个问题遇到的比较少。4. 获取SO基地址的四种姿势上面提到计算地址需要先知道基地址Frida提供了好几种获取基地址的方法各有各的适用场景。4.1 findBaseAddress最推荐这是我平时最常用的方法直接返回基地址指针var base Module.findBaseAddress(libtarget.so); console.log(基地址:, base);简单直接一行代码搞定。返回的就是NativePointer类型可以直接用于地址计算。4.2 findModuleByName和getModuleByName这两个方法都返回模块对象需要再取.base属性// 方法1 var module1 Process.findModuleByName(libtarget.so); console.log(基地址:, module1.base); // 方法2 var module2 Process.getModuleByName(libtarget.so); console.log(基地址:, module2.base);它们的主要区别在于findModuleByName在模块不存在时返回null而getModuleByName会抛出异常。根据你的错误处理习惯选择即可。4.3 enumerateModules遍历查找如果你需要同时获取多个模块的信息或者想实现一些自定义的查找逻辑可以用这个方法var targetBase null; var modules Process.enumerateModules(); for (var i 0; i modules.length; i) { if (modules[i].name libtarget.so) { targetBase modules[i].base; console.log(找到模块:, modules[i].name, 基地址:, targetBase); break; } }虽然代码多一点但灵活性最高。比如你可以同时查找多个相关模块或者根据路径特征来定位。4.4 通过地址反向查找模块有时候你有一个地址想知道它属于哪个模块var someAddress ptr(0x7f8a123456); var module Process.findModuleByAddress(someAddress); if (module) { console.log(地址所属模块:, module.name, 基地址:, module.base); }这在分析崩溃日志或者跟踪某个神秘地址时特别有用。5. HOOK实战从导出函数到任意函数掌握了地址定位现在我们来实战HOOK。我会从简单到复杂展示几种常见场景。5.1 HOOK导出函数最基础还是用我们Demo里的加法函数为例var addAddr Module.findExportByName(libnative-lib.so, Java_com_example_test_MainActivity_add); console.log(add函数地址:, addAddr); Interceptor.attach(addAddr, { onEnter: function(args) { // 注意JNI函数的第一个参数是JNIEnv*第二个是jobject // 所以我们的参数从args[2]开始 this.arg1 args[2].toInt32(); // jint a this.arg2 args[3].toInt32(); // jint b console.log(add函数被调用: ${this.arg1} ${this.arg2}); // 你可以修改参数值 // args[2] ptr(100); // 把a改成100 }, onLeave: function(retval) { var result retval.toInt32(); console.log(add函数返回: ${result}); // 你也可以修改返回值 // retval.replace(ptr(999)); } });这里有几个要点JNI函数的参数顺序JNIEnv*,jclass/jobject, 然后是实际参数用.toInt32()读取jint值用.readCString()读取字符串在onEnter里用this.xxx保存数据可以在onLeave里使用5.2 HOOK内部函数无符号假设我们通过IDA分析发现一个内部加密函数的偏移是0x5678var base Module.findBaseAddress(libcrypto.so); var encryptFunc base.add(0x5678); Interceptor.attach(encryptFunc, { onEnter: function(args) { console.log(加密函数被调用); // 假设第一个参数是输入数据指针 if (!args[0].isNull()) { console.log(输入数据:, hexdump(args[0], { length: 64, // 只显示前64字节 ansi: true })); } // 假设第二个参数是数据长度 this.dataLen args[1].toInt32(); console.log(数据长度:, this.dataLen); // 假设第三个参数是输出缓冲区 this.outputPtr args[2]; }, onLeave: function(retval) { console.log(加密函数返回:, retval.toInt32()); // 打印加密结果 if (!this.outputPtr.isNull()) { console.log(加密结果:, hexdump(this.outputPtr, { length: this.dataLen, ansi: true })); } } });hexdump()是Frida里非常实用的函数可以以十六进制格式显示内存数据。在分析加密算法时我几乎每次都会用到它。5.3 处理复杂参数类型SO层函数的参数可能很复杂不只是基本类型。比如遇到结构体指针怎么办这里我分享一个实际案例// 假设我们HOOK一个网络发送函数 var sendFunc Module.findExportByName(libnet.so, send_data); Interceptor.attach(sendFunc, { onEnter: function(args) { // args[0]可能是这样的结构体指针 // struct Packet { // int type; // int length; // char* data; // uint32_t checksum; // } var packetPtr args[0]; // 读取结构体成员 var packetType packetPtr.readInt(); // 偏移0 var dataLength packetPtr.add(4).readInt(); // 偏移4 var dataPtr packetPtr.add(8).readPointer(); // 偏移8注意是指针 var checksum packetPtr.add(12).readUInt(); // 偏移12 console.log(包类型: ${packetType}, 长度: ${dataLength}, 校验和: ${checksum}); if (!dataPtr.isNull() dataLength 0 dataLength 10240) { console.log(包数据:, hexdump(dataPtr, { length: dataLength })); } // 保存到this供onLeave使用 this.packetPtr packetPtr; this.originalChecksum checksum; }, onLeave: function(retval) { // 可以验证校验和是否正确 var newChecksum this.packetPtr.add(12).readUInt(); if (newChecksum ! this.originalChecksum) { console.log(警告校验和被修改了!); } } });关键是要理解内存布局知道每个字段的偏移量。如果SO有调试符号IDA会帮你计算好偏移。如果没有就得自己根据字段类型推算int是4字节指针在32位是4字节、64位是8字节等等。5.4 处理字符串参数字符串参数也很常见但要注意编码问题Interceptor.attach(someFunc, { onEnter: function(args) { // 方式1如果是C字符串以null结尾 var cstr args[0].readCString(); console.log(C字符串:, cstr); // 方式2如果是UTF-16字符串Windows风格 // var wstr args[0].readUtf16String(); // 方式3如果有长度参数 var length args[1].toInt32(); var buffer args[0].readByteArray(length); if (buffer) { console.log(字节数组:, buffer); // 尝试按UTF-8解码 try { var str String.fromCharCode.apply(null, buffer); console.log(解码为UTF-8:, str); } catch(e) { console.log(不是有效的UTF-8); } } } });6. 动态调用SO函数不仅仅是HOOK有时候我们不只是想监控函数还想主动调用它。比如你想测试某个加密函数或者绕过某些检查。Frida提供了NativeFunction来实现这个功能。6.1 创建NativeFunction对象// 假设我们要主动调用一个函数 // int calculate_hash(const char* input, int length, char* output); var calculateHashAddr Module.findExportByName(libcrypto.so, calculate_hash); if (calculateHashAddr) { // 定义函数签名 var calculateHash new NativeFunction( calculateHashAddr, // 函数地址 int, // 返回值类型 [pointer, int, pointer] // 参数类型 ); // 准备参数 var input Memory.allocUtf8String(Hello, World!); var output Memory.alloc(32); // 分配32字节输出缓冲区 // 调用函数 var result calculateHash(input, 13, output); console.log(哈希计算完成结果码:, result); // 读取输出 var hashResult hexdump(output, { length: 32 }); console.log(哈希值:, hashResult); // 释放内存虽然不是严格必须但好习惯 Memory.free(input); Memory.free(output); }6.2 处理不同类型的参数和返回值NativeFunction支持的类型包括void- 无返回值int,uint- 32位整数long,ulong- 指针大小的整数float,double- 浮点数pointer- 指针类型对于复杂的调用约定比如Windows的__stdcall可以在最后指定// Windows stdcall调用约定 var func new NativeFunction(address, int, [pointer, int], stdcall); // ARM的fastcall var func new NativeFunction(address, int, [pointer, int], fastcall);6.3 调用JNI函数有时候你需要从Native层回调到Java层这就需要调用JNI函数// 获取JNIEnv var env Java.vm.getEnv(); var jniEnv env.handle; // 假设我们要调用NewStringUTF var NewStringUTF new NativeFunction( env.getStringUtfChars, // 或者通过其他方式获取函数指针 pointer, [pointer, pointer] ); // 创建Java字符串 var cstr Memory.allocUtf8String(Hello from Native!); var jstr NewStringUTF(jniEnv, cstr); console.log(创建的Java字符串指针:, jstr);7. 实战案例分析一个加密函数让我们看一个真实场景。假设我们分析一个App发现它调用了一个Native加密函数我们想弄清楚它的算法。7.1 第一步定位和HOOK// 先找到可能的加密函数 var base Module.findBaseAddress(libencrypt.so); var symbols Module.enumerateSymbols(libencrypt.so); var targetFunc null; for (var i 0; i symbols.length; i) { var name symbols[i].name; // 根据经验加密函数常包含这些关键词 if (name.indexOf(encrypt) ! -1 || name.indexOf(crypt) ! -1 || name.indexOf(aes) ! -1 || name.indexOf(des) ! -1 || name.indexOf(rsa) ! -1) { console.log(发现可疑函数:, name, 地址:, symbols[i].address); targetFunc symbols[i].address; break; } } if (targetFunc) { Interceptor.attach(targetFunc, { onEnter: function(args) { console.log(\n 加密函数被调用 ); // 打印调用栈了解是谁调用的 console.log(调用栈:); var trace Thread.backtrace(this.context, Backtracer.ACCURATE); for (var j 0; j trace.length; j) { console.log( DebugSymbol.fromAddress(trace[j])); } // 记录参数 this.arg0 args[0]; // 输入 this.arg1 args[1]; // 输入长度 this.arg2 args[2]; // 输出缓冲区 this.arg3 args[3]; // 可能是密钥 console.log(输入长度:, this.arg1.toInt32()); if (!this.arg0.isNull()) { console.log(输入数据:, hexdump(this.arg0, { length: Math.min(this.arg1.toInt32(), 128), ansi: true })); } if (!this.arg3.isNull()) { console.log(密钥数据:, hexdump(this.arg3, { length: 32, ansi: true })); } }, onLeave: function(retval) { console.log(加密函数返回:, retval.toInt32()); if (!this.arg2.isNull() retval.toInt32() 0) { console.log(输出数据:, hexdump(this.arg2, { length: retval.toInt32(), ansi: true })); } console.log( 调用结束 \n); } }); }7.2 第二步分析算法特征运行上面的HOOK脚本后你可能会看到类似这样的输出输入数据: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF 00000000 68 65 6c 6c 6f 20 77 6f 72 6c 64 00 00 00 00 00 hello world..... 00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................观察输入数据如果末尾有很多00填充可能是PKCS7填充。如果数据长度总是16的倍数可能是AES的ECB或CBC模式。如果输出长度和输入一样可能是流加密或者ECB模式。7.3 第三步深入分析内部函数如果外层函数只是包装真正的算法在内部我们需要继续深入// HOOK可能的内存操作函数 var mallocAddr Module.findExportByName(null, malloc); var freeAddr Module.findExportByName(null, free); var memcpyAddr Module.findExportByName(null, memcpy); var mallocSizes new Map(); var memcpyRecords []; if (mallocAddr) { Interceptor.attach(mallocAddr, { onEnter: function(args) { this.size args[0].toInt32(); }, onLeave: function(retval) { if (!retval.isNull()) { mallocSizes.set(retval, this.size); console.log(malloc(${this.size}) ${retval}); } } }); } if (memcpyAddr) { Interceptor.attach(memcpyAddr, { onEnter: function(args) { var dst args[0]; var src args[1]; var n args[2].toInt32(); // 只记录较大的内存拷贝 if (n 16) { memcpyRecords.push({ dst: dst, src: src, size: n, data: src.readByteArray(Math.min(n, 64)) }); } } }); }通过监控内存分配和拷贝你可能会发现算法中间状态。比如AES加密中的SubBytes、ShiftRows等操作会留下痕迹。7.4 第四步验证猜想有了初步猜想后可以写测试代码验证// 假设我们怀疑是AES-128-CBC function testAES(key, iv, input) { var crypto require(crypto); var cipher crypto.createCipheriv(aes-128-cbc, key, iv); var encrypted Buffer.concat([ cipher.update(input), cipher.final() ]); return encrypted; } // 从HOOK中获取的实际数据 var capturedKey new Buffer([0x00, 0x01, 0x02, ...]); // 从日志中复制 var capturedIV new Buffer([0x00, 0x01, 0x02, ...]); var capturedInput new Buffer([0x68, 0x65, 0x6c, 0x6c, 0x6f, ...]); var myResult testAES(capturedKey, capturedIV, capturedInput); console.log(我们的计算结果:, myResult.toString(hex)); // 与HOOK捕获的输出对比如果结果一致恭喜你成功逆向出了算法8. 常见问题与避坑指南在我多年的实战中积累了一些常见问题的解决方案这里分享给大家。8.1 函数地址不对怎么办问题计算出的地址HOOK后没反应或者程序崩溃。可能原因和解决方案Thumb模式问题32位ARM上如果函数是Thumb指令集地址需要1。在IDA里查看地址最低位是否为1。偏移量错误确认IDA里显示的偏移是文件偏移还是内存偏移。通常.text段的文件偏移和内存偏移一致。SO未加载函数所在的SO可能还没加载。用Module.load()强制加载或者HOOKdlopenvar dlopen Module.findExportByName(null, dlopen); Interceptor.attach(dlopen, { onEnter: function(args) { var path args[0].readCString(); if (path path.indexOf(libtarget.so) ! -1) { console.log(正在加载目标SO:, path); this.shouldHook true; } }, onLeave: function(retval) { if (this.shouldHook) { setTimeout(function() { // 等SO加载完再HOOK hookTargetFunctions(); }, 100); } } });8.2 参数解析错误怎么办问题读取的参数值明显不对或者程序崩溃。解决方案确认调用约定ARM上通常都是AAPCS但如果是复杂的C类成员函数第一个参数可能是this指针。检查参数类型用args[0].isNull()先检查是否为null。用typeof args[0]查看类型。小端序问题ARM是小端序读取多字节数据时要注意。使用正确的读取方法// 读取各种类型的数据 var intValue args[0].toInt32(); var uintValue args[0].toUInt32(); var longValue args[0].toInt64(); var pointerValue args[0]; // 本身就是指针 var floatValue args[0].readFloat(); var doubleValue args[0].readDouble();8.3 程序检测到HOOK怎么办问题程序有反调试、反HOOK机制检测到Frida后崩溃。应对策略隐藏Frida使用定制版的frida-server修改特征。绕过检测HOOK检测函数让它总是返回false// 假设检测函数是check_debug var checkDebugAddr Module.findExportByName(libsecurity.so, check_debug); if (checkDebugAddr) { Interceptor.replace(checkDebugAddr, new NativeCallback(function() { console.log(绕过调试检测); return 0; // 返回false }, int, [])); }动态修改代码直接patch检测代码var patchAddr Module.findBaseAddress(libsecurity.so).add(0x1234); Memory.patchCode(patchAddr, 4, function(code) { // 写入NOP指令ARM 32位 code.writeUInt(0xE1A00000); // NOP });8.4 性能问题怎么处理问题HOOK太频繁导致程序卡顿。优化建议选择性HOOK不要HOOK每个函数只HOOK关键函数。减少日志输出生产环境去掉console.log或者有条件地输出。使用CModule对于性能敏感的HOOK可以用C语言写扩展const cm new CModule( #include stdint.h int hook_function(uint64_t arg1, uint64_t arg2) { // 高效的C代码 return 0; } );批量处理收集数据定期批量输出而不是每次调用都输出。9. 高级技巧寄存器操作与Inline Hook有时候我们需要更精细的控制比如读取或修改寄存器值或者实现inline hook。9.1 访问CPU寄存器在HOOK的回调函数里可以通过this.context访问寄存器Interceptor.attach(someFunc, { onEnter: function(args) { // ARM64寄存器 console.log(X0:, this.context.x0); console.log(X1:, this.context.x1); console.log(PC:, this.context.pc); // 修改寄存器值 this.context.x0 ptr(0x1234); // 修改第一个参数 // ARM32寄存器 // console.log(R0:, this.context.r0); // console.log(LR:, this.context.lr); } });这在分析混淆代码时特别有用因为编译器优化后的代码可能不会通过栈传递参数而是直接用寄存器。9.2 Inline Hook指令级HOOK有时候函数太短或者我们想HOOK函数中间的某条指令var targetAddr Module.findBaseAddress(libtarget.so).add(0x2345); // 备份原指令 var originalInstruction Instruction.parse(targetAddr); console.log(原指令:, originalInstruction.toString()); // 插入跳转到我们的代码 var hookCode Memory.alloc(Process.pageSize); Memory.patchCode(hookCode, Process.pageSize, function(code) { var writer new Arm64Writer(code); // 保存寄存器 writer.putStpRegRegImm(Arm64Register.x0, Arm64Register.x1, 0); // ... 保存其他需要保存的寄存器 // 调用我们的处理函数 writer.putLdrRegAddress(Arm64Register.x0, hookHandler); writer.putBlrReg(Arm64Register.x0); // 恢复寄存器 writer.putLdpRegRegImm(Arm64Register.x0, Arm64Register.x1, 0); // ... 恢复其他寄存器 // 执行被替换的指令 writer.putInstruction(originalInstruction); // 跳回原函数继续执行 writer.putBr(Arm64Register.x30); writer.flush(); }); // 替换原指令为跳转 Memory.patchCode(targetAddr, 4, function(code) { var writer new Arm64Writer(code); writer.putLdrRegAddress(Arm64Register.x16, hookCode); writer.putBrReg(Arm64Register.x16); writer.flush(); }); function hookHandler() { console.log(Inline hook被触发!); // 这里可以访问和修改寄存器 }Inline hook更强大但也更复杂容易出错。除非必要一般用Interceptor.attach就够了。10. 实际项目经验分享最后分享几个我在实际项目中遇到的案例和解决方案。案例一动态密钥生成有一次分析一个游戏保护它的加密密钥每次运行都不同。通过HOOK密钥生成函数我发现密钥是基于设备指纹动态生成的。解决方案是HOOK生成函数记录生成的密钥然后在外部分析中使用这个密钥。案例二多线程同步问题一个金融App的加密函数会被多个线程同时调用直接HOOK会导致日志混乱。我用了线程本地存储来区分不同线程的调用var threadData new Map(); Interceptor.attach(encryptFunc, { onEnter: function(args) { var threadId Process.getCurrentThreadId(); var data { startTime: Date.now(), input: args[0].readByteArray(args[1].toInt32()) }; threadData.set(threadId, data); console.log([线程${threadId}] 开始加密); }, onLeave: function(retval) { var threadId Process.getCurrentThreadId(); var data threadData.get(threadId); if (data) { console.log([线程${threadId}] 加密完成耗时: ${Date.now() - data.startTime}ms); threadData.delete(threadId); } } });案例三对抗代码混淆有些SO会用OLLVM等工具混淆控制流变得复杂。这时候静态分析困难但动态HOOK仍然有效。我通常的做法是先找到函数的入口和出口通过HOOK调用它的函数在入口记录所有输入在出口记录输出通过大量测试数据推测函数功能用Z3等工具辅助分析算法案例四性能优化分析一个视频处理App时它的处理函数每秒被调用上千次直接HOOK会导致卡顿。我的优化方案var sampleCount 0; var lastLogTime Date.now(); Interceptor.attach(processFunc, { onEnter: function(args) { sampleCount; // 每5秒统计一次 var now Date.now(); if (now - lastLogTime 5000) { console.log(5秒内调用次数: ${sampleCount}); sampleCount 0; lastLogTime now; // 只记录1%的调用详情 if (Math.random() 0.01) { this.shouldLog true; this.startTime now; } } if (this.shouldLog) { // 详细记录 this.inputData args[0].readByteArray(1024); } }, onLeave: function(retval) { if (this.shouldLog) { console.log(单次调用耗时: ${Date.now() - this.startTime}ms); this.shouldLog false; } } });SO层HOOK技术就像一把瑞士军刀在安卓逆向中用途广泛。从简单的函数监控到复杂的算法分析从安全研究到性能优化都离不开它。掌握这项技术需要耐心和实践但一旦熟练你就能看到别人看不到的“风景”。我建议的学习路径是先从简单的Demo开始熟悉基本的HOOK操作然后找一些开源项目练习比如一些CTF的逆向题最后尝试分析真实的App从简单的开始逐步增加难度。过程中肯定会遇到各种问题但每次解决问题的过程都是技术的积累。记住逆向工程不仅是技术活更是耐心和细心的考验。有时候一个函数要HOOK几十次对比几百组数据才能看出规律。但当你最终破解了一个复杂的加密算法或者弄明白了一个精巧的保护机制时那种成就感是无与伦比的。