安卓逆向实战:从 Frida 动态 Hook 到定位并绕过常见 App 签名校验逻辑
很多同学第一次做 Android 动态分析时,都会卡在一个很现实的问题上:App 一启动就闪退,或者进入关键页面时直接提示“签名异常”“环境风险”“请从官方渠道安装”。
这类问题的根子,往往就是签名校验、完整性校验,或者两者的组合。
这篇文章我不打算只讲“怎么写几行 Frida Hook 代码”,而是想带你走一条更接近真实项目的路径:
- 先理解 App 常见签名校验到底在校验什么;
- 再用 Frida 动态 Hook 去定位“谁在校验”;
- 最后再按不同场景选择合适的绕过方式。
说明:本文内容用于授权测试、安全研究与教学。不要把这些方法用于未授权目标。
背景与问题
Android App 的“签名校验”并不只有一种写法。实战里常见的有这几类:
- Java 层取签名比对
PackageManager.getPackageInfo()- 新版 API 里的
getSigningInfo()
- 读取 APK 自身证书摘要
- 计算
MD5/SHA1/SHA256
- 计算
- Native 层校验
- JNI 调 Java 拿签名
- 直接在 so 里做摘要比对
- 多点校验
- Application 启动时一次
- 登录前一次
- 核心接口请求前再来一次
- 和环境检测绑定
- Root 检测
- 调试检测
- Frida 检测
- 模拟器检测
很多人一上来就写一个“万能 Hook”,结果发现:
- Hook 了
getPackageInfo,App 还是闪退; - 改了返回值,结果后面 Native 校验又把你拦住;
- 某个页面能进,但接口层又报签名不一致;
- 甚至还没执行到你写的 Hook,App 就在
attachBaseContext里先做检测了。
所以这类问题的核心不是“会不会 Hook”,而是:
能不能快速确定:校验点在哪里、在什么层、依赖什么数据、应该改输入还是改结果。
前置知识与环境准备
你需要准备什么
- 一台测试 Android 设备或模拟器
- 已安装
frida-server - PC 端安装:
adbfrida-tools- 可选:
jadx、apktool
- 目标 App(仅限授权测试)
版本建议
- Frida 建议客户端与服务端版本一致
- Android 9 及以上,很多签名相关 API 行为和低版本不同
- 如果目标 App 有加固,优先准备:
- spawn 模式注入
- 延迟 Hook
- ClassLoader 切换方案
基本连通性验证
adb devices
frida-ps -U
frida -U -f com.target.app -l test.js
一个最小测试脚本:
Java.perform(function () {
console.log("Frida attached.");
});
如果这一步都不稳定,后面所有分析都会很痛苦。这个坑我踩过,尤其是设备端 frida-server 版本不匹配时,表现特别像“Hook 写错了”。
核心原理
1. App 到底在校验什么
Android 应用签名校验,本质上是把运行时拿到的签名信息,与预置的合法签名做比较。
常见数据来源包括:
- 当前包名对应的安装包签名
- APK 文件里的证书信息
- 服务器下发的签名白名单
- Native 层硬编码的摘要值
一个典型 Java 校验流程如下:
flowchart TD
A[App 启动/进入关键功能] --> B[获取当前包签名]
B --> C[计算摘要 MD5/SHA1/SHA256]
C --> D[与预置值比对]
D -->|一致| E[正常继续]
D -->|不一致| F[闪退/弹框/禁用功能]
2. 为什么 Frida 有效
Frida 的价值不只是“改返回值”,更重要的是它能让我们:
- 观察谁调用了签名 API
- 打印调用栈
- 看参数和返回值
- 在校验前改输入,在校验后改结果
- Java 层不够时,再进 Native 层
3. 绕过思路的三种层次
我通常把绕过方式分成三层:
第一层:Hook 系统 API,伪造签名来源
优点:
- 覆盖面广
- 适合 App 直接调用系统接口取签名
缺点:
- 容易误伤其他逻辑
- 新旧 API 差异要处理
第二层:Hook 业务校验函数,直接改结果
优点:
- 精准、稳定
- 对多层封装的 App 更有效
缺点:
- 需要先定位到业务方法
第三层:Hook Native 校验逻辑
优点:
- 能处理 so 层比对
- 遇到加固/混淆时常常绕不过去必须上
缺点:
- 难度更高
- ABI、符号、时机都更敏感
一张图看完整定位路径
sequenceDiagram
participant U as 分析者
participant F as Frida
participant J as Java层
participant N as Native层
participant A as App逻辑
U->>F: 注入 Hook 脚本
F->>J: 监听 getPackageInfo / getSigningInfo / MessageDigest
J->>A: 返回签名相关数据
A->>A: 做摘要/比对
alt Java 层直接校验
F->>A: Hook 业务方法并改返回值
else Native 层继续校验
F->>N: Hook JNI/导出函数/关键 libc 调用
N->>A: 返回伪造结果
end
A-->>U: 成功进入功能/不再报签名异常
逐步实战:从“看见调用”到“精准绕过”
下面用一个更接近实战的流程来做。
第一步:全局观察签名相关调用
先别急着改值。第一步一定是打点观察。
目标
监控这些关键点:
PackageManager.getPackageInfoPackageInfo.signaturesPackageInfo.signingInfoSignature.toByteArrayMessageDigest.digest- 可能的业务校验函数调用栈
Frida 观察脚本
Java.perform(function () {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
var PackageManager = Java.use("android.app.ApplicationPackageManager");
var Signature = Java.use("android.content.pm.Signature");
var MessageDigest = Java.use("java.security.MessageDigest");
function printStack(tag) {
console.log("======== " + tag + " Stack ========");
console.log(Log.getStackTraceString(Exception.$new()));
console.log("===================================");
}
// 兼容旧 API:getPackageInfo(String, int)
PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
var ret = this.getPackageInfo(pkg, flags);
console.log("[getPackageInfo] pkg=" + pkg + ", flags=" + flags);
printStack("getPackageInfo");
return ret;
};
// 监控 Signature.toByteArray
Signature.toByteArray.implementation = function () {
var ret = this.toByteArray();
console.log("[Signature.toByteArray] len=" + ret.length);
printStack("Signature.toByteArray");
return ret;
};
// 监控 MessageDigest.digest(byte[])
MessageDigest.digest.overload("[B").implementation = function (input) {
var algo = this.getAlgorithm();
console.log("[MessageDigest.digest] algo=" + algo + ", inputLen=" + input.length);
var ret = this.digest(input);
console.log("[MessageDigest.digest] retLen=" + ret.length);
return ret;
};
console.log("signature observe hooks loaded");
});
这一步你要看什么
重点不是输出一堆日志,而是要回答这几个问题:
- 谁在调用
getPackageInfo? flags是多少?- 老版本常见
64 - 新版签名信息常见
134217728
- 老版本常见
- 后续有没有立刻进入
MessageDigest.digest? - 调用栈里有没有明显业务类名?
- 如
com.xxx.security.SignCheck - 或混淆类如
a.b.c.a
- 如
如果你已经看到调用栈落在某个业务类里,那恭喜,后面大概率可以直接精确打击。
第二步:Hook 系统签名接口,验证是否为 Java 层校验
很多 App 会直接通过 PackageManager 获取当前包签名。
这时候最简单的验证方式是:先改系统 API 的输出,观察行为是否变化。
方案 A:直接拦截旧版签名获取逻辑
不少 App 还在用:
GET_SIGNATURES = 64
下面脚本演示如何打印并识别目标调用。这里先做“观察+可控干预”,而不是一开始就硬改所有包。
Java.perform(function () {
var PackageManager = Java.use("android.app.ApplicationPackageManager");
PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
console.log("[*] getPackageInfo called: pkg=" + pkg + ", flags=" + flags);
var info = this.getPackageInfo(pkg, flags);
if (pkg.indexOf("com.target.app") !== -1) {
console.log("[+] target package matched");
if ((flags & 64) !== 0) {
console.log("[+] GET_SIGNATURES requested");
}
if ((flags & 134217728) !== 0) {
console.log("[+] GET_SIGNING_CERTIFICATES requested");
}
}
return info;
};
});
方案 B:直接 Hook 业务比对函数
很多时候,比起伪造 PackageInfo,直接让业务校验永远返回 true 更稳。
假设你通过 Jadx 或栈日志定位到了:
public boolean checkSign(Context context)
那么 Frida 脚本可以这么写:
Java.perform(function () {
var SignCheck = Java.use("com.target.app.security.SignCheck");
SignCheck.checkSign.overload("android.content.Context").implementation = function (ctx) {
console.log("[+] checkSign bypassed");
return true;
};
});
如何判断这招是否有效
看这几个现象:
- 原来启动闪退,现在能进入首页
- 原来弹“签名异常”,现在不弹了
- 日志中校验函数确实被调用了
- 后续接口没再因为完整性失败而拒绝
如果只解决了启动问题,但核心功能仍失败,说明 App 可能还有第二个校验点,甚至可能在 Native 层。
第三步:处理新版签名 API
Android 9 之后,越来越多 App 开始用 SigningInfo。
典型路径是:
getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)- 读取
signingInfo - 再取:
getApkContentsSigners()getSigningCertificateHistory()
Hook 新版签名 API 的观察脚本
Java.perform(function () {
var SigningInfo = Java.use("android.content.pm.SigningInfo");
if (SigningInfo.getApkContentsSigners) {
SigningInfo.getApkContentsSigners.implementation = function () {
console.log("[*] SigningInfo.getApkContentsSigners called");
var ret = this.getApkContentsSigners();
console.log("[*] signer count=" + ret.length);
return ret;
};
}
if (SigningInfo.getSigningCertificateHistory) {
SigningInfo.getSigningCertificateHistory.implementation = function () {
console.log("[*] SigningInfo.getSigningCertificateHistory called");
var ret = this.getSigningCertificateHistory();
console.log("[*] history count=" + ret.length);
return ret;
};
}
});
一个很实用的判断点
如果你发现:
getPackageInfo被调用了signingInfo也被读了- 但最终决定逻辑的其实是某个
equals()比较
那就别死磕系统 API 伪造,直接去找业务层的“摘要比较函数”更省时间。
第四步:定位摘要比对逻辑
很多 App 不直接比较签名原始字节,而是计算摘要后比对字符串,比如:
- SHA1
- SHA256
- MD5(虽然不推荐,但仍然常见)
典型伪代码
Signature[] signs = packageInfo.signatures;
byte[] cert = signs[0].toByteArray();
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(cert);
String hex = bytesToHex(digest);
return "目标摘要".equalsIgnoreCase(hex);
Frida 监控摘要值
Java.perform(function () {
var MessageDigest = Java.use("java.security.MessageDigest");
var StringCls = Java.use("java.lang.String");
function bytesToHex(bytes) {
var result = [];
for (var i = 0; i < bytes.length; i++) {
var b = bytes[i];
if (b < 0) b += 256;
var s = b.toString(16);
if (s.length < 2) s = "0" + s;
result.push(s);
}
return result.join("");
}
MessageDigest.digest.overload("[B").implementation = function (input) {
var algo = this.getAlgorithm();
var ret = this.digest(input);
console.log("[digest] algo=" + algo + ", hex=" + bytesToHex(ret));
return ret;
};
});
这一步的意义
你可以借这个输出判断:
- App 用的是哪种摘要算法;
- 同一份签名是否在多个地方反复比对;
- 有没有把摘要结果交给 Native 层继续处理。
第五步:当 Java 层绕不过去,进入 Native 层
有些 App 会在 so 里做校验。常见迹象:
- Java 层看起来只是“拿签名”
- 但实际结果由 JNI 返回
- 关键方法名可能像:
nativeCheckSignnativeVerify- 混淆后的 native 方法
Java 声明层先定位 native 方法
Java.perform(function () {
var Target = Java.use("com.target.app.security.NativeGuard");
Target.nativeCheckSign.implementation = function () {
console.log("[+] nativeCheckSign called");
var ret = this.nativeCheckSign();
console.log("[+] nativeCheckSign ret=" + ret);
return ret;
};
});
如果这是 native 方法,Frida 仍然能在 Java 声明层拦它。
很多时候,这已经足够了:直接改 Java 声明层返回值。
Java.perform(function () {
var Target = Java.use("com.target.app.security.NativeGuard");
Target.nativeCheckSign.implementation = function () {
console.log("[+] bypass nativeCheckSign");
return true;
};
});
如果必须进 so 层
你可以先枚举导出符号:
setImmediate(function () {
var moduleName = "libtarget.so";
var exports = Module.enumerateExportsSync(moduleName);
exports.forEach(function (e) {
if (e.name.indexOf("sign") >= 0 || e.name.indexOf("verify") >= 0) {
console.log(e.type + " " + e.name + " @ " + e.address);
}
});
});
再对目标导出函数下断点:
setImmediate(function () {
var addr = Module.findExportByName("libtarget.so", "Java_com_target_app_security_NativeGuard_nativeCheckSign");
if (!addr) {
console.log("export not found");
return;
}
Interceptor.attach(addr, {
onEnter: function (args) {
console.log("[*] nativeCheckSign entered");
},
onLeave: function (retval) {
console.log("[*] nativeCheckSign leave, original=" + retval);
retval.replace(0x1);
console.log("[+] nativeCheckSign forced to true");
}
});
});
多点校验时,建议这样分层处理
真实场景下,一个 App 可能不止一个校验点。
我更推荐“由浅入深”的分层法,而不是上来写一个到处乱改的脚本。
stateDiagram-v2
[*] --> 启动校验
启动校验 --> 页面校验
页面校验 --> 接口前校验
接口前校验 --> Native补充校验
Native补充校验 --> [*]
分层策略
- 先拦启动阶段
- 避免 App 直接退出
- 再拦关键页面
- 确认是哪个功能触发校验
- 最后处理接口前校验
- 这类往往最隐蔽,也最影响实际测试
实战代码:一个更完整的可运行脚本
下面给一份适合实战起手的脚本模板。
它做了几件事:
- Hook
getPackageInfo - Hook
Signature.toByteArray - Hook
MessageDigest.digest - 支持按类名直接 bypass 某个业务函数
- 支持打印调用栈
你可以保存为 sign_trace.js 直接跑。
Java.perform(function () {
var Exception = Java.use("java.lang.Exception");
var Log = Java.use("android.util.Log");
var PackageManager = Java.use("android.app.ApplicationPackageManager");
var Signature = Java.use("android.content.pm.Signature");
var MessageDigest = Java.use("java.security.MessageDigest");
function stack(tag) {
console.log("\n===== " + tag + " =====");
console.log(Log.getStackTraceString(Exception.$new()));
console.log("========================\n");
}
function bytesToHex(bytes) {
var out = [];
for (var i = 0; i < bytes.length; i++) {
var v = bytes[i];
if (v < 0) v += 256;
var h = v.toString(16);
if (h.length === 1) h = "0" + h;
out.push(h);
}
return out.join("");
}
PackageManager.getPackageInfo.overload("java.lang.String", "int").implementation = function (pkg, flags) {
var ret = this.getPackageInfo(pkg, flags);
console.log("[getPackageInfo] pkg=" + pkg + ", flags=" + flags);
if ((flags & 64) !== 0 || (flags & 134217728) !== 0) {
stack("getPackageInfo(sign-related)");
}
return ret;
};
Signature.toByteArray.implementation = function () {
var ret = this.toByteArray();
console.log("[Signature.toByteArray] len=" + ret.length);
stack("Signature.toByteArray");
return ret;
};
MessageDigest.digest.overload("[B").implementation = function (input) {
var algo = this.getAlgorithm();
var ret = this.digest(input);
console.log("[MessageDigest.digest] algo=" + algo + ", out=" + bytesToHex(ret));
return ret;
};
// 示例:已知业务函数时,直接 bypass
try {
var SignCheck = Java.use("com.target.app.security.SignCheck");
SignCheck.checkSign.overload("android.content.Context").implementation = function (ctx) {
console.log("[bypass] com.target.app.security.SignCheck.checkSign");
return true;
};
console.log("[+] business bypass hook loaded");
} catch (e) {
console.log("[-] business hook not loaded: " + e);
}
console.log("[*] sign trace hooks ready");
});
运行方式:
frida -U -f com.target.app -l sign_trace.js
如果 App 启动太快,可以加 --no-pause 视情况调整:
frida -U -f com.target.app -l sign_trace.js --no-pause
定位路径:从静态分析到动态验证
只靠动态 Hook 也能做,但效率通常不如“静态 + 动态”结合。
我一般的做法
1. 先用 Jadx 搜索关键词
搜索这些关键字很有帮助:
getPackageInfosignaturessigningInfoMessageDigestSHA1SHA-256MD5SignatureX509CertificateCertificateFactory
2. 看谁在 Application 或启动页调用
很多校验会出现在:
Application.onCreateattachBaseContext- SplashActivity
- 登录前拦截器
3. 再用 Frida 验证
静态分析猜到位置后,用 Hook 做两件事:
- 证实它真的被执行
- 证实改结果后行为真的改变
这个闭环很重要。
不然你会很容易陷入“看起来像校验点,但其实只是辅助函数”的误判。
常见坑与排查
这一部分非常关键。很多逆向失败,不是原理没懂,而是卡在细节。
1. Hook 太晚,关键逻辑已经执行完了
现象:
- 明明脚本没报错,但 App 还是启动即退
- 日志里看不到目标方法被触发
排查:
- 用
-f方式 spawn 启动目标 App - 优先 Hook
Application.attach或更早阶段 - 遇到加固壳时,注意真实 ClassLoader 可能后加载
一个常见的早期 Hook 模板:
Java.perform(function () {
var Application = Java.use("android.app.Application");
Application.attach.overload("android.content.Context").implementation = function (ctx) {
console.log("[*] Application.attach");
this.attach(ctx);
};
});
2. 类找不到,其实是 ClassLoader 问题
现象:
Java.use("com.xxx.SignCheck")报错- Jadx 明明看得到类,Frida 却抓不到
原因:
- App 使用了自定义 ClassLoader
- 加固后真实 dex 延迟加载
思路:
- 先枚举已加载类
- 监听
ClassLoader.loadClass - 在合适时机再
Java.use
示例:
Java.perform(function () {
var ClassLoader = Java.use("java.lang.ClassLoader");
ClassLoader.loadClass.overload("java.lang.String").implementation = function (name) {
var ret = this.loadClass(name);
if (name.indexOf("Sign") >= 0 || name.indexOf("security") >= 0) {
console.log("[loadClass] " + name);
}
return ret;
};
});
3. 改了 Java 层返回值,结果还是失败
常见原因:
- Native 层还有二次校验
- 服务端也做了签名关联校验
- 校验值被缓存了
- 你 Hook 的不是最终判断点
建议:
- 跟踪最终“失败分支”
- 看弹框/闪退前最后一个业务方法
- 必要时 Hook
System.exit、finish()、异常抛出点辅助定位
4. Hook getPackageInfo 后 App 行为异常
这是我很常见的一类踩坑。
原因:
- 你把所有包都影响了
- 某些逻辑依赖真实
PackageInfo - 返回对象结构没处理完整
建议:
- 只对目标包名生效
- 能 Hook 业务函数就不要全局改系统 API
- 改值前先观察,确认最小影响范围
5. 混淆严重,找不到签名校验类
思路:
即使类名混淆了,系统 API 和标准库调用通常还在。
所以你可以从这些固定点反推:
PackageManager.getPackageInfoSignature.toByteArrayMessageDigest.digest
再通过调用栈把业务类捞出来。
安全/性能最佳实践
逆向分析里,脚本“能跑”只是第一步,跑得稳、影响小、便于复用 才更接近真实项目要求。
1. 优先做“观察型 Hook”,再做“修改型 Hook”
先确认:
- 校验点是否真的存在
- 是否只在某个页面触发
- 摘要值是否固定
再决定改输入还是改结果。
上来就大面积强改,往往会把问题搞复杂。
2. 只对目标路径生效
例如:
- 只处理目标包名
- 只在调用栈命中特定类时改值
- 只对特定方法返回值做替换
这样更不容易破坏 App 其他功能。
3. 谨慎打印大对象和高频日志
MessageDigest.digest、字符串比较、集合遍历这类方法可能调用非常频繁。
日志打太多会带来两个问题:
- App 变卡
- 你自己看日志看晕
建议:
- 先按算法名过滤
- 只对长度、摘要前几位做输出
- 调试完成后关掉详细日志
4. 业务函数优先于系统 API
如果你已经定位到最终判断函数,例如:
isSignatureValid()checkAppIntegrity()nativeVerify()
那优先 Hook 这些函数。
因为它们通常比底层系统 API 更稳定,也更不容易误伤。
5. 做好边界判断
例如:
- Android 版本差异
- 方法重载差异
- 新旧签名 API 共存
- App 多进程
一个稳一点的脚本,通常都会有 try/catch 和存在性判断。
一个简单的决策表
| 场景 | 推荐做法 | 备注 |
|---|---|---|
App 直接用 getPackageInfo 取签名 | 先观察系统 API,再反推业务类 | 适合初步定位 |
已定位到 checkSign() 一类函数 | 直接 Hook 返回值 | 精准、稳定 |
| Java 层只做过渡,so 才是真正校验 | Hook Java native 声明层或 so 导出函数 | 常见于加固或高敏感 App |
| 多点校验 | 分阶段逐个击破 | 不要试图一次性全局硬改 |
| 启动即闪退 | spawn 注入,尽量提前 Hook | 注意 attachBaseContext |
逐步验证清单
实战时你可以按这个清单走,基本不容易乱:
阶段一:确认注入稳定
-
frida-ps -U能看到设备 - 脚本能正常加载
- App 不因注入立刻崩溃
阶段二:确认签名相关调用存在
-
getPackageInfo有调用日志 -
Signature.toByteArray有调用日志 -
MessageDigest.digest有摘要日志 - 至少拿到一条有效调用栈
阶段三:确认业务校验点
- 能定位到业务类/方法
- 改该方法返回值后行为发生变化
- 关键页面能正常进入
阶段四:确认是否存在二次校验
- 启动后不报错
- 进入核心功能不报错
- 发起关键请求不再因完整性失败
阶段五:收敛脚本
- 去掉无用日志
- 缩小 Hook 范围
- 保留必要注释,方便复现
总结
签名校验绕过这件事,表面上看像是在“改一个返回值”,但真正决定效率的,是你能不能快速回答这几个问题:
- 校验发生在哪一层?Java 还是 Native?
- 它取的是原始签名、摘要值,还是业务封装后的结果?
- 最终决策点在哪个函数?
- 是单点校验,还是多点串联?
如果你是中级读者,我给你的可执行建议是:
- 先观察,后修改;
- 优先找业务判断函数,而不是全局乱改系统 API;
- Java 层无果时,及时转向 Native 层;
- 每次只解决一个校验点,并验证行为变化。
最后再强调一次边界:
本文方法适用于授权测试、教学和研究。面对带服务端强绑定校验、设备指纹联动、远程完整性验证的目标时,单纯本地 Hook 往往只能解决一部分问题,不能无限泛化。
如果你按本文流程做下来,至少能把“签名异常到底卡在哪”这个问题,从黑盒变成白盒。
而一旦能定位,绕过通常只是时间问题。