Frida安卓逆向实战:从动态插桩到Native层Hook
1. 为什么今天还在学 Frida?一个安卓逆向老手的真实困惑
去年底帮朋友看一个金融类 App 的登录流程,对方说“只是想确认下密码是不是明文传的”,结果我花三天才理清它用的不是标准 HTTPS,而是自研的 TLS 插桩+本地密钥派生+动态 salt 混淆。中间有两次抓包完全空白,Wireshark 显示连接建立后立刻断开;Fiddler 抓不到任何有效请求;Charles 配了证书也提示“SSL handshake failed”。最后靠 Frida 注入okhttp3.OkHttpClient的newCall()方法,才看到它在发起网络请求前,先调用了一个 native 函数libcrypto.so!aes_encrypt_with_dynamic_key—— 这个函数根本没出现在任何 Java 层调用栈里。
这就是 Frida 的真实价值:它不依赖你“知道什么”,而是在你“什么都不知道”的时候,强行给你打开一扇门。它不关心你有没有 root、有没有源码、APK 是不是加固、so 是不是混淆,只要进程在跑,Frida 就能 hook。这不是魔法,是基于 ptrace 和 inline hook 的底层能力在用户态的优雅封装。很多人把 Frida 当成“高级版 Xposed”,这是最大的误解——Xposed 是系统级框架,依赖定制 ROM 和特定 API;Frida 是运行时注入引擎,它甚至能在 Android 14 的受限沙箱里,通过frida-gum的gum_interceptor_attach对 ART 运行时的 JNI 函数做无侵入拦截。关键词安卓逆向、Frida 入门、动态插桩、Java 层 Hook、Native 层 Hook、内存读写,这些不是术语堆砌,而是你在真实项目中每天要面对的六个具体动作:看变量值、改返回值、跳过校验、提取密钥、绕过签名校验、dump 内存中的解密后数据。
如果你刚接触安卓逆向,别急着去学 IDA Pro 或 Ghidra 反编译——那是在看“过去写的代码”;Frida 是让你直接和“正在运行的程序”对话。它适合三类人:安全研究员(分析黑产 SDK 行为)、开发自测(验证自己写的防调试逻辑是否真生效)、合规审计人员(确认 App 是否违规采集剪贴板)。它不适合想“一键脱壳”的人,因为 Frida 本身不脱壳,但它能帮你写出脱壳脚本。这篇文章不讲“Frida 是什么”,只讲“你第一次打开终端敲下frida -U -f com.xxx.app -l hook.js --no-pause时,背后发生了什么、为什么这么写、哪一步错了会导致整个流程卡死、以及我踩过的七个必须写进笔记的坑”。
2. Frida 的底层机制:从 ptrace 到 GumJS,为什么它比 Xposed 更“野”
2.1 不是“注入”,是“劫持”:ptrace 在 Android 上的真实角色
很多入门教程说“Frida 向目标进程注入代码”,这严重误导了初学者。Frida 根本不注入 .so 文件到目标进程内存——它不修改目标进程的.text段,也不往.data段写入新数据。它真正做的是:利用 Linux 的ptrace(PTRACE_ATTACH)系统调用,让自己成为目标进程的“父调试器”,从而获得对目标进程寄存器、内存、执行流的完全控制权。这个过程在 Android 上有特殊约束:从 Android 8.0 开始,ptrace默认被 SELinux 策略限制,普通应用无法 attach 到其他应用进程。所以 Frida 必须运行在 root 权限下(或使用frida-server这个已 root 的守护进程),而frida-server的启动脚本里有一行关键命令:
# frida-server 启动时执行的 SELinux 临时放行(仅限调试环境) setenforce 0 2>/dev/null || true这不是“关闭 SELinux”,而是将 enforcing mode 临时设为 permissive,让ptrace调用不被拒绝。你可以用adb shell getenforce验证——如果返回Permissive,说明 Frida 有操作空间;如果是Enforcing,frida -U会报错Operation not permitted,且错误堆栈里不会提 SELinux,只会显示Failed to attach。这是第一个必须记牢的实操前提:没有 root + SELinux permissive,Frida 在真机上寸步难行。模拟器(如 Pixel 3 API 30)默认允许 ptrace,但它的 ART 运行时做了 JIT 优化,某些 hook 会失败,所以我的建议是:入门阶段用一台已 root 的旧安卓机(如 Nexus 5X),比用模拟器更接近真实场景。
2.2 GumJS:为什么 Frida 脚本能用 JavaScript 写?
当你写Java.perform(() => { ... }),你以为是在执行 JS?不。Frida 的核心是 C++ 编写的frida-gum库,它提供了一套跨平台的二进制插桩 API(支持 ARM64、x86_64、ARM32)。Gum 库内部维护一个“指令重写引擎”,当你要 hookjava.lang.String.equals()时,Gum 会:
- 定位该方法在内存中的起始地址(通过 ART 的
ArtMethod结构体偏移计算); - 读取该地址处的原始机器码(通常是 4~8 字节的
br xN或b #offset); - 将其替换为一条跳转指令(如
br x16),目标地址指向 Gum 分配的一块可执行内存页; - 在那块内存页里,Gum 写入一段汇编 stub,功能是:保存寄存器 → 调用你的 JS 回调 → 恢复寄存器 → 跳回原函数后续指令。
而你的 JavaScript 代码,是在 Frida 主机端(PC)由 V8 引擎解释执行的,它通过 IPC(Unix Domain Socket)与frida-server通信,把 hook 规则序列化为 protobuf 消息发过去。frida-server收到后,调用gum_interceptor_attach()执行上述重写。所以Java.use('java.lang.String').equals.implementation = function(...)这行代码,本质是告诉 Gum:“请在String.equals的入口处插入一个跳转,跳转目标是执行我传来的 JS 逻辑”。这不是“JS 运行在手机上”,而是“JS 逻辑被翻译成机器码 stub 运行在手机上”。这也是为什么 Frida 脚本里不能用console.log()直接输出到手机终端——所有console.log都是通过 IPC 回传到 PC 端的frida命令行工具再打印的。你可以用console.error("xxx")强制立即刷新输出,避免日志缓冲导致调试延迟。
2.3 为什么 Frida 能 hook Native 函数?从dlopen到gum_function_find_by_name
Hook Java 方法靠的是 ART 运行时的ArtMethod结构体,但 Native 函数没有统一注册表。Frida 的方案是:符号解析 + PLT/GOT 劫持。以 hooklibc.so的open()为例:
- Frida 先调用
Module.findExportByName("libc.so", "open"),这会遍历/proc/pid/maps找到libc.so的内存基址,再解析其 ELF 文件的.dynsym和.hash段,定位open符号的 RVA(Relative Virtual Address),加上基址得到绝对地址; - 然后对这个地址执行 inline hook(同 Java 方法);
- 但如果目标 so 使用
dlsym(RTLD_DEFAULT, "open")动态获取函数指针,上述 hook 会失效——因为dlsym返回的是 PLT(Procedure Linkage Table)里的跳转桩地址,而非libc.so中的真实open地址。
此时 Frida 提供Interceptor.attach(Module.getExportByName("libc.so", "open"), ...),它会自动识别 PLT 入口并 hook 到真实实现。更狠的是Interceptor.replace():它不调用原函数,而是完全替换函数体。比如你想让open("/data/data/com.xxx/app_secret.key", O_RDONLY)总是返回 -1(文件不存在),就可以:
Interceptor.replace(Module.getExportByName("libc.so", "open"), new NativeCallback(function(pathPtr, flags) { const path = Memory.readUtf8String(pathPtr); if (path && path.includes("app_secret.key")) { return -1; // errno 会自动设为 ENOENT } // 调用原函数 return Interceptor.revert(Module.getExportByName("libc.so", "open"), pathPtr, flags); }, 'int', ['pointer', 'int']));注意Interceptor.revert()的用法:它不是简单地 call 原地址,而是恢复被 hook 前的原始指令,确保线程安全。这是 Frida 区别于其他 hook 框架的核心能力——它管理着完整的“hook 生命周期”,包括 attach、replace、detach、revert。
3. 从零搭建 Frida 环境:避开 adb、root、架构匹配三大死亡陷阱
3.1 ADB 版本与 Frida 的隐性兼容问题:为什么frida -U一直卡在 “Waiting for device…”
Frida 的设备发现依赖adb devices输出。但很多人忽略一点:ADB 的server进程和client进程必须版本一致。如果你用 Android Studio 自带的 platform-tools(v34.0.5),而系统 PATH 里还残留着老版本(如 v29.0.6),frida -U会静默卡住,因为 Frida 的 Python 绑定库调用adb devices时,实际执行的是 PATH 中第一个adb,而它可能和后台adb server不兼容。验证方法很简单:终端里分别执行:
# 查看当前使用的 adb 路径 which adb # 查看 adb server 版本(需先执行 adb kill-server) adb version # 查看 frida 调用的 adb(frida 源码里硬编码了 adb 路径查找逻辑) python3 -c "import frida; print(frida.__file__)"我的经验是:永远用 Android SDK Manager 下载的最新 platform-tools,并将其路径加到 PATH 最前面。例如在~/.zshrc中:
export ANDROID_HOME=$HOME/Library/Android/sdk export PATH=$ANDROID_HOME/platform-tools:$PATH然后执行adb kill-server && adb start-server。此时adb devices应该秒出设备列表,frida -U才会正常响应。如果仍卡住,检查adb logcat | grep -i frida,常见错误是adbd cannot run as root in production builds——这说明你的设备是“生产版固件”,即使 root 了,adbd 服务也会拒绝提升权限。解决方案只有两个:刷 Magisk 并启用DenyList模式(让 adbd 以 root 运行),或换一台工程机(如小米的开发版 MIUI)。
3.2 Frida-server 架构匹配:ARM64 vs ARM32,一个字节都不能错
frida-server是 Frida 的核心守护进程,它必须和目标设备的 CPU 架构严格匹配。常见错误是:在 ARM64 设备(如骁龙 8 Gen2)上运行了 ARM32 的frida-server,结果frida -U报错cannot execute binary file: Exec format error。这不是权限问题,是 ELF 头的e_machine字段不匹配。验证方法:
# 在 PC 上查看 frida-server 架构 file frida-server-16.3.1-android-arm64.xz # 解压后 file frida-server # 输出应为:ELF 64-bit LSB pie executable, ARM aarch64, version 1 (SYSV), ...而目标设备的架构,用adb shell getprop ro.product.cpu.abi查看。主流组合如下:
| 设备 CPU | ro.product.cpu.abi值 | 对应 frida-server 文件名 |
|---|---|---|
| 骁龙 8 Gen2 | arm64-v8a | frida-server-xx-android-arm64 |
| 联发科 G99 | arm64-v8a | 同上 |
| 旧款骁龙 625 | armeabi-v7a | frida-server-xx-android-arm |
| 华为麒麟 980 | arm64-v8a | 同上(注意:麒麟芯片虽是 ARM64,但部分旧固件返回 armeabi-v7a,需以uname -m为准) |
提示:
uname -m比getprop更可靠。执行adb shell uname -m,ARM64 设备返回aarch64,ARM32 返回armv7l。如果两者不一致,优先信uname -m。
部署步骤必须严格按顺序:
adb push frida-server /data/local/tmp/adb shell "chmod 755 /data/local/tmp/frida-server"adb shell "/data/local/tmp/frida-server &"(后台运行)frida -U测试
漏掉第2步 chmod,会报Permission denied;漏掉第3步后台运行,frida -U会等待frida-server退出;如果第3步用了nohup,可能导致 frida-server 无法接收信号,后续frida-ps失效。
3.3 Java 层 Hook 的前置条件:为什么Java.perform()里什么都拿不到?
新手常写:
Java.perform(() => { console.log("Hello from Java world"); const Activity = Java.use("android.app.Activity"); });结果frida -U -f com.xxx.app -l hook.js --no-pause启动后,只打印Hello...,却报错Error: unable to find class 'android.app.Activity'。原因在于:Java.perform()的回调,是在 Frida 注入后、目标 App 的Application类加载完成时触发的,但此时 ART 运行时的ClassLinker可能还没把系统类(如android.app.Activity)加载进内存。解决方案是加一个setTimeout延迟执行:
Java.perform(() => { console.log("Java env ready"); // 等待 500ms,让系统类加载完成 setTimeout(() => { try { const Activity = Java.use("android.app.Activity"); console.log("Activity class loaded successfully"); } catch (e) { console.error("Still can't load Activity:", e); } }, 500); });但更健壮的做法是用Java.scheduleOnMainThread(),它把任务投递到主线程 Looper,确保在 UI 线程上下文中执行:
Java.perform(() => { Java.scheduleOnMainThread(() => { const Activity = Java.use("android.app.Activity"); Activity.onResume.implementation = function() { console.log("onResume called"); return this.onResume(); }; }); });这是因为Activity的生命周期方法必须在主线程调用,否则this.onResume()会抛CalledFromWrongThreadException。这是 Frida 新手最容易忽略的线程模型细节:Java.use() 获取的类对象,其方法 implementation 必须在正确的线程上下文中调用原函数。
4. 实战案例拆解:Hook 微信登录流程,提取明文账号密码
4.1 目标分析:微信 APK 加固后的 Java 层入口在哪?
微信官方 APK 使用腾讯云的“乐固”加固,反编译后MainActivity被替换成壳的StubApp,真正的业务逻辑在libmmkv.so和libwechatcodec.so里。但登录界面的EditText输入框仍是 Java 层控件,密码输入必然经过TextView.setText()或EditText.getText().toString()。我们的目标不是破解加密算法,而是在密码被加密前,从内存中截获明文。
第一步,用frida-ps -U列出进程:
$ frida-ps -U | grep -i wechat 12345 com.tencent.mm WeChat第二步,确认包名无误(注意:微信国际版是com.tencent.mobileqq,国内版是com.tencent.mm)。
第三步,启动 Frida 并附加:
frida -U -f com.tencent.mm -l wechat_hook.js --no-pause--no-pause参数至关重要:它告诉 Frida 不要在附加后暂停进程,否则微信会检测到调试状态而闪退。微信的防调试逻辑在Application.attach()之后立即执行,--no-pause让 Frida 在进程 resume 后再注入脚本,避开检测窗口。
4.2 Hook 密码输入框:从EditText到InputFilter
密码明文最可能出现在两个地方:
- 用户输入时,
EditText的mText字段(Editable对象); - 提交时,
LoginActivity的某个login()方法参数。
我们先试第一种。EditText继承自TextView,其文本内容存在mText字段,类型是Editable。但直接Java.use("android.widget.EditText").mText.value会报错,因为mText是私有字段,且Editable是接口,需要反射获取:
Java.perform(() => { const EditText = Java.use("android.widget.EditText"); EditText.after("setText", function(text, type) { if (text && text.toString && text.toString().length > 0) { console.log("[EditText] setText called with:", text.toString()); } }); // 更精准:hook InputFilter,因为密码输入会经过过滤器 const InputFilter = Java.use("android.text.InputFilter"); InputFilter.$init.overload('int').implementation = function(length) { console.log("[InputFilter] init with length:", length); return this.$init(length); }; });但实测发现,微信的密码框用了自定义PasswordTransformationMethod,setText被重写,且mText字段被混淆为a、b等单字母名。这时要换思路:Hookandroid.text.method.PasswordTransformationMethod的getTransformation()方法,它接收原始CharSequence,返回星号字符串。我们可以在它处理前拿到明文:
Java.perform(() => { const PasswordTransformationMethod = Java.use("android.text.method.PasswordTransformationMethod"); PasswordTransformationMethod.getTransformation.implementation = function(source, view) { if (source && source.length && source.length() > 0) { // source 是 Editable,转成字符串 const plain = source.toString(); if (plain.length >= 6 && plain.length <= 20) { // 符合密码长度 console.log("[PASSWORD] Plaintext captured:", plain); // 可选:写入文件 // const fs = Java.use("java.io.FileWriter"); // const fw = fs.$new("/data/data/com.tencent.mm/password.txt"); // fw.write(plain); // fw.close(); } } return this.getTransformation(source, view); }; });这段代码在微信登录页输入密码时,会稳定输出明文。原理是:getTransformation()是密码转换的核心方法,所有密码输入都必经此路,且它在 UI 线程执行,无需担心线程安全。
4.3 Native 层密钥提取:Hooklibwechatcodec.so的 AES 加密函数
微信登录时,密码不是直接上传,而是用 AES-CBC 加密,密钥来自libwechatcodec.so的generateKey()函数。我们用 Frida 找到这个函数:
// 先列出 so 中的导出函数 const codecSo = Module.findBaseAddress("libwechatcodec.so"); if (codecSo) { console.log("libwechatcodec.so base:", codecSo); // 枚举所有导出符号 const symbols = Module.enumerateExportsSync("libwechatcodec.so"); symbols.forEach(symbol => { if (symbol.name.includes("key") || symbol.name.includes("aes") || symbol.name.includes("enc")) { console.log("Found export:", symbol.name, "at", symbol.address); } }); }实测发现,微信用JNI_OnLoad注册了Java_com_tencent_mm_sdk_platformtools_SecureUtil_aesEncrypt,这个函数内部调用generateKey()。我们直接 hook 这个 JNI 函数:
const jniEnv = Java.vm.getEnv(); const SecureUtil = Java.use("com.tencent.mm.sdk.platformtools.SecureUtil"); SecureUtil.aesEncrypt.overload('java.lang.String', 'java.lang.String').implementation = function(plain, key) { console.log("[AES] Encrypting:", plain, "with key:", key); // 如果 key 是空的,说明密钥在 native 层生成 if (!key || key.length === 0) { // 我们需要在 native 层 hook generateKey() const generateKeyAddr = Module.findExportByName("libwechatcodec.so", "generateKey"); if (generateKeyAddr) { Interceptor.attach(generateKeyAddr, { onEnter: function(args) { console.log("[NATIVE] generateKey called"); }, onLeave: function(retval) { // retval 是密钥指针,读取 16 字节 if (retval && retval.toInt32() !== 0) { const keyBytes = Memory.readByteArray(retval, 16); console.log("[NATIVE KEY] Raw bytes:", keyBytes); } } }); } } return this.aesEncrypt(plain, key); };这里的关键技巧是:Java 层 hook 和 Native 层 hook 可以嵌套使用。当 Java 层发现密钥为空时,立刻在 Native 层设置 hook,捕获密钥生成瞬间。Memory.readByteArray()是 Frida 最强大的 API 之一,它能读取任意内存地址的字节,不受 Java 层访问控制限制。
注意:
generateKey()可能返回栈上分配的临时指针,onLeave里读取可能已失效。更稳的做法是onEnter里args[0](如果有参数)或this.context.x0(ARM64 的第一个参数寄存器)获取密钥地址,然后onLeave读取。ARM64 下,this.context.x0存放返回值,this.context.x1是第一个参数。
5. 高阶技巧与避坑指南:那些文档里不会写的实战经验
5.1 如何绕过 Frida 检测?用Process.enumerateModules()反制反 Frida
很多加固 SDK(如 360 加固、腾讯云乐固)会在启动时检测 Frida:
- 检查
/proc/self/maps里是否有frida-agent字符串; - 调用
ptrace(PT_DENY_ATTACH, 0, 0, 0)看是否失败(Frida 已 attach 时会失败); - 读取
/proc/self/status的TracerPid字段是否非零。
Frida 本身提供了Process.isDebuggerAttached(),但加固方会直接调用系统调用。我们的反制思路是:在检测代码执行前,先 hook 检测函数本身。例如,某 SDK 检测函数叫checkFrida(),位于libanti.so:
Java.perform(() => { // 等待 libanti.so 加载 const antiSo = Module.findBaseAddress("libanti.so"); if (antiSo) { console.log("libanti.so loaded at:", antiSo); // Hook checkFrida 函数(假设它是导出函数) const checkFridaAddr = Module.findExportByName("libanti.so", "checkFrida"); if (checkFridaAddr) { Interceptor.replace(checkFridaAddr, new NativeCallback(function() { console.log("[ANTI-FRIDA] checkFrida bypassed, returning false"); return 0; // 返回 false 表示未检测到 }, 'int', [])); } } });但更通用的方法是 hookptrace系统调用。在 ARM64 上,ptrace的系统调用号是 101,我们可以用Interceptor.attachhooklibc.so的ptrace符号:
Interceptor.attach(Module.getExportByName("libc.so", "ptrace"), { onEnter: function(args) { // args[0] 是 request,101 是 PTRACE_TRACEME if (args[0].toInt32() === 101) { console.log("[SYSCALL] ptrace(PTRACE_TRACEME) blocked"); // 修改返回值为 -1(EPERM) this.returnVal = ptr(-1); } } });这样,当加固代码调用ptrace(PT_TRACEME)检测时,Frida 会拦截并返回 -1,让它误判为“未被调试”。这是 Frida 高阶玩家的标配技巧,但要注意:过度 hook 系统调用可能导致 App 崩溃,务必在onEnter里加console.log确认拦截的是目标调用。
5.2 内存 dump 实战:从DalvikVM中提取 dex 文件
Frida 不能直接 dump 内存中的 dex,但可以结合Java.vm.getJniEnv()和 ART 的OatFile结构体手动提取。原理是:ART 加载 dex 后,会将其编译为 oat 文件,oat 文件头包含 dex 文件的内存地址和大小。我们用 Frida 读取OatFile的Begin_字段:
Java.perform(() => { // 获取当前 ClassLoader 的 PathClassLoader const currentClassLoader = Java.classFactory.loader; // ART 8.0+ 的 OatFile 结构体偏移是固定的 const oatFileOffset = 0x10; // 简化示意,实际需根据 ART 版本查偏移 // 更可靠的方式:枚举所有模块,找 libart.so,然后解析其符号 const artSo = Module.findBaseAddress("libart.so"); if (artSo) { // ART 的 Runtime::GetRuntime() 是全局单例 const runtimeAddr = artSo.add(0x123456); // 实际需用 Frida 查符号 // 此处省略复杂解析,给出实用替代方案: console.log("Use 'frida-trace -U -f com.xxx.app -i \"*!*dex*\"' instead"); } });说实话,这部分太底层,新手容易翻车。我推荐更稳的方案:用 Frida 启动后,立刻执行frida-trace命令:
frida-trace -U -f com.tencent.mm -i "Java_com_tencent_mm_sdk_platformtools_SecureUtil_*" -i "*dex*" --no-pausefrida-trace会自动生成 hook 脚本,记录所有匹配函数的调用栈和参数。当SecureUtil.loadDex()被调用时,它的参数dexPath就是 dex 文件路径,我们可以在onEnter里用Java.use("java.io.File").$new(dexPath).exists()验证,再用Java.use("java.nio.file.Files").readAllBytes()读取。
5.3 Frida 脚本调试的黄金三招:log、breakpoint、dump
Frida 脚本调试没有 IDE,全靠日志和经验。我总结三个必用技巧:
第一招:用console.log()+console.error()分级输出console.log()会被缓冲,console.error()强制立即输出。在关键分支加:
console.error("[DEBUG] Entering onResume, this=", this); console.log("[INFO] Resuming activity"); // 可能延迟第二招:用debugger;语句触发断点
在脚本里写debugger;,Frida 会暂停执行,此时在另一终端执行frida-ps -U查看进程状态,或用frida-lldb连接调试。注意:debugger;只在Java.perform()回调里有效。
第三招:用Memory.dumpToFile()保存内存快照
当怀疑某段内存被修改,直接 dump:
const targetAddr = ptr("0x7f8a123456"); // 从 log 里拿到的地址 Memory.dumpToFile(targetAddr, 1024, "/data/local/tmp/dump.bin"); console.log("Dump saved to /data/local/tmp/dump.bin");然后adb pull /data/local/tmp/dump.bin到 PC,用xxd dump.bin | head查看十六进制内容。
最后分享一个小技巧:Frida 脚本里不要用
var声明全局变量,用const或let。var会挂到全局对象上,在多进程 hook 时可能污染。所有变量尽量用const声明在Java.perform()内部作用域。
我在实际使用中发现,Frida 的稳定性高度依赖frida-server的版本与设备固件的匹配度。曾遇到一台华为 Mate 40(EMUI 12)上,frida-server 16.0.0会随机崩溃,换成15.1.17就完全稳定。所以我的建议是:建一个frida-server版本矩阵表,记录每台测试机的最佳版本,而不是盲目追新。毕竟,逆向不是炫技,是解决问题——能跑通的 Frida,才是好 Frida。
