安卓逆向实战:用 Frida 定位并绕过常见 APK 签名校验与反调试逻辑
很多同学一上来做 Android 逆向,最先撞上的不是业务逻辑,而是两道“门神”:
- APK 签名校验
- 反调试 / 反注入检测
现象通常很熟悉:
APP 装上能开,但一注入 Frida 就闪退;或者重打包后启动直接提示“环境异常”“签名错误”“非法客户端”。
这篇文章我不打算只讲概念,而是按真实排查路径带你做一遍:
先定位签名校验点,再用 Frida 动态绕过;接着处理常见反调试逻辑;最后给一套排查和验证清单。
说明:本文内容仅用于授权测试、安全研究与教学。不要将方法用于未授权应用。
背景与问题
Android 应用常见的保护思路,基本都围绕“判断你是不是官方包、你是不是在调试我、你是不是被注入了”展开。
在实际项目里,我最常见到的有这几类:
- Java 层签名校验
PackageManager.getPackageInfo(..., GET_SIGNATURES)GET_SIGNING_CERTIFICATES- 读取
SigningInfo - 对证书做
SHA1 / SHA256 / MD5指纹比对
- Native 层签名校验
- JNI 调 Java 签名接口
- 直接解析 APK / 证书
- 反调试
Debug.isDebuggerConnected()ApplicationInfo.FLAG_DEBUGGABLE- 读取
/proc/self/status里的TracerPid ptrace检测
- 反 Frida / 反注入
- 扫描端口
- 枚举进程映射、线程名、so 名称
- 检查
frida-server、gum-js-loop等特征
如果你只是“知道怎么 hook 一个函数”,往往不够。真正难的是:
- 校验点藏在哪里
- 到底是 Java 还是 Native 在做
- 绕过后为什么还会闪退
- 为什么脚本明明生效了但业务还是进不去
所以这篇文章的重点是:定位路径 + 可运行脚本 + 排错方法。
前置知识与环境准备
你需要准备什么
- 一台 Android 测试机或模拟器
adbfrida-tools- 与目标架构匹配的
frida-server - 基础 Java / Android API 认知
- 知道如何使用
jadx看 Java 代码
安装 Frida 工具:
pip install frida-tools
确认设备连接:
adb devices
frida-ps -U
如果是 root 机并使用 frida-server:
adb push frida-server /data/local/tmp/
adb shell "chmod 755 /data/local/tmp/frida-server"
adb shell "/data/local/tmp/frida-server &"
背景场景:我们要解决什么问题
这里假设目标 APK 有两个典型限制:
- 启动时做签名校验
如果签名不是预期值,弹“非法安装包”并退出。 - 启动后检测调试环境
发现 debugger、TracerPid 异常、Frida 特征,就主动闪退。
我们要做到的是:
- 不改包,优先动态注入绕过
- 能定位校验函数
- 能验证绕过是否真的成功
核心原理
这一部分先把“为什么这么 hook”说清楚。
1. APK 签名校验的常见链路
Java 层常见调用链如下:
flowchart TD
A[应用启动] --> B[获取 PackageManager]
B --> C[getPackageInfo / getPackageArchiveInfo]
C --> D[读取 signatures 或 signingInfo]
D --> E[证书转字节/字符串]
E --> F[做 MD5/SHA1/SHA256]
F --> G[与内置白名单比较]
G --> H{是否匹配}
H -- 是 --> I[继续业务]
H -- 否 --> J[退出/降级/提示异常]
所以动态绕过的思路通常有三种:
- 改结果:直接把“校验通过”改成
true - 改输入:把签名对象、证书摘要伪造成目标值
- 截断流程:让退出逻辑不执行
我一般建议优先级是:
- 先找最终布尔判断点
- 找不到再 hook 摘要函数
- 还不行再改 PackageManager 返回值
因为越靠近业务判断点,副作用通常越小。
2. 反调试的常见链路
sequenceDiagram
participant App as APP
participant Java as Java层检测
participant Native as Native层检测
participant Kernel as 内核/proc
App->>Java: Debug.isDebuggerConnected()
Java-->>App: true/false
App->>Kernel: 读取 /proc/self/status
Kernel-->>App: TracerPid: 0 / 非0
App->>Native: ptrace / 自定义 so 检测
Native-->>App: 正常 / 异常
App->>App: 综合判断是否退出
所以绕过策略也很明确:
- Java 层返回值改掉
- 文件读取结果改掉
- Native 导出函数拦截
- 最后兜底:把“退出”动作拦掉
3. 为什么“只 hook 一个点”经常不够
因为现在很多 APP 会做多点校验:
- Application 启动时校验一次
- 登录前再校验一次
- Native 初始化时再校验一次
- 崩溃前还会走一遍自检
你绕过了 Debug.isDebuggerConnected(),但它还会读 TracerPid;
你改了 Java 层签名摘要,但 Native 里又自己算了一遍。
所以实战里要有“分层观察”的思维。
定位路径:先找签名校验,再找反调试
第一步:静态看 Java 层关键词
先用 jadx 搜这些关键词:
getPackageInfosignaturesSigningInfogetApkContentsSignersMessageDigestisDebuggerConnectedTracerPidexitSystem.exitfinishAffinity
如果代码混淆严重,不要死盯类名,盯 API 调用链。
第二步:用 Frida 做运行时侦察
先不要急着“绕过”,先打印调用栈,看谁在触发。
下面这段脚本适合做侦察。
Java.perform(function () {
function printStack(tag) {
var Log = Java.use("android.util.Log");
var Exception = Java.use("java.lang.Exception");
console.log("====== " + tag + " ======");
console.log(Log.getStackTraceString(Exception.$new()));
}
var PM = Java.use("android.app.ApplicationPackageManager");
if (PM.getPackageInfo.overloads) {
PM.getPackageInfo.overloads.forEach(function (ov) {
ov.implementation = function () {
console.log("[*] getPackageInfo called: " + arguments[0]);
printStack("getPackageInfo");
return ov.apply(this, arguments);
};
});
}
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
console.log("[*] Debug.isDebuggerConnected called");
printStack("isDebuggerConnected");
return false;
};
});
运行:
frida -U -f com.example.target -l scout.js
这一步的目标不是立刻通关,而是确认:
- 哪个类在做签名校验
- 校验在什么时候触发
- 是否是 Java 层直接做判断
实战代码:绕过常见 APK 签名校验
下面给一版可直接运行、比较通用的脚本。它覆盖三层思路:
- 拦截签名读取
- 拦截摘要计算
- 拦截最终布尔判断/退出动作
注意:不同 Android 版本 API 有差异,脚本里做了兼容处理。
方案 A:优先绕过签名比对结果
如果你已经知道某个方法最终返回 boolean,例如 checkSign(),最稳妥是直接改它。
假设你已通过侦察发现目标类是 com.demo.sec.SecurityUtil:
Java.perform(function () {
try {
var SecurityUtil = Java.use("com.demo.sec.SecurityUtil");
SecurityUtil.checkSign.implementation = function () {
console.log("[+] checkSign bypassed");
return true;
};
} catch (e) {
console.log("[-] SecurityUtil.checkSign not found: " + e);
}
});
这种方式副作用最小,但前提是你得先定位到它。
方案 B:通用 hook PackageManager 与签名摘要
当你还没定位到最终判断点时,可以先从“签名获取”和“摘要计算”下手。
Java.perform(function () {
var fakeSha1 = "AA:BB:CC:DD:EE:FF:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE";
var fakeMd5 = "00112233445566778899AABBCCDDEEFF";
var fakeSha256 = "11223344556677889900AABBCCDDEEFF11223344556677889900AABBCCDDEEFF";
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("").toUpperCase();
}
try {
var Signature = Java.use("android.content.pm.Signature");
Signature.toByteArray.implementation = function () {
var ret = this.toByteArray();
console.log("[*] Signature.toByteArray called, len=" + ret.length);
return ret;
};
} catch (e) {
console.log("[-] Signature hook failed: " + e);
}
try {
var MessageDigest = Java.use("java.security.MessageDigest");
MessageDigest.digest.overload('[B').implementation = function (input) {
var algo = this.getAlgorithm().toString();
var ret = this.digest(input);
console.log("[*] MessageDigest.digest([B]) algo=" + algo + ", out=" + bytesToHex(ret));
// 这里只做观察,不直接修改,避免误伤全局加密逻辑
return ret;
};
MessageDigest.digest.overload().implementation = function () {
var algo = this.getAlgorithm().toString();
var ret = this.digest();
console.log("[*] MessageDigest.digest() algo=" + algo + ", out=" + bytesToHex(ret));
return ret;
};
} catch (e) {
console.log("[-] MessageDigest hook failed: " + e);
}
try {
var StringCls = Java.use("java.lang.String");
StringCls.equals.implementation = function (obj) {
var a = this.toString();
var b = obj ? obj.toString() : "null";
if (
(a.indexOf(":") > -1 && b === fakeSha1) ||
(a.length === 32 && b === fakeMd5) ||
(a.length === 64 && b === fakeSha256)
) {
console.log("[+] force equals true: " + a + " == " + b);
return true;
}
return this.equals(obj);
};
} catch (e) {
console.log("[-] String.equals hook failed: " + e);
}
});
这段代码更适合侦察 + 局部干预。
我个人不太建议一上来全局篡改 MessageDigest.digest() 返回值,因为很多 APP 的登录、请求签名、AES/HMAC 都靠它,容易把程序搞崩。
方案 C:直接拦退出逻辑,保底观察现场
当 APP 因签名失败或反调试失败立刻退出时,可以先保命,避免进程直接死掉。
Java.perform(function () {
try {
var System = Java.use("java.lang.System");
System.exit.implementation = function (code) {
console.log("[+] System.exit blocked: " + code);
};
} catch (e) {
console.log("[-] System.exit hook failed: " + e);
}
try {
var Runtime = Java.use("java.lang.Runtime");
Runtime.exit.implementation = function (code) {
console.log("[+] Runtime.exit blocked: " + code);
};
} catch (e) {
console.log("[-] Runtime.exit hook failed: " + e);
}
try {
var Activity = Java.use("android.app.Activity");
Activity.finish.implementation = function () {
console.log("[+] Activity.finish blocked: " + this.getClass().getName());
};
} catch (e) {
console.log("[-] Activity.finish hook failed: " + e);
}
});
这招不是最终方案,但很适合先保住现场再继续定位。
实战代码:绕过常见反调试逻辑
下面是我常用的一套“基础反调试绕过脚本”。
它不会覆盖所有壳和高强度对抗,但对很多普通自研检测已经足够。
Java 层检测绕过
Java.perform(function () {
try {
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
console.log("[+] bypass Debug.isDebuggerConnected");
return false;
};
Debug.waitingForDebugger.implementation = function () {
console.log("[+] bypass Debug.waitingForDebugger");
return false;
};
} catch (e) {
console.log("[-] Debug hook failed: " + e);
}
try {
var ApplicationInfo = Java.use("android.content.pm.ApplicationInfo");
Object.defineProperty(ApplicationInfo, "flags", {
get: function () {
return this.flags.value & (~0x2);
}
});
} catch (e) {
console.log("[-] ApplicationInfo.flags patch failed: " + e);
}
});
注:
ApplicationInfo.flags的处理在某些 ROM/版本上不稳定,更多时候建议去 hook 读取它的业务方法,而不是强改字段访问。
Native 层反调试:拦截 ptrace
很多 native 反调试会直接调用 ptrace。
Interceptor.attach(Module.findExportByName(null, "ptrace"), {
onEnter: function (args) {
this.request = args[0].toInt32();
console.log("[*] ptrace called, request=" + this.request);
args[0] = ptr(0);
},
onLeave: function (retval) {
console.log("[+] ptrace bypass");
retval.replace(0);
}
});
绕过 TracerPid 检测:拦截 /proc/self/status
很多 APP 会读 /proc/self/status,然后解析 TracerPid 是否为 0。
这个场景下,单纯 hook Java API 不一定够,因为它可能走的是 native 文件读取。
下面给一版 native 侧处理思路:拦截 open / read。
var openPtr = Module.findExportByName(null, "open");
var readPtr = Module.findExportByName(null, "read");
var fdMap = {};
if (openPtr) {
Interceptor.attach(openPtr, {
onEnter: function (args) {
this.path = Memory.readCString(args[0]);
},
onLeave: function (retval) {
var fd = retval.toInt32();
if (this.path && this.path.indexOf("/proc/self/status") !== -1) {
fdMap[fd] = true;
console.log("[*] opened /proc/self/status, fd=" + fd);
}
}
});
}
if (readPtr) {
Interceptor.attach(readPtr, {
onEnter: function (args) {
this.fd = args[0].toInt32();
this.buf = args[1];
},
onLeave: function (retval) {
var n = retval.toInt32();
if (n > 0 && fdMap[this.fd]) {
var content = Memory.readUtf8String(this.buf, n);
if (content && content.indexOf("TracerPid:") !== -1) {
var patched = content.replace(/TracerPid:\s+\d+/g, "TracerPid:\t0");
Memory.writeUtf8String(this.buf, patched);
console.log("[+] TracerPid patched");
}
}
}
});
}
这个脚本对很多常见检测都有效,但也有边界:
- 某些设备是
openat - 某些实现用
fgets - 某些会分段读取,不能一次性完整替换
所以你看到没生效时,不要先怀疑人生,先补 hook:
openatfopenreadlinkfgets
一套更实用的组合脚本
如果你想先快速验证环境,可以把签名、反调试、阻断退出组合起来。
setImmediate(function () {
Java.perform(function () {
console.log("[*] script loaded");
// 1) Java反调试
try {
var Debug = Java.use("android.os.Debug");
Debug.isDebuggerConnected.implementation = function () {
console.log("[+] isDebuggerConnected -> false");
return false;
};
Debug.waitingForDebugger.implementation = function () {
console.log("[+] waitingForDebugger -> false");
return false;
};
} catch (e) {}
// 2) 阻断退出
try {
var System = Java.use("java.lang.System");
System.exit.implementation = function (code) {
console.log("[+] blocked System.exit(" + code + ")");
};
} catch (e) {}
try {
var Runtime = Java.use("java.lang.Runtime");
Runtime.exit.implementation = function (code) {
console.log("[+] blocked Runtime.exit(" + code + ")");
};
} catch (e) {}
// 3) 侦察签名调用
try {
var PM = Java.use("android.app.ApplicationPackageManager");
PM.getPackageInfo.overloads.forEach(function (ov) {
ov.implementation = function () {
console.log("[*] getPackageInfo called: " + arguments[0]);
return ov.apply(this, arguments);
};
});
} catch (e) {}
// 4) 可疑字符串比对观察
try {
var StringCls = Java.use("java.lang.String");
StringCls.equals.implementation = function (obj) {
var a = this.toString();
var b = obj ? obj.toString() : "null";
if (
a.indexOf("DEBUG") >= 0 ||
b.indexOf("DEBUG") >= 0 ||
a.indexOf("frida") >= 0 ||
b.indexOf("frida") >= 0
) {
console.log("[*] String.equals suspicious: " + a + " vs " + b);
}
return this.equals(obj);
};
} catch (e) {}
});
// 5) Native ptrace
var ptracePtr = Module.findExportByName(null, "ptrace");
if (ptracePtr) {
Interceptor.attach(ptracePtr, {
onLeave: function (retval) {
retval.replace(0);
console.log("[+] ptrace bypassed");
}
});
}
});
运行方式:
frida -U -f com.example.target -l bypass.js
如果 APP 很早期就做检测,建议使用 -f 启动注入,而不是 attach 到已运行进程。
逐步验证清单
实战里最怕“脚本打了很多,结果不知道哪一步真起作用”。
所以建议按这个顺序验证:
验证 1:确认脚本注入成功
frida -U -f com.example.target -l bypass.js
看控制台是否输出:
[*] script loaded[*] getPackageInfo called[*] ptrace called
如果完全没输出,优先排查:
- 包名是否正确
- 设备架构与 frida-server 是否匹配
- 是否被更早期 native 检测杀死
验证 2:确认签名校验被触发
看是否出现:
getPackageInfo calledSignature.toByteArray calledMessageDigest.digest algo=...
有这些日志,基本能证明签名检查走到了 Java 侧。
验证 3:确认退出逻辑被阻断
看是否出现:
blocked System.exit(...)blocked Runtime.exit(...)Activity.finish blocked
如果有,说明 APP 原本确实打算退出。
验证 4:确认反调试生效
看:
isDebuggerConnected -> falseptrace bypassedTracerPid patched
如果这些都触发了,但 APP 还闪退,那大概率还有:
- 线程名检测
- so 名称检测
- 端口扫描
- 自定义完整性校验
常见坑与排查
这一部分非常重要,很多问题其实不是“不会 hook”,而是踩到了运行时细节。
1. Hook 了却没生效:重载选错了
Android Java 方法经常有多个 overload。
比如 getPackageInfo 在不同版本参数不一样:
(String, int)(VersionedPackage, int)- 新版还有 long flags 相关形式
排查方法:
Java.perform(function () {
var PM = Java.use("android.app.ApplicationPackageManager");
PM.getPackageInfo.overloads.forEach(function (ov) {
console.log(ov);
});
});
然后精确 hook。
2. APP 在你注入前就完成检测
这是最常见的坑之一。
你 attach 上去时,Application 初始化早跑完了。
解决:
frida -U -f com.example.target -l bypass.js
必要时加 --no-pause:
frida -U -f com.example.target -l bypass.js --no-pause
3. Java 层全绕过了,还是闪退
这通常说明检测在 Native 层。
排查思路:
- 看是否加载了自定义 so
- hook
dlopen/android_dlopen_ext - 看哪个 so 在启动时被加载
- 再对该 so 做导出函数枚举
示例:
var dlopen = Module.findExportByName(null, "android_dlopen_ext");
if (dlopen) {
Interceptor.attach(dlopen, {
onEnter: function (args) {
this.path = Memory.readCString(args[0]);
},
onLeave: function () {
if (this.path) {
console.log("[*] dlopen: " + this.path);
}
}
});
}
4. 全局 hook String.equals 后程序异常
这是我早期经常踩的坑。
String.equals 调用量太大,乱改会带来连锁反应。
建议:
- 先只打印可疑字符串
- 命中目标比较后再定向返回
true - 或者直接 hook 业务类里的判断函数
5. 修改 MessageDigest.digest 导致登录、请求全挂
因为很多接口签名也依赖摘要算法。
如果你把摘要统一改掉,APP 的网络校验会立刻失败。
建议:
- 默认只记录 digest,不全局篡改
- 根据调用栈识别“只在签名校验场景”修改返回值
- 或者绕过最终比较逻辑,而不是改摘要本身
6. TracerPid 替换了还是检测到
可能是以下原因:
- 它读的是
/proc/<pid>/status - 它用的是
openat、fopen、fgets - 内容分片读取,你一次替换不完整
- 它不是读文件,而是 JNI 直接调用系统接口
这个时候建议把文件相关 API 都挂上观察。
安全/性能最佳实践
逆向脚本不只是“能跑就行”,还得尽量稳。
1. 优先小范围 hook
比起全局改系统 API,我更推荐:
- 先定位业务类
- 再 hook 业务判断方法
- 最后才考虑通用 API 级别拦截
原因很简单:副作用更小,稳定性更高。
2. 日志要够,但别刷爆
很多初学者会把每次 String.equals、digest 都打出来。
结果不是 APP 卡死,就是 Frida 自己被日志拖慢。
建议:
- 只打印可疑参数
- 加调用计数
- 达到阈值后自动静默
示例:
var hit = 0;
if (hit < 20) {
console.log("...");
hit++;
}
3. 区分“侦察脚本”和“绕过脚本”
我自己的习惯是分成两类文件:
scout.js:只观察,不改逻辑bypass.js:只做必要修改
这样你调试时不会把问题搅在一起。
4. 保留边界条件判断
比如拦截 open 时,不要把所有文件都改。
只处理明确目标路径:
/proc/self/status/proc/<pid>/maps- 某个特定 APK 路径
否则系统行为容易被误伤。
5. 对 Native 检测要有心理预期
Frida 很强,但不是“一个脚本包打天下”。
如果遇到:
- 强壳
- 自定义 inline hook 检测
- syscall 直调
- 完整性校验 + 线程监控 + map 扫描组合拳
那就需要进一步:
- 补 syscall 级别拦截
- 配合静态分析 so
- 必要时改 ELF / 脱壳后分析
别指望一段通用脚本解决所有目标。
一个完整的分析思路示意
flowchart LR
A[启动APP并用 -f 注入] --> B[阻断退出逻辑]
B --> C[侦察签名相关API]
C --> D{Java层能定位到最终判断吗}
D -- 能 --> E[直接hook返回true]
D -- 不能 --> F[观察摘要/签名对象]
F --> G{是否存在Native检测}
G -- 是 --> H[hook ptrace/open/read/dlopen]
G -- 否 --> I[验证业务是否恢复]
H --> I
I --> J[精简脚本并保留最小绕过集]
总结
这类题目的关键,不是背几个 API,而是建立一条稳定的排查路线:
- 先保命:拦住
exit/finish - 先侦察:看
getPackageInfo、MessageDigest、isDebuggerConnected - 优先改最终判断:比全局改摘要更稳
- Java 不够就下沉到 Native:补
ptrace、/proc/self/status、dlopen - 逐步验证:不要一口气堆十几个 hook
如果你只记住一个建议,那就是:
优先定位“最终决定是否退出/拒绝服务”的那个判断点,再做最小化绕过。
这样脚本最稳,副作用最少,也最接近真实对抗环境下的工作方式。
最后再强调一次边界:本文方法适用于授权测试、教学研究、安全评估。面对受保护目标时,请确保你的行为合规、可授权、可审计。