签名校验

签名校验

芽衣的帖子 - 吾爱破解 - 52pojie.cn

1.什么是校验

在安卓逆向中,校验通常指开发者通过代码逻辑对应用的关键信息(如签名、文件完整性、运行环境等)进行验证,确保应用未被篡改或破解。

  • 常见的校验有
    1. 签名校验(Signature Check)

      • 目的:验证APK的签名是否与开发者预期一致,防止应用被重新打包或篡改。

      • 实现方式

        • 代码中通过PackageManager获取签名信息,与预设的签名哈希值对比。
        • 若签名不匹配,触发退出或限制功能(如付费验证失效)。
      • 示例代码

        1
        2
        3
        4
        5
        6
        7
        // 获取当前APK的签名信息
        Signature[] signatures = getPackageManager().getPackageInfo(getPackageName(), PackageManager.GET_SIGNATURES).signatures;
        String currentSignature = signatures[0].toCharsString();
        // 对比预设的合法签名
        if (!currentSignature.equals("预设的签名哈希值")) {
        exit();
        }
    2. 文件完整性校验

      • 目的:验证APK或资源文件(如DEX、SO库)是否被修改。
      • 实现方式
        • 计算文件的哈希值(如MD5、SHA-1)并与预设值对比。
        • 检测关键文件的大小或修改时间是否异常。
    3. 环境校验

      • 目的:检测是否运行在模拟器、Root环境或调试模式。
      • 实现方式
        • 检查系统属性(如ro.build.tags是否包含test-keys)。
        • 检测su文件是否存在(Root环境)。
        • 使用android.os.Debug.isDebuggerConnected()检测调试状态。
    4. 逻辑校验

      • 目的:验证关键业务逻辑是否被篡改(如付费验证、加密算法)。
      • 实现方式
        • 校验关键函数的返回值是否符合预期。
        • 使用代码混淆或Native代码(C/C++)隐藏校验逻辑。
    5. JNI/Native 层校验

    • 许多应用会在 C/C++ 层进行完整性检查,检测 lib.so 是否被修改,或者检查 Java 方法的行为是否被 Hook。
    • 例如,某些反调试技术会在 lib.so 中实现 ptrace 检测或 frida 检测。

签名校验(最常见)、dexcrc校验、apk完整性校验、路径文件校验等

2.什么是APK签名

APK签名是Android系统的安全机制,用于验证应用来源和完整性。

通过对 Apk 进行签名,开发者可以证明对 Apk 的所有权和控制权,可用于安装和更新其应用。而在 Android 设备上的安装 Apk ,如果是一个没有被签名的 Apk,则会被拒绝安装。在安装 Apk 的时候,软件包管理器也会验证 Apk 是否已经被正确签名,并且通过签名证书和数据摘要验证是否合法没有被篡改。只有确认安全无篡改的情况下,才允许安装在设备上。

简单来说,APK 的签名主要作用有两个:

  1. 证明 APK 的所有者。
  2. 允许 Android 市场和设备校验 APK 的正确性。

(1)签名机制

每个安卓应用在打包时都会使用开发者的私钥进行签名,签名信息存储在 META-INF 目录下,主要包括:

  • CERT.RSA(公钥证书)
  • CERT.SF(摘要信息)
  • MANIFEST.MF(文件哈希值)

当用户安装 APK 时,Android 系统会:

  1. 使用 CERT.RSA 内的公钥验证 CERT.SF 的真实性。
  2. 通过 CERT.SF 的哈希值检查 MANIFEST.MF,确认文件未被修改。
  3. 通过 MANIFEST.MF 中记录的哈希值,校验 APK 内所有文件是否被篡改。

如果签名校验失败,APK 将无法安装。

**(2)**Android 目前支持以下四种应用签名方案:

  • V1 签名(JAR 签名,Android 1.6+)

    • 只校验 META-INF 目录下的签名文件,对 APK 本身的完整性保护较弱,可通过 Zip 方式修改 APK 而不影响签名。

    • V1 签名的机制主要就在 META-INF 目录下的三个文件,MANIFEST.MF,ANDROID.SF,ANDROID.RSA,他们都是 V1 签名的产物。

      (1)MANIFEST.MF:这是摘要文件。程序遍历Apk包中的所有文件(entry),对非文件夹非签名文件的文件,逐个用SHA1(安全哈希算法)生成摘要信息,再用Base64进行编码。如果你改变了apk包中的文件,那么在apk安装校验时,改变后的文件摘要信息与MANIFEST.MF的检验信息不同,于是程序就不能成功安装。

      (2)ANDROID.SF:这是对摘要的签名文件。对前一步生成的MANIFEST.MF,使用SHA1-RSA算法,用开发者的私钥进行签名。在安装时只能使用公钥才能解密它。解密之后,将它与未加密的摘要信息(即,MANIFEST.MF文件)进行对比,如果相符,则表明内容没有被异常修改。

      (3)ANDROID.RSA文件中保存了公钥、所采用的加密算法等信息。

  • V2 签名(APK Signature Scheme v2,Android 7.0+)

    • 直接对整个 APK 文件进行签名,提高了安全性,防止对 APK 进行 Zip 修改后仍能通过签名校验。
    • 在某些情况下,直接对apk进行v1签名可以绕过apk的签名校验

    v2方案会将 APK 文件视为 blob,并对整个文件进行签名检查。对 APK 进行的任何修改(包括对 ZIP 元数据进行的修改)都会使 APK 签名作废。这种形式的 APK 验证不仅速度要快得多,而且能够发现更多种未经授权的修改。

  • V3 签名(APK Signature Scheme v3,Android 9.0+)

    • 增加了对 Key Rotation(密钥轮换)的支持,允许开发者更换私钥后仍能保持应用更新。
  • V4 签名(APK Signature Scheme v4,Android 11+)

    • 用于加速 APK 安装,但不会影响完整性校验。

3.什么是签名校验

  • 基本概念

    1. 什么是签名校验?

    签名校验是 Android 应用常见的安全机制,用于检测应用是否被篡改或重新签名。它的主要目的是防止二次打包和恶意篡改。

    当开发者编译和打包 APK 时,APK 会使用开发者的私钥进行签名。Android 系统在安装应用时会验证签名,如果 APK 被篡改,签名就会失效,导致无法安装。

    但在 逆向分析 中,攻击者可以修改 APK 并重新签名,因此应用通常会在运行时 自检签名,如果签名不匹配,应用可能会终止运行或执行反调试逻辑。


    2. 签名校验的工作原理

    签名校验通常分为 系统级校验应用自校验

    (1)系统级签名校验

    • 作用:当用户安装 APK 时,Android 系统会自动检查 APK 的签名。
    • 流程
      1. 提取 APK 的 CERT.RSA 证书。
      2. 使用该证书的公钥验证 CERT.SFMANIFEST.MF 文件,确保 APK 未被篡改。
      3. 如果 APK 签名无效(如篡改了 classes.dexAndroidManifest.xml),则安装失败。
    • 特点
      • 由 Android 系统执行,不可绕过。
      • 只有相同签名的应用才能相互更新(应用升级必须使用相同的开发者签名)。

    (2)应用自校验(运行时签名校验)

    • 作用:在应用运行时检查自身签名,防止被二次打包或篡改后运行。
    • 实现方式
      • 获取应用的签名信息,并与预存的合法签名进行比对。
      • 如果签名不匹配,则终止应用运行或执行反调试逻辑。

    3. 签名校验的实现方式

    在 Android 代码中,签名校验通常通过以下方式实现:

    (1)获取签名(兼容 Android 7.0+)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    java
    复制编辑
    import android.content.pm.PackageInfo;
    import android.content.pm.PackageManager;
    import android.content.pm.Signature;
    import java.security.MessageDigest;

    public static String getSignature(Context context) {
    try {
    PackageManager pm = context.getPackageManager();
    PackageInfo packageInfo = pm.getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
    Signature[] signatures = packageInfo.signingInfo.getApkContentsSigners();

    MessageDigest md = MessageDigest.getInstance("SHA-256");
    md.update(signatures[0].toByteArray());
    return bytesToHex(md.digest()); // 计算签名哈希值
    } catch (Exception e) {
    return null;
    }
    }

    private static String bytesToHex(byte[] bytes) {
    StringBuilder hexString = new StringBuilder();
    for (byte b : bytes) {
    String hex = Integer.toHexString(0xff & b);
    if (hex.length() == 1) {
    hexString.append('0');
    }
    hexString.append(hex);
    }
    return hexString.toString();
    }

    • 该代码计算应用的 SHA-256 签名哈希值,并与预存的值比对,防止篡改。

    (2)比对签名

    1
    2
    3
    4
    5
    6
    7
    8
    java
    复制编辑
    String expectedSignature = "ABC123DEF456..."; // 预存的签名哈希
    String currentSignature = getSignature(context);
    if (!expectedSignature.equals(currentSignature)) {
    System.exit(0); // 签名不匹配,退出应用
    }


    4. 签名校验的常见绕过方法

    在逆向工程中,攻击者通常会修改 APK 并重新签名,使其能够安装并运行。但如果应用进行了运行时签名校验,则需要使用以下方法绕过:

    (1)Hook 绕过

    使用 Frida/Xposed Hook getPackageInfo() 方法,伪造签名返回值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    javascript
    复制编辑
    Java.perform(function() {
    var pm = Java.use("android.app.ApplicationPackageManager");
    pm.getPackageInfo.overload("java.lang.String", "int").implementation = function(pkg, flags) {
    var info = this.getPackageInfo(pkg, flags);
    var fakeSignature = Java.use("android.content.pm.Signature").$new("ABC123DEF456..."); // 伪造签名
    info.signatures.value = [fakeSignature];
    return info;
    };
    });

    • 该代码 Hook 了 getPackageInfo() 方法,使其始终返回合法的签名。

    (2)修改 Smali 代码

    1. 反编译 APK:

      1
      2
      3
      bash
      复制编辑
      apktool d app.apk -o app_smali
    2. 找到 getSignature() 方法,将签名比对逻辑改为 return true

      1
      2
      3
      4
      5
      6
      smali
      复制编辑
      const-string v0, "ABC123DEF456..."
      return v0

      //使校验函数直接返回true或跳过校验步骤
    3. 重新打包并签名:

      1
      2
      3
      4
      5
      bash
      复制编辑
      apktool b app_smali -o new_app.apk
      zipalign -p 4 new_app.apk new_app_aligned.apk
      apksigner sign --ks mykeystore.jks --out new_app_signed.apk new_app_aligned.apk

    (3)直接修改 APK 签名

    • 使用 zipalignapksigner 重新签名 APK:

      1
      2
      3
      4
      bash
      复制编辑
      zipalign -p 4 modded.apk modded_aligned.apk
      apksigner sign --ks mykeystore.jks --out modded_signed.apk modded_aligned.apk
    • 但是如果应用内置了签名校验代码,仍然需要 Hook 相关方法绕过。


    5. 总结

    如何增强签名校验?(开发者视角)

    1. 避免硬编码签名值
      • 动态从服务器获取合法签名,或通过代码混淆分散存储。
    2. Native层校验
      • 将校验逻辑写入C/C++库(.so文件),并添加反调试、代码混淆等保护。
    3. 多维度校验
      • 同时校验签名哈希、证书序列号、公钥等信息,而非单一哈希值。
    4. 环境检测
      • 结合Root检测、模拟器检测,防止校验逻辑在调试环境中运行。
    5. 服务端协同
      • 关键业务请求时,服务端二次校验客户端的签名信息。
    签名校验类型 作用 绕过方式
    系统级签名校验 检查 APK 安装时的合法性 无法绕过,必须重新签名
    应用自校验 运行时检测签名是否匹配 Hook getPackageInfo()、修改 Smali
    • 签名校验主要用于防止二次打包和破解,但可以通过 Hook 或修改 Smali 绕过。
    • 更高级的保护手段可能会将签名校验与代码完整性校验、JNI 保护等结合,增加破解难度。

如何判断是否有签名校验?

不做任何修改,直接签名安装,应用闪退则说明大概率有签名校验

一般来说,普通的签名校验会导致软件的闪退,黑屏,卡启动页等

当然,以上都算是比较好的,有一些比较狠的作者,则会直接rm -rf /,把基带都格掉的一键变砖。

1
2
3
4
5
6

kill/killProcess-----kill/KillProcess()可以杀死当前应用活动的进程,这一操作将会把所有该进程内的资源(包括线程全部清理掉).当然,由于ActivityManager时刻监听着进程,一旦发现进程被非正常Kill,它将会试图去重启这个进程。这就是为什么,有时候当我们试图这样去结束掉应用时,发现它又自动重新启动的原因.

system.exit-----杀死了整个进程,这时候活动所占的资源也会被释放。

finish----------仅仅针对Activity,当调用finish()时,只是将活动推向后台,并没有立即释放内存,活动的资源并没有被清理

在我个人见过最恶心的签名校验中,当属三角校验(低调大佬教的)最烦人。

所谓三角校验,就是so检测dex,动态加载的dex(在软件运行时会解压释放一段dex文件,检测完后就删除)检测so,dex检测动态加载的dex

  • 示例

    1. 打开算法助手的拦截和防止退出

    1. 我们可以在日志中查看到拦截信息

    1. 查找到闪退的方法

    1. 查询到闪退的代码将其注释掉,就可以解决闪退等问题。

    1. 我们也可以通过算法助手的签名监听来进行定位

    1. 通过日志中的签名信息找到其相应的堆栈进行查询

    1. 对堆栈进行查询找到关键方法checkSign进行分析。

    1. 通过获取安装包签名信息的值与SIGNATURE的值进行比对

    1. 我们可以将判断的逻辑if-nez改为if-eqz,直接把判断取反

    1. 直接对sign值进行查询

    1. 将包内的sign替换掉,就可以通过普通签名校验

普通获取签名校验代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private boolean SignCheck() {
    String trueSignMD5 = "d0add9987c7c84aeb7198c3ff26ca152";
    String nowSignMD5 = "";
    try {
        // 得到签名的MD5
        PackageInfo packageInfo = getPackageManager().getPackageInfo(getPackageName(),PackageManager.GET_SIGNATURES);
        Signature[] signs = packageInfo.signatures;
        String signBase64 = Base64Util.encodeToString(signs[0].toByteArray());
        nowSignMD5 = MD5Utils.MD5(signBase64);
    } catch (PackageManager.NameNotFoundException e) {
        e.printStackTrace();
    }
    return trueSignMD5.equals(nowSignMD5);
}

系统将应用的签名信息封装在 PackageInfo 中,调用 PackageManager 的 getPackageInfo(String packageName, int flags) 即可获取指定包名的签名信息。

4.签名校验对抗

方法一:核心破解插件,不签名安装应用

方法二:一键过签名工具,例如MT、NP、ARMPro、CNFIX、Modex的去除签名校验功能

方法三:具体分析签名校验逻辑(手撕签名校验)

方法四:io重定向–VA&SVC:ptrace+seccomp

SVC的TraceHook沙箱的实现&无痕Hook实现思路

方法五:去作者家严刑拷打拿到.jks文件和密码

  • jks文件了

    .jks 文件Java KeyStore 文件,主要用于存储加密密钥、证书和私钥,通常用于 SSL/TLS 证书管理。Java 应用程序使用 .jks 文件来确保安全的通信。

    常见用途

    1. SSL/TLS 证书管理(HTTPS 服务器、客户端身份验证)
    2. Java 应用安全(如 Spring Boot、Tomcat)
    3. Android 签名(用于 APK 签名)

    常见命令(keytool 工具)

    keytool 是 Java 自带的密钥管理工具,可用于创建和管理 .jks 文件。

    1. 生成新的 JKS 文件

    1
    2
    keytool -genkey -alias myalias -keyalg RSA -keystore mykeystore.jks -validity 3650 -storepass mypassword

    • alias myalias:指定别名
    • keyalg RSA:密钥算法
    • keystore mykeystore.jks:JKS 文件名
    • validity 3650:证书有效期(天)
    • storepass mypassword:密钥库密码

    2. 查看 JKS 文件内容

    1
    2
    keytool -list -keystore mykeystore.jks -storepass mypassword

    3. 导入证书到 JKS

    1
    2
    keytool -import -trustcacerts -alias mycert -file mycert.crt -keystore mykeystore.jks

    4. 导出证书

    1
    2
    keytool -export -alias myalias -file mycert.crt -keystore mykeystore.jks

    5. 转换 JKS 到 PKCS12

    1
    2
    keytool -importkeystore -srckeystore mykeystore.jks -destkeystore mykeystore.p12 -deststoretype PKCS12

    JKS 在 Android 中的应用

    Android 应用使用 JKS 文件(通常是 .keystore 格式)来签名 APK。例如:

    1
    2
    arsigner -verbose -keystore my-release-key.jks my-app.apk myalias

5.手动实现PM代{过}{滤}理

1.什么是PMS

思路源自:Android中Hook 应用签名方法

PackageManagerService(简称PMS),是Android系统核心服务之一,处理包管理相关的工作,常见的比如安装、卸载应用等。

2.实现方法以及原理解析

HOOK PMS代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package com.zj.hookpms;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;

import android.content.Context;
import android.content.pm.PackageManager;
import android.util.Log;

public class ServiceManagerWraper {

    public final static String ZJ = "ZJ595";

    public static void hookPMS(Context context, String signed, String appPkgName, int hashCode) {
        try {
            // 获取全局的ActivityThread对象
            Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
            Method currentActivityThreadMethod =
                    activityThreadClass.getDeclaredMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            // 获取ActivityThread里面原始的sPackageManager
            Field sPackageManagerField = activityThreadClass.getDeclaredField("sPackageManager");
            sPackageManagerField.setAccessible(true);
            Object sPackageManager = sPackageManagerField.get(currentActivityThread);
            // 准备好代{过}{滤}理对象, 用来替换原始的对象
            Class<?> iPackageManagerInterface = Class.forName("android.content.pm.IPackageManager");
            Object proxy = Proxy.newProxyInstance(
                    iPackageManagerInterface.getClassLoader(),
                    new Class<?>[]{iPackageManagerInterface},
                    new PmsHookBinderInvocationHandler(sPackageManager, signed, appPkgName, 0));
            // 1. 替换掉ActivityThread里面的 sPackageManager 字段
            sPackageManagerField.set(currentActivityThread, proxy);
            // 2. 替换 ApplicationPackageManager里面的 mPM对象
            PackageManager pm = context.getPackageManager();
            Field mPmField = pm.getClass().getDeclaredField("mPM");
            mPmField.setAccessible(true);
            mPmField.set(pm, proxy);
        } catch (Exception e) {
            Log.d(ZJ, "hook pms error:" + Log.getStackTraceString(e));
        }
    }

    public static void hookPMS(Context context) {
        String Sign = "原包的签名信息";
        hookPMS(context, Sign, "com.zj.hookpms", 0);
    }
}

ActivityThread的静态变量sPackageManager

ApplicationPackageManager对象里面的mPM变量

  • 获取原包签名信息

    1. 使用MT管理器,点击签名信息并查看原始数据

    1. 这一串内容均为其那名信息,将内容全选复制为base64并与原包签名信息进行替换

    3.替换信息后,调用代码

该方法较为过时,简单了解一下即可。

6.IO重定向

  • 什么是IO重定向

    IO 重定向(Input/Output Redirection)是指在计算机系统中,将标准输入(stdin)、标准输出(stdout)或标准错误输出(stderr)重定向到不同的文件或设备,而不是默认的终端(屏幕和键盘)。这是 Unix/Linux 和 Windows 命令行中常见的操作,主要用于控制程序的输入和输出。
    
    在 **Android 逆向** 中,**IO 重定向**(Input/Output Redirection)主要用于劫持、修改或捕获应用程序的标准输入、标准输出(stdout)和标准错误(stderr)。它在分析、调试和 Hook 过程中非常重要。
    

    1. IO 重定向在 Android 逆向中的作用

    在 Android 逆向过程中,我们通常希望获取应用程序的 调试日志标准输出标准错误,或者将输入重定向到目标进程。由于 Android 应用通常运行在 Linux 环境(基于 Android 内核) 下,它也遵循 Linux 的 IO 机制,因此可以使用 重定向技术 进行信息捕获或干预。


    2. IO 重定向的常见用途

    (1)捕获应用标准输出

    有些 Android 应用会在 stdoutstderr 中打印调试信息,例如:

    1
    2
    3
    4
    c
    复制编辑
    printf("Secret Key: %s\n", secretKey);

    但这些信息不会直接显示在 Logcat 中,而是需要重定向输出才能捕获。

    方法1:使用 strace 或 logcat

    • 使用 strace 监视 IO 调用

      1
      2
      3
      4
      bash
      复制编辑
      strace -p <PID> -e write

      这可以监视 write 系统调用,捕获应用写入 stdoutstderr 的数据。

    • 使用 logcat 捕获日志

      1
      2
      3
      4
      bash
      复制编辑
      logcat -s "AppTag"

    方法2:使用 run-as 重定向

    如果目标应用是 debuggable,可以使用 run-as 切换到应用的 UID 并重定向 IO:

    1
    2
    3
    4
    bash
    复制编辑
    run-as com.example.app sh -c 'exec > /data/data/com.example.app/output.log 2>&1'

    这会将应用的 stdoutstderr 重定向到 output.log,然后可以用 adb pull 拉取日志:

    1
    2
    3
    4
    bash
    复制编辑
    adb pull /data/data/com.example.app/output.log .


    (2)Hook IO 函数

    重定向 openreadwrite 等系统调用,可以在动态调试中捕获关键数据,例如应用读写的文件、网络数据等。

    方法1:使用 Frida Hook IO

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    python
    复制编辑
    import frida

    script_code = """
    Interceptor.attach(Module.findExportByName(null, 'write'), {
    onEnter: function(args) {
    send("Writing data: " + Memory.readUtf8String(args[1]));
    }
    });
    """
    device = frida.get_usb_device()
    session = device.attach("com.target.app")
    script = session.create_script(script_code)
    script.on("message", lambda msg, data: print(msg))
    script.load()

    作用:

    • Hook write() 系统调用,劫持目标进程的输出内容(如日志、密钥等)。
    • 适用于需要 监视应用输出数据 的场景。

    (3)修改应用输入

    在 Android 逆向中,某些应用会通过 stdin 读取数据,比如命令行工具或者 Shell 脚本。可以通过 输入重定向 伪造数据输入,例如:

    1
    2
    3
    4
    bash
    复制编辑
    echo "fake_password" | ./target_binary

    或者 Hook read() 函数,修改 stdin 输入:

    1
    2
    3
    4
    5
    6
    7
    8
    python
    复制编辑
    Interceptor.attach(Module.findExportByName(null, 'read'), {
    onEnter: function(args) {
    args[1].writeUtf8String("hacked_input");
    }
    });


    (4)劫持 Java 层 IO

    在 Java 层,System.out.print() 默认会输出到 stdout,可以通过 Hook System.out 来修改或捕获:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    java
    复制编辑
    System.setOut(new PrintStream(new OutputStream() {
    @Override
    public void write(int b) {
    // 劫持 IO,改写输出
    }
    }));

    这在 Hook Java 方法时可以用于修改应用日志或隐藏关键输出。


    3. 总结

    用途 技术
    捕获 stdoutstderr stracelogcatrun-as
    Hook IO 函数 FridaXposedLD_PRELOAD
    修改输入 stdin 重定向、Hook read()
    Java 层 IO 劫持 System.setOut()

    IO 重定向在 Android 逆向中的核心作用:

    1. 获取应用未公开的调试信息
    2. 监视或篡改应用输入
    3. 分析目标应用的日志、密钥或敏感数据
    4. 结合 Frida/Xposed 进行 Hook,提高调试能力

例:在读A文件的时候指向B文件

平头哥的核心代码

Virtual Engine for Android(Support 12.0 in business version)

IO重定向可以干嘛?

1,可以让文件只读,不可写

2,禁止访问文件

3,路径替换

具体实现:

过签名检测(读取原包)

风控对抗(例:一个文件记录App启动的次数)

过Root检测,Xposed检测(文件不可取)

  • 新的API签名校验与普通签名校验Ture方法一致

    1. 分析API的签名校验算法,发现它newsign定位好签名之后,将新的API与原来的进行比对,仍可以采用普通校验的方式将新的API替换,ture处理掉。

  • CRC校验

    1. 观察校验部分的代码发现,解压后获取dex文件,获取它的crc的一个值。而我们每做一次修改,dex文件中的值都会发生对应的改变,而它的值需要与j进行一个对比,我们顺着思路查看j在哪里.

    1. Ctrl+F顺着j进行查找,发现是通过一个方法传入一个值

    1. 我们顺着观察发现string类里面有crc的值

    1. 跳转查看发现每一次修改都会使数值发生转变,不可能每一次都对数值进行修改,写死会导致校验失败。需要运用io重定向

    1. 代码已经写在so文件中,现在缺少一个调用。使用smail代码。它是获取上下文的文本,然后传入到hook方法中,hook方法是一个native方法。
    2. 首先打开编辑器搜索到checkSign方法后,打开导航找到crc,查看他的调用。

    1. 因为要进行重定向,要在校验之前完成这部分工作才可以读到原包。在方法开头将我们的调用代码放入进去。

    1. 在软件数据目录下(数据目录1)新建一个files文件,将没有经过修改的原包复制在该文件中,重命名为base.apk,这样才可以实现一个io的重定向读取原包的信息。(具体原因在代码部分解释)

    1. 以上就是一个简单的io重定向的实现(crc读取dex的一个crc值,而hash读取整个APK一个hash的一个值)

io重定向原码,相当于hook的open,openat等几个函数(用于读取底层文件函数)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
using namespace std;
string packname;
string origpath;
string fakepath;

int (*orig_open)(const char *pathname, int flags, ...);
int (*orig_openat)(int,const char *pathname, int flags, ...);
FILE *(*orig_fopen)(const char *filename, const char *mode);
static long (*orig_syscall)(long number, ...);
int (*orig__NR_openat)(int,const char *pathname, int flags, ...);

void* (*orig_dlopen_CI)(const char *filename, int flag);
void* (*orig_dlopen_CIV)(const char *filename, int flag, const void *extinfo);
void* (*orig_dlopen_CIVV)(const char *name, int flags, const void *extinfo, void *caller_addr);

static inline bool needs_mode(int flags) {
    return ((flags & O_CREAT) == O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE);
}
bool startsWith(string str, string sub){
    return str.find(sub)==0;
}

bool endsWith(string s,string sub){
    return s.rfind(sub)==(s.length()-sub.length());
}
bool isOrigAPK(string  path){

    if(path==origpath){
        return true;
    }
    return false;
}
//该函数的功能是在打开一个文件时进行拦截,并在满足特定条件时将文件路径替换为另一个路径

//fake_open 函数有三个参数:
//pathname:一个字符串,表示要打开的文件的路径。
//flags:一个整数,表示打开文件的方式,例如只读、只写、读写等。
//mode(可选参数):一个整数,表示打开文件时应用的权限模式。
int fake_open(const char *pathname, int flags, ...) {
    mode_t mode = 0;
    if (needs_mode(flags)) {
        va_list args;
        va_start(args, flags);
        mode = static_cast<mode_t>(va_arg(args, int));
        va_end(args);
    }
    //LOGI("open,  path: %s, flags: %d, mode: %d",pathname, flags ,mode);
    string cpp_path= pathname;
    if(isOrigAPK(cpp_path)){
        LOGI("libc_open, redirect: %s, --->: %s",pathname, fakepath.data());
        return orig_open("/data/user/0/com.zj.wuaipojie/files/base.apk", flags, mode);
}//这里已经将我们io后的路径写死,也是我们实例中新建对应文件的原因。
  return  orig_open(pathname, flags, mode);

}

//该函数的功能是在打开一个文件时进行拦截,并在满足特定条件时将文件路径替换为另一个路径

//fake_openat 函数有四个参数:
//fd:一个整数,表示要打开的文件的文件描述符。
//pathname:一个字符串,表示要打开的文件的路径。
//flags:一个整数,表示打开文件的方式,例如只读、只写、读写等。
//mode(可选参数):一个整数,表示打开文件时应用的权限模式。
//openat 函数的作用类似于 open 函数,但是它使用文件描述符来指定文件路径,而不是使用文件路径本身。这样,就可以在打开文件时使用相对路径,而不必提供完整的文件路径。
//例如,如果要打开相对于当前目录的文件,可以使用 openat 函数,而不是 open 函数,因为 open 函数只能使用绝对路径。
//
int fake_openat(int fd, const char *pathname, int flags, ...) {
    mode_t mode = 0;
    if (needs_mode(flags)) {
        va_list args;
        va_start(args, flags);
        mode = static_cast<mode_t>(va_arg(args, int));
        va_end(args);
    }
    LOGI("openat, fd: %d, path: %s, flags: %d, mode: %d",fd ,pathname, flags ,mode);
    string cpp_path= pathname;
    if(isOrigAPK(cpp_path)){
        LOGI("libc_openat, redirect: %s, --->: %s",pathname, fakepath.data());
        return  orig_openat(fd,fakepath.data(), flags, mode);
    }
    return orig_openat(fd,pathname, flags, mode);

}
FILE *fake_fopen(const char *filename, const char *mode) {
    string cpp_path= filename;
    if(isOrigAPK(cpp_path)){
        return  orig_fopen(fakepath.data(), mode);
    }
    return orig_fopen(filename, mode);
}
//该函数的功能是在执行系统调用时进行拦截,并在满足特定条件时修改系统调用的参数。
//syscall 函数是一个系统调用,是程序访问内核功能的方法之一。使用 syscall 函数可以调用大量的系统调用,它们用于实现操作系统的各种功能,例如打开文件、创建进程、分配内存等。
//
static long fake_syscall(long number, ...) {
    void *arg[7];
    va_list list;

    va_start(list, number);
    for (int i = 0; i < 7; ++i) {
        arg[i] = va_arg(list, void *);
    }
    va_end(list);
    if (number == __NR_openat){
        const char *cpp_path = static_cast<const char *>(arg[1]);
        LOGI("syscall __NR_openat, fd: %d, path: %s, flags: %d, mode: %d",arg[0] ,arg[1], arg[2], arg[3]);
        if (isOrigAPK(cpp_path)){
            LOGI("syscall __NR_openat, redirect: %s, --->: %s",arg[1], fakepath.data());
            return orig_syscall(number,arg[0], fakepath.data() ,arg[2],arg[3]);
        }
    }
    return orig_syscall(number, arg[0], arg[1], arg[2], arg[3], arg[4], arg[5], arg[6]);

}

//函数的功能是获取当前应用的包名、APK 文件路径以及库文件路径,并将这些信息保存在全局变量中
//函数调用 GetObjectClass 和 GetMethodID 函数来获取 context 对象的类型以及 getPackageName 方法的 ID。然后,函数调用 CallObjectMethod 函数来调用 getPackageName 方法,获取当前应用的包名。最后,函数使用 GetStringUTFChars 函数将包名转换为 C 字符串,并将包名保存在 packname 全局变量中
//接着,函数使用 fakepath 全局变量保存了 /data/user/0/<packname>/files/base.apk 这样的路径,其中 <packname> 是当前应用的包名。
//然后,函数再次调用 GetObjectClass 和 GetMethodID 函数来获取 context 对象的类型以及 getApplicationInfo 方法的 ID。然后,函数调用 CallObjectMethod 函数来调用 getApplicationInfo 方法,获取当前应用的 ApplicationInfo 对象。
//它先调用 GetObjectClass 函数获取 ApplicationInfo 对象的类型,然后调用 GetFieldID 函数获取 sourceDir 字段的 ID。接着,函数使用 GetObjectField 函数获取 sourceDir 字段的值,并使用 GetStringUTFChars 函数将其转换为 C 字符串。最后,函数将 C 字符串保存在 origpath 全局变量中,表示当前应用的 APK 文件路径。
//最后,函数使用 GetFieldID 和 GetObjectField 函数获取 nativeLibraryDir 字段的值,并使用 GetStringUTFChars 函数将其转换为 C 字符串。函数最后调用 LOGI 函数打印库文件路径,但是并没有将其保存在全局变量中。

extern "C" JNIEXPORT void JNICALL
Java_com_zj_wuaipojie_util_SecurityUtil_hook(JNIEnv *env, jclass clazz, jobject context) {
    jclass conext_class = env->GetObjectClass(context);
    jmethodID methodId_pack = env->GetMethodID(conext_class, "getPackageName",
                                               "()Ljava/lang/String;");
    auto packname_js = reinterpret_cast<jstring>(env->CallObjectMethod(context, methodId_pack));
    const char *pn = env->GetStringUTFChars(packname_js, 0);
    packname = string(pn);

    env->ReleaseStringUTFChars(packname_js, pn);
    //LOGI("packname: %s", packname.data());
    fakepath= "/data/user/0/"+ packname +"/files/base.apk";

    jclass conext_class2 = env->GetObjectClass(context);
    jmethodID methodId_pack2 = env->GetMethodID(conext_class2,"getApplicationInfo","()Landroid/content/pm/ApplicationInfo;");
    jobject application_info = env->CallObjectMethod(context,methodId_pack2);
    jclass pm_clazz = env->GetObjectClass(application_info);

    jfieldID package_info_id = env->GetFieldID(pm_clazz,"sourceDir","Ljava/lang/String;");
    auto sourceDir_js = reinterpret_cast<jstring>(env->GetObjectField(application_info,package_info_id));
    const char *sourceDir = env->GetStringUTFChars(sourceDir_js, 0);
    origpath = string(sourceDir);
    LOGI("sourceDir: %s", sourceDir);

    jfieldID package_info_id2 = env->GetFieldID(pm_clazz,"nativeLibraryDir","Ljava/lang/String;");
    auto nativeLibraryDir_js = reinterpret_cast<jstring>(env->GetObjectField(application_info,package_info_id2));
    const char *nativeLibraryDir = env->GetStringUTFChars(nativeLibraryDir_js, 0);
    LOGI("nativeLibraryDir: %s", nativeLibraryDir);
    //LOGI("%s", "Start Hook");

    //启动hook
    void *handle = dlopen("libc.so",RTLD_NOW);
    auto pagesize = sysconf(_SC_PAGE_SIZE);
    auto addr = ((uintptr_t)dlsym(handle,"open") & (-pagesize));
    auto addr2 = ((uintptr_t)dlsym(handle,"openat") & (-pagesize));
    auto addr3 = ((uintptr_t)fopen) & (-pagesize);
    auto addr4 = ((uintptr_t)syscall) & (-pagesize);

    //解除部分机型open被保护
    mprotect((void*)addr, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
    mprotect((void*)addr2, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
    mprotect((void*)addr3, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);
    mprotect((void*)addr4, pagesize, PROT_READ | PROT_WRITE | PROT_EXEC);

    DobbyHook((void *)dlsym(handle,"open"), (void *)fake_open, (void **)&orig_open);
    DobbyHook((void *)dlsym(handle,"openat"), (void *)fake_openat, (void **)&orig_openat);
    DobbyHook((void *)fopen, (void *)fake_fopen, (void**)&orig_fopen);
    DobbyHook((void *)syscall, (void *)fake_syscall, (void **)&orig_syscall);
}

1
2
3
4
5
6
7
8
 
        sget-object p10, Lcom/zj/wuaipojie/util/ContextUtils;->INSTANCE:Lcom/zj/wuaipojie/util/ContextUtils;

    invoke-virtual {p10}, Lcom/zj/wuaipojie/util/ContextUtils;->getContext()Landroid/content/Context;

    move-result-object p10

    invoke-static {p10}, Lcom/zj/wuaipojie/util/SecurityUtil;->hook(Landroid/content/Context;)V

7.其他常见校验

root检测:

反制手段

1.算法助手、对话框取消等插件一键hook

2.分析具体的检测代码

3.利用IO重定向使文件不可读

4.修改Andoird源码,去除常见指纹

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
 复制代码 隐藏代码
fun isDeviceRooted(): Boolean {
    return checkRootMethod1() || checkRootMethod2() || checkRootMethod3()
}

fun checkRootMethod1(): Boolean {
    val buildTags = android.os.Build.TAGS
    return buildTags != null && buildTags.contains("test-keys")
}

fun checkRootMethod2(): Boolean {
    val paths = arrayOf("/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su",
            "/system/bin/failsafe/su", "/data/local/su", "/su/bin/su")
    for (path in paths) {
        if (File(path).exists()) return true
    }
    return false
}

fun checkRootMethod3(): Boolean {
    var process: Process? = null
    return try {
        process = Runtime.getRuntime().exec(arrayOf("/system/xbin/which", "su"))
        val bufferedReader = BufferedReader(InputStreamReader(process.inputStream))
        bufferedReader.readLine() != null
    } catch (t: Throwable) {
        false
    } finally {
        process?.destroy()
    }
}

定义了一个 isDeviceRooted() 函数,该函数调用了三个检测 root 的方法:checkRootMethod1()checkRootMethod2() 和 checkRootMethod3()

checkRootMethod1() 方法检查设备的 build tags 是否包含 test-keys。这通常是用于测试的设备,因此如果检测到这个标记,则可以认为设备已被 root。

checkRootMethod2() 方法检查设备是否存在一些特定的文件,这些文件通常被用于执行 root 操作。如果检测到这些文件,则可以认为设备已被 root。

checkRootMethod3() 方法使用 Runtime.exec() 方法来执行 which su 命令,然后检查命令的输出是否不为空。如果输出不为空,则可以认为设备已被 root。

模拟器检测

1
2
3
4
fun isEmulator(): Boolean {
        return Build.FINGERPRINT.startsWith("generic") || Build.FINGERPRINT.startsWith("unknown") || Build.MODEL.contains("google_sdk") Build.MODEL.contains("Emulator") || Build.MODEL.contains("Android SDK built for x86") || Build.MANUFACTURER.contains("Genymotion") || Build.HOST.startsWith("Build") || Build.PRODUCT == "google_sdk"
        }

通过检测系统的 Build 对象来判断当前设备是否为模拟器。具体方法是检测 Build.FINGERPRINT 属性是否包含字符串 "generic"

模拟器检测对抗

反调试检测

安卓系统自带调试检测函数

1
2
3
4
5
6
fun checkForDebugger() {
    if (Debug.isDebuggerConnected()) {
        // 如果调试器已连接,则终止应用程序
        System.exit(0)
    }
}

debuggable属性

1
2
3
4
5
6
7
public boolean getAppCanDebug(Context context)//上下文对象为xxActivity.this
{
    boolean isDebug = context.getApplicationInfo() != null &&
            (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
    return isDebug;
}

ptrace检测

1
2
3
4
int ptrace_protect()//ptrace附加自身线程 会导致此进程TracerPid 变为父进程的TracerPid 即zygote
{
    return ptrace(PTRACE_TRACEME,0,0,0);;//返回-1即为已经被调试
}

每个进程同时刻只能被1个调试进程ptrace  ,主动ptrace本进程可以使得其他调试器无法调试

调试进程名检测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int SearchObjProcess()
{
    FILE* pfile=NULL;
    char buf[0x1000]={0};

    pfile=popen("ps","r");
    if(NULL==pfile)
    {
        //LOGA("SearchObjProcess popen打开命令失败!\n");
        return -1;
    }
    // 获取结果
    //LOGA("popen方案:\n");
    while(fgets(buf,sizeof(buf),pfile))
    {

        char* strA=NULL;
        char* strB=NULL;
        char* strC=NULL;
        char* strD=NULL;
        strA=strstr(buf,"android_server");//通过查找匹配子串判断
        strB=strstr(buf,"gdbserver");
        strC=strstr(buf,"gdb");
        strD=strstr(buf,"fuwu");
        if(strA || strB ||strC || strD)
        {
            return 1;
            // 执行到这里,判定为调试状态

        }
    }
    pclose(pfile);
    return 0;
}

[原创]对安卓反调试和校验检测的一些实践与结论

frida检测

一些Frida检测手段

8.smali语法小课堂之赋值

1.Int型赋值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149

.method private static final onCreate$lambda-0(Lcom/zj/wuaipojie/ui/SmaliLearn;Landroid/widget/TextView;Landroid/widget/TextView;Landroid/widget/TextView;Landroid/view/View;)V
    .registers 9

    .line 21
    invoke-virtual {p0}, Lcom/zj/wuaipojie/ui/SmaliLearn;->isVip()I

    move-result p4
        //判断vip的值分别对应不用的会员的等级
    if-eqz p4, :cond_35

    const/4 v0, 0x1

    if-eq p4, v0, :cond_2d

    const/4 v0, 0x4

    if-eq p4, v0, :cond_25

    const/16 v0, 0x10

    if-eq p4, v0, :cond_1d

    const/16 v0, 0x63

    if-eq p4, v0, :cond_15

    goto :goto_3c

    :cond_15
    const-string p4, "至尊会员"

    .line 26
    check-cast p4, Ljava/lang/CharSequence;

    invoke-virtual {p1, p4}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    goto :goto_3c

    :cond_1d
    const-string p4, "超级会员"

    .line 25
    check-cast p4, Ljava/lang/CharSequence;

    invoke-virtual {p1, p4}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    goto :goto_3c

    :cond_25
    const-string p4, "大会员"

    .line 24
    check-cast p4, Ljava/lang/CharSequence;

    invoke-virtual {p1, p4}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    goto :goto_3c

    :cond_2d
    const-string p4, "会员"

    .line 23
    check-cast p4, Ljava/lang/CharSequence;

    invoke-virtual {p1, p4}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    goto :goto_3c

    :cond_35
    const-string p4, "非会员"

    .line 22
    check-cast p4, Ljava/lang/CharSequence;

    invoke-virtual {p1, p4}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

        .line 28
        //判断vipEndTime的时间戳是否小于系统时间
    :goto_3c
    new-instance p1, Ljava/util/Date;

    invoke-direct {p1}, Ljava/util/Date;-><init>()V

    invoke-virtual {p1}, Ljava/util/Date;->getTime()J

    move-result-wide v0

    .line 29
    new-instance p1, Ljava/text/SimpleDateFormat;

    const-string p4, "yyyy-MM-dd"

    invoke-direct {p1, p4}, Ljava/text/SimpleDateFormat;-><init>(Ljava/lang/String;)V

    .line 30
    invoke-virtual {p0}, Lcom/zj/wuaipojie/ui/SmaliLearn;->vipEndTime()J

    move-result-wide v2

    cmp-long p4, v2, v0

    if-gez p4, :cond_5c

    const-string p1, "已过期"

    .line 31
    check-cast p1, Ljava/lang/CharSequence;

    invoke-virtual {p2, p1}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    goto :goto_6d

    .line 33
    :cond_5c
    invoke-virtual {p0}, Lcom/zj/wuaipojie/ui/SmaliLearn;->vipEndTime()J

    move-result-wide v0

    invoke-static {v0, v1}, Ljava/lang/Long;->valueOf(J)Ljava/lang/Long;

    move-result-object p4

    invoke-virtual {p1, p4}, Ljava/text/SimpleDateFormat;->format(Ljava/lang/Object;)Ljava/lang/String;

    move-result-object p1

    check-cast p1, Ljava/lang/CharSequence;

    invoke-virtual {p2, p1}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    .line 35
    :goto_6d
    iget p0, p0, Lcom/zj/wuaipojie/ui/SmaliLearn;->vip_coin:I

    if-eqz p0, :cond_74

    .line 36
    invoke-static {p0}, Ljava/lang/String;->valueOf(I)Ljava/lang/String;

    move-result-object p0

    check-cast p0, Ljava/lang/CharSequence;

    invoke-virtual {p3, p0}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)V

    :cond_74
    return-void
.end method

const/4和const/16的区别?

const/4 最大只允许存放4个二进制位(4bit),

const/16 最大值允许存放16个二进制位(16bit), 第一位(即最高位)默认为符号位。单位换算 1byte=8bit

举例说明下寄存器的取值范围: # 以下数据定义高位默认为符号位

const/4 v0,0x2 # 最大只允许存放半字节数据 取值范围为 -8 and 7

const/16 v0 , 0xABCD # 定义一个寄存器变量,最大只允许存放16位数据 比如short类型数据 取值范围为-32768~32767

const v0 , 0xA# 定义一个寄存器, 最大只允许存放32位数据,比如int类型数据 将数字10赋值给v0 取值范围-2147483647~2147483647

const/high16 #定义一个寄存器, 最大只允许存放高16位数值 比如0xFFFF0000末四位补0 存入高四位0XFFFF

2.Long型赋值

const-wide vx, lit32 表示将一个 32 位的常量存储到 vx 与 vx+1 两个寄存器中 —— 即一个 long 类型的数据

1
2
3
4
5
6
7
8
9

.method public final vipEndTime()J
    .registers 3

    const-wide v0, 0x1854460ef29L

    return-wide v0
.end method

会员到期时间就是2022年12月24日。那么1854460ef29L 怎么来的呢?也就是(2022年12月24日-1970年1月1日)×365天×24小时×60分钟×60秒×1000毫秒,转换成16进制就大概是那个数了

在线时间戳转换

3.变量赋值(正则)

1
2
3
4
5
6
7
8
9
10
11
12
13

        iget p0, p0, Lcom/zj/wuaipojie/ui/SmaliLearn;->vip_coin:I

    if-eqz p0, :cond_74

    .line 36
    invoke-static {p0}, Ljava/lang/String;->valueOf(I)Ljava/lang/String;

    move-result-object p0

    check-cast p0, Ljava/lang/CharSequence;

    invoke-virtual {p3, p0}, Landroid/widget/TextView;->setText(Ljava/lang/CharSequence;)

1
2
(.*) .*
const/4 $1 0x1 //替换

签名校验
https://cc-nx.github.io/2025/03/23/ZJ6/签名校验 1b2a6f4b4daf80b3891ddef3c55cac14/
作者
CC
发布于
2025年3月23日
许可协议