Android签名验证与反调试机制的对抗技术

Posted Tr0e

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android签名验证与反调试机制的对抗技术相关的知识,希望对你有一定的参考价值。

前言

android 的 APK 文件为了防止被篡改和重打包,经常会做签名校验来保证自身完整性,当程序被篡改后将提示用户或者直接退出运行。同时有些 APP 为了防止被攻击者动态调试和分析,还做了反调试机制。本文来学习记录下 Android 签名验证机制与反调试机制的实现原理和其对抗技术。

签名验证

Android 系统使用 JAR 包的签名机制对 APK 进行完整性保护,确保 APK 在不安全的网络传输时的完整性得到保护。但 Android 系统没有对数字签名的颁发者进行管理,任何人都可以生成数字签名,并使用该签名对 APK 包进行重新签名。如果 APP 本身不对自身的签名来源进行有效的完整性检查,攻击者可以篡改应用(插入恶意代码、木马、后门、广告等),重新签名并且二次发布,导致应用程序完整性被破坏。为了说明 APK 签名比对对软件安全的有效性,我们有必要了解一下 Android APK 的签名机制。

1.1 签名机制

对比一个没有签名的 APK 和一个签名好的 APK,我们会发现,签名好的 APK 包中多了一个叫做 META-INF 的文件夹。里面有三个文件,分别名为 MANIFEST.MF、CERT.SF 和 CERT.RSA,这些就是使用 signapk.jar 生成的签名文件。

签名文件作用
MANIFEST.MF保存了 apk 所有文件的摘要信息(SHA-1+Base64)
CERT.SF保存了对 MANIFEST.MF 文件再进行一次 SHA-1 并 Base64 加密的信息,并同时保存了 MANIFEST.MF 文件的摘要信息
CERT.RSA保存了公钥和所采用的加密算法等信息

其中 signapk.jar 是 Android 源码包中的一个签名工具,由于 Android 是个开源项目,所以可以直接找到 signapk.jar 的源码,路径为 /build/tools/signapk/SignApk.java。通过阅读 signapk 源码,我们可以理清签名 APK 包的整个过程。

1、 生成 MANIFEST.MF 文件:

程序遍历 update.apk 包中的所有文件(entry),对非文件夹非签名文件的文件,逐个生成 SHA1 的数字签名信息,再用 Base64 进行编码。具体代码见这个方法:

private static Manifest addDigestsToManifest(JarFile jar)

关键代码如下:

for (JarEntry entry: byName.values()) {
     String name = entry.getName();
     if (!entry.isDirectory() && !name.equals(JarFile.MANIFEST_NAME) &&
         !name.equals(CERT_SF_NAME) && !name.equals(CERT_RSA_NAME) &&
         (stripPattern == null ||!stripPattern.matcher(name).matches())){
         InputStream data = jar.getInputStream(entry);
         while ((num = data.read(buffer)) > 0) {
         md.update(buffer, 0, num);
       }
       Attributes attr = null;
       if (input != null) attr = input.getAttributes(name);
       attr = attr != null ? new Attributes(attr) : new Attributes();
       attr.putValue("SHA1-Digest", base64.encode(md.digest()));
       output.getEntries().put(name, attr);
    }
}

之后将生成的签名写入 MANIFEST.MF 文件,关键代码如下:

Manifest manifest = addDigestsToManifest(inputJar);
je = new JarEntry(JarFile.MANIFEST_NAME);
je.setTime(timestamp);
outputJar.putNextEntry(je);
manifest.write(outputJar);

这里简单介绍下 SHA1 数字签名。简单地说,它就是一种安全哈希算法,类似于 MD5 算法。它把任意长度的输入,通过散列算法变成固定长度的输出(这里我们称作“摘要信息”)。你不能仅通过这个摘要信息复原原来的信息。另外,它保证不同信息的摘要信息彼此不同。因此如果你改变了 apk 包中的文件,那么在 apk 安装校验时,改变后的文件摘要信息与 MANIFEST.MF 的检验信息不同,于是程序就不能成功安装。

2、 生成 CERT.SF 文件:

对前一步生成的 Manifest,使用 SHA1-RSA 算法,用私钥进行签名。关键代码如下:

Signature signature = Signature.getInstance("SHA1withRSA");
signature.initSign(privateKey);
je = new JarEntry(CERT_SF_NAME);
je.setTime(timestamp);
outputJar.putNextEntry(je);
writeSignatureFile(manifest,
new SignatureOutputStream(outputJar, signature));

RSA 是一种非对称加密算法。用私钥通过 RSA 算法对摘要信息进行加密。在安装时只能使用公钥才能解密它。解密之后,将它与未加密的摘要信息进行对比,如果相符,则表明内容没有被异常修改。

3、 生成 CERT.RSA 文件:

生成 MANIFEST.MF 没有使用密钥信息,生成 CERT.SF 文件使用了私钥文件。那么我们可以很容易猜测到,CERT.RSA 文件的生成肯定和公钥相关。CERT.RSA 文件中保存了公钥、所采用的加密算法等信息。核心代码如下:

je = new JarEntry(CERT_RSA_NAME);
je.setTime(timestamp);
outputJar.putNextEntry(je);
writeSignatureBlock(signature, publicKey, outputJar);

其中 writeSignatureBlock 的代码如下:

private static void writeSignatureBlock(
      Signature signature, X509Certificate publicKey, OutputStream out)
         throws IOException, GeneralSecurityException {
             SignerInfo signerInfo = new SignerInfo(
             new X500Name(publicKey.getIssuerX500Principal().getName()),
                  publicKey.getSerialNumber(),
                  AlgorithmId.get("SHA1"),
                  AlgorithmId.get("RSA"),
                  signature.sign());

        PKCS7 pkcs7 = new PKCS7(
              new AlgorithmId[] { AlgorithmId.get("SHA1") },
              new ContentInfo(ContentInfo.DATA_OID, null),
              new X509Certificate[] { publicKey },
              new SignerInfo[] { signerInfo });

       pkcs7.encodeSignedData(out);
}

好了,分析完APK包的签名流程,我们可以清楚地意识到:

  1. Android 签名机制其实是对 APK 包完整性和发布机构唯一性的一种校验机制;
  2. Android 签名机制不能阻止 APK 包被修改,但修改后的再签名无法与原先的签名保持一致(拥有私钥的情况除外);
  3. APK 包加密的公钥就打包在 APK 包内,且不同的私钥对应不同的公钥,我们可以对比公钥来判断私钥是否一致;
  4. Android 并不要求所有应用程序的签名证书都由可信任 CA 的根证书签名,通过这点保证了其生态系统的开放性,所有人都可以用自己生成的证书对应用程序签名。

如果想修改一个已经发布的应用程序,哪怕是修改一张图片,都必须对其进行重新签名。但是,签原始应用的私钥一般是拿不到的(肯定在原始应用程序开发者的手上,且不可能公布出去),所以只能用另外一组公私钥对,生成一个新的证书,对重打包的应用进行签名,因此重打包的 apk 中所带证书的公钥肯定和原始应用不一样。同时,在手机上如果想安装一个应用程序,应用程序安装器会先检查相同包名的应用是否已经被安装过,如果已经安装过,会继续判断已经安装的应用和将要安装的应用,其所携带的数字证书中的公钥是否一致。如果相同,则继续安装;而如果不同,则会提示用户先卸载前面已安装的应用。

1.2 签名验签

在程序中获取 APK 的签名时,通过 signature 方法进行获取,如下:

packageInfo = manager.getPackageInfo(pkgname,PackageManager.GET_SIGNATURES);
signatures = packageInfo.signatures;
for (Signature signature : signatures) {
    builder.append(signature.toCharsString());
}
signature = builder.toString();

所以一般的程序就是在代码中通过判断 signature 的值,来判断 APK 是否被重新打包过。

APK 签名比对的应用场景大致有三种:

  1. 程序自检测:在程序运行时,自我进行签名比对,比对样本可以存放在 APK 包内,也可存放于云端。缺点是程序被破解时,自检测功能同样可能遭到破坏,使其失效;
  2. 可信赖的第三方检测:由可信赖的第三方程序负责 APK 的软件安全问题,对比样本由第三方收集,放在云端,这种方式适用于杀毒安全软件或者APP Market之类的软件下载市场,缺点是需要联网检测,在无网络情况下无法实现功能(不可能把大量的签名数据放在移动设备本地);
  3. 系统限定安装:这就涉及到改 Android 系统了,限定仅能安装某些证书的 APK,软件发布商需要向系统发布上申请证书,如果发现问题,能追踪到是哪个软件发布商的责任,适用于系统提供商或者终端产品生产商,缺点是过于封闭,不利于系统的开放性。

以上三种场景,虽然各有缺点,但缺点并不是不能克服的。例如,我们可以考虑程序自检测的功能用 native method的 方法实现等等。软件安全是一个复杂的课题,往往需要多种技术联合使用,才能更好的保障软件不被恶意破坏。

附上一个完整 APK 签名校验工具类:

public class SignCheckUtil {

    private Context context;
    private String cer = null;
    private String type = "SHA1";
    private String sha1RealCer = "签名SHA1值";
    private String md5RealCer = "签名MD5";
    private static final String TAG = "sign";

    public SignCheckUtil(Context context,String type) {
        this.context = context;
        this.type = type;
    }

    /**
     * 获取应用的签名
     *
     * @return
     */
    public String getCertificateSHA1Fingerprint() {
        String hexString = "";
        //获取包管理器
        PackageManager pm = context.getPackageManager();
        //获取当前要获取 SHA1 值的包名,也可以用其他的包名,但需要注意,
        //在用其他包名的前提是,此方法传递的参数 Context 应该是对应包的上下文。
        String packageName = context.getPackageName();
        //签名信息
        Signature[] signatures = null;

        try {
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
                PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getPackageName(), PackageManager.GET_SIGNING_CERTIFICATES);
                SigningInfo signingInfo = packageInfo.signingInfo;
                signatures = signingInfo.getApkContentsSigners();
            } else {
                //获得包的所有内容信息类
                PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
                signatures = packageInfo.signatures;
            }
            byte[] cert = signatures[0].toByteArray();
            //将签名转换为字节数组流
            InputStream input = new ByteArrayInputStream(cert);
            //证书工厂类,这个类实现了出厂合格证算法的功能
            CertificateFactory cf = CertificateFactory.getInstance("X509");
            //X509 证书,X.509 是一种非常通用的证书格式
            X509Certificate c = null;
            c = (X509Certificate) cf.generateCertificate(input);
            //加密算法的类,这里的参数可以使 MD4,MD5 等加密算法
            MessageDigest md = MessageDigest.getInstance(type);
            //获得公钥
            byte[] publicKey = md.digest(c.getEncoded());
            //字节到十六进制的格式转换
            hexString = byte2HexFormatted(publicKey);
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchAlgorithmException e1) {
            e1.printStackTrace();
        } catch (CertificateEncodingException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        }
        return hexString.trim();
    }

    //这里是将获取到得编码进行16 进制转换
    private String byte2HexFormatted(byte[] arr) {
        StringBuilder str = new StringBuilder(arr.length * 2);
        for (int i = 0; i < arr.length; i++) {
            String h = Integer.toHexString(arr[i]);
            int l = h.length();
            if (l == 1)
                h = "0" + h;
            if (l > 2)
                h = h.substring(l - 2, l);
            str.append(h.toUpperCase());
            if (i < (arr.length - 1))
                str.append(':');
        }
        return str.toString();
    }

    /**
     * 检测签名是否正确
     *
     * @return true 签名正常 false 签名不正常
     */
    public boolean check() {

        if (this.sha1RealCer != null || md5RealCer!= null) {
            cer = getCertificateSHA1Fingerprint();
            if ((TextUtils.equals(type,"SHA1") && this.cer.equals(this.sha1RealCer)) || (TextUtils.equals(type,"MD5") && this.cer.equals(this.md5RealCer))) {
                return true;
            }
        }
        return false;
    }
}

1.3 签名绕过

在讲签名绕过的方式前,需要先明确 DEX 校验和签名校验:

  1. 将 APK 以压缩包的形式打开删除原签名后,再签名,安装能够正常打开,但是用 IDE(即 apk 改之理,会自动反编译 dex)工具二次打包,却出现非正常情况的,如:闪退/弹出非正版提示框,则可以确定是dex文件的校验;
  2. 将 APK 以压缩包的形式打开删除原签名再签名,安装之后打开异常的,则基本可以断定是签名检验。如果在断网的情况下同样是会出现异常,则是本地的签名检验;如果首先出现的是提示网络没有连接,则是服务器端的签名校验。

针对给类签名校验方式的绕过:

签名校验方式介绍绕过方式
Java 层校验获取签名信息和验证的方法都写在 Android 的 Java 层1)Hook Java 层函数的返回值;2)反编译修改校验函数逻辑并二次打包 ;3)动态调试 APK 并篡改内存中校验函数的返回值
SO 层校验获取签名信息和验证的方法都写在 Android 的 So 层1) Hook SO 层函数的返回值;2)反汇编程序、修改校验函数逻辑并二次打包 ;3)动态调试 APK 并篡改内存中校验函数的返回值
服务器验证在 Android 的 Java 层获取签名信息,上传服务器在服务端进行签名然后返回验证结果1)拦截并篡改服务端的校验返回结果;2)反编译程序并篡改破坏校验过程

具体的对抗案例分析可参见:APK签名校验绕过,此处不展开叙述。

反反调试

反调试在代码保护中扮演着很重要的角色,虽然并不能完全阻止逆向行为,但是能在长期的攻防战中给破解人员不断的增加逆向难度。

2.1 tracerPid检测

APK 在被调试的状态下,Linux 会向/proc/<pid>/status文件中写入一些进程状态信息,其中最大的变化就是文件中的 TracerPid 字段被写入了调试进程的 pid,如下图所示:
所以可以通过检测/proc/<pid>/status文件中 TracerPid 的值是否为 0 来判断当前进程正在被调试,是的话则杀死进程。具体的 So 层检测示例代码如下:

#include <unistd.h>

...
void check_process_status(){
    int buffsize=1024;
    char filename[buffsize];    // 文件名
    char line[buffsize];        // 文件中的每一行
    int pid=getpid();           // 获取进程号
    sprintf(filename,"/proc/%d/status",pid);
    FILE *fp=fopen(filename,"r");
    if (fp != NULL){
        while (fgets(line,buffsize,fp)){
            if (strncmp(line,"TracerPid",9)==0){
                int status=atoi(&line[10]);
                if (status!=0){
                    fclose(fp);
                    kill(pid,SIGKILL);   // 杀死进程
                }
                break;
            }
        }
    }
    fclose(fp);
}

jint JNI_OnLoad(JavaVM* vm, void* reserved){
    check_process_status();
    ...
}

至于破解反调试的方法:Frida Hook 篡改校验调试状态的函数的返回值,或者使用 IDA 反汇编 APK 并篡改程序逻辑后重新打包,具体参见:IDA动态调试破解AliCrackme与反调试对抗

2.2 进程名称检测

根据上一种反调试方法我们知道可以通过检测 TracerPid 的值判断程序是否被调试,而 TracerPid 的值就是调试器的进程号,调试器的进程名则被存储在/proc/<pid>/cmdline文件中,这里的 pid 为调试器的 pid。所以可以检测/proc/<pid>/cmdline文件中的内容是否包含一些调试器的进程名,比如 android_server,来判断程序是否被调试。

校验代码示例如下:

void check_process_name(){
    int buffsize=1024;
    char filename[buffsize];
    char line[buffsize];
    char name[buffsize];
    char nameline[buffsize];
    int pid=getpid();
    sprintf(filename,"/proc/%d/status",pid);
    FILE *fp=fopen(filename,"r");
    if (fp!=NULL){
        while (fgets(line,buffsize,fp)){
            // 检测/proc/<pid>/status文件的某一行中是否包含TracerPid
            if (strstr(line,"TracerPid")!=NULL){  
                int status=atoi(&line[10]);
                if (status!=0){
                    sprintf(name,"/proc/%d/cmdline",status);
                    FILE *fpname=fopen(name,"r");
                    if (fpname!=NULL){
                        while (fgets(nameline,buffsize,fpname)!=NULL){
                            // 检测/proc/<pid>/cmdline文件的某一行是否包含android_server
                            if (strstr(nameline,"android_server")!=NULL){  
                                kill(pid,SIGKILL);
                            }
                        }
                    }
                    fclose(fpname);
                }
            }
        }
    }
    fclose(fp);
}

若要绕过反调试,修改 android_server 的文件名即可。

2.3 关键文件检测

在使用 IDA 动态调试之前一般会先将 IDA Pro 目录下的 android_server 放入到 /data/local/tmp 目录下,所以可以检测 /data/local/tmp 目录是否包含一个名为 android_server 的文件。

在 native_lib.cpp 文件中添加一个 check_name() 方法,并在 JNI_OnLoad() 中调用:

void check_name(){
    char* root_path="/data/local/tmp";
    DIR* dir;
    dir=opendir(root_path);   // 打开目录
    int pid=getpid();
    if (dir!=NULL){
        dirent* currentDir;
        while ((currentDir=readdir(dir))!=NULL){
            if (strncmp(currentDir->d_name,"android_server",14)==0){
                kill(pid,SIGKILL);
            }
        }
        closedir(dir);
    }
}

若要绕过反调试,可以修改 android_server 的文件名,或者将 android_server 放在其他目录。

2.4 调试端口检测

android_server 的默认监听的端口号是 23946,所以可以通过检测这个端口号来起到一定的反调试作用。在 Linux 系统中,/proc/net/tcp文件会记录一些连接信息,在启动 android_server 以后,该文件中多了一行内容:

可以看到,/proc/net/tcp 文件中多了一个运行在 5D8A 端口上的连接信息,而 5D8A 正好是 23946 的十六进制,因此可以检测该文件中的端口号来达到反调试的效果。

在 native_lib.cpp 中添加一个 check_port() 方法,并在 JNI_OnLoad() 中调用:

void check_port(){
    int buffsize=1024;
    char filename[buffsize];
    char line浅谈android反调试之  签名校验

APP安全加固全过程(混淆/签名验证/反调试)

IDA动态调试破解AliCrackme与反调试对抗

IDA动态调试破解AliCrackme与反调试对抗

JavaScript加密代码反调试

JavaScript加密代码反调试