安卓逆向实战:基于 Frida 与 JADX 联动定位并绕过应用签名校验逻辑
很多 Android 应用会做“自签名校验”:启动时读取自身 APK 的签名信息,再和代码里预置的指纹、哈希或证书内容比较。一旦不一致,就闪退、禁用功能,或者直接提示“环境异常”。
如果你在做安全研究、兼容性测试,或者只是想搞清楚某个 App 到底把校验写在哪儿,JADX + Frida 这套组合非常高效:
- JADX 负责静态看代码:找到校验入口、比对逻辑、关键类名
- Frida 负责动态验证:在运行时打印参数、改返回值、直接绕过判断
这篇文章我会按“先定位,再验证,最后绕过”的思路带你走一遍。重点不是堆概念,而是让你看完能自己上手。
说明:本文内容仅用于授权测试、安全研究与教学演示,请勿用于未授权场景。
背景与问题
为什么签名校验总是绕不过去?
因为它不一定只写在一个地方。常见情况包括:
-
Java 层直接校验
- 调
PackageManager - 取
getPackageInfo(...).signatures或signingInfo - 算 MD5 / SHA1 / SHA256 后比对
- 调
-
Native 层二次校验
- Java 只是壳,真正校验在
.so - Java 返回正常,但 Native 再次拦截
- Java 只是壳,真正校验在
-
多点校验
- Application 启动校验一次
- 核心页面进来再校验一次
- 网络请求前再校验一次
-
混淆与反调试
- 类名、方法名全混淆
- 动态加载 Dex
- 检测 Frida、Root、调试器
所以实际做的时候,最怕的不是“不会 Hook”,而是没找准点。这也是为什么我通常会把 JADX 静态分析 和 Frida 动态验证 连起来用。
前置知识与环境准备
你需要准备什么
- 一台已安装
adb的电脑 - 一台测试 Android 设备或模拟器
jadx-guifrida-tools- 目标 APK
- 对 Java/Kotlin 基本语法有概念
工具安装示例
pip install frida-tools
frida --version
确认设备连通:
adb devices
确认 Frida Server 已在设备上运行后,再测试进程枚举:
frida-ps -U
核心原理
签名校验本质上是一条“取值 -> 变换 -> 比较 -> 分支”的链路。
一个典型流程
- 获取包签名
- 计算证书摘要
- 与预置值比对
- 不一致则返回 false / 抛异常 / 结束进程
flowchart TD
A[应用启动] --> B[读取自身包信息]
B --> C[提取签名/证书]
C --> D[计算 MD5/SHA1/SHA256]
D --> E{与内置指纹比较}
E -- 一致 --> F[正常执行]
E -- 不一致 --> G[提示异常/退出/功能禁用]
逆向时我们关注哪些点?
通常有三类可下手的位置:
- 源头:Hook
PackageManager/Signature/MessageDigest - 中间:Hook 自定义摘要函数、证书转换函数
- 结果:Hook 最终布尔判断,强制返回
true
为什么要联动使用 JADX 和 Frida?
因为两者解决的问题不同:
sequenceDiagram
participant A as JADX 静态分析
participant B as 逆向人员
participant C as Frida 动态验证
participant D as 目标应用
A->>B: 提供类名、方法名、字符串常量
B->>C: 编写 Hook 脚本
C->>D: 注入并拦截调用
D-->>C: 返回运行时参数/返回值
C-->>B: 输出日志、修改结果
B->>A: 回看代码确认真实分支
- JADX 告诉你“可能在哪里”
- Frida 告诉你“运行时到底是不是这里”
这比盲 Hook 高效太多。
用 JADX 定位签名校验逻辑
第一步:全局搜关键词
把 APK 丢进 jadx-gui 后,先全局搜索这些词:
signaturesignaturessigningInfoPackageManagergetPackageInfoMessageDigestSHA1SHA-256MD5X509CertificateCertificateFactory
如果目标 App 混淆比较重,还可以搜:
- 异常提示文本,比如“签名错误”“环境异常”“非法安装包”
- 固定长度的十六进制字符串
- Base64 字符串
第二步:关注这些代码形态
例如下面这种就很典型:
PackageInfo pi = context.getPackageManager().getPackageInfo(
context.getPackageName(), 64
);
Signature[] signatures = pi.signatures;
byte[] cert = signatures[0].toByteArray();
String sha1 = digest(cert);
return "A1:B2:C3:...".equalsIgnoreCase(sha1);
或者 Android 9+ 常见写法:
PackageInfo pi = context.getPackageManager().getPackageInfo(
context.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES
);
SigningInfo info = pi.signingInfo;
Signature[] signatures = info.getApkContentsSigners();
第三步:顺着调用链往上找
你找到的摘要比较方法,通常不是入口。真正关键的是:
- 谁调用了它?
- 调用结果被谁消费?
- 是不是进入
if (!check()) { finish(); }这种分支?
这一步很重要。因为你最终想 Hook 的,不一定是摘要函数本身,而是控制流程的那个布尔函数。
实战示例:从静态定位到动态绕过
下面用一个可运行的教学示例来演示。假设目标应用中,我们在 JADX 里找到了如下代码:
public class SecurityUtil {
public static boolean checkSign(Context context) {
try {
PackageInfo pi = context.getPackageManager().getPackageInfo(
context.getPackageName(), 64
);
Signature sig = pi.signatures[0];
String sha1 = sha1(sig.toByteArray());
return "12AB34CD56EF".equalsIgnoreCase(sha1);
} catch (Exception e) {
return false;
}
}
}
上层调用:
if (!SecurityUtil.checkSign(this)) {
Toast.makeText(this, "环境异常", 0).show();
finish();
}
这时最稳的做法通常是:
- 先 Hook
checkSign,确认命中 - 再强制返回
true - 如果没命中,再退回去 Hook
PackageManager或摘要函数
实战代码(可运行)
方案一:直接 Hook 目标校验方法
这是我最推荐的第一手方案,简单、稳定、对业务副作用小。
Java.perform(function () {
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
SecurityUtil.checkSign.overload("android.content.Context").implementation = function (ctx) {
console.log("[*] SecurityUtil.checkSign() called");
var original = this.checkSign(ctx);
console.log("[*] original result = " + original);
console.log("[*] force return true");
return true;
};
});
启动方式:
frida -U -f com.demo.app -l bypass_sign.js --no-pause
适用场景
- 已经在 JADX 明确找到校验方法
- 方法签名清晰
- 没有太多重载干扰
方案二:Hook 摘要计算,观察真实比较值
如果你还不确定目标比对的究竟是什么,可以先 Hook MessageDigest 或自定义摘要函数。
下面示例 Hook Java 层的 MessageDigest.digest():
Java.perform(function () {
var MessageDigest = Java.use("java.security.MessageDigest");
var Arrays = Java.use("java.util.Arrays");
MessageDigest.digest.overload("[B").implementation = function (input) {
var result = this.digest(input);
console.log("[*] MessageDigest.digest([B) called");
console.log(" input length = " + input.length);
console.log(" output = " + Arrays.toString(result));
return result;
};
});
不过实际排查中,我更建议直接 Hook 业务自己的摘要方法,例如:
Java.perform(function () {
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
SecurityUtil.sha1.overload("[B").implementation = function (data) {
console.log("[*] sha1 called, data length = " + data.length);
var ret = this.sha1(data);
console.log("[*] sha1 result = " + ret);
return ret;
};
});
为什么先打印不先改?
因为很多时候你以为是 SHA1,结果它是:
- 证书转字符串后再摘要
- 摘要后再做大写/加冒号
- 只取前 16 位比较
- 多个签名拼接后再算
先看清楚,再决定绕过点,成功率高很多。
方案三:通用 Hook PackageManager.getPackageInfo
当代码混淆很严重、业务方法找不到时,可以从系统 API 入手。
Java.perform(function () {
var ApplicationPackageManager = Java.use("android.app.ApplicationPackageManager");
ApplicationPackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
console.log("[*] getPackageInfo called");
console.log(" pkg = " + pkg + ", flags = " + flags);
return this.getPackageInfo(pkg, flags);
};
});
如果你发现目标在启动阶段频繁取自己包信息,那么可以继续顺着这个调用栈找。
加上堆栈打印会更实用:
Java.perform(function () {
var Log = Java.use("android.util.Log");
var Exception = Java.use("java.lang.Exception");
var ApplicationPackageManager = Java.use("android.app.ApplicationPackageManager");
ApplicationPackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
console.log("[*] getPackageInfo(" + pkg + ", " + flags + ")");
console.log(Log.getStackTraceString(Exception.$new()));
return this.getPackageInfo(pkg, flags);
};
});
这样你就能从日志里反推出调用方类名,再回到 JADX 继续看。
方案四:直接修改最终分支结果
如果上层是一个集中校验器,比如:
public boolean isEnvSafe() {
return checkSign() && checkRoot() && checkDebugger();
}
那么你可以直接 Hook 最终方法:
Java.perform(function () {
var EnvChecker = Java.use("com.demo.app.EnvChecker");
EnvChecker.isEnvSafe.implementation = function () {
console.log("[*] EnvChecker.isEnvSafe() called");
return true;
};
});
这个方案的优缺点
优点:
- 成本最低
- 成功率高
- 对细节不敏感
缺点:
- 可能漏掉其他流程
- 可能掩盖真正的校验点,不利于分析全貌
如果你做的是研究而不是“先跑起来”,还是建议回头把真实链路补齐。
一套我常用的定位流程
下面这张图可以作为你实战时的“小抄”。
flowchart LR
A[JADX 搜索关键词] --> B[找到签名/摘要相关方法]
B --> C[分析调用链与最终分支]
C --> D[Frida 打日志验证是否命中]
D --> E{命中?}
E -- 是 --> F[强制返回 true 或改参数]
E -- 否 --> G[Hook 系统 API / 打印堆栈]
G --> H[回到 JADX 继续定位]
逐步验证清单
我建议按这个顺序做,别一上来就写一大坨脚本。
1. 先确认进程能正常注入
frida -U -f com.demo.app -l test.js --no-pause
test.js:
Java.perform(function () {
console.log("[*] Frida attached");
});
2. 验证目标类是否存在
Java.perform(function () {
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
console.log(SecurityUtil);
});
3. 验证方法重载是否正确
Java.perform(function () {
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
console.log(SecurityUtil.checkSign.overloads);
});
4. 先打印参数和返回值
不要急着改返回值,先确认它真的被调用:
Java.perform(function () {
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
SecurityUtil.checkSign.overload("android.content.Context").implementation = function (ctx) {
console.log("[*] checkSign hit");
var ret = this.checkSign(ctx);
console.log("[*] ret = " + ret);
return ret;
};
});
5. 最后再强制绕过
确认命中后,再改成 return true;。
常见坑与排查
1. Hook 了但完全不生效
常见原因:
- 类名写错
- 方法重载选错
- 目标代码执行得太早
- 实际校验在其他进程
- 代码在 Native 层,不在 Java 层
排查建议
先枚举类名,看是不是混淆/多 Dex 导致:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf("Security") !== -1 || name.indexOf("sign") !== -1) {
console.log(name);
}
},
onComplete: function () {}
});
});
如果怀疑启动太早,可以改为 spawn 模式注入:
frida -U -f com.demo.app -l bypass_sign.js --no-pause
2. 调用原方法时死递归
这是 Frida 新手最容易踩的坑之一。
例如:
implementation = function (ctx) {
return this.checkSign(ctx); // 这里如果写法不当,可能递归
}
通常要确保你 Hook 的是特定重载,并在该重载上调用原始实现。最稳妥的写法是先保存重载对象:
Java.perform(function () {
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
var target = SecurityUtil.checkSign.overload("android.content.Context");
target.implementation = function (ctx) {
console.log("[*] hit checkSign");
var ret = target.call(this, ctx);
console.log("[*] original ret = " + ret);
return true;
};
});
这个写法我自己也更常用,出问题少。
3. Android 版本差异导致签名 API 不同
老版本常见:
PackageInfo.signatures
新版本常见:
PackageInfo.signingInfogetApkContentsSigners()getSigningCertificateHistory()
如果你只盯着 signatures,可能会漏掉新版实现。
4. 目标 App 有多进程
有些应用把安全校验放在独立进程。你 Hook 主进程没反应,不代表脚本错了。
先看进程列表:
frida-ps -Uai
再确认包名对应的所有进程。
5. Java 层绕过了,App 还是退出
这通常说明还有别的检测:
- Native 签名校验
- Root 检测
- 调试检测
- 完整性校验
- 服务器二次校验
这时别死磕一个点,回到调用链看“失败后还有谁参与决策”。
6. 混淆后方法名看不懂,怎么找?
我自己的经验是,不要执着于类名语义,改盯这三样:
- 常量:固定哈希、错误文案、包名
- API 组合:
getPackageInfo + MessageDigest - 调用时机:启动页、Application、登录前
有时一个叫 a.a.a.a() 的方法,照样能靠行为特征锁定。
安全/性能最佳实践
虽然这里讲的是“绕过”,但在研究和测试过程中,仍然要有边界感和工程意识。
1. 优先做最小化 Hook
如果已经定位到 checkSign(),就别上来全局 Hook MessageDigest。
原因很简单:
- 日志噪音大
- 性能开销高
- 容易影响其他业务逻辑
- 排查结果反而变乱
建议:从最具体的业务方法开始,逐步往底层扩。
2. 先观测,后修改
实战里我一般分两轮:
- 第一轮:只打印参数、返回值、调用栈
- 第二轮:确认后再改返回值
这样能避免“虽然绕过去了,但根本不知道自己改的是哪一层”的尴尬。
3. 对启动关键路径少做重操作
像 Application.onCreate()、启动页首屏逻辑,本来时间窗口就短。这里如果:
- 打太多日志
- Hook 太多系统 API
- 做复杂字符串处理
很容易引发卡顿甚至时序变化,导致现象不稳定。
4. 保留可回滚脚本版本
建议把脚本拆成几个阶段:
observe_sign.jstrace_pkginfo.jsbypass_sign.js
不要把所有逻辑塞进一个文件。这样你回退和比对更方便。
5. 注意合法授权边界
签名校验绕过属于典型安全敏感操作。用于:
- 自有 App 测试
- 授权渗透/加固评估
- 教学研究
是合理的;用于未授权目标则可能触碰法律与合规问题。这条边界一定要明确。
一个更完整的可运行脚本示例
下面给一个我平时排查时会用的“观察 + 绕过”合并版脚本。你可以按实际类名修改。
Java.perform(function () {
console.log("[*] script loaded");
try {
var Log = Java.use("android.util.Log");
var Exception = Java.use("java.lang.Exception");
var SecurityUtil = Java.use("com.demo.app.SecurityUtil");
var target = SecurityUtil.checkSign.overload("android.content.Context");
target.implementation = function (ctx) {
console.log("[*] SecurityUtil.checkSign hit");
console.log(Log.getStackTraceString(Exception.$new()));
var ret = target.call(this, ctx);
console.log("[*] original return = " + ret);
if (ret !== true) {
console.log("[*] bypass signature check");
}
return true;
};
console.log("[*] hook installed");
} catch (e) {
console.log("[!] hook failed: " + e);
}
});
运行:
frida -U -f com.demo.app -l bypass_sign.js --no-pause
如果校验在 Native 层怎么办?
这篇重点放在 Java 层联动定位,但你要知道一个边界:如果 JADX 里只看到一个 JNI 包装方法,实际比较在 .so 里完成,那么只 Hook Java 入口可能不够。
这时可以继续走两条路:
- Hook JNI 导出函数 /
RegisterNatives - 在 Java 层直接 Hook 最终结果方法,绕过上层消费逻辑
也就是说,JADX 找不到完整逻辑,并不代表没有价值。它至少能帮你确认:
- Native 是从哪个 Java 方法进的
- 结果返回给谁
- 最终在哪里触发异常流程
总结
这套 JADX + Frida 的联动方法,核心不是“某个神奇 Hook 点”,而是一种高效定位思路:
- 先用 JADX 找到签名相关代码形态
- 顺着调用链定位最终控制分支
- 用 Frida 动态验证是否真实命中
- 优先 Hook 具体业务方法,而不是一开始全局乱扫
- 如果业务点不明确,再退回系统 API 和调用栈排查
如果你只记住一句话,我会建议记这个:
静态分析负责缩小范围,动态 Hook 负责确认真相。
在签名校验这类场景里,这种联动往往比单独使用任一工具都高效。
最后给几个可执行建议:
- 能 Hook 最终布尔判断,就先 Hook 最终布尔判断
- 要研究完整链路,再去 Hook 摘要函数和系统 API
- Java 层无果时,及时怀疑 Native 层与多进程
- 先观察、后修改,别把“定位”和“绕过”混在第一步里
只要按这个节奏做,绝大多数 Java 层签名校验都能比较快地定位清楚。