
1. 为什么现在做安卓逆向Frida 已经不是“可选项”而是“入场券”我第一次在客户现场调试一个支付 SDK 的异常行为时用的是传统的 smali 修改 apktool 重打包方案。整个流程走下来反编译 → 定位关键方法 → 插入日志 → 修复签名 → 安装测试 → 失败 → 查 logcat → 发现资源 ID 冲突 → 回退重来……光是验证一个isRooted()的返回值就被卡了整整两天。那时候我还在想“要是能不改代码、不重打包直接在运行时把函数调用拦下来、看看参数、甚至改掉返回值就好了。”——结果三个月后我在一个安全团队的内部分享会上看到主讲人用 Frida 三行脚本就把同一个问题秒级定位还顺手绕过了设备检测逻辑。那一刻我才真正意识到Frida 不是又一个调试工具它是安卓逆向工作流的“操作系统层”重构。今天谈“安卓逆向”已经不能只盯着 dex2jar、JADX、smali 这些静态分析老三样了。真实业务场景里90% 以上的加固 App比如某头部金融类 App、某主流短视频 SDK都启用了运行时完整性校验、动态类加载、Native 层校验、甚至 Java 层反射调用链混淆。你静态看到的checkSecurity()方法可能在运行时被替换成从 asset 加载的加密字节数组再通过ClassLoader.defineClass动态注入你反编译出的getDeviceId()实际执行路径可能在 so 库里跳转了四次中间还夹着 ptrace 检测。静态分析只能告诉你“它长什么样”而 Frida 告诉你“它正在做什么、打算做什么、以及你能不能让它不做”。这正是 Frida 成为当前安卓逆向事实标准的核心原因它不依赖源码不修改 APK不触发加固壳的典型检测特征如文件系统写入、dex 文件读取而是通过注入一个轻量级的 JavaScript 引擎到目标进程内存中以“寄生”方式接管 Java/Native 函数调用生命周期。它解决的不是“怎么看到代码”的问题而是“怎么和正在运行的代码对话”的问题。关键词安卓逆向实战、Frida 环境搭建、基础 Hook 技巧背后对应的是三个不可回避的现实需求第一快速验证加固策略的绕过路径第二在无源码、无符号、高混淆环境下精准定位业务逻辑断点第三构建可复用、可脚本化的动态分析流水线替代手工 patch 的低效模式。适合谁看如果你还在用adb shell手动ps | grep查进程、靠logcat -s猜日志 tag、或者每次改一行 smali 就要等五分钟重打包安装那这篇就是为你写的。它不要求你精通 ARM 汇编或 JVM 字节码规范但要求你愿意接受一种新的调试范式把 App 当成一个活的、可交互的系统而不是一份静态的、待解剖的标本。接下来的内容全部基于真实项目踩坑沉淀——没有“理论上可行”只有“我试过、失败过、最终跑通了”的完整链路。2. Frida 环境搭建不是“装个工具”而是构建一套可信的跨设备通信管道很多人卡在第一步frida -U -f com.example.app -l hook.js --no-pause执行后报错Failed to spawn: unable to find process或Permission denied。这不是 Frida 本身的问题而是你搭建的“通信管道”从物理层就断了。Frida 的工作模型非常清晰你的 PCClient通过 USB/网络连接一台 Android 设备Target设备上必须运行一个名为frida-server的守护进程Daemon它负责与目标 App 进程建立内存级注入并将 JS 脚本指令翻译成底层操作。环境搭建的本质是让 Client、Daemon、Target 三者之间形成一条低延迟、高权限、可验证的双向信道。下面拆解每一个环节的真实细节。2.1 设备端frida-server 的选型、部署与权限固化frida-server不是通用二进制它严格绑定 Android 系统架构arm64-v8a / armeabi-v7a、Android 版本API Level、以及 SELinux 策略。官方 GitHub Release 页面提供多个预编译版本但直接下载frida-server-16.3.1-android-arm64.xz解压后 push 到/data/local/tmp/并chmod 755是常见误区。问题出在两点一是 Android 10 默认禁止从/data/local/tmp/执行可执行文件exec权限被 SELinuxneverallow规则拦截二是部分厂商定制 ROM如华为 EMUI、小米 MIUI会主动 kill 掉非系统签名的后台 daemon 进程。实操方案必须分三步走获取匹配的二进制访问 https://github.com/frida/frida/releases找到最新版如16.3.1下载对应架构的frida-server-*.android-*.xz。注意不要用frida-server-*.android-universal.xz它体积大且兼容性反而差也不要试图用 Termux 编译交叉编译链配置极其繁琐且易出错。绕过 SELinux 执行限制adb shell进入设备后先确认 SELinux 状态adb shell getenforce # 返回 Permissive 或 Enforcing若为Enforcing临时切换为Permissive仅调试用重启失效adb shell su -c setenforce 0提示setenforce 0需 root 权限。若设备未 root必须使用 Magisk 模块Frida Server自动处理 SELinux 上下文和开机自启或选择支持userdebug模式的系统镜像如 Pixel 原生 AOSP。部署并守护进程将解压后的frida-serverpush 到/data/local/tmp/后不能直接./frida-server 。因为 Android 的init进程会回收前台 shell 的子进程。正确做法是adb shell su -c cd /data/local/tmp ./frida-server --daemonize --enable-jit--daemonize让其转入后台--enable-jit启用 JIT 编译加速 JS 执行对复杂 Hook 脚本至关重要。验证是否存活adb shell ps -A | grep frida # 应看到类似u0_a123 12345 1 1234567 89012 S /data/local/tmp/frida-server我踩过的最大坑是在一台三星 S22One UI 5.1上frida-server启动后立即被system_server杀死。排查发现是 Samsung Knox 的knox_service主动监控/data/local/tmp/下的可执行文件。解决方案是将frida-server重命名为com.android.systemui伪装成系统服务并修改其 SELinux 上下文adb shell su -c chcon u:object_r:system_file:s0 /data/local/tmp/com.android.systemui这个技巧在 OEM 定制深度的设备上屡试不爽。2.2 客户端Python 环境与 Frida 绑定的稳定性加固PC 端pip install frida-tools看似简单但实际项目中常因 Python 版本、wheel 兼容性、代理设置导致frida命令无法识别设备。根本原因是frida-tools依赖fridaPython 包而后者需要与设备端frida-server版本严格匹配如 server 是 16.3.1client 也必须是 16.3.1。pip install frida默认安装最新版极易 mismatch。可靠方案是强制指定版本并验证通信。以 Python 3.9 为例pip uninstall frida frida-tools -y pip install frida16.3.1 frida-tools11.2.1安装后立刻验证frida-ps -U # 列出所有已连接设备的进程 # 正常应输出PID Name # 1234 com.android.systemui # 5678 com.example.app若报错Failed to enumerate processes: unable to connect to remote frida-server说明 client/server 版本不一致或 daemon 未运行。此时不要重装先检查adb devices是否显示设备确保 USB 调试已开且选择了“文件传输”模式而非“仅充电”adb shell ps | grep frida是否有进程确认 daemon 存活frida --version与adb shell /data/local/tmp/frida-server --version输出是否一致注意Windows 用户若遇到OSError: [WinError 126] 找不到指定的模块大概率是 Visual C Redistributable 缺失需安装vc_redist.x64.exe2015-2022 版本。2.3 网络调试当 USB 连接不可用时的替代方案某些场景如远程协作、CI/CD 自动化分析无法直连 USB。Frida 支持 TCP 调试但需手动配置。步骤如下设备端启动 server 并监听端口如 27042adb shell su -c /data/local/tmp/frida-server --host0.0.0.0:27042 --daemonizePC 端开启端口转发adb forward tcp:27042 tcp:27042使用 IP 地址连接-H参数frida -H 127.0.0.1:27042 -U -f com.example.app -l hook.js此方案规避了 USB 物理限制但安全性较低端口暴露生产环境务必配合防火墙规则。3. Java 层 Hook从“打印参数”到“篡改业务逻辑”的四层穿透Hook Java 方法是 Frida 最常用场景但多数教程止步于Java.use(java.lang.String).$init.implementation function() { console.log(called); }。这只能证明“能 Hook”离“解决实际问题”还差四层穿透识别目标类与方法、处理重载与泛型、捕获异常与返回值、最后才是业务逻辑干预。下面以一个真实案例展开某电商 App 的登录接口LoginManager.login(String phone, String pwd)总是返回“验证码错误”但抓包发现请求体中的pwd字段已被加密。我们需要定位加密逻辑并获取明文密码。3.1 第一层穿透精准定位目标类与方法避免Java.enumerateLoadedClasses的陷阱初学者常写Java.enumerateLoadedClasses({ onMatch: function(className) { if (className.includes(Login)) console.log(className); }, onComplete: function() {} });这会输出数百个类如androidx.appcompat.R$color,com.google.android.material.R$attr淹没真正目标。更高效的方式是结合adb logcat过滤adb logcat | grep -i login\|auth\|security发现日志中有LoginManager: start login for 138****1234说明LoginManager类存在。接着用 Frida 动态枚举其方法Java.perform(function () { var LoginManager Java.use(com.example.app.LoginManager); console.log(LoginManager methods:, Object.getOwnPropertyNames(LoginManager)); });输出中找到login方法。但注意Object.getOwnPropertyNames()只返回 JS 层定义的方法名不包含重载信息。真实方法签名需用Java.use(com.example.app.LoginManager).login.overloads查看。3.2 第二层穿透处理重载方法与参数类型overloads的正确打开方式login方法有多个重载login(String, String)login(String, String, boolean)login(Context, String, String)直接LoginManager.login.implementation会报错TypeError: Cannot set property implementation of [object Object] which has only a getter。必须指定具体 overloadvar targetMethod LoginManager.login.overloads[0]; // 取第一个重载 targetMethod.implementation function(phone, pwd) { console.log([] login called with phone:, phone, pwd:, pwd); return this.login(phone, pwd); // 原逻辑 };关键点overloads是数组索引需根据参数个数和类型确定。overloads[0].toString()可打印完整签名console.log(targetMethod.toString()); // 输出function login(java.lang.String, java.lang.String)3.3 第三层穿透捕获返回值与异常try/catch在 Hook 中的必要性上述脚本只能看到输入但加密逻辑可能在login内部调用EncryptUtil.encrypt(pwd)。我们需在encrypt方法中埋点。但EncryptUtil可能未加载懒加载需用Java.scheduleOnMainThread等待Java.perform(function () { // 等待 EncryptUtil 加载 Java.scheduleOnMainThread(function () { try { var EncryptUtil Java.use(com.example.app.util.EncryptUtil); EncryptUtil.encrypt.overloads[0].implementation function(input) { console.log([*] EncryptUtil.encrypt input:, input); var result this.encrypt(input); console.log([*] EncryptUtil.encrypt output:, result); return result; }; } catch (e) { console.log([-] EncryptUtil not loaded yet:, e.message); } }); });这里try/catch不是防 JS 错误而是防Java.use在类未加载时抛出JavaException。Java.scheduleOnMainThread确保在主线程执行避免ClassNotFoundException。3.4 第四层穿透篡改业务逻辑绕过校验的两种范式回到登录问题我们发现pwd在encrypt前是明文但login返回“验证码错误”。进一步 Hooklogin的返回值targetMethod.implementation function(phone, pwd) { console.log([] login called with phone:, phone, pwd:, pwd); var result this.login(phone, pwd); console.log([] login returned:, result); // 假设 result 是 JSONObject其中 code401 表示验证码错误 if (result ! null result.containsKey result.containsKey(code)) { var code result.get(code); if (code 401) { console.log([!] Bypassing captcha check); // 构造成功响应 var JSONObject Java.use(org.json.JSONObject); var successJson JSONObject.$new(); successJson.put(code, 200); successJson.put(msg, success); successJson.put(token, fake_token_123); return successJson; } } return result; };这是“返回值劫持”范式。另一种是“参数劫持”在login调用前将pwd替换为已知有效密码targetMethod.implementation function(phone, pwd) { console.log([] login called with phone:, phone, pwd:, pwd); // 强制使用测试密码 var testPwd 123456; console.log([!] Forcing password to:, testPwd); return this.login(phone, testPwd); };两种范式适用场景不同返回值劫持适合响应解析逻辑简单、且服务端不校验 token 真实性的场景参数劫持适合需要真实密码参与后续流程如生成签名的场景。4. Native 层 Hook当 Java 层被混淆成“天书”时如何直击 so 库核心很多加固 App 会把关键逻辑下沉到 Native 层.so文件Java 层只留空壳。例如某金融 App 的getRiskScore()方法反编译后只剩return nativeGetRiskScore();而nativeGetRiskScore对应的 so 库经过OLLVM混淆函数名全为sub_12345字符串全加密。此时 Java Hook 失效必须转向 Native Hook。4.1 定位目标 so 与符号Module.findBaseAddress与DebugSymbol.fromAddress首先确认目标 so 是否加载adb shell cat /proc/$(pidof com.example.app)/maps | grep \.so # 输出7f8a123000-7f8a124000 r-xp 00000000 103:02 123456 /data/app/~~xxx/com.example.app-xxx/lib/arm64/libsecurity.so得到基地址0x7f8a123000和 so 名称libsecurity.so。Frida 中获取var libAddr Module.findBaseAddress(libsecurity.so); if (libAddr ! null) { console.log([] libsecurity.so base address:, libAddr); } else { console.log([-] libsecurity.so not found); }但基地址只是入口我们需要具体函数地址。nm -D libsecurity.so在 PC 端查看符号表但混淆后符号为空。此时用Module.enumerateSymbols枚举所有导出符号Module.enumerateSymbols(libsecurity.so, { onMatch: function(symbol) { if (symbol.type function symbol.name.includes(risk)) { console.log([*] Found risk-related symbol:, symbol.name, symbol.address); } }, onComplete: function() {} });若仍无结果则用Module.enumerateExports查看所有导出函数包括未命名的Module.enumerateExports(libsecurity.so, { onMatch: function(exp) { console.log([*] Export:, exp.name, exp.address, exp.type); }, onComplete: function() {} });常见导出函数名如Java_com_example_app_Security_nativeGetRiskScoreJNI 函数名这就是我们要 Hook 的目标。4.2 Hook JNI 函数Interceptor.attach与this.context的妙用JNI 函数参数固定为(JNIEnv*, jclass/jobject, ...)需从寄存器读取。ARM64 架构下JNIEnv*在x0jclass/jobject在x1后续参数依次在x2,x3...。Frida 提供this.context访问 CPU 寄存器var jniEnvAddr null; Interceptor.attach(Module.findExportByName(libsecurity.so, Java_com_example_app_Security_nativeGetRiskScore), { onEnter: function(args) { // args[0] 是 JNIEnv*, args[1] 是 jobject jniEnvAddr args[0]; console.log([] nativeGetRiskScore called); // 读取 JNIEnv 中的 GetStringUTFChars 方法指针用于解密字符串 // JNIEnv 结构体首地址是 vtable偏移 0x3d0 是 GetStringUTFCharsARM64 var envPtr args[0]; var vtablePtr Memory.readPointer(envPtr); var getStringFunc Memory.readPointer(vtablePtr.add(0x3d0)); // 实际偏移需查 JNI spec console.log([*] GetStringUTFChars addr:, getStringFunc); }, onLeave: function(retval) { console.log([] nativeGetRiskScore returned:, retval); } });但手动计算偏移易错。更稳方案是用Java.vm.getEnv()获取当前 JNIEnv并调用其方法Java.perform(function () { var env Java.vm.getEnv(); Interceptor.attach(Module.findExportByName(libsecurity.so, Java_com_example_app_Security_nativeGetRiskScore), { onEnter: function(args) { // 从 args[2] 读取 jstring 参数假设第三个参数是输入字符串 var jstr args[2]; if (jstr ! null) { // 调用 JNIEnv-GetStringUTFChars var strPtr env.getStringUtfChars(jstr, null); var str Memory.readUtf8String(strPtr); console.log([*] Input string:, str); env.releaseStringUtfChars(jstr, strPtr); } } }); });Java.vm.getEnv()是 Frida 14.2 新增 API直接返回当前线程的 JNIEnv无需手动解析寄存器。4.3 绕过 Native 层校验ptrace检测与isDebuggerConnected很多 so 库会检测调试器调用ptrace(PTRACE_TRACEME, ...)检测是否已被 trace读取/proc/self/status查TracerPid调用android.os.Debug.isDebuggerConnected()Frida 本身就是一个 tracer所以这些检测必过。HookptraceInterceptor.attach(Module.findExportByName(null, ptrace), { onEnter: function(args) { if (args[0].toInt32() 0) { // PTRACE_TRACEME console.log([!] ptrace(PTRACE_TRACEME) blocked); this.block true; // 阻止调用 } } });HookisDebuggerConnectedJava.perform(function () { var Debug Java.use(android.os.Debug); Debug.isDebuggerConnected.implementation function() { console.log([!] isDebuggerConnected forced to false); return false; }; });注意ptraceHook 需在 so 加载前执行否则检测代码已运行。用Java.performNow确保最早执行Java.performNow(function () { // 所有 Hook 代码放这里 });5. 实战避坑指南那些文档不会写的“血泪教训”Frida 文档写得极简但真实项目中 80% 的时间花在解决“文档没提”的边缘 case 上。以下是我在 37 个不同加固 App涵盖腾讯乐固、360 加固、梆梆、网易易盾、阿里聚安全逆向中总结的硬核避坑点每一条都来自凌晨三点的崩溃日志。5.1 “Hook 失败但无报错”Java.perform的执行时机陷阱现象脚本中Java.use(xxx).method.implementation ...无任何输出目标方法也未被拦截。原因Java.perform是异步的它在 JVM 初始化完成后才执行但某些 App如启动时就调用System.loadLibrary的会在perform完成前就加载 so 并执行 JNI 函数。解决方案强制同步等待。// 错误可能错过 early load Java.perform(function () { // Hook 代码 }); // 正确确保在 Application.attachBaseContext 之后执行 Java.perform(function () { var ActivityThread Java.use(android.app.ActivityThread); ActivityThread.currentApplication.implementation function () { var app this.currentApplication(); // 此时 Application 已创建所有类已加载 setupHooks(); // 所有 Hook 逻辑放这里 return app; }; });5.2 “so 库加载失败”dlopen的路径与权限黑洞现象Module.findBaseAddress(libxxx.so)返回 null但adb shell cat /proc/pid/maps明确显示该 so 已加载。原因App 使用System.load(/data/data/com.example.app/files/libxxx.so)绝对路径加载Frida 的Module.findBaseAddress只搜索LD_LIBRARY_PATH和APK/lib/下的 so。解决方案Hookdlopen拦截加载路径。Interceptor.attach(Module.findExportByName(null, dlopen), { onEnter: function(args) { var path Memory.readCString(args[0]); if (path path.includes(libxxx.so)) { console.log([] dlopen loading:, path); // 记录路径后续用 Module.load() 加载 } } });5.3 “JS 脚本超时崩溃”setTimeout与Java.perform的冲突现象脚本中setTimeout(function(){ Java.perform(...) }, 1000)导致 Frida server 崩溃。原因setTimeout在 JS 线程执行而Java.perform必须在 Java 主线程。解决方案永远用Java.scheduleOnMainThread替代setTimeout。// 错误 setTimeout(function() { Java.perform(function() { /* ... */ }); }, 1000); // 正确 Java.scheduleOnMainThread(function() { Java.perform(function() { /* ... */ }); });5.4 “内存泄漏导致 App 卡死”Memory.allocUtf8String的引用计数陷阱现象频繁调用Memory.allocUtf8String(test)后App 内存飙升直至 OOM。原因allocUtf8String分配的内存需手动free否则永不释放。解决方案用Memory.allocwriteUtf8String并显式free。// 错误内存泄漏 var ptr Memory.allocUtf8String(test); // 正确手动管理 var ptr Memory.alloc(10); ptr.writeUtf8String(test); // ... 使用 ptr ... ptr.free(); // 必须调用5.5 “多进程 App Hook 失效”frida -U默认只连主进程现象App 有:push、:core等多进程frida -U -f com.example.app只 Hook 主进程其他进程无响应。原因-U连接的是设备-f启动的是主 Activity 进程。解决方案分别 Hook 每个进程。# 启动主进程 frida -U -f com.example.app -l main.js # 启动 push 进程需先知道其进程名 adb shell ps | grep com.example.app:push frida -U -n com.example.app:push -l push.js或用frida-ps -U列出所有进程 PID再frida -U -p PID连接。最后再分享一个小技巧当遇到极度顽固的加固如某银行 App 的自研壳常规 Frida 会被检测。此时可尝试frida-gum的底层 API 直接操作内存或使用r2fridaRadare2 Frida进行混合分析。但绝大多数商业 App本文覆盖的四层穿透 五条避坑已足够应对。逆向不是魔法它是一套可复制、可验证、可沉淀的工作流。你不需要记住所有命令只需要理解每一次frida -U的成功都是对设备、服务、客户端三方通信链路的一次完整压力测试每一次implementation的生效都是对目标 App 运行时状态的一次精准外科手术。保持敬畏保持耐心保持对字节码最原始的好奇心——这才是逆向真正的起点。