安卓逆向实战:基于 Frida 与 JADX 的应用签名校验与反调试绕过分析
很多 Android 应用在启动时会做两件事:校验自身签名,以及检测调试/注入环境。这两类保护并不算“高深”,但在实际分析里非常高频。对中级读者来说,真正的难点往往不是“知道它存在”,而是:如何快速定位、如何稳定绕过、如何确认绕过没有副作用。
这篇文章我会从一个偏“实战排障”的角度来讲,使用 JADX 做静态定位,使用 Frida 做动态验证与绕过,把一条常见分析链路完整走一遍。你可以把它当成一个通用模板,后面遇到类似应用时直接套思路。
说明:以下内容仅用于授权测试、应用加固研究与教学分析。请勿用于未授权目标。
背景与问题
在 Android 应用保护里,签名校验和反调试通常承担这两个职责:
- 签名校验
- 防止 APK 被二次打包
- 防止渠道篡改
- 防止研究者修改 smali 后重签运行
- 反调试
- 检测
Debug.isDebuggerConnected() - 检测
TracerPid - 检测 Frida 端口、线程名、内存特征
- 检测
ro.debuggable、test-keys等系统状态
- 检测
为什么这两项经常一起出现?
因为开发者的默认思路通常是:
- 启动时先判断环境是否异常;
- 再判断 APK 是否被改动;
- 一旦命中,直接闪退、卡死、返回假数据,甚至延迟触发。
这也意味着,逆向时我们很少能只绕过一个点。更常见的情况是:签名校验和反调试互相配合,必须联动处理。
前置知识
在开始前,默认你已经了解这些内容:
- Android APK 基本结构
- Java 层常见 Hook 思路
- Frida 基础用法
- JADX 基础搜索能力
- ADB 常用命令
如果你已经能独立完成以下动作,读本文会比较顺:
- 用
adb install安装 APK - 用
jadx-gui搜索字符串和方法引用 - 用
frida -U -f 包名 -l hook.js注入脚本 - 看懂 Java/Kotlin 反编译代码
环境准备
建议使用下面这套环境,兼容性相对稳定:
- Android 8 ~ 13 测试机 / 模拟器
adbjadx/jadx-guifrida-tools- 与设备架构匹配的
frida-server - 可选:
objection
先确认基础连通性:
adb devices
frida-ps -U
如果 frida-ps -U 看不到设备,优先检查:
- USB 调试是否开启
frida-server是否已启动- PC 与设备端 Frida 版本是否一致
- SELinux/Root 环境是否影响注入
分析总览:先静态定位,再动态验证
我个人比较推荐的路径是:
- 先用 JADX 找入口
- 再用 Frida 小范围验证
- 最后做最小化绕过
这样做的好处是:不会上来就“大面积 Hook 全世界”,排查更稳,副作用也更小。
flowchart TD
A[解包并用 JADX 打开 APK] --> B[搜索签名相关关键词]
B --> C[定位 PackageManager / Signature / SHA1 / MD5]
C --> D[搜索反调试相关关键词]
D --> E[定位 Debug / TracerPid / ptrace / SystemProperties]
E --> F[Frida 动态 Hook 验证执行路径]
F --> G[最小化修改返回值]
G --> H[复测关键功能]
背景与问题:一个典型目标会长什么样
一个典型“有点保护但不算特别重”的目标 App,常见特征是:
- 入口 Activity 正常启动,但很快闪退
- 登录页能看到,但操作后报“环境异常”
- 打开关键页面时才检测签名
- Frida 一注入就退,或者卡在启动页
这种情况下,很多人第一反应是直接全局 Hook finish()、System.exit()、killProcess()。这当然有时能救急,但它更像“止血”,不是“定位”。
更好的思路是:
- 先搞清楚谁在做签名校验
- 再找出谁在做调试检测
- 最后判断它们是启动阶段触发,还是按需触发
核心原理
这一节我们把常见实现方式拆开看。
1. 签名校验原理
Android 应用做签名校验时,通常会:
- 通过
PackageManager读取当前包信息 - 取出
Signature/SigningInfo - 计算证书摘要(MD5 / SHA1 / SHA256)
- 与内置常量比较
常见 API:
- 旧版:
getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
- 新版:
getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)PackageInfo.signingInfo
很多应用不会直接比较原始签名,而是比较摘要字符串,比如:
toCharsString()MessageDigest.getInstance("SHA-256")- 再转十六进制字符串
2. 反调试原理
Java 层常见检测:
android.os.Debug.isDebuggerConnected()android.os.Debug.waitingForDebugger()
Native / 系统侧常见检测:
- 读取
/proc/self/status中的TracerPid - 调用
ptrace - 查找 Frida 线程名,如
gum-js-loop、gmain - 扫描 27042 等默认端口
- 检测系统属性
ro.debuggablero.securero.build.tags
3. 为什么 JADX + Frida 组合效率高
- JADX 负责“猜测”和缩小范围
- Frida 负责“证实”和快速试错
静态分析能告诉你“可疑点在哪”,但它不一定告诉你“运行时是否真的走到这里”。动态 Hook 正好补上这一块。
用 JADX 定位:从哪里开始找
签名校验关键词
在 JADX 中,优先搜这些:
getPackageInfoGET_SIGNATURESGET_SIGNING_CERTIFICATESSignaturesigningInfoMessageDigestSHA1SHA-256MD5
如果代码混淆了,搜字符串常量往往更有效,比如:
- 固定摘要字符串
debuggableenvironment errorsignature mismatchtamper
反调试关键词
继续搜这些:
isDebuggerConnectedwaitingForDebugger/proc/self/statusTracerPidptracefrida27042ro.debuggabletest-keyskillProcessSystem.exit
我实际排查时常用的判断标准
如果你搜到某个方法里同时出现:
PackageManagerMessageDigest- 一段固定 hash
- 校验失败后调用
finish()/throw
那它大概率就是签名校验点。
如果你搜到某个方法里出现:
Debug.isDebuggerConnected()- 读取
/proc/self/status - 命中异常后
Process.killProcess(Process.myPid())
那就是反调试点。
Mermaid:签名校验执行链
sequenceDiagram
participant App as App启动流程
participant Guard as 校验模块
participant PM as PackageManager
participant Digest as MessageDigest
App->>Guard: 调用校验入口
Guard->>PM: getPackageInfo(packageName, flags)
PM-->>Guard: 返回签名信息
Guard->>Digest: 计算 SHA-256/MD5
Digest-->>Guard: 返回摘要
Guard->>Guard: 与内置摘要比较
alt 匹配成功
Guard-->>App: 继续运行
else 匹配失败
Guard-->>App: 闪退/终止/假返回
end
实战代码(可运行)
下面给出一套偏通用的 Frida 脚本。它的目标不是“一把梭全绕过”,而是:
- 先打印关键调用,帮助你确认执行链;
- 再逐步改返回值;
- 最后只保留必要 Hook。
建议分阶段使用,不要一上来全开。
第一步:探测签名校验与反调试调用
Java.perform(function () {
console.log("[*] probe script start");
// 1) 调试检测探测
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
var ret = this.isDebuggerConnected();
console.log("[Debug.isDebuggerConnected] => " + ret);
return ret;
};
Debug.waitingForDebugger.implementation = function () {
var ret = this.waitingForDebugger();
console.log("[Debug.waitingForDebugger] => " + ret);
return ret;
};
// 2) 进程退出探测
var Process = Java.use("android.os.Process");
Process.killProcess.implementation = function (pid) {
console.log("[killProcess] pid=" + pid + " blocked");
};
var System = Java.use("java.lang.System");
System.exit.implementation = function (code) {
console.log("[System.exit] code=" + code + " blocked");
};
// 3) 常见签名读取点探测
var PackageManager = Java.use("android.app.ApplicationPackageManager");
PackageManager.getPackageInfo.overloads.forEach(function (ov) {
ov.implementation = function () {
var pkg = arguments[0];
var flags = arguments[1];
console.log("[getPackageInfo] pkg=" + pkg + ", flags=" + flags);
return ov.apply(this, arguments);
};
});
// 4) MessageDigest 探测
var MessageDigest = Java.use("java.security.MessageDigest");
MessageDigest.getInstance.overloads.forEach(function (ov) {
ov.implementation = function () {
var algo = arguments[0];
console.log("[MessageDigest.getInstance] algo=" + algo);
return ov.apply(this, arguments);
};
});
console.log("[*] probe hooks installed");
});
运行方式:
frida -U -f 目标包名 -l probe.js
这一步的目标很明确:不要急着改逻辑,先看日志。
第二步:直接绕过 Java 层反调试
如果你已经确认它调用了 Debug.isDebuggerConnected(),那么可以先这样改:
Java.perform(function () {
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
console.log("[bypass] isDebuggerConnected => false");
return false;
};
Debug.waitingForDebugger.implementation = function () {
console.log("[bypass] waitingForDebugger => false");
return false;
};
});
这类 Hook 很常见,也很稳。但要注意:如果目标真正的检测在 native 层,这一步只能绕过一部分。
第三步:绕过常见退出逻辑
有些 App 命中检测后不会直接崩,而是主动退出。为了保留现场、方便继续分析,可以临时挡住这些调用:
Java.perform(function () {
var Process = Java.use("android.os.Process");
Process.killProcess.implementation = function (pid) {
console.log("[block] killProcess(" + pid + ")");
};
var System = Java.use("java.lang.System");
System.exit.implementation = function (code) {
console.log("[block] System.exit(" + code + ")");
};
var Activity = Java.use("android.app.Activity");
Activity.finish.implementation = function () {
console.log("[block] Activity.finish(): " + this.getClass().getName());
};
});
这个脚本很适合启动即闪退场景。但我建议把它当作排障辅助,不要长期依赖。因为有些页面本来就应该 finish,这种全局 Hook 可能会影响正常流程。
第四步:针对签名校验做最小化绕过
签名校验更推荐Hook 目标校验函数本身。但在你还没完全定位前,可以先 Hook 摘要比较函数,观察是否存在固定值比较。
方案 A:Hook String.equals
这个方法偏粗暴,只适合短时间探测。
Java.perform(function () {
var StringCls = Java.use("java.lang.String");
StringCls.equals.implementation = function (obj) {
var a = this.toString();
var b = obj ? obj.toString() : "null";
if (a.length > 20 || b.length > 20) {
console.log("[String.equals] " + a + " <=> " + b);
}
return this.equals(obj);
};
});
如果你看到某两个长 hash 在比较,就能进一步回到 JADX 定位上游方法。
注意:这里不要长期强改
equals返回值,副作用非常大。
方案 B:Hook 目标校验函数
比如你在 JADX 中定位到:
public boolean verifySign(Context context) {
// ...
}
那么直接 Hook 它最稳:
Java.perform(function () {
var Guard = Java.use("com.example.app.security.SignGuard");
Guard.verifySign.overload("android.content.Context").implementation = function (ctx) {
console.log("[bypass] verifySign(Context) => true");
return true;
};
});
这是我最推荐的方式:修改最靠近业务判断的点,副作用小,可控性强。
第五步:处理 /proc/self/status 与 TracerPid
不少 App 在 Java 层通过文件读取来检查 TracerPid。如果 JADX 里能看到它使用 BufferedReader 或 FileInputStream 读 /proc/self/status,可以这样处理:
Java.perform(function () {
var File = Java.use("java.io.File");
var FileInputStream = Java.use("java.io.FileInputStream");
FileInputStream.$init.overload("java.io.File").implementation = function (file) {
var path = file.getAbsolutePath();
if (path.indexOf("/proc/self/status") >= 0) {
console.log("[detect] open " + path);
}
return this.$init(file);
};
FileInputStream.$init.overload("java.lang.String").implementation = function (path) {
if (path.indexOf("/proc/self/status") >= 0) {
console.log("[detect] open " + path);
}
return this.$init(path);
};
});
如果确认是 Java 层自己解析文本,那么下一步就不是盲目 Hook 文件流,而是回到 JADX,找到“解析 TracerPid 的那个方法”,直接改其返回值。
更完整的联动脚本示例
下面给一个更适合实战启动阶段的联动脚本。它集合了:
- Java 层调试检测绕过
- 退出逻辑拦截
- 系统属性伪装
- 常见包信息调用观察
Java.perform(function () {
console.log("[*] bypass bundle start");
// 1. Debug
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
return false;
};
Debug.waitingForDebugger.implementation = function () {
return false;
};
// 2. SystemProperties
try {
var SysProp = Java.use("android.os.SystemProperties");
SysProp.get.overload("java.lang.String").implementation = function (key) {
if (key === "ro.debuggable") return "0";
if (key === "ro.secure") return "1";
if (key === "ro.build.tags") return "release-keys";
return this.get(key);
};
SysProp.get.overload("java.lang.String", "java.lang.String").implementation = function (key, def) {
if (key === "ro.debuggable") return "0";
if (key === "ro.secure") return "1";
if (key === "ro.build.tags") return "release-keys";
return this.get(key, def);
};
} catch (e) {
console.log("[!] SystemProperties hook failed: " + e);
}
// 3. Exit blockers
var Process = Java.use("android.os.Process");
Process.killProcess.implementation = function (pid) {
console.log("[block] killProcess(" + pid + ")");
};
var System = Java.use("java.lang.System");
System.exit.implementation = function (code) {
console.log("[block] System.exit(" + code + ")");
};
// 4. Package info observation
try {
var PM = Java.use("android.app.ApplicationPackageManager");
PM.getPackageInfo.overloads.forEach(function (ov) {
ov.implementation = function () {
var pkg = arguments[0];
var flags = arguments[1];
console.log("[PM.getPackageInfo] " + pkg + " flags=" + flags);
return ov.apply(this, arguments);
};
});
} catch (e) {
console.log("[!] getPackageInfo hook failed: " + e);
}
console.log("[*] bypass bundle installed");
});
运行:
frida -U -f 目标包名 -l bypass.js
Mermaid:排障决策图
flowchart LR
A[App 启动闪退] --> B{Frida 注入后更快闪退?}
B -- 是 --> C[先拦截 killProcess/System.exit/finish]
B -- 否 --> D[直接观察日志]
C --> E{是否命中 Debug/TracerPid 检测?}
D --> E
E -- 是 --> F[Hook isDebuggerConnected / 解析函数]
E -- 否 --> G{是否命中签名比较?}
F --> G
G -- 是 --> H[Hook 目标校验函数返回 true]
G -- 否 --> I[继续回到 JADX 搜索常量与调用链]
逐步验证清单
做这类绕过时,我强烈建议按清单推进。很多人脚本写了不少,但没有验证顺序,最后不知道是哪一步生效的。
验证 1:确认注入时机
- 使用
-f启动注入,而不是 App 启动后再 attach - 看 Frida 日志是否在 Application 初始化前输出
- 如果太晚,启动期检测可能已经触发
示例:
frida -U -f 目标包名 -l bypass.js --no-pause
验证 2:确认是否真有 Java 层反调试
- Hook
Debug.isDebuggerConnected - 看日志是否触发
- 如果完全不触发,不要在这个点上浪费太久
验证 3:确认是否存在主动退出逻辑
- Hook
System.exit - Hook
killProcess - Hook
Activity.finish
如果这些频繁触发,说明应用在“检测失败后主动收尾”。
验证 4:确认签名校验路径
- 观察
getPackageInfo - 观察
MessageDigest.getInstance - 结合 JADX 中的 hash 常量定位具体方法
验证 5:最小化保留 Hook
最终最好只保留:
- 1~2 个反调试 Hook
- 1 个签名校验函数 Hook
不要长期带着一堆“全局大锤”脚本跑。
常见坑与排查
这部分很重要。我自己踩过的坑,大多都不是“不会 Hook”,而是“Hook 对了但现象还是不对”。
坑 1:Hook 写了,但方法根本没走到
表现:
- 脚本正常加载
- 没报错
- 但日志一条没有
排查:
- 检查类名是否混淆后变化
- 检查是否多 Dex 动态加载
- 检查 Hook 时机是否太早或太晚
- 使用
Java.enumerateLoadedClasses辅助确认类是否已加载
示例:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("sign") >= 0 || name.indexOf("security") >= 0) {
console.log(name);
}
},
onComplete: function () {
console.log("done");
}
});
});
坑 2:getPackageInfo Hook 了,但签名校验还是没绕过
原因可能有这些:
- App 校验的是 native 层签名
- App 读取的是
SigningInfo而不是旧版Signature[] - App 只是在“观察”包信息,真正比较发生在后面
- 校验值经过二次变换,比如 Base64、截断、拼接盐值
建议:
- 不要执着于改
getPackageInfo返回对象 - 回到业务层,直接 Hook 最终
boolean校验函数
坑 3:一挡 finish(),页面不退了,但功能还是不能用
这是典型“止血成功,治疗失败”。
说明:
- 你拦住了结果
- 但没有拦住原因
后续要做的是继续找:
- 是谁设置了“异常状态”
- 哪个字段或返回值影响后续逻辑
- 是否还有埋点上报、延迟退出、假数据分支
坑 4:Frida 一注入就被杀
常见原因:
- 检测默认 Frida 端口
- 检测 Frida 线程名
- 检测 maps 中的特征字符串
- native 层在启动期快速检测
应对思路:
- 尽量使用 spawn 模式
- 优先处理启动阶段退出逻辑
- 必要时转向 native Hook 或更隐蔽的注入方案
- 如果 Java 层完全看不到检测点,十有八九要往 so 里看了
坑 5:Hook 重载写错
像 getPackageInfo、SystemProperties.get 这类方法通常有多个 overload。重载签名一旦不对,脚本看起来“没报错”,但不会命中目标。
建议先打印 overload:
Java.perform(function () {
var PM = Java.use("android.app.ApplicationPackageManager");
PM.getPackageInfo.overloads.forEach(function (ov) {
console.log(ov);
});
});
安全/性能最佳实践
逆向分析脚本也需要“工程化一点”,否则自己会被自己的脚本坑到。
1. 优先最小 Hook 面
不要动不动全局 Hook:
String.equalsMessageDigest.digest- 所有
FileInputStream
这些虽然能快速看到很多信息,但日志量会爆炸,还可能拖慢目标 App。
更好的方式:
- 先探测
- 再定位
- 最后只保留关键方法
2. 先记录,再修改
我比较推荐这个顺序:
- 先打印参数和返回值
- 确认命中
- 再改返回值
这样出问题时,你知道是“原逻辑问题”还是“Hook 带来的副作用”。
3. 对退出逻辑的拦截只用于诊断
拦截 killProcess、System.exit、finish 很有用,但只适合:
- 启动期止血
- 保留现场
- 抓调用链
如果长期保留,可能导致:
- 生命周期错乱
- 页面状态残缺
- 误判“已经绕过成功”
4. 注意版本兼容
Android 9 以后签名相关 API 有变化:
- 旧版偏
GET_SIGNATURES - 新版偏
GET_SIGNING_CERTIFICATES+SigningInfo
所以你在不同设备上测试,现象可能不一样。不要在 Android 7 上试通了,就默认 Android 13 一样。
5. 日志要有节制
Frida 日志太多时,会明显拖慢启动过程,甚至改变原本时序。这个在反调试场景里尤其敏感。
建议:
- 只打印关键方法
- 避免高频热点函数里大量
console.log - 定位完成后删掉观测日志
一个推荐的实战流程模板
如果让我把整件事压缩成一个最实用的模板,我会这样做:
阶段 1:静态找点
- JADX 搜
isDebuggerConnected - 搜
TracerPid - 搜
getPackageInfo - 搜
MessageDigest - 搜固定 hash 常量
- 标记关键类与方法
阶段 2:动态确认
- Hook
Debug.isDebuggerConnected - Hook
getPackageInfo - Hook
MessageDigest.getInstance - Hook
System.exit/killProcess
阶段 3:止血
- 挡掉退出逻辑
- 保证 App 不会立刻退
- 找到后续调用栈
阶段 4:最小绕过
- 直接 Hook 目标校验方法
return true - 直接 Hook 目标调试判断方法
return false
阶段 5:回归验证
- 去掉多余 Hook
- 重新跑关键路径
- 检查登录、页面跳转、接口请求是否正常
边界条件:什么时候这套方法不够用
这篇文章主要覆盖的是:
- Java 层为主
- 或者 Java 层能明显看出入口
- 且 反调试没有做到很重的 native 对抗
如果你遇到这些情况,这套方法就不够了:
- 所有关键检测都在 so 中
- 应用有强壳保护
- 动态加载 Dex,校验逻辑运行时下发
- 检测 Frida 特征非常激进
- 使用 inline/native 混合校验,Java 层只是壳
这时就要进一步:
- 看 native 导出与 JNI 注册
- 用
frida-trace或手写 native Hook - 分析
/proc、ptrace、open/read等 libc 调用 - 必要时结合脱壳、内存转储
也就是说,JADX + Frida 是高频入口,不是万能钥匙。
总结
这类题目的核心,不是“背下来多少 Hook 模板”,而是掌握一条稳定方法论:
- JADX 先缩小范围
- Frida 再验证实际执行
- 先止血,再定位根因
- 最终只保留最小绕过点
对于签名校验:
- 优先找最终
boolean判断函数 - 不要一开始就硬改底层签名对象
对于反调试:
- 先看 Java 层
Debug、SystemProperties、TracerPid - 如果注入即死,再考虑 native 层检测
如果你只记住一句话,我希望是这句:
逆向里最稳的绕过,不是“拦住所有异常”,而是“改掉最终判断”。
这样做更容易复现、更容易迁移,也更不容易把自己绕进去。
希望这篇文章能帮你把“签名校验 + 反调试”这类常见保护,真正从“知道概念”推进到“可以上手拆”。