安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见 App 签名校验逻辑
很多 Android App 会做签名校验:
有的是为了防二次打包,有的是为了限制调试环境,还有的是把签名结果当作某些敏感逻辑的“开关”。
站在开发者角度,这很正常;站在逆向分析角度,签名校验往往就是第一道门。本文不讨论恶意用途,而是聚焦合法授权测试、加固分析、攻防学习场景,带你用 JADX 做静态定位,再用 Frida 做动态验证与绕过。
我会按“先定位、再验证、最后最小修改绕过”的思路来讲。这个流程比上来就全局 Hook 更稳,也更容易知道自己改动了什么。
背景与问题
App 为什么做签名校验
常见目的有几类:
-
防二次打包
- 重新签名后,应用检测到证书不一致,直接退出或限制功能。
-
环境检测
- 配合 root、debuggable、模拟器检测一起做,签名校验只是其中一环。
-
接口保护
- 某些老项目会把签名摘要参与请求参数计算,服务端也会校验。
-
动态加载保护
- 插件、SO、Dex 动态加载前会先检查宿主签名。
常见表现
你在分析时,通常会遇到这些现象:
- App 一启动就闪退
- 某个核心页面打不开
- 登录、支付、会员等功能不可用
- 日志里出现
signature invalid、tampered、verify failed - Frida 附加后行为异常,但 UI 不明显报错
很多中级学习者卡在这一步,不是不会写 Hook,而是找不到真正的校验点。
所以本文重点不是“给你一个万能脚本”,而是教你怎么从 APK 里定位逻辑,再用 Frida 精准切入。
前置知识与环境准备
你需要准备的工具
- JADX
- 用来反编译 APK,查看 Java/Kotlin 层逻辑
- Frida
- 动态 Hook Java 层 / Native 层
- adb
- 安装 APK、看日志、转发端口
- 一台测试机或模拟器
- 推荐 Android 8~13 均可
- frida-server
- 设备端服务,与 Frida 版本保持一致
基本命令确认
先确认设备连通:
adb devices
确认 Frida 能识别进程:
frida-ps -U
如果要在启动时附加目标 App:
frida -U -f com.example.target -l bypass.js --no-pause
合法边界
请只在以下范围内操作:
- 自有 App 的安全测试
- 获得授权的渗透/加固评估
- 教学与实验环境
不要把本文方法用于未授权目标。
核心原理
Android 的签名校验,本质上是:App 在运行时取到自身 APK 或安装包签名,再与内置的期望值进行比对。
常见实现路径
- PackageManager 获取签名
- 计算证书摘要
- MD5 / SHA1 / SHA256
- 和硬编码值比较
- 触发限制逻辑
- return false
- finish()
- throw RuntimeException
- native 校验失败后退出
一张总览图
flowchart TD
A[启动 App] --> B[获取当前包签名]
B --> C[证书转字节/字符串]
C --> D[计算摘要 MD5/SHA1/SHA256]
D --> E{是否与预置值一致}
E -- 是 --> F[继续执行核心功能]
E -- 否 --> G[退出/限制功能/报错]
常见 Java 层校验写法
1)老接口:GET_SIGNATURES
旧项目里很常见:
PackageInfo pi = pm.getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES);
Signature[] signs = pi.signatures;
String md5 = md5(signs[0].toByteArray());
return "预置摘要".equalsIgnoreCase(md5);
2)新接口:GET_SIGNING_CERTIFICATES
Android 9 以后更常见:
PackageInfo pi = pm.getPackageInfo(getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
SigningInfo signingInfo = pi.signingInfo;
Signature[] signs = signingInfo.getApkContentsSigners();
3)直接比较签名字符串
String sign = signatures[0].toCharsString();
if (!EXPECTED.equals(sign)) {
System.exit(0);
}
4)校验封装在工具类或 Application 里
例如:
AppSignUtil.checkSign(context)SecurityManager.verifySignature()BaseApplication.attachBaseContext()SplashActivity.onCreate()
动态分析时的思路
我一般按这个顺序:
-
JADX 搜关键字
getPackageInfosignaturesSigningInfoSHA1SHA-256toCharsStringMessageDigestPackageManager
-
找比较点
equalsequalsIgnoreCasecontains- 布尔返回值
-
找失败后的动作
finish()exitProcessSystem.exit- 抛异常
- 调 native 方法
-
Frida 动态验证
- 打印方法参数与返回值
- 确认是不是核心校验
- 最后只改必要位置
用 JADX 定位签名校验逻辑
这一段很关键。很多时候你真正需要 Hook 的不是系统 API,而是App 自己封装的校验方法。
第一步:全局搜索关键字
在 JADX 中全局搜索这些词:
getPackageInfoGET_SIGNATURESGET_SIGNING_CERTIFICATESsignaturessigningInfoMessageDigestSHA1SHA-256X509Certificate
如果搜索结果很多,优先看:
ApplicationSplashActivityLoginActivitySecurityUtilCheckSignUtilNativeBridge
第二步:识别“签名获取 + 摘要计算 + 比较”三段式
典型伪代码如下:
public static boolean check(Context context) {
try {
PackageManager pm = context.getPackageManager();
PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 64);
Signature signature = pi.signatures[0];
byte[] cert = signature.toByteArray();
String sha1 = digest(cert, "SHA-1");
return "12AB34CD...".equalsIgnoreCase(sha1);
} catch (Exception e) {
return false;
}
}
只要你看到类似结构,基本就是目标点。
第三步:顺藤摸瓜找到调用链
不要停在工具函数本身,继续看谁调用了它。
因为你真正要绕过的,可能是下面这种业务逻辑:
if (!SignCheckUtil.check(this)) {
Toast.makeText(this, "环境异常", 0).show();
finish();
}
或者:
boolean ok = SecurityManager.verify(this);
nativeInit(ok);
这会决定你的 Hook 策略是:
- 直接改
check()返回值 - 还是改获取签名的系统 API
- 还是拦截失败分支
一张定位路径图
sequenceDiagram
participant A as 分析者
participant J as JADX
participant C as 校验工具类
participant B as 业务入口
A->>J: 搜索 signatures / signingInfo / MessageDigest
J-->>A: 返回工具类与调用点
A->>C: 查看签名获取与摘要计算
A->>B: 查看失败分支
A->>A: 决定 Hook 粒度
实战:Frida 绕过常见签名校验
下面给出一个完整的实践流程。
为了更贴近实际,我按三层来做:
- 先观察
- 再验证
- 最后绕过
实战代码(可运行)
方案一:Hook App 自己的校验函数(首选)
如果你已经通过 JADX 找到了类似 com.example.app.SecurityUtil.checkSign(Context) 的方法,最稳的办法就是直接改它的返回值。
示例脚本:强制返回 true
Java.perform(function () {
var SecurityUtil = Java.use('com.example.app.SecurityUtil');
SecurityUtil.checkSign.overload('android.content.Context').implementation = function (ctx) {
console.log('[+] SecurityUtil.checkSign() called, force return true');
return true;
};
});
运行:
frida -U -f com.example.target -l bypass.js --no-pause
这种方法优点很明显:
- 改动小
- 命中准
- 不容易影响其他逻辑
如果有多个重载,先枚举一下:
Java.perform(function () {
var SecurityUtil = Java.use('com.example.app.SecurityUtil');
SecurityUtil.checkSign.overloads.forEach(function (o) {
console.log(o);
});
});
方案二:Hook PackageManager.getPackageInfo,替换或观测签名获取
如果找不到上层封装,或者校验点很多,可以先从系统 API 入手观察。
观察版脚本:打印包名与 flags
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 => pkg=' + pkg + ', flags=' + flags);
return this.getPackageInfo(pkg, flags);
};
});
如果目标使用的是新版签名接口,还要看更高版本的重载:
Java.perform(function () {
var ApplicationPackageManager = Java.use('android.app.ApplicationPackageManager');
ApplicationPackageManager.getPackageInfo.overloads.forEach(function (ov) {
console.log('[*] overload => ' + ov);
});
});
直接绕过思路
如果 App 最终比较的是 PackageInfo.signatures 或 SigningInfo 的内容,你可以进一步 Hook 签名对象的字符串转换或摘要计算函数。
方案三:Hook 摘要比较函数,直接让比较通过
有些 App 会把签名处理得比较绕,但最后一定有一个“比较”的动作。
这时可以 Hook java.lang.String.equals 或业务类里的比较函数做验证,不过我不建议长期用全局 Hook,因为副作用较大。
验证版:仅打印可疑比较
Java.perform(function () {
var StringCls = Java.use('java.lang.String');
StringCls.equals.overload('java.lang.Object').implementation = function (obj) {
var left = this.toString();
var right = obj ? obj.toString() : 'null';
if (left.length > 20 || right.length > 20) {
if (left.indexOf(':') !== -1 || right.indexOf(':') !== -1 || /^[0-9A-Fa-f]+$/.test(left) || /^[0-9A-Fa-f]+$/.test(right)) {
console.log('[*] String.equals => left=' + left + ', right=' + right);
}
}
return this.equals(obj);
};
});
这个脚本主要用于观察,看是不是在比较证书摘要。
确认后,应该回去找更精准的业务函数下手。
方案四:Hook MessageDigest,定位签名摘要算法
当你在 JADX 里只看到了很多加密逻辑,不确定哪个是签名摘要时,可以 Hook MessageDigest。
Java.perform(function () {
var MessageDigest = Java.use('java.security.MessageDigest');
MessageDigest.getInstance.overload('java.lang.String').implementation = function (alg) {
console.log('[*] MessageDigest.getInstance => ' + alg);
return this.getInstance(alg);
};
});
如果想进一步打印输入输出,可以继续 Hook digest()。不过要注意输出可能很多。
Java.perform(function () {
var MessageDigest = Java.use('java.security.MessageDigest');
MessageDigest.digest.overload('[B').implementation = function (data) {
var ret = this.digest(data);
console.log('[*] MessageDigest.digest called, inputLen=' + data.length + ', outputLen=' + ret.length);
return ret;
};
});
方案五:直接拦截失败动作
这是我在一些“逻辑分散、校验点不好找”的样本里常用的兜底法。
比如 App 校验失败后直接 finish(),那就先阻止它退出,保住现场。
Hook Activity.finish
Java.perform(function () {
var Activity = Java.use('android.app.Activity');
Activity.finish.implementation = function () {
var name = this.getClass().getName();
console.log('[!] Activity.finish intercepted => ' + name);
// 不调用原方法,阻止关闭
};
});
Hook System.exit
Java.perform(function () {
var SystemCls = Java.use('java.lang.System');
SystemCls.exit.implementation = function (code) {
console.log('[!] System.exit intercepted => code=' + code);
// 阻止退出
};
});
这个办法不一定能真正“通过校验”,但它可以让你继续观察后续逻辑,快速缩小排查范围。
一套更完整的组合脚本
下面给一个更实用的组合脚本:
它会同时做三件事:
- 观察签名相关 API
- 打印摘要算法
- 如果命中特定业务类,强制返回成功
你只需要把类名改成自己在 JADX 里看到的即可。
Java.perform(function () {
console.log('[+] script loaded');
// 1. 观察 PackageInfo 获取
try {
var APM = Java.use('android.app.ApplicationPackageManager');
APM.getPackageInfo.overload('java.lang.String', 'int').implementation = function (pkg, flags) {
console.log('[*] getPackageInfo(pkg=' + pkg + ', flags=' + flags + ')');
return this.getPackageInfo(pkg, flags);
};
} catch (e) {
console.log('[-] hook getPackageInfo failed: ' + e);
}
// 2. 观察摘要算法
try {
var MessageDigest = Java.use('java.security.MessageDigest');
MessageDigest.getInstance.overload('java.lang.String').implementation = function (alg) {
console.log('[*] MessageDigest algorithm => ' + alg);
return this.getInstance(alg);
};
} catch (e) {
console.log('[-] hook MessageDigest failed: ' + e);
}
// 3. 精准绕过业务函数
try {
var SecurityUtil = Java.use('com.example.app.SecurityUtil');
SecurityUtil.checkSign.overload('android.content.Context').implementation = function (ctx) {
console.log('[+] bypass SecurityUtil.checkSign');
return true;
};
} catch (e) {
console.log('[-] hook SecurityUtil.checkSign failed: ' + e);
}
// 4. 兜底:拦截退出
try {
var SystemCls = Java.use('java.lang.System');
SystemCls.exit.implementation = function (code) {
console.log('[!] blocked System.exit(' + code + ')');
};
} catch (e) {
console.log('[-] hook System.exit failed: ' + e);
}
});
逐步验证清单
我建议你不要一次写太多 Hook,而是按下面的顺序逐步验证:
第 1 步:确认静态定位没偏
- 在 JADX 中找到疑似签名校验函数
- 确认它被启动流程或核心功能调用
- 确认失败分支存在明显限制逻辑
第 2 步:只打印,不修改
先运行观察脚本,确认:
getPackageInfo是否真的被调用- 摘要算法是否命中
- 业务校验函数是否在关键时机被执行
第 3 步:只改一个点
优先改:
checkSign()返回值verify()返回值- 单一业务判断分支
不要一开始就全局改 String.equals()。
第 4 步:观察功能是否恢复
检查:
- App 是否不再闪退
- 目标页面是否可以进入
- 网络请求是否恢复
- 是否仍有 native 层报错
第 5 步:精简脚本
当你已经确认绕过成功后,把临时观测 Hook 删除,只保留:
- 最少的业务 Hook
- 必要的退出拦截
- 少量日志
这样稳定性最好。
常见坑与排查
这一部分基本都是实战里最容易翻车的点。
1. Hook 了,但没有生效
常见原因:
- 类名写错
- 方法重载签名不对
- 目标类还没加载
- 你附加太晚,校验已经执行完了
排查方法:
Java.perform(function () {
Java.enumerateLoadedClasses({
onMatch: function (name) {
if (name.indexOf('Security') !== -1 || name.indexOf('Sign') !== -1) {
console.log(name);
}
},
onComplete: function () {}
});
});
如果校验在启动早期执行,优先使用:
frida -U -f com.example.target -l bypass.js --no-pause
而不是 attach 到已经启动的进程。
2. Android 版本不同,签名 API 不一样
老版本常见:
PackageInfo.signatures
新版本常见:
PackageInfo.signingInfoSigningInfo.getApkContentsSigners()
所以在 JADX 里看到的实现可能与网上示例不同。
不要死记 API 名字,要抓住本质:一定有“取签名 -> 处理 -> 比较”。
3. 校验在 Native 层
表现通常是:
- Java 层看起来只做了很少的事情
- 校验函数很快进入
native方法 - Java 层 Hook 返回 true 后仍然退出
例如:
public native boolean verifyNative(Context context);
这时需要进一步:
- 找
System.loadLibrary - 看 native 方法注册
- 用 Frida 的
Interceptor.attach去 Hook so 导出或 JNI 注册函数
如果你还在学习阶段,建议先确认 Java 层是否只是“前置筛选”,不要一上来就把问题全归到 native。
4. App 开了混淆,函数名全乱了
混淆不是大问题,关键还是看行为特征。
我通常这样找:
- 搜
getPackageInfo - 搜
MessageDigest - 搜硬编码摘要字符串
- 看返回
boolean的短函数 - 看
Application、attachBaseContext、启动 Activity
即使类名是 a.a.a.b,逻辑结构也骗不了人。
5. Hook 过于粗暴,App 其他功能异常
比如你全局改了:
String.equalsMessageDigest.digestActivity.finish
会导致:
- 登录逻辑异常
- 页面无法正常关闭
- 摘要算法结果紊乱
我的经验是:
全局 Hook 只用于定位,不用于最终方案。
最终脚本尽量只改业务函数返回值。
6. 多进程 App,Hook 到了错误进程
有些 App 会把校验放在:
:push:guard:remote- 独立壳进程
先看看进程列表:
frida-ps -Uai
必要时指定进程名附加。
安全/性能最佳实践
虽然我们这里讨论的是逆向分析,但做动态 Hook 也要讲“工程习惯”。
1. 优先精准 Hook,避免全局污染
推荐优先级:
- 业务工具类
checkSign()/verify() - 单一调用点
- 系统签名 API
- 全局比较或退出函数
这样能最大限度减少副作用。
2. 日志适度,不要刷爆输出
像 String.equals、MessageDigest.digest 这类方法调用频率可能很高。
日志过多会导致:
- Frida 卡顿
- App 响应变慢
- 关键日志被淹没
建议只打印满足条件的调用,或者阶段性开启。
3. 对异常做好兜底
Frida 脚本里尽量用 try/catch 包住每个 Hook 点:
try {
// hook logic
} catch (e) {
console.log(e);
}
这样即使某个类不存在,也不会让整个脚本失效。
4. 保持版本匹配
确保:
frida-tools版本fridaPython 包版本frida-server版本
三者尽量一致。
很多“莫名其妙连不上”“注入后崩溃”的问题,本质就是版本不匹配。
5. 先验证边界,再做结论
即使绕过了本地签名校验,也不代表整个保护链都过了。
因为还有可能存在:
- 服务端二次校验
- native 层再次校验
- Dex/so 完整性校验
- 运行时反调试
所以当你看到“界面能打开了”,不要立刻下结论。
最好再验证:
- 网络请求是否成功
- 核心功能是否可用
- 是否存在延迟触发的退出逻辑
一个实战思路模板
如果你以后拿到一个陌生 APK,可以直接按这个模板走:
flowchart LR
A[导入 JADX] --> B[搜索签名相关 API]
B --> C[锁定工具类和调用入口]
C --> D[Frida 观察调用链]
D --> E[确认真实校验点]
E --> F[精准 Hook 返回值]
F --> G[验证功能恢复]
G --> H[删除多余 Hook 保留最小脚本]
总结
这类签名校验绕过,最怕两件事:
- 只会“抄脚本”,不会定位逻辑
- 一上来就全局 Hook,结果把现场弄乱
更稳的做法是:
-
JADX 静态定位
- 找“取签名 -> 算摘要 -> 做比较 -> 失败分支”
-
Frida 动态验证
- 先打印,再确认调用链
-
最小化绕过
- 优先改业务函数返回值
- 不行再拦截系统 API 或失败动作
-
验证完整链路
- 不只看 UI,还要看网络和延迟触发逻辑
如果你是中级读者,我给你的可执行建议只有一句:
以后遇到签名校验,先别急着 Hook 系统层,先在 JADX 里把调用链走通。
你一旦能稳定找到“真正决定成败的那个 boolean”,后面的绕过其实只是技术细节。
如果目标样本已经把校验下沉到 native 或做了壳保护,那就是另一套打法了;但即便如此,本文这套“静态定位 + 动态验证 + 最小修改”的方法,仍然是非常可靠的起点。