)
Frida Hook .so文件实战从零开始搭建Android逆向分析环境附完整代码如果你对移动应用内部运作机制感到好奇或者在工作中需要分析某个App的核心算法那么动态分析工具Frida绝对是你绕不开的利器。它不像静态分析那样需要面对海量的汇编代码而是允许你在应用运行时像外科手术般精准地观察和修改其行为。对于Android平台许多核心逻辑都封装在.so动态链接库中无论是加密算法、许可证校验还是游戏的反作弊逻辑。掌握Frida Hook .so文件就等于拿到了窥探和干预这些“黑盒”的钥匙。这篇文章我将从一个实践者的角度带你从零开始一步步搭建起一个稳定、高效的Android逆向分析环境并附上可以直接运行的完整代码让你能立刻上手体验动态分析的魅力。1. 环境搭建打造你的移动分析工作站在开始任何Hook操作之前一个稳定、功能齐全的基础环境是成功的基石。这不仅仅是安装几个软件更是对工具链、调试流程和问题排查能力的整体构建。1.1 核心工具安装与配置首先我们需要在分析主机通常是你的电脑上安装Frida的核心组件。我强烈建议使用Python的虚拟环境来管理依赖这能避免不同项目间的包版本冲突。# 创建并激活一个Python虚拟环境 python3 -m venv frida_env source frida_env/bin/activate # Linux/macOS # 或 frida_env\Scripts\activate # Windows # 安装Frida工具包 pip install frida-tools frida安装完成后可以通过以下命令验证安装是否成功并查看版本信息frida --version frida-ps --help注意frida-tools版本最好与后续在手机上安装的frida-server版本保持一致这是避免连接失败的最常见技巧。接下来是Android设备的准备。你需要一台已经获取root权限的Android手机或模拟器。对于模拟器我推荐使用Android Studio自带的AVD并选择x86或arm64的Google APIs镜像便于后续操作。在真实设备上Magisk是目前最主流的root方案。设备准备就绪后需要将对应版本的frida-server推送到设备上并运行。这是Frida在目标设备上的“守护进程”。下载frida-server访问Frida的GitHub发布页根据你设备的CPU架构通常是arm64和Frida版本下载对应的frida-server-xx.x.x-android-arm64.xz文件。推送与执行# 解压下载的文件 xz -d frida-server-xx.x.x-android-arm64.xz # 将文件推送到设备的临时目录并赋予执行权限 adb push frida-server-xx.x.x-android-arm64 /data/local/tmp/frida-server adb shell chmod 755 /data/local/tmp/frida-server # 切换到root身份并运行server注意设备需已root adb shell su cd /data/local/tmp ./frida-server 验证连接保持设备通过USB连接电脑在主机终端运行frida-ps -U如果成功列出设备上的进程列表恭喜你最基础的环境已经打通。1.2 辅助工具链集成仅有Frida还不够一个高效的逆向环境需要其他工具协同工作。这里我整理了一个必备工具清单及其作用工具名称主要用途备注ADB (Android Debug Bridge)与Android设备通信的核心命令行工具。Android SDK Platform-Tools的一部分务必保持更新。IDA Pro / Ghidra静态反汇编与分析.so文件用于定位关键函数地址、分析逻辑。IDA功能强大但商业授权Ghidra免费开源是绝佳替代。Jadx / JEB反编译Android APK的Java/Kotlin代码理解整体应用逻辑。Jadx免费且速度快适合快速浏览JEB在深度分析上更专业。Burp Suite / Charles网络抓包分析用于定位与网络通信相关的加解密或校验函数。配合Frida可以实现从协议到实现的完整分析链路。Objection基于Frida的运行时移动安全测试框架封装了许多常用命令。可以快速进行内存搜索、绕过SSL Pinning等提高效率。安装Objection可以进一步简化我们的工作pip install objection安装后使用objection -g com.target.app explore可以快速注入一个交互式环境。环境搭建中常见的“坑”莫过于连接失败。如果frida-ps -U报错可以按以下顺序排查设备未授权确保电脑已通过adb devices授权调试。端口冲突Frida默认使用TCP端口27042。确保该端口未被占用或使用-D参数指定其他端口。版本不匹配主机frida-tools与设备frida-server的主版本号必须一致。SELinux限制在某些严格模式下可能需要临时禁用SELinuxadb shell su -c “setenforce 0”。2. 初识Frida脚本从Hook一个简单导出函数开始环境就绪让我们立刻写第一个脚本感受Frida的威力。我们将从一个最简单的目标开始Hook一个.so文件中的导出函数。导出函数是那些在库的符号表中公开可见的函数最容易定位。2.1 你的第一个Hook脚本假设我们有一个目标应用它使用了一个名为libnative-lib.so的库其中有一个导出函数stringFromJNI我们想监控它的调用和返回值。创建一个名为first_hook.js的文件内容如下Java.perform(function () { console.log([*] 脚本已注入开始寻找目标模块...); // 1. 枚举所有已加载的模块用于确认目标so是否加载 var modules Process.enumerateModules(); var targetModule null; for (var i 0; i modules.length; i) { if (modules[i].name.indexOf(libnative-lib) ! -1) { targetModule modules[i]; console.log([] 找到目标模块: targetModule.name targetModule.base); break; } } if (!targetModule) { console.log([-] 未找到目标模块请确认so是否已加载。); return; } // 2. 尝试获取导出函数地址 var funcName stringFromJNI; var funcAddress Module.getExportByName(targetModule.name, funcName); if (funcAddress) { console.log([] 成功找到函数 funcName 地址: funcAddress); // 3. 使用Interceptor进行挂钩Hook Interceptor.attach(funcAddress, { // 函数被调用时触发 onEnter: function (args) { console.log(\n 函数 funcName 被调用 ); console.log(调用线程ID: Process.getCurrentThreadId()); // args是NativeFunction的参数数组args[0]通常是JNIEnv* // 这里我们打印第一个参数假设是指针的地址 console.log(第一个参数 (JNIEnv*) 地址: args[0]); // 可以保存一些上下文信息供onLeave使用 this.startTime Date.now(); }, // 函数即将返回时触发 onLeave: function (retval) { var elapsed Date.now() - this.startTime; console.log(函数执行耗时: elapsed ms); // retval是函数的返回值一个NativePointer对象 // 如果函数返回字符串指针可以读取其内容 // var returnedString retval.readUtf8String(); // console.log(返回值 (字符串): returnedString); console.log(返回值 (原始指针): retval); console.log( 调用结束 \n); } }); } else { console.log([-] 未找到导出函数 funcName 。该函数可能被strip剥离符号需要其他方法定位。); } });这个脚本清晰地展示了Frida Hook的基本流程枚举模块确认目标动态库是否已加载到目标进程的内存空间。定位函数通过符号名getExportByName获取函数在内存中的地址。附加拦截器在函数地址上附加一个拦截器定义onEnter进入和onLeave离开时的回调行为。2.2 运行与调试脚本编写好脚本后我们需要将其注入到目标进程中。假设目标应用的包名是com.example.targetapp。方法一使用Frida CLI命令行注入frida -U -f com.example.targetapp -l first_hook.js --no-pause-U: 连接到USB设备。-f: 启动一个新的应用进程。-l: 加载指定的JavaScript脚本。--no-pause: 立即恢复应用执行否则应用会在启动时暂停。方法二使用Python脚本控制更灵活创建一个runner.py文件import frida import sys def on_message(message, data): if message[type] send: print(f[*] {message[payload]}) else: print(message) # 连接到USB设备 device frida.get_usb_device() # 附加到正在运行的应用进程 # pid device.spawn([com.example.targetapp]) # 如果要启动新进程 # session device.attach(pid) session device.attach(com.example.targetapp) # 附加到已运行的进程 # 读取并创建JavaScript脚本 with open(first_hook.js, r, encodingutf-8) as f: js_code f.read() script session.create_script(js_code) # 注册消息回调用于接收JS中console.log的信息 script.on(message, on_message) print([*] 正在加载脚本...) script.load() # 保持脚本运行防止Python脚本退出 sys.stdin.read()运行python runner.py然后在设备上操作目标应用触发相关功能你就能在电脑终端看到实时的Hook日志输出。提示在开发调试阶段善用console.log()输出信息至关重要。对于复杂对象可以使用JSON.stringify()将其转换为字符串查看例如console.log(JSON.stringify(module, null, 2))。3. 深入核心定位与Hook非导出函数现实世界中的.so文件尤其是发布版本经常使用strip命令移除了符号表这意味着我们无法再通过getExportByName这样简单的方式通过函数名找到它。这时我们就需要更高级的定位技巧。3.1 通过偏移地址定位这是最经典的方法需要借助静态分析工具如IDA Pro或Ghidra的辅助。基本思路是用IDA打开目标.so文件找到目标函数的相对虚拟地址RVA即相对于.so文件加载基址的偏移。在运行时用Frida获取.so文件在内存中的实际加载基址。将基址与RVA相加得到函数在内存中的绝对地址。假设我们用IDA分析libtarget.so发现函数secret_algorithm的偏移地址File Offset 或 RVA是0x1234。我们的Hook脚本需要这样写Java.perform(function () { var moduleName libtarget.so; var moduleBase Module.findBaseAddress(moduleName); if (moduleBase) { console.log([] 模块 moduleName 基地址: moduleBase); // 从IDA中获取的函数偏移 var functionOffset 0x1234; // 计算函数在内存中的绝对地址 var functionAddress moduleBase.add(functionOffset); console.log([] 计算得到函数地址: functionAddress); // 验证地址是否可读可选但推荐 try { Memory.readByteArray(functionAddress, 4); console.log([] 地址验证通过准备Hook。); } catch (e) { console.log([-] 地址访问失败: e); return; } Interceptor.attach(functionAddress, { onEnter: function(args) { console.log([*] secret_algorithm 被调用!); // 可以在这里分析参数 }, onLeave: function(retval) { // 处理返回值 } }); } else { console.log([-] 未找到模块: moduleName); } });这种方法的关键在于偏移地址的准确性。你需要确认IDA中看到的地址是内存中的RVA而不是文件中的偏移。通常IDA加载.so后.text等段的虚拟地址VA就是相对于基址的RVA。3.2 通过特征码Pattern搜索定位当偏移地址因版本更新而改变或者你只有函数的二进制特征时特征码搜索是更鲁棒的方法。原理是在模块的代码段内存中搜索一段独一无二的字节序列。例如假设目标函数的机器码开头几个字节是F0 48 2D E9 10 B0 8D E2这是一条典型的ARM指令序列。Java.perform(function () { var moduleName libtarget.so; var lib Process.getModuleByName(moduleName); if (lib) { // 定义特征码空格和问号??用于表示通配符 var pattern F0 48 2D E9 10 B0 8D E2; console.log([*] 开始在模块 moduleName 中搜索特征码: pattern); // Memory.scanSync 是同步扫描对于大模块可能阻塞生产环境可考虑异步扫描 var results Memory.scanSync(lib.base, lib.size, pattern); if (results.length 0) { console.log([] 找到 results.length 个匹配项。); results.forEach(function (match, index) { console.log( [ index ] 匹配地址: match.address); // 通常取第一个匹配项进行Hook if (index 0) { Interceptor.attach(match.address, { onEnter: function(args) { console.log([*] 通过特征码找到的函数被调用!); } }); } }); } else { console.log([-] 未找到匹配特征码的函数。); } } });注意特征码需要足够独特以避免误匹配到其他函数。通常选取函数开头包含特定寄存器操作、常量池引用等不易变化的指令序列。使用IDA的“二进制复制”功能可以方便地获取十六进制字符串。4. 高级实战处理复杂参数与主动调用成功Hook函数只是第一步真正有价值的是理解和操纵函数的输入输出。Native函数的参数类型五花八门从基本整型、指针到复杂结构体都需要我们正确解析。4.1 解析常见参数类型在onEnter回调的args数组中每个元素都是一个NativePointer对象。如何解读它取决于函数原型。Interceptor.attach(targetAddress, { onEnter: function(args) { console.log([*] 函数原型示例: void myFunc(const char* str, int count, MyStruct* data, int* outputArray)); // 参数0: C风格字符串 (const char*) if (!args[0].isNull()) { var inputString args[0].readUtf8String(); // 读取以null结尾的字符串 console.log(参数0 (字符串): inputString); // 也可以读取原始字节: args[0].readByteArray(length) } // 参数1: 整型 (int) var count args[1].toInt32(); console.log(参数1 (整型): count); // 其他类型: .toUInt32(), .toInt64(), .toDouble() // 参数2: 结构体指针 (MyStruct*) // 假设MyStruct { int id; float value; } if (!args[2].isNull()) { var structPtr args[2]; var structId structPtr.add(0).readInt(); // 读取偏移0处的int var structValue structPtr.add(4).readFloat(); // 读取偏移4处的float console.log(参数2 (结构体): id${structId}, value${structValue}); } // 参数3: 整型数组指针 (int*) if (!args[3].isNull() count 0) { var arrayPtr args[3]; console.log(参数3 (数组内容): ); for (var i 0; i Math.min(count, 5); i) { // 只打印前5个 var element arrayPtr.add(i * 4).readInt(); // int通常4字节 console.log( [${i}] ${element}); } } // 保存参数供onLeave使用 this.savedCount count; this.savedArrayPtr args[3]; }, onLeave: function(retval) { // 修改返回值 // 假设函数返回int我们想把它改成100 // retval.replace(ptr(100)); // 修改输出参数数组内容 if (this.savedArrayPtr this.savedCount 0) { console.log([*] 尝试修改输出数组的第一个元素为999); this.savedArrayPtr.writeInt(999); // 直接修改内存 } } });4.2 主动调用NativeFunction与RPC除了被动监听Frida还允许我们主动调用Native函数这在进行漏洞验证、算法测试时非常有用。这需要我们知道函数的完整原型。Java.perform(function () { // 找到函数地址假设是导出函数 var encryptFuncAddr Module.getExportByName(libcrypto.so, AES_encrypt); // 定义函数原型void AES_encrypt(const unsigned char *in, unsigned char *out, const AES_KEY *key); // 对应 NativeFunction 参数: (返回类型, [参数类型列表], 调用约定) var encryptFunc new NativeFunction(encryptFuncAddr, void, [pointer, pointer, pointer]); // 准备参数分配内存并写入数据 var input Memory.alloc(16); // 分配16字节 Memory.writeByteArray(input, [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); var output Memory.alloc(16); // 分配输出缓冲区 // 这里简化了AES_KEY结构体的构建实际需要根据库定义来 var key Memory.alloc(176); // 假设AES-128 key schedule大小 // ... 初始化key内存 ... console.log([*] 准备主动调用 AES_encrypt...); // 主动调用函数 encryptFunc(input, output, key); // 读取加密结果 var encryptedData output.readByteArray(16); console.log(加密结果: hexdump(encryptedData, { offset: 0, length: 16, header: false, ansi: false })); });更进一步我们可以通过Frida的**RPC远程过程调用**机制将JavaScript函数暴露给外部的Python控制器调用实现动态交互。JavaScript端 (rpc_script.js):Java.perform(function () { // 定义RPC方法 rpc.exports { // 读取指定地址的内存 readMemory: function (address, size) { return Memory.readByteArray(ptr(address), size); }, // 调用一个简单的加法函数假设已知地址 addNumbers: function (a, b) { var addFuncAddr ptr(0x12345678); // 假设的函数地址 var addFunc new NativeFunction(addFuncAddr, int, [int, int]); return addFunc(a, b); }, // 扫描内存中的字符串 scanForString: function (moduleName, searchString) { var lib Process.getModuleByName(moduleName); var results []; if (lib) { Memory.scan(lib.base, lib.size, { onMatch: function(address, size){ results.push(address.toString()); }, onComplete: function(){ // 扫描完成 } }); } return results; } }; });Python控制端 (controller.py):import frida import codecs def on_message(message, data): if message[type] send: print(f[MSG] {message[payload]}) elif message[type] error: print(f[ERR] {message[stack]}) # 连接并附加进程 device frida.get_usb_device() session device.attach(com.example.targetapp) with codecs.open(rpc_script.js, r, utf-8) as f: js_code f.read() script session.create_script(js_code) script.on(message, on_message) script.load() # 现在可以调用JS中暴露的RPC方法了 api script.exports # 调用readMemory print([*] 调用 readMemory...) data api.read_memory(0x4000, 100) # 注意Python端方法名是snake_case print(f读取到 {len(data)} 字节数据) # 调用addNumbers result api.add_numbers(5, 3) print(f5 3 {result}) # 保持连接 input(按回车键退出...)这种模式极大地扩展了Frida的灵活性你可以用Python编写复杂的测试逻辑实时控制注入的脚本实现自动化分析。