ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Frida入门到实战:hook so层与Java层的姿势总结

Frida入门到实战:hook so层与Java层的姿势总结 Frida 是一款跨平台的动态二进制插桩工具凭借 JavaScript 脚本化、轻量灵活、全平台覆盖的特性成为移动端逆向分析、安全测试领域的标配工具。无论是 Java 层的业务逻辑 Hook还是 so 原生层的算法分析Frida 都能提供统一的操作范式。本文将从基础环境搭建出发系统总结 Java 层与 so 层的常用 Hook 姿势结合实战案例梳理完整的分析链路帮助读者快速掌握 Frida 的核心用法。一、Frida 基础入门1.1 什么是 FridaFrida 本质是一个注入式调试框架它向目标进程注入 frida-gum 引擎允许开发者通过 JavaScript 脚本直接操作进程内存、Hook 函数、调用原生方法。它支持 Android、iOS、Linux、Windows、macOS 等主流平台尤其在 Android 逆向场景中Frida 可以无缝工作在 Java 虚拟机层与 Native 原生层是分析 APP 逻辑、破解验证、逆向算法的核心工具。1.2 环境搭建电脑端安装 Python 环境后执行pip install frida frida-tools即可安装客户端工具与依赖库。手机端下载与手机架构arm64/armv7、电脑端版本完全一致的 frida-server推送到手机/data/local/tmp目录赋予执行权限并后台运行。环境验证手机通过 USB 连接电脑并开启调试执行frida-ps -U可列出手机端进程即为环境搭建成功。1.3 核心概念与基础脚本结构Frida 有两种核心运行模式对应不同分析场景Attach 模式附加到已经运行的进程适合调试后台运行的应用、页面交互逻辑无需重启 APP。Spawn 模式应用启动时直接注入能 Hook 应用初始化、Application 生命周期、so 加载阶段的逻辑适合分析启动流程。基础脚本的通用结构如下// Java 层操作必须包裹在 Java.perform 中确保在 Java 线程执行 Java.perform(function () { // Java 层 Hook 逻辑写在这里 }); // Native 层操作可直接全局执行 Interceptor.attach(目标函数地址, { onEnter: function(args) { // 函数进入时触发用于读取参数 }, onLeave: function(retval) { // 函数返回时触发用于读取/修改返回值 } });运行脚本的基础命令# Attach 模式附加到运行中的进程 frida -U 应用包名 -l script.js # Spawn 模式冷启动应用并注入脚本 frida -U -f 应用包名 -l script.js二、Java 层 Hook 常用姿势Java 层 Hook 是 Frida 最常用的场景核心围绕Java.use()获取类引用通过重写方法实现拦截。2.1 基础方法 Hook最常规的场景是拦截指定类的普通方法读取参数、修改返回值或直接替换逻辑。示例Hook 登录校验方法打印输入密码与返回结果Java.perform(function () { // 通过完整类名获取目标类引用 var LoginManager Java.use(com.example.app.LoginManager); // 重写方法实现 LoginManager.verifyPassword.implementation function (inputPwd) { // 打印入参 console.log([Java Hook] 输入密码 inputPwd); // 调用原方法保留原有逻辑 var result this.verifyPassword(inputPwd); console.log([Java Hook] 校验结果 result); // 也可直接返回 true 绕过校验 // return true; return result; }; });2.2 构造方法与重载方法构造方法 Hook使用$init代表类的构造函数用于监听对象创建过程。重载方法 Hook同一个方法名存在多个参数版本时必须用overload()精确指定参数类型。示例Hook 重载方法与构造函数var UserInfo Java.use(com.example.app.UserInfo); // Hook 带两个参数的构造方法 UserInfo.$init.overload(java.lang.String, int).implementation function (name, age) { console.log([构造] 创建用户 name 年龄 age); this.$init(name, age); // 必须调用原构造否则对象无法初始化 }; // Hook 重载的 getSign 方法String int 参数版本 UserInfo.getSign.overload(java.lang.String, int).implementation function (data, type) { console.log([重载] data data type type); return this.getSign(data, type); };2.3 字段读写与内部类 Hook修改成员字段通过.value读写对象的成员变量静态字段可直接通过类访问。内部类 HookJava 内部类在虚拟机中以外部类$内部类的形式命名按此规则引用即可。示例修改字段与 Hook 匿名内部类var MainActivity Java.use(com.example.app.MainActivity); // Hook 按钮点击的匿名内部类 var ClickListener Java.use(com.example.app.MainActivity$1); ClickListener.onClick.implementation function (v) { console.log([内部类] 登录按钮被点击); this.onClick(v); }; // 修改静态字段强制开启调试模式 MainActivity.IS_DEBUG.value true; console.log([字段] 调试模式状态 MainActivity.IS_DEBUG.value);2.4 主动调用与批量 HookFrida 不仅能被动拦截还可以主动调用类的静态 / 实例方法批量 Hook 则适合快速定位目标函数。示例主动调用静态加密方法Java.perform(function () { var SignUtils Java.use(com.example.app.utils.SignUtils); // 主动调用 md5 加密方法 var sign SignUtils.md5Encrypt(test123456); console.log([主动调用] 加密结果 sign); });批量 Hook 目标类的所有方法用于快速定位关键逻辑var targetClass Java.use(com.example.app.utils.SecurityUtils); var methods targetClass.class.getDeclaredMethods(); methods.forEach(function (method) { var methodName method.getName(); targetClass[methodName].implementation function () { console.log([批量Hook] 调用方法 methodName); return this[methodName].apply(this, arguments); }; });三、Nativeso 层Hook 核心姿势so 层 Hook 针对 Native 代码核心依赖Interceptor拦截模块与Module内存定位模块。3.1 Native 层 Hook 的两种思路so 层 Hook 主要覆盖两类场景Java Native 方法Java 代码中声明为native的方法既可以从 Java 层 Hook也可以从 so 层 Hook。纯 Native 函数so 内部的普通函数导出或未导出比如系统 libc 的strcmp、自定义 so 的内部算法函数只能通过 Native 层 Hook。3.2 导出函数 Hook导出函数是 so 对外暴露的接口可以直接通过Module.findExportByName()定位内存地址。示例 1Hook Java Native 方法so 层视角// 确保目标 so 已加载完成 Module.ensureInitialized(libsecurity.so); // 按 JNI 命名规则查找导出函数 var nativeSign Module.findExportByName(libsecurity.so, Java_com_example_app_utils_SignUtils_nativeSign); Interceptor.attach(nativeSign, { onEnter: function (args) { // JNI 函数前两个参数固定为 JNIEnv* 和 jobject // args[2] 才对应 Java 层传入的第一个参数 var input Memory.readCString(args[2]); console.log([so Hook] 签名输入 input); }, onLeave: function (retval) { var result Memory.readCString(retval); console.log([so Hook] 签名结果 result); } });示例 2Hook 系统 libc 的 strcmp 函数// 常用于绕过字符串比较校验 var strcmp Module.findExportByName(libc.so, strcmp); Interceptor.attach(strcmp, { onEnter: function (args) { var str1 Memory.readCString(args[0]); var str2 Memory.readCString(args[1]); console.log([strcmp] 比较 str1 vs str2); }, onLeave: function (retval) { // 修改返回值为 0强制判定字符串相等 retval.replace(0); } });3.3 未导出函数 Hook基址 偏移核心算法函数通常不会导出需要先通过 IDA Pro 等静态分析工具得到函数在 so 文件中的偏移再加上内存加载基址计算出运行时地址。示例通过基址 偏移 Hook 未导出算法函数// 获取 so 在内存中的加载基址 var baseAddr Module.findBaseAddress(libnative.so); if (!baseAddr) { console.log([-] libnative.so 尚未加载); return; } // IDA 中分析得到的函数文件偏移示例值 var funcOffset 0x3F2C; // 计算运行时的函数地址 var targetFunc baseAddr.add(funcOffset); Interceptor.attach(targetFunc, { onEnter: function (args) { console.log([未导出函数] 进入目标函数参数1 args[0]); this.arg1 args[0]; // 保存上下文onLeave 中使用 }, onLeave: function (retval) { console.log([未导出函数] 返回值 retval.toInt32()); } });3.4 内存操作与参数解析Native 层参数多为指针类型需要通过Memory模块读写内存内容常用 API 包括Memory.readCString(addr)读取 C 风格字符串Memory.readInt(addr)/Memory.readLong(addr)读取整型数值Memory.writeInt(addr, value)向指定内存写入数值hexdump(addr, {length: len})以十六进制格式打印内存示例解析结构体类型参数Interceptor.attach(targetFunc, { onEnter: function (args) { // args[1] 为结构体指针偏移 0x8 处是 int 类型的长度字段 var dataPtr Memory.readPointer(args[1]); var dataLen Memory.readInt(args[1].add(8)); var data Memory.readByteArray(dataPtr, dataLen); console.log([结构体] 数据长度 dataLen); console.log(hexdump(data, { length: dataLen })); } });四、实战案例从 Java 到 so 的全链路 Hook4.1 案例一Java 层登录明文抓取场景某 APP 登录密码在 Java 层做了加密后传输需要获取用户输入的原始明文密码。分析思路反编译 APK 定位登录按钮点击事件追踪到密码处理类LoginHelper的encryptPassword方法Hook 该方法的入参即可得到明文。完整脚本Java.perform(function () { var LoginHelper Java.use(com.example.app.login.LoginHelper); LoginHelper.encryptPassword.overload(java.lang.String).implementation function (pwd) { console.log( 登录明文抓取 ); console.log(原始明文密码 pwd); var encrypted this.encryptPassword(pwd); console.log(加密后密码 encrypted); console.log(); return encrypted; }; });执行命令frida -U -f com.example.app -l login_hook.js启动 APP 输入密码后控制台即可输出原始明文。4.2 案例二so 层加密函数 Hook 与参数还原场景某 APP 的核心签名算法放在 so 层Java 层调用nativeGetSign(String data)返回签名字符串需要深入分析算法的输入输出。分析思路先通过 Java 层 Hook 确认 native 方法的调用时机与入参用 IDA 分析libsign.so从导出的 JNI 函数向下追踪找到内部核心加密函数未导出偏移 0x256CHook 核心函数打印输入缓冲区与输出结果。完整脚本// 1. Java 层确认 native 方法调用时机 Java.perform(function () { var SignUtils Java.use(com.example.app.utils.SignUtils); SignUtils.nativeGetSign.implementation function (data) { console.log([Java] 调用 nativeGetSign入参 data); var result this.nativeGetSign(data); console.log([Java] 返回结果 result); return result; }; }); // 2. so 层监听 so 加载并 Hook 核心函数 setImmediate(function () { var base Module.findBaseAddress(libsign.so); if (!base) { // so 未加载时Hook dlopen 监听加载事件 var dlopen Module.findExportByName(null, dlopen); Interceptor.attach(dlopen, { onEnter: function (args) { this.soPath Memory.readCString(args[0]); }, onLeave: function () { if (this.soPath this.soPath.indexOf(libsign.so) ! -1) { console.log([so] libsign.so 加载完成开始Hook核心函数); hookCoreFunc(); } } }); } else { hookCoreFunc(); } }); function hookCoreFunc() { var baseAddr Module.findBaseAddress(libsign.so); var coreFunc baseAddr.add(0x256C); // IDA 分析得到的函数偏移 Interceptor.attach(coreFunc, { onEnter: function (args) { this.inputBuf args[0]; this.inputLen args[1].toInt32(); console.log([so核心] 输入数据长度 this.inputLen); console.log(hexdump(this.inputBuf, { length: this.inputLen })); }, onLeave: function (retval) { console.log([so核心] 输出签名 Memory.readCString(retval)); } }); }五、进阶技巧与避坑指南5.1 Spawn 与 Attach 模式选型Spawn 模式适合分析应用启动逻辑、Application 初始化、so 加载阶段的校验缺点是会重启应用。Attach 模式适合分析页面交互、后台任务无需重启 APP缺点是无法拦截启动阶段已经执行过的代码。5.2 常见时序问题多 ClassLoader 问题插件化、热修复 APP 存在多个类加载器直接Java.use可能找不到类。需要遍历 ClassLoader 定位目标类所在的加载器。so 加载时机问题so 是动态加载的脚本执行时 so 可能尚未加载。标准解决方案是 Hookdlopen/android_dlopen_ext监听加载事件加载完成后再执行 Hook。5.3 反检测与对抗技巧很多 APP 会检测 Frida 特征常用对抗手段基础隐藏重命名 frida-server 二进制文件修改默认监听端口使用非默认端口连接。特征屏蔽Hookstrcmp、strstr等函数拦截对 frida、gum-js 等特征字符串的检测。注入方式对抗使用 frida-gadget 代替 frida-server通过重打包 APK 注入 so绕过 ptrace 检测。5.4 高频坑点汇总JNI 参数偏移Native 函数前两个参数是 JNIEnv* 和 jobjectJava 层第一个参数对应 Native 层的 args [2]。位数差异64 位系统指针长度为 8 字节32 位为 4 字节计算结构体偏移时必须区分。Java 对象打印直接打印 Java 对象会显示[object Object]需要调用toString()或手动拼接字段。线程安全Java.perform必须在有 Java 环境的线程执行Native 线程中调用需使用Java.scheduleOnMainThread。六、总结Frida 的核心价值在于打通了 Java 层与 Native 层的分析壁垒Java 层 Hook 适合快速定位业务逻辑、锁定关键函数入口so 层 Hook 则深入底层用于还原加密算法、绕过 native 校验。实际逆向分析中通常遵循 “先 Java 后 so先导出后未导出” 的思路从上层业务逻辑逐步追踪到底层算法结合静态分析与动态调试形成完整的分析链路。掌握本文的基础姿势与实战技巧足以应对绝大多数 APP 的逆向分析场景。在此基础上还可以进一步学习 Frida RPC、Stalker 代码追踪、内存漫游等进阶功能向更深度的逆向分析方向进阶。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进