跳转到内容
123xiao | 无名键客

《安卓逆向实战:用 Frida 定位并绕过常见 APK 签名校验与反调试逻辑》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:用 Frida 定位并绕过常见 APK 签名校验与反调试逻辑

很多同学一上来做 Android 逆向,最先撞上的不是业务逻辑,而是两道“门神”:

  1. APK 签名校验
  2. 反调试 / 反注入检测

现象通常很熟悉:
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-servergum-js-loop 等特征

如果你只是“知道怎么 hook 一个函数”,往往不够。真正难的是:

  • 校验点藏在哪里
  • 到底是 Java 还是 Native 在做
  • 绕过后为什么还会闪退
  • 为什么脚本明明生效了但业务还是进不去

所以这篇文章的重点是:定位路径 + 可运行脚本 + 排错方法


前置知识与环境准备

你需要准备什么

  • 一台 Android 测试机或模拟器
  • adb
  • frida-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 有两个典型限制:

  1. 启动时做签名校验
    如果签名不是预期值,弹“非法安装包”并退出。
  2. 启动后检测调试环境
    发现 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
  • 改输入:把签名对象、证书摘要伪造成目标值
  • 截断流程:让退出逻辑不执行

我一般建议优先级是:

  1. 先找最终布尔判断点
  2. 找不到再 hook 摘要函数
  3. 还不行再改 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 搜这些关键词:

  • getPackageInfo
  • signatures
  • SigningInfo
  • getApkContentsSigners
  • MessageDigest
  • isDebuggerConnected
  • TracerPid
  • exit
  • System.exit
  • finishAffinity

如果代码混淆严重,不要死盯类名,盯 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 签名校验

下面给一版可直接运行、比较通用的脚本。它覆盖三层思路:

  1. 拦截签名读取
  2. 拦截摘要计算
  3. 拦截最终布尔判断/退出动作

注意:不同 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:

  • openat
  • fopen
  • readlink
  • fgets

一套更实用的组合脚本

如果你想先快速验证环境,可以把签名、反调试、阻断退出组合起来。

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 called
  • Signature.toByteArray called
  • MessageDigest.digest algo=...

有这些日志,基本能证明签名检查走到了 Java 侧。

验证 3:确认退出逻辑被阻断

看是否出现:

  • blocked System.exit(...)
  • blocked Runtime.exit(...)
  • Activity.finish blocked

如果有,说明 APP 原本确实打算退出。

验证 4:确认反调试生效

看:

  • isDebuggerConnected -> false
  • ptrace bypassed
  • TracerPid 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
  • 它用的是 openatfopenfgets
  • 内容分片读取,你一次替换不完整
  • 它不是读文件,而是 JNI 直接调用系统接口

这个时候建议把文件相关 API 都挂上观察。


安全/性能最佳实践

逆向脚本不只是“能跑就行”,还得尽量稳。

1. 优先小范围 hook

比起全局改系统 API,我更推荐:

  • 先定位业务类
  • 再 hook 业务判断方法
  • 最后才考虑通用 API 级别拦截

原因很简单:副作用更小,稳定性更高


2. 日志要够,但别刷爆

很多初学者会把每次 String.equalsdigest 都打出来。
结果不是 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,而是建立一条稳定的排查路线:

  1. 先保命:拦住 exit/finish
  2. 先侦察:看 getPackageInfoMessageDigestisDebuggerConnected
  3. 优先改最终判断:比全局改摘要更稳
  4. Java 不够就下沉到 Native:补 ptrace/proc/self/statusdlopen
  5. 逐步验证:不要一口气堆十几个 hook

如果你只记住一个建议,那就是:

优先定位“最终决定是否退出/拒绝服务”的那个判断点,再做最小化绕过。

这样脚本最稳,副作用最少,也最接近真实对抗环境下的工作方式。

最后再强调一次边界:本文方法适用于授权测试、教学研究、安全评估。面对受保护目标时,请确保你的行为合规、可授权、可审计。


分享到:

上一篇
《从零到贡献者:中级开发者参与开源项目的实战路径与高质量 PR 提交流程》
下一篇
《Java 中基于 CompletableFuture 的并发编排实战:从异步聚合到超时控制与线程池调优》