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

《安卓逆向实战:基于 Frida 与 JADX 的应用签名校验与反调试绕过分析》

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

安卓逆向实战:基于 Frida 与 JADX 的应用签名校验与反调试绕过分析

很多 Android 应用在启动时会做两件事:校验自身签名,以及检测调试/注入环境。这两类保护并不算“高深”,但在实际分析里非常高频。对中级读者来说,真正的难点往往不是“知道它存在”,而是:如何快速定位、如何稳定绕过、如何确认绕过没有副作用

这篇文章我会从一个偏“实战排障”的角度来讲,使用 JADX 做静态定位,使用 Frida 做动态验证与绕过,把一条常见分析链路完整走一遍。你可以把它当成一个通用模板,后面遇到类似应用时直接套思路。

说明:以下内容仅用于授权测试、应用加固研究与教学分析。请勿用于未授权目标。


背景与问题

在 Android 应用保护里,签名校验和反调试通常承担这两个职责:

  • 签名校验
    • 防止 APK 被二次打包
    • 防止渠道篡改
    • 防止研究者修改 smali 后重签运行
  • 反调试
    • 检测 Debug.isDebuggerConnected()
    • 检测 TracerPid
    • 检测 Frida 端口、线程名、内存特征
    • 检测 ro.debuggabletest-keys 等系统状态

为什么这两项经常一起出现?

因为开发者的默认思路通常是:

  1. 启动时先判断环境是否异常;
  2. 再判断 APK 是否被改动;
  3. 一旦命中,直接闪退、卡死、返回假数据,甚至延迟触发。

这也意味着,逆向时我们很少能只绕过一个点。更常见的情况是:签名校验和反调试互相配合,必须联动处理。


前置知识

在开始前,默认你已经了解这些内容:

  • Android APK 基本结构
  • Java 层常见 Hook 思路
  • Frida 基础用法
  • JADX 基础搜索能力
  • ADB 常用命令

如果你已经能独立完成以下动作,读本文会比较顺:

  • adb install 安装 APK
  • jadx-gui 搜索字符串和方法引用
  • frida -U -f 包名 -l hook.js 注入脚本
  • 看懂 Java/Kotlin 反编译代码

环境准备

建议使用下面这套环境,兼容性相对稳定:

  • Android 8 ~ 13 测试机 / 模拟器
  • adb
  • jadx / jadx-gui
  • frida-tools
  • 与设备架构匹配的 frida-server
  • 可选:objection

先确认基础连通性:

adb devices
frida-ps -U

如果 frida-ps -U 看不到设备,优先检查:

  • USB 调试是否开启
  • frida-server 是否已启动
  • PC 与设备端 Frida 版本是否一致
  • SELinux/Root 环境是否影响注入

分析总览:先静态定位,再动态验证

我个人比较推荐的路径是:

  1. 先用 JADX 找入口
  2. 再用 Frida 小范围验证
  3. 最后做最小化绕过

这样做的好处是:不会上来就“大面积 Hook 全世界”,排查更稳,副作用也更小。

flowchart TD
    A[解包并用 JADX 打开 APK] --> B[搜索签名相关关键词]
    B --> C[定位 PackageManager / Signature / SHA1 / MD5]
    C --> D[搜索反调试相关关键词]
    D --> E[定位 Debug / TracerPid / ptrace / SystemProperties]
    E --> F[Frida 动态 Hook 验证执行路径]
    F --> G[最小化修改返回值]
    G --> H[复测关键功能]

背景与问题:一个典型目标会长什么样

一个典型“有点保护但不算特别重”的目标 App,常见特征是:

  • 入口 Activity 正常启动,但很快闪退
  • 登录页能看到,但操作后报“环境异常”
  • 打开关键页面时才检测签名
  • Frida 一注入就退,或者卡在启动页

这种情况下,很多人第一反应是直接全局 Hook finish()System.exit()killProcess()。这当然有时能救急,但它更像“止血”,不是“定位”。

更好的思路是:

  • 先搞清楚谁在做签名校验
  • 再找出谁在做调试检测
  • 最后判断它们是启动阶段触发,还是按需触发

核心原理

这一节我们把常见实现方式拆开看。

1. 签名校验原理

Android 应用做签名校验时,通常会:

  1. 通过 PackageManager 读取当前包信息
  2. 取出 Signature / SigningInfo
  3. 计算证书摘要(MD5 / SHA1 / SHA256)
  4. 与内置常量比较

常见 API:

  • 旧版:
    • getPackageInfo(packageName, PackageManager.GET_SIGNATURES)
  • 新版:
    • getPackageInfo(packageName, PackageManager.GET_SIGNING_CERTIFICATES)
    • PackageInfo.signingInfo

很多应用不会直接比较原始签名,而是比较摘要字符串,比如:

  • toCharsString()
  • MessageDigest.getInstance("SHA-256")
  • 再转十六进制字符串

2. 反调试原理

Java 层常见检测:

  • android.os.Debug.isDebuggerConnected()
  • android.os.Debug.waitingForDebugger()

Native / 系统侧常见检测:

  • 读取 /proc/self/status 中的 TracerPid
  • 调用 ptrace
  • 查找 Frida 线程名,如 gum-js-loopgmain
  • 扫描 27042 等默认端口
  • 检测系统属性
    • ro.debuggable
    • ro.secure
    • ro.build.tags

3. 为什么 JADX + Frida 组合效率高

  • JADX 负责“猜测”和缩小范围
  • Frida 负责“证实”和快速试错

静态分析能告诉你“可疑点在哪”,但它不一定告诉你“运行时是否真的走到这里”。动态 Hook 正好补上这一块。


用 JADX 定位:从哪里开始找

签名校验关键词

在 JADX 中,优先搜这些:

  • getPackageInfo
  • GET_SIGNATURES
  • GET_SIGNING_CERTIFICATES
  • Signature
  • signingInfo
  • MessageDigest
  • SHA1
  • SHA-256
  • MD5

如果代码混淆了,搜字符串常量往往更有效,比如:

  • 固定摘要字符串
  • debuggable
  • environment error
  • signature mismatch
  • tamper

反调试关键词

继续搜这些:

  • isDebuggerConnected
  • waitingForDebugger
  • /proc/self/status
  • TracerPid
  • ptrace
  • frida
  • 27042
  • ro.debuggable
  • test-keys
  • killProcess
  • System.exit

我实际排查时常用的判断标准

如果你搜到某个方法里同时出现:

  • PackageManager
  • MessageDigest
  • 一段固定 hash
  • 校验失败后调用 finish() / throw

那它大概率就是签名校验点。

如果你搜到某个方法里出现:

  • Debug.isDebuggerConnected()
  • 读取 /proc/self/status
  • 命中异常后 Process.killProcess(Process.myPid())

那就是反调试点。


Mermaid:签名校验执行链

sequenceDiagram
    participant App as App启动流程
    participant Guard as 校验模块
    participant PM as PackageManager
    participant Digest as MessageDigest

    App->>Guard: 调用校验入口
    Guard->>PM: getPackageInfo(packageName, flags)
    PM-->>Guard: 返回签名信息
    Guard->>Digest: 计算 SHA-256/MD5
    Digest-->>Guard: 返回摘要
    Guard->>Guard: 与内置摘要比较
    alt 匹配成功
        Guard-->>App: 继续运行
    else 匹配失败
        Guard-->>App: 闪退/终止/假返回
    end

实战代码(可运行)

下面给出一套偏通用的 Frida 脚本。它的目标不是“一把梭全绕过”,而是:

  1. 先打印关键调用,帮助你确认执行链;
  2. 再逐步改返回值;
  3. 最后只保留必要 Hook。

建议分阶段使用,不要一上来全开。


第一步:探测签名校验与反调试调用

Java.perform(function () {
    console.log("[*] probe script start");

    // 1) 调试检测探测
    var Debug = Java.use("android.os.Debug");
    Debug.isDebuggerConnected.implementation = function () {
        var ret = this.isDebuggerConnected();
        console.log("[Debug.isDebuggerConnected] => " + ret);
        return ret;
    };

    Debug.waitingForDebugger.implementation = function () {
        var ret = this.waitingForDebugger();
        console.log("[Debug.waitingForDebugger] => " + ret);
        return ret;
    };

    // 2) 进程退出探测
    var Process = Java.use("android.os.Process");
    Process.killProcess.implementation = function (pid) {
        console.log("[killProcess] pid=" + pid + " blocked");
    };

    var System = Java.use("java.lang.System");
    System.exit.implementation = function (code) {
        console.log("[System.exit] code=" + code + " blocked");
    };

    // 3) 常见签名读取点探测
    var PackageManager = Java.use("android.app.ApplicationPackageManager");
    PackageManager.getPackageInfo.overloads.forEach(function (ov) {
        ov.implementation = function () {
            var pkg = arguments[0];
            var flags = arguments[1];
            console.log("[getPackageInfo] pkg=" + pkg + ", flags=" + flags);
            return ov.apply(this, arguments);
        };
    });

    // 4) MessageDigest 探测
    var MessageDigest = Java.use("java.security.MessageDigest");
    MessageDigest.getInstance.overloads.forEach(function (ov) {
        ov.implementation = function () {
            var algo = arguments[0];
            console.log("[MessageDigest.getInstance] algo=" + algo);
            return ov.apply(this, arguments);
        };
    });

    console.log("[*] probe hooks installed");
});

运行方式:

frida -U -f 目标包名 -l probe.js

这一步的目标很明确:不要急着改逻辑,先看日志。


第二步:直接绕过 Java 层反调试

如果你已经确认它调用了 Debug.isDebuggerConnected(),那么可以先这样改:

Java.perform(function () {
    var Debug = Java.use("android.os.Debug");

    Debug.isDebuggerConnected.implementation = function () {
        console.log("[bypass] isDebuggerConnected => false");
        return false;
    };

    Debug.waitingForDebugger.implementation = function () {
        console.log("[bypass] waitingForDebugger => false");
        return false;
    };
});

这类 Hook 很常见,也很稳。但要注意:如果目标真正的检测在 native 层,这一步只能绕过一部分。


第三步:绕过常见退出逻辑

有些 App 命中检测后不会直接崩,而是主动退出。为了保留现场、方便继续分析,可以临时挡住这些调用:

Java.perform(function () {
    var Process = Java.use("android.os.Process");
    Process.killProcess.implementation = function (pid) {
        console.log("[block] killProcess(" + pid + ")");
    };

    var System = Java.use("java.lang.System");
    System.exit.implementation = function (code) {
        console.log("[block] System.exit(" + code + ")");
    };

    var Activity = Java.use("android.app.Activity");
    Activity.finish.implementation = function () {
        console.log("[block] Activity.finish(): " + this.getClass().getName());
    };
});

这个脚本很适合启动即闪退场景。但我建议把它当作排障辅助,不要长期依赖。因为有些页面本来就应该 finish,这种全局 Hook 可能会影响正常流程。


第四步:针对签名校验做最小化绕过

签名校验更推荐Hook 目标校验函数本身。但在你还没完全定位前,可以先 Hook 摘要比较函数,观察是否存在固定值比较。

方案 A:Hook String.equals

这个方法偏粗暴,只适合短时间探测。

Java.perform(function () {
    var StringCls = Java.use("java.lang.String");

    StringCls.equals.implementation = function (obj) {
        var a = this.toString();
        var b = obj ? obj.toString() : "null";

        if (a.length > 20 || b.length > 20) {
            console.log("[String.equals] " + a + " <=> " + b);
        }

        return this.equals(obj);
    };
});

如果你看到某两个长 hash 在比较,就能进一步回到 JADX 定位上游方法。

注意:这里不要长期强改 equals 返回值,副作用非常大。

方案 B:Hook 目标校验函数

比如你在 JADX 中定位到:

public boolean verifySign(Context context) {
    // ...
}

那么直接 Hook 它最稳:

Java.perform(function () {
    var Guard = Java.use("com.example.app.security.SignGuard");

    Guard.verifySign.overload("android.content.Context").implementation = function (ctx) {
        console.log("[bypass] verifySign(Context) => true");
        return true;
    };
});

这是我最推荐的方式:修改最靠近业务判断的点,副作用小,可控性强。


第五步:处理 /proc/self/statusTracerPid

不少 App 在 Java 层通过文件读取来检查 TracerPid。如果 JADX 里能看到它使用 BufferedReaderFileInputStream/proc/self/status,可以这样处理:

Java.perform(function () {
    var File = Java.use("java.io.File");
    var FileInputStream = Java.use("java.io.FileInputStream");

    FileInputStream.$init.overload("java.io.File").implementation = function (file) {
        var path = file.getAbsolutePath();
        if (path.indexOf("/proc/self/status") >= 0) {
            console.log("[detect] open " + path);
        }
        return this.$init(file);
    };

    FileInputStream.$init.overload("java.lang.String").implementation = function (path) {
        if (path.indexOf("/proc/self/status") >= 0) {
            console.log("[detect] open " + path);
        }
        return this.$init(path);
    };
});

如果确认是 Java 层自己解析文本,那么下一步就不是盲目 Hook 文件流,而是回到 JADX,找到“解析 TracerPid 的那个方法”,直接改其返回值。


更完整的联动脚本示例

下面给一个更适合实战启动阶段的联动脚本。它集合了:

  • Java 层调试检测绕过
  • 退出逻辑拦截
  • 系统属性伪装
  • 常见包信息调用观察
Java.perform(function () {
    console.log("[*] bypass bundle start");

    // 1. Debug
    var Debug = Java.use("android.os.Debug");
    Debug.isDebuggerConnected.implementation = function () {
        return false;
    };
    Debug.waitingForDebugger.implementation = function () {
        return false;
    };

    // 2. SystemProperties
    try {
        var SysProp = Java.use("android.os.SystemProperties");
        SysProp.get.overload("java.lang.String").implementation = function (key) {
            if (key === "ro.debuggable") return "0";
            if (key === "ro.secure") return "1";
            if (key === "ro.build.tags") return "release-keys";
            return this.get(key);
        };

        SysProp.get.overload("java.lang.String", "java.lang.String").implementation = function (key, def) {
            if (key === "ro.debuggable") return "0";
            if (key === "ro.secure") return "1";
            if (key === "ro.build.tags") return "release-keys";
            return this.get(key, def);
        };
    } catch (e) {
        console.log("[!] SystemProperties hook failed: " + e);
    }

    // 3. Exit blockers
    var Process = Java.use("android.os.Process");
    Process.killProcess.implementation = function (pid) {
        console.log("[block] killProcess(" + pid + ")");
    };

    var System = Java.use("java.lang.System");
    System.exit.implementation = function (code) {
        console.log("[block] System.exit(" + code + ")");
    };

    // 4. Package info observation
    try {
        var PM = Java.use("android.app.ApplicationPackageManager");
        PM.getPackageInfo.overloads.forEach(function (ov) {
            ov.implementation = function () {
                var pkg = arguments[0];
                var flags = arguments[1];
                console.log("[PM.getPackageInfo] " + pkg + " flags=" + flags);
                return ov.apply(this, arguments);
            };
        });
    } catch (e) {
        console.log("[!] getPackageInfo hook failed: " + e);
    }

    console.log("[*] bypass bundle installed");
});

运行:

frida -U -f 目标包名 -l bypass.js

Mermaid:排障决策图

flowchart LR
    A[App 启动闪退] --> B{Frida 注入后更快闪退?}
    B -- 是 --> C[先拦截 killProcess/System.exit/finish]
    B -- 否 --> D[直接观察日志]
    C --> E{是否命中 Debug/TracerPid 检测?}
    D --> E
    E -- 是 --> F[Hook isDebuggerConnected / 解析函数]
    E -- 否 --> G{是否命中签名比较?}
    F --> G
    G -- 是 --> H[Hook 目标校验函数返回 true]
    G -- 否 --> I[继续回到 JADX 搜索常量与调用链]

逐步验证清单

做这类绕过时,我强烈建议按清单推进。很多人脚本写了不少,但没有验证顺序,最后不知道是哪一步生效的。

验证 1:确认注入时机

  • 使用 -f 启动注入,而不是 App 启动后再 attach
  • 看 Frida 日志是否在 Application 初始化前输出
  • 如果太晚,启动期检测可能已经触发

示例:

frida -U -f 目标包名 -l bypass.js --no-pause

验证 2:确认是否真有 Java 层反调试

  • Hook Debug.isDebuggerConnected
  • 看日志是否触发
  • 如果完全不触发,不要在这个点上浪费太久

验证 3:确认是否存在主动退出逻辑

  • Hook System.exit
  • Hook killProcess
  • Hook Activity.finish

如果这些频繁触发,说明应用在“检测失败后主动收尾”。

验证 4:确认签名校验路径

  • 观察 getPackageInfo
  • 观察 MessageDigest.getInstance
  • 结合 JADX 中的 hash 常量定位具体方法

验证 5:最小化保留 Hook

最终最好只保留:

  • 1~2 个反调试 Hook
  • 1 个签名校验函数 Hook

不要长期带着一堆“全局大锤”脚本跑。


常见坑与排查

这部分很重要。我自己踩过的坑,大多都不是“不会 Hook”,而是“Hook 对了但现象还是不对”。

坑 1:Hook 写了,但方法根本没走到

表现:

  • 脚本正常加载
  • 没报错
  • 但日志一条没有

排查:

  1. 检查类名是否混淆后变化
  2. 检查是否多 Dex 动态加载
  3. 检查 Hook 时机是否太早或太晚
  4. 使用 Java.enumerateLoadedClasses 辅助确认类是否已加载

示例:

Java.perform(function () {
    Java.enumerateLoadedClasses({
        onMatch: function (name) {
            if (name.indexOf("sign") >= 0 || name.indexOf("security") >= 0) {
                console.log(name);
            }
        },
        onComplete: function () {
            console.log("done");
        }
    });
});

坑 2:getPackageInfo Hook 了,但签名校验还是没绕过

原因可能有这些:

  • App 校验的是 native 层签名
  • App 读取的是 SigningInfo 而不是旧版 Signature[]
  • App 只是在“观察”包信息,真正比较发生在后面
  • 校验值经过二次变换,比如 Base64、截断、拼接盐值

建议:

  • 不要执着于改 getPackageInfo 返回对象
  • 回到业务层,直接 Hook 最终 boolean 校验函数

坑 3:一挡 finish(),页面不退了,但功能还是不能用

这是典型“止血成功,治疗失败”。

说明:

  • 你拦住了结果
  • 但没有拦住原因

后续要做的是继续找:

  • 是谁设置了“异常状态”
  • 哪个字段或返回值影响后续逻辑
  • 是否还有埋点上报、延迟退出、假数据分支

坑 4:Frida 一注入就被杀

常见原因:

  • 检测默认 Frida 端口
  • 检测 Frida 线程名
  • 检测 maps 中的特征字符串
  • native 层在启动期快速检测

应对思路:

  • 尽量使用 spawn 模式
  • 优先处理启动阶段退出逻辑
  • 必要时转向 native Hook 或更隐蔽的注入方案
  • 如果 Java 层完全看不到检测点,十有八九要往 so 里看了

坑 5:Hook 重载写错

getPackageInfoSystemProperties.get 这类方法通常有多个 overload。重载签名一旦不对,脚本看起来“没报错”,但不会命中目标。

建议先打印 overload:

Java.perform(function () {
    var PM = Java.use("android.app.ApplicationPackageManager");
    PM.getPackageInfo.overloads.forEach(function (ov) {
        console.log(ov);
    });
});

安全/性能最佳实践

逆向分析脚本也需要“工程化一点”,否则自己会被自己的脚本坑到。

1. 优先最小 Hook 面

不要动不动全局 Hook:

  • String.equals
  • MessageDigest.digest
  • 所有 FileInputStream

这些虽然能快速看到很多信息,但日志量会爆炸,还可能拖慢目标 App。

更好的方式:

  • 先探测
  • 再定位
  • 最后只保留关键方法

2. 先记录,再修改

我比较推荐这个顺序:

  1. 先打印参数和返回值
  2. 确认命中
  3. 再改返回值

这样出问题时,你知道是“原逻辑问题”还是“Hook 带来的副作用”。

3. 对退出逻辑的拦截只用于诊断

拦截 killProcessSystem.exitfinish 很有用,但只适合:

  • 启动期止血
  • 保留现场
  • 抓调用链

如果长期保留,可能导致:

  • 生命周期错乱
  • 页面状态残缺
  • 误判“已经绕过成功”

4. 注意版本兼容

Android 9 以后签名相关 API 有变化:

  • 旧版偏 GET_SIGNATURES
  • 新版偏 GET_SIGNING_CERTIFICATES + SigningInfo

所以你在不同设备上测试,现象可能不一样。不要在 Android 7 上试通了,就默认 Android 13 一样。

5. 日志要有节制

Frida 日志太多时,会明显拖慢启动过程,甚至改变原本时序。这个在反调试场景里尤其敏感。

建议:

  • 只打印关键方法
  • 避免高频热点函数里大量 console.log
  • 定位完成后删掉观测日志

一个推荐的实战流程模板

如果让我把整件事压缩成一个最实用的模板,我会这样做:

阶段 1:静态找点

  • JADX 搜 isDebuggerConnected
  • TracerPid
  • getPackageInfo
  • MessageDigest
  • 搜固定 hash 常量
  • 标记关键类与方法

阶段 2:动态确认

  • Hook Debug.isDebuggerConnected
  • Hook getPackageInfo
  • Hook MessageDigest.getInstance
  • Hook System.exit / killProcess

阶段 3:止血

  • 挡掉退出逻辑
  • 保证 App 不会立刻退
  • 找到后续调用栈

阶段 4:最小绕过

  • 直接 Hook 目标校验方法 return true
  • 直接 Hook 目标调试判断方法 return false

阶段 5:回归验证

  • 去掉多余 Hook
  • 重新跑关键路径
  • 检查登录、页面跳转、接口请求是否正常

边界条件:什么时候这套方法不够用

这篇文章主要覆盖的是:

  • Java 层为主
  • 或者 Java 层能明显看出入口
  • 反调试没有做到很重的 native 对抗

如果你遇到这些情况,这套方法就不够了:

  • 所有关键检测都在 so 中
  • 应用有强壳保护
  • 动态加载 Dex,校验逻辑运行时下发
  • 检测 Frida 特征非常激进
  • 使用 inline/native 混合校验,Java 层只是壳

这时就要进一步:

  • 看 native 导出与 JNI 注册
  • frida-trace 或手写 native Hook
  • 分析 /procptraceopen/read 等 libc 调用
  • 必要时结合脱壳、内存转储

也就是说,JADX + Frida 是高频入口,不是万能钥匙。


总结

这类题目的核心,不是“背下来多少 Hook 模板”,而是掌握一条稳定方法论:

  1. JADX 先缩小范围
  2. Frida 再验证实际执行
  3. 先止血,再定位根因
  4. 最终只保留最小绕过点

对于签名校验:

  • 优先找最终 boolean 判断函数
  • 不要一开始就硬改底层签名对象

对于反调试:

  • 先看 Java 层 DebugSystemPropertiesTracerPid
  • 如果注入即死,再考虑 native 层检测

如果你只记住一句话,我希望是这句:

逆向里最稳的绕过,不是“拦住所有异常”,而是“改掉最终判断”。

这样做更容易复现、更容易迁移,也更不容易把自己绕进去。

希望这篇文章能帮你把“签名校验 + 反调试”这类常见保护,真正从“知道概念”推进到“可以上手拆”。


分享到:

上一篇
《Web3 中级实战:基于 EIP-712 与钱包签名实现去中心化登录(SIWE)完整方案》
下一篇
《Web3 中级实战:基于 EIP-712 与钱包签名实现安全的链上登录与授权流程》