安卓So层Hook实战:Frida逆向分析底层函数与加密算法 1. 项目概述为什么So层Hook是安卓安全研究的“深水区”在安卓应用逆向与安全分析的圈子里Hook技术就像是研究者的“手术刀”能够动态地修改和监控应用的行为。我们常说的Hook根据目标的不同主要分为Java层Hook和Native层So层Hook。Java层Hook比如用Xposed或者Frida去Hook一个Activity的onCreate方法相对直观因为Java代码逻辑清晰方法签名明确。但当你面对一个核心算法被编译进.so动态库、关键验证逻辑藏在C/C代码里的应用时Java层的“手术刀”就钝了你必须潜入更底层的“深水区”——So层。So层Hook之所以被称为“深水区”是因为它直接与操作系统和硬件架构对话。这里没有Java虚拟机的保护伞内存地址、寄存器、指令集ARM/ARM64/x86成了你每天要打交道的对象。一个.so文件本质上是编译后的机器码Hook它意味着你要在二进制指令的海洋里精准地找到那个目标函数并改变它的执行流程。这不仅仅是技术难度的提升更是对研究者底层功底和调试耐心的巨大考验。Frida作为一个动态插桩工具其强大之处在于它提供了一套相对统一的API让我们能够用JavaScript或Python这种高级语言去完成对底层二进制世界的“遥控”极大地降低了So层Hook的入门门槛和操作复杂度。这篇文章就是带你从理论到实战完整地走一遍用Frida进行安卓So层Hook的流程无论你是想分析加密算法、绕过安全检测还是单纯对底层技术充满好奇都能在这里找到可落地的答案。2. So层Hook的核心原理与Frida的角色要理解So层Hook必须先抛开Java世界面向对象的那套思维进入系统编程和进程内存的视角。2.1 So库与进程内存空间一个安卓应用在运行时其进程内存空间是结构化的。Java代码运行在Dalvik或ART虚拟机中而So库如libnative-lib.so则是由系统链接器Linker加载到进程的特定内存区域。这个区域通常包含代码段.text存放可执行指令、数据段.data/.bss存放全局/静态变量等。当应用调用一个So库中的原生函数时实际上是通过一个存储在内存中的函数指针跳转到对应代码段的指令地址开始执行。2.2 Hook的本质拦截与重定向Hook的本质就是改变这个执行流程。传统的方式比如基于LD_PRELOAD的库劫持或者直接修改内存中的指令Inline Hook其核心思想都是找到目标函数在内存中的地址修改该地址处的指令使其跳转到我们自定义的代码Hook函数去执行在我们自定义的代码执行完毕后可以选择再跳转回原函数继续执行或者完全替换其逻辑。这个过程涉及几个关键步骤定位Find确定目标函数在内存中的绝对地址。这需要解析ELFSo文件格式文件头计算符号偏移并加上So库加载的基地址。备份Backup保存目标地址处即将被覆盖的原始指令以便后续恢复或继续执行。注入Inject将跳转指令例如ARM架构的B或BL指令x86的JMP指令写入目标地址使其指向我们Hook函数的地址。执行Execute在Hook函数中我们可以读取、修改函数的参数和返回值记录调用信息或者完全改变函数行为。2.3 Frida如何简化这一切手动完成上述步骤是极其繁琐且容易出错的尤其是处理不同CPU架构的指令集差异和地址对齐问题时。Frida的价值就在于它封装了这些底层细节。注入引擎Frida通过frida-server在目标设备上运行将自身注入到目标进程中获得在该进程内存空间中执行代码的能力。JavaScript绑定它提供了一个强大的JavaScript运行时环境。我们写的Hook脚本是JavaScript但Frida在背后通过其原生C模块将这些JS调用转换为对目标进程内存的实际操作。Interceptor API这是So层Hook的核心。Interceptor.attach(targetAddress, callbacks)这个API看上去很简单但Frida在背后默默完成了所有脏活累活它计算并应用了正确的跳转指令管理了原始指令的备份和上下文寄存器、栈的保存与恢复。我们只需要关心onEnter函数进入时和onLeave函数离开时这两个回调函数里要做什么。内存操作APIMemory.readByteArray,Memory.writeUtf8String,Memory.alloc等API让我们能够像操作JavaScript数组一样安全地读写目标进程的任意内存地址这对于分析函数参数尤其是结构体指针至关重要。注意Frida的便利性建立在对其内部机制的一定信任上。在极端追求稳定性和隐匿性的场景下如对抗某些强检测的环境可能需要回归到手写Inline Hook或PLT Hook但Frida绝对是学习、研究和快速验证想法的最佳工具。3. 实战环境搭建与目标分析工欲善其事必先利其器。一个稳定的实验环境是成功的第一步。3.1 环境准备清单测试设备/模拟器推荐模拟器对于初学者雷电模拟器、夜神模拟器是不错的选择它们自带Root权限方便调试。本文示例使用雷电模拟器Android 7.164位。确保在模拟器设置中开启VT虚拟化技术以获得最佳性能。真机如果需要测试更真实的设备环境或特定架构如ARM v8-A一台已Root的安卓手机是必要的。重要提示Root手机会带来安全风险请仅在用于研究的专用设备上操作。Frida生态安装PC端开发机通过Python的pip安装Frida客户端和工具包。pip install frida-tools安装后命令行应能识别frida、frida-ps等命令。目标端安卓设备/模拟器这是关键一步。你需要下载与目标设备架构匹配的frida-server。在PC上执行adb shell getprop ro.product.cpu.abi查看设备架构常见输出arm64-v8a,armeabi-v7a,x86_64。前往Frida的GitHub Releases页面下载对应版本的frida-server-xx.x.x-android-xx.xz。例如对于64位ARM设备应下载android-arm64版本。解压得到frida-server文件通过adb推送到设备并赋予执行权限adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server运行Frida服务端在设备上启动服务端。建议使用让它在后台运行并重定向输出到空设备以避免阻塞shell。adb shell su cd /data/local/tmp ./frida-server 验证连接在PC上执行frida-ps -U如果能看到设备上的进程列表说明连接成功。目标应用与分析工具选择一个包含So库的简单应用作为练习目标。你可以自己用Android Studio写一个包含JNI调用的Demo App这样你完全清楚内部逻辑便于验证Hook效果。反编译工具jadx-gui用于快速查看Java代码和So库的JNI函数映射。逆向分析工具IDA Pro或Ghidra用于静态分析So库理解函数逻辑和确定Hook点。radare2也是一个强大的免费选择。3.2 目标应用分析定位Hook点假设我们有一个简单的Demo应用它有一个libnative-lib.so其中导出了一个JNI函数Java_com_example_demo_MainActivity_stringFromJNI这个函数内部调用了另一个内部函数int add(int a, int b)我们最终目标是Hook这个内部的add函数。分析步骤如下Java层定位用jadx-gui打开APK找到调用原生方法的Java类。你会看到类似native String stringFromJNI();的声明。记下完整的类名和方法名。So库导出函数So库中供Java调用的JNI函数名称是固定的格式。使用readelf -s libnative-lib.so | grep Java或直接在IDA中查看导出函数表就能找到Java_com_example_demo_MainActivity_stringFromJNI的地址。但这通常不是我们的最终目标因为JNI函数封装了复杂的JNIEnv操作。深入So内部用IDA加载libnative-lib.so找到Java_com_example_demo_MainActivity_stringFromJNI函数进行反编译。在伪代码中你很可能看到它调用了某个内部函数比如add(x, y)。这个add函数在导出表中是不可见的局部符号我们需要通过偏移地址来定位它。计算绝对地址Hook需要的是函数在内存中运行时的绝对地址。公式是函数绝对地址 So库加载基地址 函数在文件内的偏移地址。基地址在Frida中可以通过Module.findBaseAddress(libnative-lib.so)动态获取。偏移地址在IDA中函数add的地址显示的是文件偏移地址例如0x1234。在IDA开头的Segments视图里查看.text代码段的虚拟地址VirtAddr例如0x1000。那么函数在内存中的相对虚拟地址RVA约为0x1234。更精确的做法是直接使用IDA中函数所在的虚拟内存地址VA但需要确认IDA的加载基址是否与运行时一致。一个稳妥的方法是偏移地址 IDA中显示的add函数地址 - IDA中.text段的起始虚拟地址。实操心得对于未导出函数除了计算偏移还有一个更常用的方法——模式匹配Pattern Search。在IDA中找到add函数开头的一段独特的机器码序列例如 “01 0A 40 F9 02 0B 40 F9”在Frida脚本中使用Memory.scanSync在So库的代码段内搜索这个模式来动态定位函数地址。这种方法不依赖于固定的偏移兼容性更好尤其是在So库有多个版本或经过混淆时。4. Frida So层Hook脚本编写实战理论铺垫完毕现在让我们动手写一个完整的Frida JavaScript脚本来Hook上面提到的内部add函数。4.1 基础Hook脚本框架首先我们编写一个基础的脚本它能够附加到目标进程并Hook指定的函数。// hook_so_add.js Java.perform(function () { console.log([*] Script loaded. Starting SO hook...); // 1. 指定目标库名称 var libName libnative-lib.so; // 2. 获取SO库的加载基地址 var baseAddr Module.findBaseAddress(libName); if (baseAddr) { console.log([] Found, libName, at, baseAddr); } else { console.log([-] Failed to find, libName); return; } // 3. 计算目标函数绝对地址假设我们已经通过IDA分析得到偏移 // 假设 add 函数的文件偏移是 0x1234 .text段VirtAddr是 0x1000 // 那么 RVA 0x1234 - 0x1000 0x234 var offsetAdd 0x234; // 这是你需要根据自己分析修改的值 var addAddr baseAddr.add(offsetAdd); // baseAddr offset console.log([*] Calculated add function address:, addAddr); // 4. 使用Interceptor进行Hook Interceptor.attach(addAddr, { // onEnter: 当函数被调用时执行args是传入的参数列表 onEnter: function (args) { console.log(\n[ add Function Called ]); // 在ARM64上前几个整型参数通常通过寄存器X0, X1, X2...传递Frida帮我们抽象成了args[0], args[1]... // 我们需要根据函数签名来理解args。对于 int add(int a, int b): this.arg0 args[0].toInt32(); // 第一个int参数 this.arg1 args[1].toInt32(); // 第二个int参数 console.log( arg0 (a): ${this.arg0}); console.log( arg1 (b): ${this.arg1}); // 我们可以修改参数 // args[1] ptr(100); // 例如把第二个参数强制改为100 }, // onLeave: 当函数返回时执行retval是返回值 onLeave: function (retval) { // 返回值可能是指针或整型根据函数返回类型转换 var result retval.toInt32(); console.log( Original return value: ${result}); // 我们可以修改返回值 var newRetval this.arg0 this.arg1 1000; // 例如给结果加1000 console.log( Modified return value: ${newRetval}); retval.replace(ptr(newRetval)); // 替换返回值 console.log([ add Function Leave ]\n); } }); console.log([] Hook for add has been installed.); });脚本解析与关键点Java.perform确保我们的代码在Frida的Java运行时中执行这是访问Frida API所必需的。Module.findBaseAddress动态获取基地址这是实现跨版本兼容的关键。Interceptor.attach核心Hook API。第一个参数是目标函数的绝对地址NativePointer类型。onEnter中的this在回调函数中this指向一个对象用于在onEnter和onLeave之间传递信息。我们将参数保存到this上以便在onLeave中使用。args和retval它们都是NativePointer类型。需要使用.toInt32(),.toInt64(),.readCString()等方法将其转换为可读的值。如何转换完全取决于被Hook函数的原始签名。retval.replace()用于修改函数的返回值。4.2 处理复杂参数与结构体现实中的函数参数远不止整型那么简单。经常需要处理字符串、数组、结构体指针。示例Hook一个参数为字符串的函数假设函数void logMessage(char* msg)。Interceptor.attach(logMessageAddr, { onEnter: function (args) { // args[0] 是一个指向C风格字符串char*的指针 var messagePtr args[0]; // 从指针读取UTF-8字符串 var message messagePtr.readUtf8String(); console.log([logMessage] Input: ${message}); // 甚至可以修改字符串内容 // 首先分配一块新内存写入新字符串 var newMessage Hooked: message; var newPtr Memory.allocUtf8String(newMessage); // 替换原指针注意这要求函数内部不会对原指针进行free等操作否则可能崩溃 args[0] newPtr; }, onLeave: function (retval) { // void函数无返回值 } });示例读取结构体成员假设函数int processUser(User* user)其中User是一个结构体包含int id; char name[32];。// 首先我们需要知道结构体在内存中的布局偏移量 // 假设通过逆向分析得知id 在偏移0处name 在偏移4处32位系统int占4字节 Interceptor.attach(processUserAddr, { onEnter: function (args) { var userPtr args[0]; // User* 指针 // 读取id (4字节整型) var userId userPtr.add(0).readInt(); // 从指针位置0偏移处读int // 读取name (以0结尾的字符串) var userName userPtr.add(4).readUtf8String(); // 从指针位置4偏移处读字符串 console.log(Processing User - ID: ${userId}, Name: ${userName}); } });注意事项处理指针和结构体是So层Hook中最容易导致应用崩溃的地方。务必确保你对目标函数的调用约定如ARM的AAPCS64和参数传递方式有基本了解。你读取的内存地址是有效的、可读的。尝试读取未映射的内存会引发崩溃。修改指针或内存内容时要考虑原函数的内存管理逻辑谁分配谁释放避免内存泄漏或重复释放。4.3 使用“模式搜索”动态定位未导出函数对于没有导出符号的内部函数前面计算偏移的方法很脆弱。更健壮的方法是搜索函数特征码。function hookAddByPattern() { var libName libnative-lib.so; var baseAddr Module.findBaseAddress(libName); if (!baseAddr) return; // 在IDA中查看add函数的开头机器码例如 ARM64 可能是 F8 5F BC A9 F6 57 01 A9 ... // 我们取前8-12个字节作为特征码。注意要去掉地址部分。 var pattern F8 5F BC A9 F6 57 01 A9; // 将十六进制字符串模式转换为字节数组 var patternBytes pattern.split( ).map(function (b) { return parseInt(b, 16); }); // 扫描整个.text段通常具有可执行权限 // 首先找到代码段 var module Process.getModuleByName(libName); var ranges module.enumerateRanges(r-x); // 查找可读可执行的内存范围即代码段 ranges.forEach(function (range) { console.log([*] Scanning range: ${range.base} - ${range.base.add(range.size)}); // 使用Memory.scanSync进行同步扫描对于大范围异步scan更佳 var results Memory.scanSync(range.base, range.size, pattern); if (results.length 0) { console.log([] Found pattern at: ${results[0].address}); // 假设第一个结果就是我们的目标函数 var addAddr results[0].address; // 安装Hook代码同上 Interceptor.attach(addAddr, { onEnter: function(args) { /* ... */ }, onLeave: function(retval) { /* ... */ } }); } }); } Java.perform(hookAddByPattern);5. 高级技巧与实战问题排查掌握了基础Hook后我们来看看如何应对更复杂的场景和常见问题。5.1 挂钩HookingJNI函数本身有时我们可能需要直接Hook JNI函数例如Java_com_example_MainActivity_stringFromJNI。方法与Hook内部函数类似但参数是JNIEnv和jclass/jobject。var jniFuncAddr Module.findExportByName(libnative-lib.so, Java_com_example_demo_MainActivity_stringFromJNI); Interceptor.attach(jniFuncAddr, { onEnter: function(args) { // args[0] 是 JNIEnv* // args[1] 是 jclass 或 jobject (调用者) console.log([] JNI function called!); // 可以通过JNI API调用原始方法但需谨慎 }, onLeave: function(retval) { // retval 是 jstring 等Java对象引用 var javaString Java.vm.getEnv().getStringUtfChars(retval, null); console.log(JNI function returned: ${javaString}); } });5.2 处理多线程与同步问题So库函数可能被多个线程同时调用。Frida的Interceptor回调本身是线程安全的但你的JavaScript代码如果操作共享资源比如一个全局的日志数组就需要考虑同步。// 一个简单的线程安全计数器示例 var callCount 0; var lock new Lock(); // Frida 提供的简单锁 Interceptor.attach(targetAddr, { onEnter: function(args) { lock.acquire(); callCount; var currentThreadId Process.getCurrentThreadId(); console.log([Thread-${currentThreadId}] Call #${callCount}); this.threadId currentThreadId; lock.release(); }, onLeave: function(retval) { lock.acquire(); console.log([Thread-${this.threadId}] Leave); lock.release(); } });5.3 绕过简单的Frida检测一些安全加固的应用会检测Frida的存在。常见检测手段包括检测端口默认的Frida-server监听在27042端口。可以尝试使用-l 0.0.0.0:9999参数启动frida-server改变监听端口。检测进程名/文件检查/proc/self/maps或/proc/self/task/pid/fd中是否包含frida字符串。可以在编译frida-server时重命名相关特征。检测线程名Frida会注入一些特定名称的线程如gmain,gdbus。这需要修改Frida源码或使用更隐蔽的注入方式。在脚本层面一个简单的对抗是避免使用太有特征的模块名和函数名。5.4 常见问题排查速查表问题现象可能原因排查步骤与解决方案TypeError: cannot read property add of null未找到So库基地址。1. 确认库名拼写正确包括后缀.so。2. 确认目标进程已加载该库Process.enumerateModules()。3. 对于系统库可能需要等待其延迟加载使用setTimeout延迟Hook。Error: access violation accessing 0x...访问了无效的内存地址。1. 检查计算出的函数地址是否正确。2. 在读写内存前使用Memory.isReadable(address)检查。3. 确认参数索引args[0],args[1]是否符合函数调用约定。Hook后应用立即崩溃指令覆盖错误或上下文破坏。1.最常见原因Hook了函数的前几条指令但这些指令包含重要的PC相对寻址如adrp。Frida的Interceptor通常能处理但某些特殊指令序列可能导致问题。尝试Hook函数稍后的位置如addAddr.add(4)但需确保不会跳过关键参数设置。2. 在onEnter/onLeave中错误地修改了寄存器或栈内容。脚本注入成功但无日志输出Hook点错误或函数未被调用。1. 使用console.log(Module.findExportByName(...))验证函数地址是否有效。2. 在脚本开头打印所有加载的模块确认目标库已加载。3. 尝试Hook一个已知的、肯定会调用的导出函数如strlen来测试脚本基础功能。frida-ps -U能看到进程但脚本无法附加应用具有反调试或ptrace保护。1. 尝试使用frida -U --no-pause -f com.example.app在应用启动时注入。2. 某些加固会防止附加需要更底层的绕过手段这超出了基础Frida的范围。5.5 性能考量与脚本优化减少console.log频繁的日志输出会极大拖慢目标进程甚至导致检测。在稳定后考虑将日志写入文件或仅在需要时开启。避免阻塞操作不要在onEnter/onLeave回调中执行网络请求、复杂文件IO等耗时操作这会阻塞目标线程。使用Interceptor.detachAll在脚本结束时或需要重新Hook时清理之前的所有Hook避免残留。6. 从Hook到主动调用完成行为闭环Hook是观察和修改但有时我们需要“主动”让目标函数执行例如调用一个加密函数来生成我们需要的密文。这需要用到NativeFunction。假设我们已经知道了add函数的地址和签名int (int a, int b)。Java.perform(function () { var baseAddr Module.findBaseAddress(libnative-lib.so); var addAddr baseAddr.add(0x234); // 目标函数地址 // 创建一个NativeFunction对象 // 第一个参数函数地址NativePointer // 第二个参数返回值类型int, pointer, void... // 第三个参数参数类型数组[int, int] // 第四个参数ABI调用约定通常用default对于iOS/某些特殊场景可能需要sysv或win64 var addFunc new NativeFunction(addAddr, int, [int, int], default); // 现在我们可以像调用普通JS函数一样调用它 var result addFunc(5, 3); console.log(Active call add(5, 3) ${result}); // 输出 8 // 甚至可以和Hook结合先Hook然后在Hook回调里主动调用原函数 var originalAdd null; Interceptor.attach(addAddr, { onEnter: function(args) { console.log(Intercepted call: ${args[0].toInt32()} ${args[1].toInt32()}); // 保存原函数引用如果需要绕过Hook执行原逻辑 originalAdd addFunc; }, onLeave: function(retval) { // 如果想在Hook后仍然执行原逻辑并获取结果可以这样做 // var trueResult originalAdd(this.savedArg0, this.savedArg1); // console.log(True result was: ${trueResult}); } }); });主动调用的核心挑战在于确定函数签名参数类型、个数、顺序和调用约定。这通常需要结合逆向分析看它如何被其他代码调用和文档如果是公开API来确定。一个错误的签名会导致栈不平衡必然崩溃。7. 实战案例逆向一个简单的加密函数让我们综合运用以上知识完成一个小目标逆向一个So库中的encrypt函数该函数接收一个字符串返回加密后的十六进制字符串。静态分析IDA打开So库找到encrypt函数。分析发现其签名可能是char* encrypt(const char* input)。内部调用了malloc分配内存并返回指针。编写Hook脚本Java.perform(function() { var encryptAddr Module.findExportByName(libcrypto.so, encrypt); // 假设是导出函数 Interceptor.attach(encryptAddr, { onEnter: function(args) { this.input args[0].readUtf8String(); console.log([encrypt] Input: ${this.input}); // 保存输入用于后续分析 }, onLeave: function(retval) { // 返回值是一个新分配的char*指针 var output retval.readUtf8String(); console.log([encrypt] Output: ${output}); // 记录输入输出对用于分析加密逻辑 console.log(Pair: ${this.input} - ${output}); // 注意原函数内部malloc了内存调用者需要负责free。 // 我们这里只是读取不要尝试free(retval)。 } }); });动态收集数据运行Hook脚本触发应用的各种加密操作收集大量的明文密文对。分析算法根据输入输出对尝试推断加密算法。例如如果输出长度固定可能是哈希MD5, SHA1如果输出长度与输入相关可能是AES/ DES CBC模式可以尝试输入有规律的数据如全零、递增序列观察输出变化。主动调用验证一旦你推测出算法比如是简单的XOR就可以用NativeFunction主动调用encrypt或者更彻底地用JavaScript/ Python重新实现该算法验证其正确性。这个从Hook观察到分析理解再到主动调用或重实现的过程就是So层逆向分析的完整闭环。它赋予你的不仅仅是对某个应用的破解能力更是对底层代码逻辑的深刻洞察力。