作者:bigfog

前言

反调试在代码保护中扮演者重要的角色。逆向人员人员拿到apk后会对其反编译,阅读易读的java代码,有必要还会对其进行动态调试。反调试虽然不能完全阻止程序被逆向。但能一定程度上增大分析的难度。本文从四个层面对一些反调试的方法进行了说明。随着技术的日新月异,肯定还会有新的方法不断涌现。

Java层面保护

代码混淆技术

因为分析native代码难度较大,可以有效的保护程序。但一些开发者不具备C/C++编程基础,就可以考虑下使用代码混淆技术了。java语言编写的代码很容易被反编译,所以安卓的SDK中加入了Proguard代码混淆工具,开发人员可以使用该工具对代码进行混淆。
ProGuard是一个压缩、优化和混淆Java字节码文件的免费的工具,它可以删除无用的类、字段、方法和属性。可以删除没用的注释,最大限度地优化字节码文件。它还可以使用简短的无意义的名称来重命名已经存在的类、字段、方法和属性。常常用于Android开发用于混淆最终的项目,增加项目被反编译的难度。

这里只是对Proguard作简单介绍,具体操作可自行百度

调试器检测

  • isDebuggerConnected():安卓系统在android.os.Debug类中提供了isDebuggerConected()方法,用于检测是否有调试器链接。返回值为true则表示被调试。
if(android.os.Debug.isDebuggerConnected()==0)
{
    android.os.Process.killProcess(android.os.Process.myPid());
}
  • 检测android:debuggable="false"属性。可以通过在AndroidManifest文件的application中加入android:debuggable="false"使程序禁止调试。因为程序要调试的话需要将属性值改为"true"。因此可以在代码中检测这个属性的值,来判断程序是否被修改过。
if(getApplicationInfo().flags&=ApplicationInfo.FLAG_DEBUGGABLE!=0)
{
   system.out.println("Debug");
   android.os.Process.killProcess(android.os.Process.myPid());
}

Native层面

Ptrace自身进程

Ptrace函数原型为:

int ptrace(int request,int pid,int addr,int data)
  • 第一个参数指示了ptrace要执行的命令
  • 第二个参数指示了ptrace要跟踪的进程
  • 第三个参数指示了要监控的内存地址
  • 第四个参数指示了存放读取出的或者要写入的数据。

ptrace可以提供父进程观察和控制子进程的能力。当使用ptrace跟踪后,所有发送给被跟踪的子进程的信号都转发给父进程,而子进程则会被阻塞。这时子进程的状态就会被标注为TASK_TRACED。而父进程收到信号后就可以对停止下来的子进程进行检查和修改,然后让子进程继续运行。ptrace有一个很重要的特性 :一个进程只能被一个进程调试。根据这个特点,只需要在自己的进程调用ptrace就能一定程度上阻止被其他调试器调试。


在调试状态下,linux会向/proc/pid/status写入一些进程状态信息,比如TracePid会写入调试进程的pid。

调试前

调试后

进程名

这里对android_server的名字进行了重命名,可以看到主进程很明显是anzhuo_server。注意:TracerPid值就是进程的父进程pid值。根据一个进程只能被一个进程调试的特性,可以让进程自己ptrace自己,然后让android_server不能够调试。代码还是比较简单。

void test_ptrace(void){
ptrace(PTRACE_TRACEME,0,0,0);
}

PTRACE_TRACEME代表本进程被自身进程ptrace。因为一个进程只能被附加一次,所以,程序一旦被自身附加,后面的调试附加就会失败。

进程状态检测

检查进程状态也可以理解为检测Tracepid的值。使用IDA调试,需要在手机上启动android_server。一旦调试附加,TracedPid的值就是android_server的进程值。根据上面调试前后tracepid的值。如果不为0,则当前进程可能正在被调试。根据这个特性,可以来判断程序是否被调试。可以在JNI_Onload中加入检测函数。

void check_tracerpid()
{
    int pid = getpid();
    int bufsize = 256;
    char filename[bufsize];
    char line[bufsize];
    int tracerpid;
    FILE *fp;
    sprintf(filename, "/proc/%d/status", pid);
    fp = fopen(filename, "r");
    if (fp != NULL) {
        while (fgets(line, bufsize, fp)) {
            if (strstr(line, "TracerPid") != NULL) {
                tracerpid = atoi(&line[10]);
                if (tracerpid != 0) {
                    int ret = kill(pid, SIGKILL);
                }
                break;
            }
        }
        fclose(fp);
    }
}

调试端口检测

netstat命令经常用来查看正在运行应用的本地端口号,pid,uid等信息。而执行cat /proc/net/tcp 也可以来查看正在运行应用的本地端口号,这就提供了一个检测思路。读取/proc/net/tcp的内容,查找23946端口(IDA调试的默认端口),如果找到了该端口则说明进程正在被调试。

void CheckPort23946()
{
    FILE* pfile=NULL;
    char buf[0x1000]={0};
    // 执行命令
    char* strCatTcp= "cat /proc/net/tcp |grep :5D8A";
    //char* strNetstat="netstat |grep :23946";
    pfile=popen(strCatTcp,"r");
    if(NULL==pfile)
    {
        LOGA("CheckPort23946ByTcp popen打开命令失败!\n");
        return;
    }
    while(fgets(buf,sizeof(buf),pfile))
    {
        // 执行到这里,判定为调试状态
        LOGA("执行cat /proc/net/tcp |grep :5D8A的结果:\n");
        LOGB("%s",buf);
    }//while
    pclose(pfile);
}

进程名称检测

端口可以检测那可以通过检测进程名字来判断是否被调试。利用ps命令列出所有进程,然后遍历查找,查找固定的进程名,如android_server gdb_server android_x64/86_server mac_server/64等。

void SearchProcess()
{
    FILE* pfile=NULL;
    char buf[0x1000]={0};
    // 执行命令
  
   pfile=popen("ps","r");
    if(NULL==pfile)
    {
        LOGA("SearchProcess popen打开命令失败!\n");
        return;
    }
    // 获取结果
    LOGA("popen方案:\n");
    while(fgets(buf,sizeof(buf),pfile))
    {
        // 打印进程
        LOGB("遍历进程:%s\n",buf);
        // 查找子串
        char* strA=NULL,strB=NULL,strC=NULL,strD=NULL;
        strA=strstr(buf,"android_server");
        strB=strstr(buf,"gdbserver");
        strC=strstr(buf,"gdb");
        strD=strstr(buf,"fuwu");
        //可以自己加
        if(strA || strB ||strC || strD)
        {
            // 执行到这里,判定为调试状态
            LOGB("发现目标进程:%s\n",buf);
        }//if
    }//while
    pclose(pfile);
}

以上是一种方案这里也有另外一种操作。

调试进程的时候,进程会被IDA中的android_server附加,而在/proc/pid/cmdline中会有进程的进程名,因此通过android_server的进程号就可以找到她的进程名。这里总觉得和上面有重复的感觉,那就换种思路。常规操作下,android_server都会放在/data/local/tmp目录下,那直接检测这个目录下有没有程序不就行了,如果有就退出。

void check_andser()
{
    ...
    // 获取tracerpid之后
    if (tracerpid != 0) {
        sprintf(filename, "/proc/%d/cmdline", tracerpid);
        FILE *fd = fopen(filename, "r");
        if (fd != NULL) {
            while (fgets(nameline, bufsize, fd)) {
                if (strstr(nameline, "android_server") != NULL) {
                    int ret = kill(pid, SIGKILL);
                }
            }
        }
    }
}

APK线程检测

这个也是第一次听说。网上看大佬博客:正常apk进程一般会有十几个线程在运行(比如会有jdwp线程),

自己写可执行文件加载so一般只有一个线程,可以根据这个差异来进行调试环境检测。这里就不做介绍了。

程序运行时间差检测

对程序进行动态调试时经常会下断点或者对相关的寄存器查看。这样程序的就会被挂起暂停执行。这时代码的前后时间差就会比正常时间大,因此可以利用这个特点来判断代码是否被调试。

int gettimeofday(struct timeval *tv, struct timezone *tz);
void check_time()
{
    int pid = getpid();
    struct timeval t1;
    struct timeval t2;
    struct timezone tz;
    gettimeofday(&t1, &tz);
    gettimeofday(&t2, &tz);
    int timeoff = (t2.tv_sec) - (t1.tv_sec);
    if (timeoff > 1) {
        int ret = kill(pid, SIGKILL);
        return ;
    }
}

防止dump

在对程序脱壳中,经常可能做的就是dump内存操作。脱壳时会到/proc/pid/mem或者/proc/pid/maps下dump内存数据。这里可以使用inotify对文件进行监控,如果发现对文件系统事件的打开,读写,有可能程序在被分析破解。此时通过inotify收到事件变化,就可以执行杀死进程操作。

void check_inotify()
{
    int ret, len, i;
    int pid = getpid();
    const int MAXLEN = 2048;
    char buf[1024];
    char readbuf[MAXLEN];
    int fd, wd;
    fd_set readfds;//定义文件描述符字的集合
    fd = inotify_init();//用于创建一个 inotify 实例的系统调用,并返回一个指向该实例的文件描述符
    sprintf(buf, "/proc/%d/maps", pid);
    wd = inotify_add_watch(fd, buf, IN_ALL_EVENTS);
    //增加对文件或者目录的监控,并指定需要监控哪些事件。标志用于控制是否将事件添加到已有的监控中,是否只有路径代表一个目录才进行监控,是否要追踪符号链接,是否进行一次性监控,当首次事件出现后就停止监控
    if (wd >= 0) {
        while (1) {
            i = 0;
            FD_ZERO(&readfds); // 对文件描述符集清空
            FD_SET(fd, &readfds); // 将fd加入readfds集合
            ret = select(fd + 1, &readfds, 0, 0, 0);//检查套接字是否可读
            if (ret == -1) {
                break;
            } 
            if (ret) {
                len = read(fd, readbuf, MAXLEN);//读取包含一个或者多个事件信息的缓存
                while (i < len) {
                    struct inotify_event *event = (struct inotify_event *) &readbuf[i];
                    if ((event->mask & IN_ACCESS) || (event->mask & IN_OPEN)) {
                        int ret = kill(pid, SIGKILL);
                        return;
                    }
                    i += sizeof(struct inotify_event) + event->len;
                }
            }
        }
    }
    inotify_rm_watch(fd, wd);//从监控列表中移出监控项目
    close(fd);//关闭文件描述符,并且移除所有在该描述符上的所有监控。当关于某实例的所有文件描述符都关闭时,资源和下层对象都将释放,以供内核再次使用。
}

当应用程序读取到一个通告时,事件的顺序也被读取到提供的缓存中。事件在一个变长结构中被返回。结构如下:

struct inotify_event
{
  int wd;               /* Watch descriptor.  */
  uint32_t mask;        /* Watch mask.  */
  uint32_t cookie;      /* Cookie to synchronize two events.  */
  uint32_t len;         /* Length (including NULs) of name.  */
  char name __flexarr;  /* Name.  */
  };

这里要注意的是,只有当监控对象是一个目录并且事件与目录内部相关相关有关,且与目录本身无关时,才提供name字段。如果 IN_MOVED_FROM 事件与相应的 IN_MOVED_TO 事件都与被监控的项目有关,cookie 就可用于将两者关联起来。事件类型在掩码字段中返回,并伴随着能够被内核设置的标志。例如,如果事件与目录有关,则标志 IN_ISDIR 将由内核设置。

典型的监控程序需要进行如下操作:

  1. 使用inotify_init打开一个文件描述符。
  2. 添加一个或者多个监控。
  3. 等待事件。
  4. 处理事件,然后返回并等待更多事件。
  5. 当监控不再活动时,或者接到某个信号之后,关闭文件描述符,清空,然后退出。

断点检测

Arm程序下断点,调试器首先要保存目标地址处指令,然后将目标地址处指令替换成断点指令。例如0x01,0x00,0x9f,0xef等。当命中断点后,系统产生SIGTRAP信号,调试器收到信号后,首先恢复断点处原指令,然后回退被跟踪进程的当前pc。这时当控制权回到被调试程序时,正好执行断点位置指令。这就是arm平台断点的原理。

#include <stdlib.h>
#include <stdio.h>
#include <elf.h>
#include <string.h>
#include <unistd.h>
#include <dlfcn.h>

void checkBreakPoint ();
unsigned long getLibAddr (const char *lib);
#define LOG_TAG "ANTIDBG_DEMO"
#include <android/log.h>
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
int main ()
{
    dlopen ("./libdemo.so", RTLD_NOW);
    sleep (60);
    checkBreakPoint ();
    return 0;
}
unsigned long getLibAddr (const char *lib) 
{
    puts ("Enter getLibAddr");
    unsigned long addr = 0;
    char lineBuf[256];

    snprintf (lineBuf, 256-1, "/proc/%d/maps", getpid ());
    FILE *fp = fopen (lineBuf, "r");
    if (fp == NULL) {
        perror ("fopen failed");
        goto bail;
    }
    while (fgets (lineBuf, sizeof(lineBuf), fp)) {
        if (strstr (lineBuf, lib)) {
            char *temp = strtok (lineBuf, "-");
            addr = strtoul (temp, NULL, 16);
            break;
        }
    }
bail: 
    fclose(fp);
    return addr;
}

void checkBreakPoint ()
{
    int i, j;
    unsigned int base, offset, pheader;
    Elf32_Ehdr *elfhdr;
    Elf32_Phdr *ph_t;

    base = getLibAddr ("libdemo.so");
    if (base == 0) {
        LOGI ("getLibAddr failed");
        return;
    }

    elfhdr = (Elf32_Ehdr *) base;
    pheader = base + elfhdr->e_phoff;

    for (i = 0; i < elfhdr->e_phnum; i++) {
        ph_t = (Elf32_Phdr*)(pheader + i * sizeof(Elf32_Phdr)); // traverse program header

        if ( !(ph_t->p_flags & 1) ) continue;
        offset = base + ph_t->p_vaddr;
        offset += sizeof(Elf32_Ehdr) + sizeof(Elf32_Phdr) * elfhdr->e_phnum;

        char *p = (char*)offset;
        for (j = 0; j < ph_t->p_memsz; j++) {
            if(*p == 0x01 && *(p+1) == 0xde) {
                LOGI ("Find thumb bpt %p", p);
            } else if (*p == 0xf0 && *(p+1) == 0xf7 && *(p+2) == 0x00 && *(p+3) == 0xa0) {
                LOGI ("Find thumb2 bpt %p", p);
            } else if (*p == 0x01 && *(p+1) == 0x00 && *(p+2) == 0x9f && *(p+3) == 0xef) {
                LOGI ("Find arm bpt %p", p);
            }
            p++;
        }
    }
}

单步检测

这种单步检测可以就用代码执行时间差来发现。

emulator层面

一般在分析apk的过程中会借助android模拟器。因此从apk自我保护的角度出发,可以对程序的运行环境检测,判断是否运行在模拟器中,如果运行在模拟器中则退出整个应用程序或者调到其他分支。可以通过属性检测,虚拟机文件检测,基于cache行为,代码指令执行检测等。

属性检测

判断当前设备是否是模拟器。

public class AntiEmulator {
    private static String[] known_pipes={
            "/dev/socket/qemud",
            "/dev/qemu_pipe"
    };

    private static String[] known_qemu_drivers = {
            "goldfish"
    };

    private static String[] known_files = {
            "/system/lib/libc_malloc_debug_qemu.so",
            "/sys/qemu_trace",
            "/system/bin/qemu-props"
    };

    private static String[] known_numbers = { "15555215554", "15555215556",
            "15555215558", "15555215560", "15555215562", "15555215564",
            "15555215566", "15555215568", "15555215570", "15555215572",
            "15555215574", "15555215576", "15555215578", "15555215580",
            "15555215582", "15555215584", };
    private static String[] known_device_ids = { 
            "000000000000000" // 默认ID
    };
    private static String[] known_imsi_ids = { 
            "310260000000000" // 默认的 imsi id
    };
    //检测“/dev/socket/qemud”,“/dev/qemu_pipe”这两个通道
    public static boolean checkPipes(){
        for(int i = 0; i < known_pipes.length; i++){
            String pipes = known_pipes[i];
            File qemu_socket = new File(pipes);
            if(qemu_socket.exists()){
                Log.v("Result:", "Find pipes!");
                return true;
            }
        }
        Log.i("Result:", "Not Find pipes!");
        return false;
    }
    // 检测驱动文件内容
    // 读取文件内容,然后检查已知QEmu的驱动程序的列表
    public static Boolean checkQEmuDriverFile(){
        File driver_file = new File("/proc/tty/drivers");
        if(driver_file.exists() && driver_file.canRead()){
            byte[] data = new byte[1024];  //(int)driver_file.length()
            try {
                InputStream inStream = new FileInputStream(driver_file);
                inStream.read(data);
                inStream.close();       
            } catch (Exception e) {
                // TODO: handle exception
                e.printStackTrace();
            }
            String driver_data = new String(data);
            for(String known_qemu_driver : AntiEmulator.known_qemu_drivers){
                if(driver_data.indexOf(known_qemu_driver) != -1){
                    Log.i("Result:", "Find know_qemu_drivers!");
                    return true;
                }
            }
        }
        Log.i("Result:", "Not Find known_qemu_drivers!");
        return false;
    }

    //检测模拟器上特有的几个文件
    public static Boolean CheckEmulatorFiles(){
        for(int i = 0; i < known_files.length; i++){
            String file_name = known_files[i];
            File qemu_file = new File(file_name);
            if(qemu_file.exists()){
                Log.v("Result:", "Find Emulator Files!");
                return true;
            }
        }
        Log.v("Result:", "Not Find Emulator Files!");
        return false;
    }

    // 检测模拟器默认的电话号码
    public static Boolean CheckPhoneNumber(Context context) {
        TelephonyManager telephonyManager = (TelephonyManager) context
                .getSystemService(Context.TELEPHONY_SERVICE);

        String phonenumber = telephonyManager.getLine1Number();

        for (String number : known_numbers) {
            if (number.equalsIgnoreCase(phonenumber)) {
                Log.v("Result:", "Find PhoneNumber!");
                return true;
            }
        }
        Log.v("Result:", "Not Find PhoneNumber!");
        return false;
    }

    //检测设备IDS 是不是 “000000000000000”
    public static Boolean CheckDeviceIDS(Context context) {
        TelephonyManager telephonyManager = (TelephonyManager) context
                .getSystemService(Context.TELEPHONY_SERVICE);

        String device_ids = telephonyManager.getDeviceId();

        for (String know_deviceid : known_device_ids) {
            if (know_deviceid.equalsIgnoreCase(device_ids)) {
                Log.v("Result:", "Find ids: 000000000000000!");
                return true;
            }
        }
        Log.v("Result:", "Not Find ids: 000000000000000!");
        return false;
    }

    // 检测imsi id是不是“310260000000000”
    public static Boolean CheckImsiIDS(Context context){
        TelephonyManager telephonyManager = (TelephonyManager)
                context.getSystemService(Context.TELEPHONY_SERVICE);

        String imsi_ids = telephonyManager.getSubscriberId();

        for (String know_imsi : known_imsi_ids) {
            if (know_imsi.equalsIgnoreCase(imsi_ids)) {
                Log.v("Result:", "Find imsi ids: 310260000000000!");
                return true;
            }
        }
        Log.v("Result:", "Not Find imsi ids: 310260000000000!");
        return false;
    }

    //检测手机上的一些硬件信息
    public static Boolean CheckEmulatorBuild(Context context){
        String BOARD = android.os.Build.BOARD;
        String BOOTLOADER = android.os.Build.BOOTLOADER;
        String BRAND = android.os.Build.BRAND;
        String DEVICE = android.os.Build.DEVICE;
        String HARDWARE = android.os.Build.HARDWARE;
        String MODEL = android.os.Build.MODEL;
        String PRODUCT = android.os.Build.PRODUCT;
        if (BOARD == "unknown" || BOOTLOADER == "unknown"
                || BRAND == "generic" || DEVICE == "generic"
                || MODEL == "sdk" || PRODUCT == "sdk"
                || HARDWARE == "goldfish")
        {
            Log.v("Result:", "Find Emulator by EmulatorBuild!");
            return true;
        }
        Log.v("Result:", "Not Find Emulator by EmulatorBuild!");
        return false;
    }

    //检测手机运营商家
    public static boolean CheckOperatorNameAndroid(Context context) {
        String szOperatorName = ((TelephonyManager)
                context.getSystemService("phone")).getNetworkOperatorName();

        if (szOperatorName.toLowerCase().equals("android") == true) {
            Log.v("Result:", "Find Emulator by OperatorName!");
            return true;
        }
        Log.v("Result:", "Not Find Emulator by OperatorName!");
        return false;
    }
}

也可以检测一些其他的值,如电池的电池状态,电池电量,Secure.ANDROIOD_ID,Deviceid,手机号码等。

虚拟机文件检测

相对于真实设备,上述的检测手段不再适用。安卓模拟器中存在一些特殊的文件或者目录,如/system/bin/qemu-props,该可执行文件可以用来在模拟器中设置系统属性。常用的模拟器都会进行一定程度的定制,这样都会有些带有该型号文件特征的文件,比如海马玩在system/bin下就有一个droid4x-prop,夜神模拟器在system/bin路径下有nox-prop。一般的手机上不会有这一文件,这可以作为一个思路来进行检测。

检测列表
/system/lib/libc_malloc_debug_qemu.so
/system/lib/libc_malloc_debug_qemu.so-arm
/system/bin/qemu_props
/system/bin/androVM-prop
/system/bin/microvirt-prop
/system/bin/windroyed(文卓爷模拟器)
/system/bin/microvirt
/system/bin/nox-prop(夜神模拟器)
/system/bin/ttVM-prop(天天模拟器)
/system/bin/droid4x-prop(海马玩模拟器)

基于cache行为检测

cache原理模拟器和真机是有所不同,真机的cache模块是分区的,模拟器的是没有分区,是一整个cache。

res层面

资源检测防止重打包

SDK中提供了检测软件签名的方法,可以调用PackageManager类的getPackageInfo()方法,为第2个参数传入PackageManager.GET_SIGNATURES,返回的PackageInfo对象的signature字段就是软件发布时的签名,但签名内容过长,不适合在代码中作比较,可以使用签名对象的hashcode()方法来获取hash值,比较hash值即可。

可以有两种方案:

  • 直接在 java代码中实现,签名不一致则退出应用。
  • 将签名信息携带有关参数,发送到服务端进行签名校验,失败就返回错误。
    虽然有这些签名校验的存在,但始终可以在本地解决。但也是一种方案。

使用伪加密

伪加密是Android4.2.x系统发布前的加密方式之一,通过java代码对APK(压缩文件)进行伪加密,其修改原理是修改连续4位字节标记为”P K 01 02”的后第5位字节,奇数表示不加密偶数表示加密。伪加密后的APK不但可以防止PC端对它的解压和查看也同样能防止反编译工具编译。


虽然伪加密可以起到一定防破解作用,但同时会出现这样的问题,首先使用伪加密对其APK加密后市场无法对其进行安全检测,导致部分市场会拒绝这类APK上 传;其次,伪加密的加密方式和解密方式也早已公布导致它的安全程度也大大降低;再次,Android4.2.x系统无法安装伪加密的APK;最后伪加密只 是对APK做简单保护,在java层源码加壳保护、核心so库、资源文件、主配文件、第三方架包方面却没有任何保护处理。因此APK伪加密是无法真正有效的防破解

总结

上述的一些反调试方法很大程度参考了网上一些大佬的博客,有些可能已经不再适用。但作为菜鸟几天的学习收获,了解到了很多有用的知识。虽然总结的很繁琐,但挺值得。继续努力吧!

博客链接

参考链接

https://blog.csdn.net/feibabeibei_beibei/article/details/60956307
https://blog.csdn.net/darmao/article/details/78816964
https://blog.csdn.net/iEearth/article/details/72849990
https://www.ibm.com/developerworks/cn/linux/l-inotify/index.html
https://blog.csdn.net/earbao/article/details/53933306