Countermeasure Technology of Android Signature Verification and Debugging Mechanism

Preface

In order to prevent tampering and repackaging of Android's APK files, signature checks are often done to ensure their integrity. When a program is tampered with, users are prompted to exit or run directly. At the same time, some APP s have anti-debugging mechanisms to prevent dynamic debugging and analysis by attackers. In this paper, we will learn how to record the implementation principle of Android signature verification mechanism and anti-debugging mechanism and their countermeasure technology.

Signature Verification

The Android system uses the signature mechanism of the JAR package to protect the integrity of the APK, ensuring that the integrity of the APK is protected when transmitted over an unsecured network. However, the Android system does not manage the issuers of digital signatures. Anyone can generate a digital signature and use it to re-sign the APK package. If APP itself does not check the integrity of its signature source effectively, an attacker can tamper with the application (insert malicious code, Trojan horse, backdoor, advertisement, etc.), re-sign and re-publish it, which can result in compromised application integrity. To illustrate the validity of APK signature matching for software security, it is necessary to understand the signature mechanism of Android APK.

1.1 Signature Mechanism

Comparing an unsigned APK with a signed APK, we find that there is an additional folder called META-INF in the signed APK package. There are three files named MANIFEST.MF, CERT.SF, and CERT.RSA, which are signature files generated using signapk.jar.

Signature fileEffect
MANIFEST.MFSave summary information for all apk files (SHA-1+Base64)
CERT.SFSave the information of SHA-1 and Base64 encrypted MANIFEST.MF file once more, and also save the summary information of MANIFEST.MF file
CERT.RSASaves information such as the public key and the encryption algorithm used

Signapk.jar is one of the signing tools in the Android source package. Since Android is an open source project, you can find the source of signapk.jar directly by/build/tools/signapk/SignApk.java. By reading the signapk source, we can understand the whole process of signing an APK package.

1. Generate MANIFEST.MF file:

The program iterates through all the files (entries) in the update.apk package, generates the SHA1 digital signature information one by one for files that are not folder unsigned, and then encodes them with Base64. See this method for specific code:

private static Manifest addDigestsToManifest(JarFile jar)

The key codes are as follows:

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);
    }
}

The generated signature is then written to the MANIFEST.MF file with the following key code:

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

Here is a brief introduction to the SHA1 digital signature. Simply put, it is a secure hash algorithm, similar to the MD5 algorithm. It transforms any length of input into a fixed length output through a hash algorithm (here we call "summary information"). You can't just restore the original information from this summary. In addition, it ensures that summary information for different information is different from each other. So if you change the files in the apk package, the changed file summary information is different from that of MANIFEST.MF when the apk installation checks, and the program cannot be successfully installed.

2. Generate CERT.SF file:

For the Manifest generated in the previous step, use the SHA1-RSA algorithm and sign with the private key. The key codes are as follows:

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 is an asymmetric encryption algorithm. The summary information is encrypted by RSA algorithm with a private key. You can only decrypt it using the public key during installation. After decryption, compare it with unencrypted summary information, and if it matches, the content is not abnormally modified.

3. Generate CERT.RSA file:

The key information was not used to generate the MANIFEST.MF, and the private key file was used to generate the CERT.SF file. So it's easy to guess that the generation of the CERT.RSA file is definitely related to the public key. The CERT.RSA file holds information such as the public key and the encryption algorithm used. The core code is as follows:

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

The code for the writeSignatureBlock is as follows:

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);
}

Okay, after analyzing the signing process of the APK package, we can clearly realize that:

  1. The Android signature mechanism is actually a verification mechanism for the integrity of APK packages and the uniqueness of publishing agencies.
  2. The Android signature mechanism cannot prevent the APK package from being modified, but the modified re-signature cannot be consistent with the original signature (except in the case of a private key);
  3. The public key encrypted by the APK package is packaged in the APK package, and different private keys correspond to different public keys. We can compare the public keys to determine whether the private keys are consistent.
  4. Android does not require that all applications'signing certificates be signed by the root certificate of a trusted CA, which ensures the openness of their ecosystem, allowing everyone to sign applications with their own certificates.

If you want to modify a published application, even if you modify a picture, you must re-sign it. However, signing the private key of the original application is usually not available (it must be in the hands of the original application developer and cannot be published), so only a new set of public-private key pairs can be used to generate a new certificate to sign the repackaged application, so the public key of the certificate in the repackaged apk must be different from the original application. At the same time, if you want to install an application on your mobile phone, the application installer will first check if the application with the same package name has been installed. If it has already been installed, it will continue to determine if the installed application and the application to be installed have the same public key as the digital certificate it carries. If the same, continue the installation; If different, the user is prompted to uninstall the previously installed application first.

1.2 Signature Verification

When obtaining the APK's signature in the program, it is obtained by the signature method as follows:

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

So the general procedure is to determine whether the APK has been repackaged by determining the signature value in the code.

There are roughly three scenarios for APK signature comparison:

  1. Program self-detection: When the program is running, self-signature comparison is made. The comparison samples can be stored in the APK package or in the cloud. The disadvantage is that when a program is cracked, the self-detection function may also be damaged, making it invalid.
  2. Trusted third-party testing: software security issues for APK are handled by trusted third-party equations, and comparison samples are collected by third parties and placed in the cloud. This method is suitable for anti-virus security software or software download markets such as APP Market. The disadvantage is that network-based testing is required and functionality cannot be achieved without network (It is not possible to place a large amount of signature data locally on a mobile device);
  3. System Limited Installation: This involves changing the Android system. Only certain certificate APK s can be installed. Software publishers need to apply for certificates on the system publication. If problems are found, the responsibility of which software publisher can be tracked. This applies to system providers or end-product manufacturers. The disadvantage is that it is too closed to make the system open.

Although each of the above three scenarios has its own shortcomings, the shortcomings are not insurmountable. For example, we can consider the function of program self-detection to be implemented by native method, and so on. Software security is a complex topic, which often requires a combination of technologies to better protect software from malicious destruction.

Attach a complete APK signature verification tool class:

public class SignCheckUtil {

    private Context context;
    private String cer = null;
    private String type = "SHA1";
    private String sha1RealCer = "autograph SHA1 value";
    private String md5RealCer = "autograph MD5";
    private static final String TAG = "sign";

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

    /**
     * Get the signature of the application
     *
     * @return
     */
    public String getCertificateSHA1Fingerprint() {
        String hexString = "";
        //Get Package Manager
        PackageManager pm = context.getPackageManager();
        //Get the package name you want to get the SHA1 value for now, or use another package name, but be aware that
        //The parameter Context passed by this method should be the context of the corresponding package, provided that other package names are used.
        String packageName = context.getPackageName();
        //Signature information
        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 {
                //Get all content information classes for the package
                PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_SIGNATURES);
                signatures = packageInfo.signatures;
            }
            byte[] cert = signatures[0].toByteArray();
            //Convert signature to byte array stream
            InputStream input = new ByteArrayInputStream(cert);
            //Certificate Factory Class, which implements the functions of the Factory Certificate Algorithm
            CertificateFactory cf = CertificateFactory.getInstance("X509");
            //X509 certificate, X.509 is a very common certificate format
            X509Certificate c = null;
            c = (X509Certificate) cf.generateCertificate(input);
            //Encryption algorithm class, where parameters enable encryption algorithms such as MD4,MD5
            MessageDigest md = MessageDigest.getInstance(type);
            //Get Public Key
            byte[] publicKey = md.digest(c.getEncoded());
            //Byte to Hexadecimal Format Conversion
            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();
    }

    //Here is the hexadecimal conversion to get the encoding
    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();
    }

    /**
     * Check if the signature is correct
     *
     * @return true Signature Normal false Signature Abnormal
     */
    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 Signature bypass

Before you talk about how signatures bypass, you need to clarify the DEX and signature checks:

  1. After opening and deleting the original signature in the form of a compressed package, the APK can be re-signed and the installation can open normally. However, if the APK is re-packaged with the IDE (that is, APK will automatically decompile dex) tool, an abnormal situation occurs, such as flipping back/popping up an ungenuine prompt box, the verification of the DEX file can be determined.
  2. Open the APK as a compressed package, delete the original signature, sign it again, and open the exception after installation, you can basically conclude that signature verification. If the same exception occurs when the network is disconnected, it is a local signature check. If the first indication is that the network is not connected, it is a server-side signature verification.

Bypass for verification of signature to class:

Signature Verification MethodintroduceBypass mode
Java Layer ChecksThe methods for obtaining signature information and verification are written in the Java layer of Android1) Return value of Hook Java layer function; 2) Decompilation modifies the logic of the check function and packages it twice; 3) Debugging APK dynamically and tampering with the return value of in-memory checking functions
SO Layer CheckThe methods for obtaining signature information and verification are written at the So layer of Android1) The return value of the Hook SO layer function; 2) Disassemble the program, modify the logic of the check function and package it twice; 3) Debugging APK dynamically and tampering with the return value of in-memory checking functions
Server ValidationGet the signature information in the Java layer of Android, upload the server to sign on the server side, and return the verification results1) Intercept and tamper with the returned results of the server's checks; 2) Decompilator and tampering to break the checking process

A specific analysis of the case can be found in: APK Signature Check Bypass Not described here.

Reverse Debugging

Anti-debugging plays an important role in code protection. Although it can not completely prevent reverse behavior, it can continuously increase the reverse difficulty for crackers in the long-term offensive and defensive battles.

2.1 tracerPid Detection

While the APK is being debugged, Linux writes some process state information to the / proc/<pid>/status file. The biggest change is that the TracerPid field in the file is written to the PID of the debugging process, as shown in the following figure:
So you can tell if the current process is being debugged by detecting if the value of TracerPid in the /proc/<pid>/status file is 0, or if it is, kill the process. The specific So layer detection sample code is as follows:

#include <unistd.h>

...
void check_process_status(){
    int buffsize=1024;
    char filename[buffsize];    // file name
    char line[buffsize];        // Each line in the file
    int pid=getpid();           // Get the process number
    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);   // Kill Process
                }
                break;
            }
        }
    }
    fclose(fp);
}

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

For methods to crack debugging: Frida Hook tampers with the return value of a function that checks the debug state, or uses IDA to disassemble the APK and tamper with program logic to repackage, see: IDA dynamic debugging cracks AliCrackme against anti-debugging.

2.2 Process Name Detection

Based on the previous debugging method, we know that you can determine whether a program is debugged by detecting the value of TracerPid, which is the process number of the debugger, and the process name of the debugger is stored in the / proc/<pid>/cmdline file, where PID is the PID of the debugger. So you can detect if the contents of the / proc/<pid>/cmdline file contain the process names of some debuggers, such as android_server, to determine if the program has been debugged.

Examples of the check code are as follows:

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)){
            // Detection of TracerPid in a line of /proc/<pid>/status file
            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){
                            // Detect if a line of the / proc/<pid>/cmdline file contains android_server
                            if (strstr(nameline,"android_server")!=NULL){  
                                kill(pid,SIGKILL);
                            }
                        }
                    }
                    fclose(fpname);
                }
            }
        }
    }
    fclose(fp);
}

To bypass debugging, modify android_ The file name of the server is sufficient.

2.3 Critical File Detection

Android_in the IDA Pro directory is typically preferred before dynamic debugging with IDA Servers are placed in the / data/local/tmp directory, so you can detect if the / data/local/tmp directory contains an android_server's file.

In native_ Add a check_to the lib.cpp file Name() method, and in JNI_ Called in OnLoad():

void check_name(){
    char* root_path="/data/local/tmp";
    DIR* dir;
    dir=opendir(root_path);   // Open Catalog
    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);
    }
}

To bypass debugging, you can modify android_ The file name of the server, or the android_server is placed in a different directory.

2.4 Debug Port Detection

Android_ The default port number that servers listen on is 23946, so it can be used as a debugging countermeasure by detecting this port number. On Linux systems, /proc/net/tcp files record some connection information, and at startup, android_ After server, there is one more line in the file:

You can see that there is one more connection information running on port 5D8A in the / proc/net/tcp file, and that 5D8A is exactly hexadecimal of 23946, so you can detect the port number in the file for debugging purposes.

In native_ Add a check_to lib.cpp Port() method, and in JNI_ Called in OnLoad():

void check_port(){
    int buffsize=1024;
    char filename[buffsize];
    char line[buffsize];
    int pid=getpid();
    sprintf(filename,"/proc/net/tcp");
    FILE *fp=fopen(filename,"r");
    if (fp!=NULL){
        while (fgets(line,buffsize,fp)){
            if (strncmp(line,"5D8A",4)){
                kill(pid,SIGKILL);
            }
        }
    }
    fclose(fp);
}

To bypass debugging, just modify android_server's running port is fine, that is, it's starting android_ Run the following command on server:

./android_server -p 24000

2.5 ptrace value detection

The Linux kernel provides a ptrace function:

#include <sys/ptrace.h>
long ptrace(enum _ptrace_request request,pid_t pid,void* addr,void* data)

ptrace allows process A to control process B, and process A can check and modify the memory and registers of process B. However, a process can only be tracked by another process at most, which allows the process to track itself with the parameter request set to PTRACE_TRACEME, when a program is attached to its own debugging, other debugging operations will fail.

Add a ptrace_to native-lib.cpp Me() method, and in JNI_ Called in OnLoad():

#include <linux/ptrace.h>
#include <sys/ptrace.h>

void ptrace_me(){
    ptrace(PTRACE_TRACEME,0,NULL,NULL);
}

Bypassing is also done by disassembling the program and tampering with the check logic before repackaging.

2.6 Time Difference Detection

Normally, the time difference between two lines of code is very short for a program, but for a debugger, the time difference between two lines of code is large when debugging step by step. By detecting the time difference between two lines of code, you can roughly judge whether the program is debugged or not.

The function gettimeofday() can be used in C to derive time with a precision of microseconds.

#include<sys/time.h>
int gettimeofday(struct timeval *tv,struct timezone *tz )

gettimeofday() stores the current time in the structure referred to by tv, and information about the local time zone in the structure referred to by tz. Execution success returns 0 and failure returns -1.

Add a check_in native-lib.cpp Time() method, and in JNI_ Called in the OnLoad() method:

void check_time(){
    int pid=getpid();
    struct timeval start;
    struct timeval end;
    struct timezone tz;
    gettimeofday(&start,&tz);
    gettimeofday(&end,&tz);
    int timeoff=end.tv_sec-start.tv_sec;
    if (timeoff>1){
        kill(pid,SIGKILL);
    }
}

To bypass debugging, simply press F9 twice to execute the gettimeofday function.

2.7 Built-in Function Detection

Android's android.os.Debug class provides the isDebuggerConnected() method to detect whether a debugger is mounted on a program. Modify the code in MainActivity.java:

public class MainActivity extends AppCompatActivity {

    // Used to load the 'native-lib' library on application startup.
    static {
        if (!Debug.isDebuggerConnected()){
            System.loadLibrary("native-lib");
        }else {
            Process.killProcess(Process.myPid());
        }
    }
    ...
}

The cracking method is naturally the most convenient way for Frida hook to tamper with the return value...

2.8 Debugging Breakpoint Detection

Debuggers such as IDA insert breakpoint breakpoint instructions into breakpoint addresses while debugging dynamically, and temporarily back up the original instructions elsewhere, so you can scan your code for breakpoint assembly instructions.

In general, Android's assembly code has ARM and Thumb, so you need to check both:

- Arm: 0x01,0x00,0x9f,0xef
- Thumb16: 0x01,0xde
- Thumb32: 0xf0,0xf7,0x00,0xa0

Add check_to native-lib.cpp Break_ Point method, and in JNI_ Called in OnLoad:

#include <elf.h>

...

unsigned int getLibAddr(){
    unsigned int ret=0;
    char name[]="libnative-lib.so";
    char buf[4096],*tmp;
    int pid=getpid();
    FILE *fp;
    sprintf(buf,"/proc/%d/maps",pid);
    fp=fopen(buf,"r");
    if (fp!=NULL){
        while (fgets(buf,sizeof(buf),fp)!=NULL){
            if (strstr(buf,name)){
                tmp=strtok(buf,"-");
                ret=strtoul(tmp,NULL,16);
                break;
            }
        }
    }
    fclose(fp);
    return ret;
}
bool check_break_point(){
    unsigned int base,offset,pheader;
    Elf32_Ehdr *elfhdr;   // ELF_Header
    Elf32_Phdr *elfphdr;  // Program_Header
    base=getLibAddr();
    if (base == 0){
        return false;
    }
    elfhdr=(Elf32_Ehdr*) base;
    pheader=base+elfhdr->e_phoff;     // e_phoff: header offset

    for (int i=0;i<elfhdr->e_phnum;i++){    // e_phnum: the number of elements in the program header table
        elfphdr=(Elf32_Phdr*)(pheader+i*sizeof(Elf32_Phdr));
        if (!(elfphdr->p_flags & 1)){
            continue;
        }
        offset=base+elfphdr->p_vaddr;      // p_vaddr: the segment's data maps to the location in the virtual address space
        offset+=sizeof(Elf32_Ehdr)+sizeof(Elf32_Phdr)*elfhdr->e_phnum;

        char *p=(char *)offset;
        for (int j = 0; j < elfphdr->p_memsz; ++j) {    // p_memsz: length of segment in virtual address space
            if (*p == 0x01 && *(p+1) == 0xde){          // Thumb16
                return true;
            } else if (*p == 0xf0 && *(p+1) == 0xf7 && *(p+2) == 0x00 && *(p+3) == 0xa0){    // Thumb32
                return true;
            } else if (*p == 0x01 && *(p+1) == 0x00 && *(p+2) == 0x9f && *(p+3) == 0xef){    // ARM
                return true;
            }
            p++;
        }
    }
    return false;
}

jint JNI_OnLoad(JavaVM* vm, void* reserved){
    if (check_break_point()){
        int pid=getpid();
        kill(pid,SIGKILL);
    }
    ...
}

Cracking method: Frida hook So layer function tampers with return value...

summary

To sum up, we can see the signature verification and anti-debugging mechanisms against APP, and there are several general ways to combat APP:

  1. Use Frida to Hook and tamper with the judgment of the Java/SO layer check logic function;
  2. Use AndroidKiller to decompile the APP program and tamper with the logic of the check function and package it twice;
  3. Use IDA Pro for dynamic debugging analysis, tamper with disassembled programs and package them twice.

With the height of one foot and the height of one foot, security and reinforcement on the client side can only increase the difficulty of APP cracking, not ensure that APP cannot be cracked.

Posted on Thu, 28 Oct 2021 12:52:51 -0400 by thesecraftonlymultiply