数据, 术→技巧, 研发

Android 设备安全检测

钱魏Way · · 857 次浏览

为了应付黑产,需要对Android设备进行安全性检测来确定风险的大小。

Android安全机制

Android采用分层的系统架构,由下往上分别是linux内核层、硬件抽象层、系统运行时库层、应用程序框架层和应用程序层。Android将安全设计贯穿系统架构的各个层面,覆盖系统内核、虚拟机、应用程序框架层以及应用层各个环节,力求在开放的同时,也恰当保护用户的数据、应用程序和设备的安全。Android安全模型主要提供以下几种安全机制:

  • 进程沙箱隔离机制,使得Android应用程序在安装时被赋予独特的用户标识(UID),并永久保持。应用程序及其运行的Dalvik虚拟机运行在独立的Linux进程空间,与其它应用程序完全隔离。在特殊情况下,进程间还可以存在相互信任关系。如源自同一开发者或同一开发机构的应用程序,通过Android提供的共享UID(Shared UserId)机制,使得具备信任关系的应用程序可以运行在同一进程空间。
  • 应用程序签名机制,规定APK文件必须被开发者进行数字签名,以便标识应用程序作者和在应用程序之间的信任关系。在安装应用程序APK时,系统安装程序首先检查APK是否被签名,有签名才能安装。当应用程序升级时,需要检查新版应用的数字签名与已安装的应用程序的签名是否相同,否则,会被当做一个新的应用程序。Android开发者有可能把安装包命名为相同的名字,通过不同的签名可以把他们区分开来,也保证签名不同的包不被替换,同时防止恶意软件替换安装的应用。
  • 权限声明机制,要想获得在对象上进行操作,就需要把权限和此对象的操作进行绑定。不同级别要求应用程序行使权限的认证方式也不一样,Normal级申请就可以使用,Dangerous级需要安装时由用户确认,Signature和Signatureorsystem级则必须是系统用户才可用。
  • 访问控制机制,确保系统文件和用户数据不受非法访问。
  • 进程通信机制,基于共享内存的Binder实现,提供轻量级的远程进程调用(RPC)。通过接口描述语言(AIDL)定义接口与交换数据的类型,确保进程间通信的数据不会溢出越界。
  • 内存管理机制,基于Linux的低内存管理机制,设计实现了独特的LMK,将进程重要性分级、分组,当内存不足时,自动清理级别进程所占用的内存空间。同时,引入的Ashmem内存机制,使得Android具备清理不再使用共享内存区域的能力。

Android安全检测内容

常见的Android安全检测内容:模拟器检测、root检测、hook检测、多开检测、debug检测。

模拟器识别检测

Android模拟器常常被用来刷单,如何准确的识别模拟器成为App开发中的一个重要模块,目前也有专门的公司提供相应的SDK供开发者识别模拟器。目前流行的Android模拟器大概分为两种,一种是基于Qemu,另一类是基于Genymotion(VirtualBox类),网上现在流行用一些模拟器特征进行鉴别,比如:

  • 判断Build中的一些模拟器特征值
  • 匹配Qemu的一些特征文件以及属性
  • 通过获取cpu信息,将x86的给过滤掉(真机一般都是基于ARM)

不过里面的很多手段都能通过改写ROM或者Xposed作假,让判断的性能打折扣。其实,现在绝大部分手机都是基于ARM架构,其他CPU架构给忽略不计,模拟器全部运行在PC上,因此,只需要判断是运行的设备否是ARM架构即可。

ARM与Simpled X86在架构上有很大区别,ARM采用的哈弗架构将指令存储跟数据存储分开,与之对应的,ARM的一级缓存分为I-Cache(指令缓存)与D-Cahce(数据缓存),而Simpled X86只有一块缓存,而模拟器采用的可以看做是Simpled-x86架构,如果我们将一段代码可执行代码动态映射到内存,在执行的时候,Simpled-X86架构上动态修改这部分代码后,指令缓存会被同步修改,而ARM修改的却是D-Cahce中的内容,此时I-Cache中的指令并不一定被更新,这样,程序就会在ARM与Simpled-x86上有不同的表现,根据计算结果便可以知道究竟是还在ARM平台上运行,为什么说模拟器采用的是Simpled-x86架构,拿QEMU来说,它采用了一些手段,主动保证了Self-Modifying Code的同步性,看QEMU对于Self-Modifying Code的处理:

On RISC targets, correctly written software uses memory barriers and cache flushes, so some of the protection above would not be necessary. However, QEMU still requires that the generated code always matches the target instructions in memory in order to handleexceptions correctly.

无论是x86还是ARM,只要是静态编译的程序,都没有修改代码段的权限,所以,首先需要将上面的汇编代码翻译成可执行文件,再需要申请一块内存,将可执行代码段映射过去,执行。

以下实现代码是测试代码的核心,主要就是将地址e2844001的指令add r4, r4, #1,在运行中动态替换为e2877001的指令add r7, r7, #1,这里目标是ARM-V7架构的,要注意它采用的是三级流水,PC值=当前程序执行位置+8。通过arm交叉编译链编译出的可执行代码如下:

8410:       e92d41f0        push    {r4, r5, r6, r7, r8, lr}
8414:       e3a07000        mov     r7, #0
8418:       e1a0800f        mov     r8, pc      // 本平台针对ARM7,三级流水  PC值=当前程序执行位置+8
841c:       e3a04000        mov     r4, #0
8420:       e2877001        add     r7, r7, #1
    ....
842c:       e1a0800f        mov     r8, pc
8430:       e248800c        sub     r8, r8, #12   // PC值=当前程序执行位置+8
8434:       e5885000        str     r5, [r8]
8438:       e354000a        cmp     r4, #10
843c:       aa000002        bge     844c <out>
.....

如果是在ARM上运行,e2844001处指令无法被覆盖,最终执行的是add r4,#1 ,而在x86平台上,执行的是add r7,#1 ,代码执行完毕, r0的值在模拟器上是1,而在真机上是10。之后,将上述可执行代码通过mmap,映射到内存并执行即可,具体做法如下,将可执行的二进制代码直接拷贝可执行代码区,去执行:

#include <jni.h>
#include <stddef.h>
#include <unistd.h>
#include <sys/mman.h>
#include <android/log.h>
#include<fcntl.h>
#include <signal.h>

#define LOGI(...) __android_log_print(ANDROID_LOG_ERROR,"lishang",__VA_ARGS__)
#define PROT PROT_EXEC|PROT_WRITE|PROT_READ
//这里其实主要是检测是不是在x86或者在arm上运行


const int handledSignals[] = {
        SIGSEGV, SIGABRT, SIGFPE, SIGILL, SIGBUS
};
const int handledSignalsNum = sizeof(handledSignals) / sizeof(handledSignals[0]);
struct sigaction old_handlers[5];

void my_sigaction(int signal, siginfo_t *info, void *reserved) {
    LOGI("Crash detect  signal %d",signal);
    exit(0);
}

int load(JNIEnv *env) {
    struct sigaction handler;
    memset(&handler, 0, sizeof(sigaction));
    handler.sa_sigaction = my_sigaction;
    handler.sa_flags = SA_RESETHAND;
    int i = 0;
    for ( ; i < handledSignalsNum; ++i) {
        sigaction(handledSignals[i], &handler, &old_handlers[i]);
    }
    return 1;
}

int a = -1;
int (*asmcheck)(void);
JNIEXPORT jboolean JNICALL Java_com_snail_antifake_jni_EmulatorDetectUtil_detect
        (JNIEnv *env, jobject jobject1) {
    load(env);
char code[] =
     	"\xff\xc3\x00\xd1"//	sub	sp, sp, #0x30
     	"\xfd\x7b\x02\xa9"//	x29, x30, [sp,#32]
     	"\x02\x00\x80\xd2"//	x2, #0x0
     	"\x00\x00\x80\xd2"//	mov	x0, #0x0
     	"\x42\x04\x00\x91"//	add	x2, x2, #0x1
     	"\xe3\xff\xff\x10"//	adr	x3, 18 <smc>
     	"\x61\x00\x40\xf9"//	ldr	x1, [x3]
     	"\x00\x04\x00\x91"//	add	x0, x0, #0x1
     	"\xe3\xff\xff\x10"//	adr	x3, 24 <code>
      	"\x61\x00\x00\xf9"//	str	x1, [x3]
     	"\x1f\x28\x00\xf1" //	cmp	x0, #0xa
     	"\x8a\x00\x00\x54"//	b.ge	44 <out>
     	"\x5f\x28\x00\xf1"  //	cmp	x2, #0xa
     	"\x4a\x00\x00\x54"//	b.ge	44 <out>
     	"\xf9\xff\xff\x17"//	b	24 <code>
 		"\xfd\x7b\x42\xa9"//	ldp	x29, x30, [sp,#32]
     	"\xff\xc3\x00\x91"//	add	sp, sp, #0x30
     	"\xc0\x03\x5f\xd6" //	ret
     	"\x00\x00\xa0\xe1" //	nop
     	"\x00\x00\xa0\xe1" //	nop
     	"\x00\x00\xa0\xe1" //	nop
     	"\x00\x00\xa0\xe1" //	nop
        ;

   LOGI(" start  detect");
    void *exec = mmap(NULL, (size_t) getpagesize(), PROT, MAP_ANONYMOUS | MAP_PRIVATE, -1,
                      (off_t) 0);
    LOGI(" mmap sucess exec  %x  %d ", exec,(size_t) getpagesize());
    if (exec == (void *) -1) {
        int fd = fopen("/dev/zero", "w+");
        exec = mmap(NULL, (size_t) getpagesize(), PROT, MAP_PRIVATE, fd, (off_t) 0);
        if (exec == (void *) -1) {
            return 10;
        }
    }

    memcpy(exec, code,  sizeof(code));
    LOGI(" mmap copy  exec  %x", exec);
        LOGI(" mmap copy  exec  %x", exec);
            LOGI(" mmap copy  exec  %x", exec);
    //如果不是 (size_t) getpagesize() 是sizeof(code),就必须加上LOGI(" mmap sucess exec  %x", exec); ,才能降低崩溃概率,这尼玛操蛋
    asmcheck = (int *) exec;
       __clear_cache(exec, exec+ (size_t) getpagesize() );
      a= asmcheck();
   // a=detectAsm ();
        LOGI(" ret --  %x", a);
    munmap(exec, getpagesize());

      return a == 1;
}

经验证, 无论是Android自带的模拟器,还是夜神模拟器,或者Genymotion造假的模拟器,都能准确识别。为了防止在真机上出现崩溃,最好还是单独开一个进程服务,利用Binder实现模拟器鉴别的查询。其他方案:

检测模拟器不存在的相关文件

jboolean specialFilesEmulatorcheck() {

    if (exists("/system/lib/libdroid4x.so") // 文卓爷
        || exists("/system/bin/windroyed") // 文卓爷
        || exists("/system/bin/microvirtd") // 逍遥
        || exists("/system/bin/nox-prop") // 夜神
        || exists("/system/bin/ttVM-prop") // 天天
        || exists("/system/lib/libc_malloc_debug_qemu.so")) {

        return JNI_TRUE;
    }
    return JNI_FALSE;

}

检测特殊目录,/sys/class/thermal/thermal_zoneX/temp(温度挂载文件)

@return 大于 0 为真机,等于 0 为模拟器

jboolean thermalCheck() {

    //当前手机的温度检测,手机下均有thermal_zone文件
    DIR *dirptr = NULL;
    int i = 0;
    struct dirent *entry;

    if ((dirptr = opendir("/sys/class/thermal/")) != NULL) {
        while ((entry = readdir(dirptr))) {
            LOGE("%s  \n", entry->d_name);
            if (!strcmp(entry->d_name, ".") || !strcmp(entry->d_name, "..")) {
                continue;
            }
            char *tmp = entry->d_name;
            if (strstr(tmp, "thermal_zone") != NULL) {
                i++;
            }
        }
        closedir(dirptr);
    } else {
        LOGE("open thermal fail");
    }
    return i > 0 ? JNI_FALSE : JNI_TRUE;
}

build 文件检测示例

jboolean buildCheck() {
    char name[64] = "";
    getProperty("ro.product.name", name);
    if (strcmp(name, "ChangWan") == 0
        || strcmp(name, "Droid4X") == 0
        || strcmp(name, "lgshouyou") == 0
        || strcmp(name, "nox") == 0
        || strcmp(name, "ttVM_Hdragon") == 0) {
        return JNI_TRUE;
    }
    return JNI_FALSE;
}

蓝牙文件检测

jboolean bluetoothCheck() {
    if (!exists("/system/lib/libbluetooth_jni.so")) {
        return JNI_TRUE;
    }
    return JNI_FALSE;
}

通过设备信息

private var sIsProbablyRunningOnEmulator: Boolean? = null

fun isProbablyRunningOnEmulator(): Boolean {
    var result = sIsProbablyRunningOnEmulator
    if (result != null)
        return result
    // Android SDK emulator
    result = (Build.FINGERPRINT.startsWith("google/sdk_gphone_")
            && Build.FINGERPRINT.endsWith(":user/release-keys")
            && Build.MANUFACTURER == "Google" && Build.PRODUCT.startsWith("sdk_gphone_") && Build.BRAND == "google"
            && Build.MODEL.startsWith("sdk_gphone_"))
            //
            || 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")
            //bluestacks
            || "QC_Reference_Phone" == Build.BOARD && !"Xiaomi".equals(Build.MANUFACTURER, ignoreCase = true) //bluestacks
            || Build.MANUFACTURER.contains("Genymotion")
            || Build.HOST=="Build2" //MSI App Player
            || Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")
            || Build.PRODUCT == "google_sdk"
            // another Android SDK emulator check
            || SystemProperties.getProp("ro.kernel.qemu") == "1"
    sIsProbablyRunningOnEmulator = result
    return result
}

root检测

在Linux操作系统中,root的权限是最高的,也被称为超级权限的拥有者。在系统中,每个文件、目录和进程,都归属于某一个用户,没有用户许可其它普通用户是无法操作的,但对root除外。

root用户的特权性还表现在:

  • root可以超越任何用户和用户组来对文件或目录进行读取、修改或删除(在系统正常的许可范围内);
  • 对可执行程序的执行、终止;
  • 对硬件设备的添加、创建和移除等;
  • 也可以对文件和目录进行属主和权限进行修改,以适合系统管理的需要(因为root是系统中权限最高的特权用户);

root是超越任何用户和用户组的,基于用户ID的权限机制的沙盒是隔离不了它的。

root的方式通常可以分为2种:

  • 不完全Root, 通过各种系统漏洞,替换或添加SU程序到设备,获取Root权限,而在获取root权限以后,会装一个程序用以提醒用户是否给予程序最高权限,可以一定程度上防止恶意软件,通常会使用Superuser或者 SuperSU。
  • 完全Root,替换设备原有的ROM,以实现取消secure设置。

通过对部分应用的ROOT检测机制进行逆向分析,以及查阅相关资料发现一些事情。各应用ROOT检测机制没有相对统一的标准,具体表现在检查内容、处置方式等方面,下面是我整理的一些常用的检测方法:

查看系统是否测试版

我们可以查看发布的系统版本,是test-keys(测试版),还是release-keys(发布版)。

public static boolean checkDeviceDebuggable(){
    String buildTags = android.os.Build.TAGS;
    if (buildTags != null && buildTags.contains("test-keys")) {
        Log.i(LOG_TAG,"buildTags="+buildTags);
        return true;
    }
    return false;
}

若是非官方发布版,很可能是完全root的版本,存在使用风险。可是在实际情况下,我遇到过某些厂家的正式发布版本,也是test-keys,可能大家对这个标识也不是特别注意吧。所以具体是否使用,还要多考虑考虑呢。也许能解决问题,也许会给自己带来些麻烦。

检查常用目录是否存在su

su是Linux下切换用户的命令,在使用时不带参数,就是切换到超级用户。通常我们获取root权限,就是使用su命令来实现的,所以可以检查这个命令是否存在。

有三个方法来测试su是否存在:

1)检测在常用目录下是否存在su

public static boolean checkRootPathSU()
{
    File f=null;
    final String kSuSearchPaths[]={"/system/bin/","/system/xbin/","/system/sbin/","/sbin/","/vendor/bin/"};
    try{
        for(int i=0;i<kSuSearchPaths.length;i++)
        {
            f=new File(kSuSearchPaths[i]+"su");
            if(f!=null&&f.exists())
            {
                Log.i(LOG_TAG,"find su in : "+kSuSearchPaths[i]);
                return true;
            }
        }
    }catch(Exception e)
    {
        e.printStackTrace();
    }
    return false;
}

2)使用which命令查看是否存在su

public static boolean checkRootWhichSU() {
    String[] strCmd = new String[] {"/system/xbin/which","su"};
    ArrayList<String> execResult = executeCommand(strCmd);
    if (execResult != null){
        Log.i(LOG_TAG,"execResult="+execResult.toString());
        return true;
    }else{
        Log.i(LOG_TAG,"execResult=null");
        return false;
    }
}

其中调用了一个函数 executeCommand(),是执行linux下的shell命令。具体实现如下:

public static ArrayList<String> executeCommand(String[] shellCmd){
    String line = null;
    ArrayList<String> fullResponse = new ArrayList<String>();
    Process localProcess = null;
    try {
        Log.i(LOG_TAG,"to shell exec which for find su :");
        localProcess = Runtime.getRuntime().exec(shellCmd);
    } catch (Exception e) {
        return null;
    }
    BufferedWriter out = new BufferedWriter(new OutputStreamWriter(localProcess.getOutputStream()));
    BufferedReader in = new BufferedReader(new InputStreamReader(localProcess.getInputStream()));
    try {
        while ((line = in.readLine()) != null) {
            Log.i(LOG_TAG,"–> Line received: " + line);
            fullResponse.add(line);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    Log.i(LOG_TAG,"–> Full response was: " + fullResponse);
    return fullResponse;
}

然而,这个方法也存在一个缺陷,就是需要系统中存在which这个命令。

3)执行su,看能否获取到root权限

由于上面两种查找方法都存在可能查不到的情况,以及有su文件与设备root的差异,所以,有这第三中方法:我们执行这个命令su。这样,系统就会在PATH路径中搜索su,如果找到,就会执行,执行成功后,就是获取到真正的超级权限了。

#include "include/root-check.h"
#include "include/utils.h"
#include "include/log.h"

#include <stdio.h>
#include <string.h>

/**
 * root 检测
 * @return 0: false 1: true 2: not support
 */
int rootCheck(char *dest) {

    FILE *f = NULL;
    f = popen("su -v", "r");
    if (f != NULL) {

        if (fgets(dest, BUF_SIZE_256, f)) {
            if (strlen(dest) != 0) {
                pclose(f);
                LOGD("this is rooted.");
                return 1;
            }
        }
        pclose(f);
        LOGD("this not is root.");
        return 0;
    } else {
        LOGD("file pointer is null.");
        return 2;
    }

}

这种检测su的方法,应该是最靠谱的,不过,也有个问题,就是在已经root的设备上,会弹出提示框,请求给app开启root权限。这个提示不太友好,可能用户会不喜欢。

检查是否存在Superuser.apk

Superuser.apk是一个被广泛使用的用来root安卓设备的软件,所以可以检查这个app是否存在。

public static boolean checkSuperuserApk(){
    try {
        File file = new File("/system/app/Superuser.apk");
        if (file.exists()) {
            Log.i(LOG_TAG,"/system/app/Superuser.apk exist");
            return true;
        }
    } catch (Exception e) { }
    return false;
}

执行busybox

Android是基于Linux系统的,可是在终端Terminal中操作,会发现一些基本的命令都找不到。这是由于Android系统为了安全,将可能带来风险的命令都去掉了,最典型的,例如su,还有find、mount等。对于一个已经获取了超级权限的人来讲,这是很不爽的事情,所以,便要想办法加上自己需要的命令了。一个个添加命令也麻烦,有一个很方便的方法,就是使用被称为“嵌入式Linux中的瑞士军刀”的Busybox。简单的说BusyBox就好像是个大工具箱,它集成压缩了 Linux 的许多工具和命令。

所以若设备root了,很可能Busybox也被安装上了。这样我们运行busybox测试也是一个好的检测方法。

public static synchronized boolean checkBusybox()
{
    try
    {
        Log.i(LOG_TAG,"to exec busybox df");
        String[] strCmd = new String[] {"busybox","df"};
        ArrayList<String> execResult = executeCommand(strCmd);
        if (execResult != null){
            Log.i(LOG_TAG,"execResult="+execResult.toString());
            return true;
        }else{
            Log.i(LOG_TAG,"execResult=null");
            return false;
        }
    } catch (Exception e)
    {
        Log.i(LOG_TAG, "Unexpected error - Here is what I know: "
                + e.getMessage());
        return false;
    }
}

访问/data目录,查看读写权限

在Android系统中,有些目录是普通用户不能访问的,例如 /data、/system、/etc 等。我们就已/data为例,来进行读写访问。本着谨慎的态度,先写入一个文件,然后读出,查看内容是否匹配,若匹配,才认为系统已经root了。

public static synchronized boolean checkAccessRootData()
    {
        try
        {
            Log.i(LOG_TAG,"to write /data");
            String fileContent = "test_ok";
            Boolean writeFlag = writeFile("/data/su_test",fileContent);
            if (writeFlag){
                Log.i(LOG_TAG,"write ok");
            }else{
                Log.i(LOG_TAG,"write failed");
            }

            Log.i(LOG_TAG,"to read /data");
            String strRead = readFile("/data/su_test");
            Log.i(LOG_TAG,"strRead="+strRead);
            if(fileContent.equals(strRead)){
                return true;
            }else {
                return false;
            }
        } catch (Exception e)
        {
            Log.i(LOG_TAG, "Unexpected error - Here is what I know: "
                    + e.getMessage());
            return false;
        }
    }

//写文件
    public static Boolean writeFile(String fileName,String message){
        try{
            FileOutputStream fout = new FileOutputStream(fileName);
            byte [] bytes = message.getBytes();
            fout.write(bytes);
            fout.close();
            return true;
        }
        catch(Exception e){
            e.printStackTrace();
            return false;
        }
    }

//读文件
    public static String readFile(String fileName){
        File file = new File(fileName);
        try {
            FileInputStream fis= new FileInputStream(file);
            byte[] bytes = new byte[1024];
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            int len;
            while((len=fis.read(bytes))>0){
                bos.write(bytes, 0, len);
            }
            String result = new String(bos.toByteArray());
            Log.i(LOG_TAG, result);
            return result;
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

使用PackageManager,检查root应用程序的列表

public boolean detectRootManagementApps(String[] additionalRootManagementApps) {

    // Create a list of package names to iterate over from constants any others provided
    ArrayList<String> packages = new ArrayList<>(Arrays.asList(Const.knownRootAppsPackages));
    if (additionalRootManagementApps!=null && additionalRootManagementApps.length>0){
        packages.addAll(Arrays.asList(additionalRootManagementApps));
    }

    return isAnyPackageFromListInstalled(packages);
}

private boolean isAnyPackageFromListInstalled(List<String> packages){
    boolean result = false;

    PackageManager pm = mContext.getPackageManager();

    for (String packageName : packages) {
        try {
            // Root app detected
            pm.getPackageInfo(packageName, 0);
            QLog.e(packageName + " ROOT management app detected!");
            result = true;
        } catch (PackageManager.NameNotFoundException e) {
            // Exception thrown, package is not installed into the system
        }
    }

    return result;
}

Hook检测

Hook的目的是为了对目标进程函数的替换和注入,Hook的危害是巨大的,Hook后的应用程序毫无安全可言。

通常有以下三种方式来检测一个App是否被hook:

  • 安装目录中是否存在hook工具
  • 存储中是否存在hook安装文件
  • 运行栈中是否存在hook相关类

检测主流 hook 框架: frida、Xposed、substrate

int frameCheck() {

    char path[BUF_SIZE_32];
    sprintf(path, "/proc/%d/maps", getpid());

    // 读取数据
    FILE *f = NULL;
    char buf[BUF_SIZE_512];
    f = fopen(path, "r");

    if (f != NULL) {
        while (fgets(buf, BUF_SIZE_512, f)) {
            // fgets 当读取 (n-1) 个字符时,或者读取到换行符时,或者到达文件末尾时,它会停止,具体视情况而定。
            LOGI("maps: %s", buf);
            if (strstr(buf, "frida")
                || strstr(buf, "com.saurik.substrate")
                || strstr(buf, "XposedBridge.jar")) {
                fclose(f);
                return 1;
            }
        }
    }

    fclose(f);
    return 0;

}

检查核心文件,若存在则为危险环境。不一定正在被 hook,但是有风险

int pkgCheck() {
    if (exists("/data/data/de.robv.android.xposed.installer")
        || exists("/data/data/com.saurik.substrate")
        || exists("/data/local/tmp/frida-server")) {
        return 1;
    }
    return 0;
}

检测已加载到内存的 Substrate 核心文件

int substrateSoCheck() {
    // 直接加载危险的so
    void *imagehandle = dlopen("libsubstrate-dvm.so", RTLD_GLOBAL | RTLD_NOW);
    if (imagehandle != NULL) {
        void *sym = dlsym(imagehandle, "MSJavaHookMethod");
        if (sym != NULL) {
            dlclose(imagehandle);
            LOGE("find Cydia Substrate");
            //发现Cydia Substrate相关模块
            return 1;
        }
    }
    LOGE("not find Cydia Substrate");
    return 0;
}

Xposed 类检测

尝试加载 Xposed 公共类,若可以加载则为 Xposed 环境中

public static boolean classCheck() {
    try {
        Class.forName("de.robv.android.xposed.XC_MethodHook");
        return true;
    } catch (Exception e) {
    }
    try {
        Class.forName("de.robv.android.xposed.XposedHelpers");
        return true;
    } catch (Exception e) {
    }
    return false;
}

检测已加载到内存的 xhook 核心文件

int xhookCheck() {
    // 直接加载危险的so
    void *imagehandle = dlopen("libxhook.so", RTLD_GLOBAL | RTLD_NOW);
    if (imagehandle != NULL) {
        void *sym = dlsym(imagehandle, "xhook_register");
        if (sym != NULL) {
            dlclose(imagehandle);
            LOGE("find xhook");
            return 1;
        }
    }
    LOGE("not find xhook");
    return 0;
}

自造异常读取栈

Xposed Installer框架对每个由Zygote孵化的App进程都会介入,因此在程序方法异常栈中就会出现Xposed相关的“身影”,我们可以通过自造异常Catch来读取异常堆栈的形式,用以检查其中是否存在Xposed的调用方法。

/**
 * 检测调用栈中的可疑方法
 */
public static boolean exceptionCheck() {
    try {
        throw new Exception("Deteck hook");
    } catch (Exception e) {
        int zygoteInitCallCount = 0;
        for (StackTraceElement item : e.getStackTrace()) {
            // 检测"com.android.internal.os.ZygoteInit"是否出现两次,如果出现两次,则表明Substrate框架已经安装
            if ("com.android.internal.os.ZygoteInit".equals(item.getClassName())) {
                zygoteInitCallCount++;
                if (zygoteInitCallCount == 2) {
                    LogUtils.i("Substrate is active on the device.");
                    return true;
                }
            }
            if ("com.saurik.substrate.MS$2".equals(item.getClassName()) && "invoke".equals(item.getMethodName())) {
                LogUtils.i("A method on the stack trace has been hooked using Substrate.");
                return true;
            }
            if ("de.robv.android.xposed.XposedBridge".equals(item.getClassName())
                    && "main".equals(item.getMethodName())) {
                LogUtils.i("Xposed is active on the device.");
                return true;
            }
            if ("de.robv.android.xposed.XposedBridge".equals(item.getClassName())
                    && "handleHookedMethod".equals(item.getMethodName())) {
                LogUtils.i("A method on the stack trace has been hooked using Xposed.");
                return true;
            }
        }
    }

    return false;
}

检查关键Java方法被变为Native JNI方法

当一个Android App中的Java方法被莫名其妙地变成了Native JNI方法,则非常有可能被Xposed Hook了。由此可得,检查关键方法是不是变成Native JNI方法,也可以检测是否被Hook。

通过反射调用Modifier.isNative(method.getModifiers())方法可以校验方法是不是Native JNI方法,Xposed同样可以篡改isNative这个方法的返回值。

反射读取XposedHelper类字段

通过反射遍历XposedHelper类中的fieldCache、methodCache、constructorCache变量,读取HashMap缓存字段,如字段项的key中包含App中唯一或敏感方法等,即可认为有Xposed注入。

boolean methodCache = CheckHook(clsXposedHelper, "methodCache", keyWord);

private static boolean CheckHook(Object cls, String filedName, String str) {
    boolean result = false;
    String interName;
    Set keySet;
    try {
        Field filed = cls.getClass().getDeclaredField(filedName);
        filed.setAccessible(true);
        keySet = filed.get(cls)).keySet();
        if (!keySet.isEmpty()) {
            for (Object aKeySet: keySet) {
                interName = aKeySet.toString().toLowerCase();
                if (interName.contains("meituan") || interName.contains("dianping") ) {
                    result = true;
                    break;
                	} 
                }
            }
        ...
    return result;
}

多开检测

通过在宿主容器上面新建一个进程供插件APK寄宿,然后通过hook一些系统接口欺骗应用——让虚拟化后应用以为自己是正常运行的独立APP,欺骗系统——让系统认为此虚拟化应用是一个已正常安装在系统的应用。

权限访问检测

shell 命令执行在单独进程,不受宿主控制。故可以通过 shell 命令访问内部存储目录,若可以访问则正常,否则为多开环境。

/**
 * 0. 多开检测 false
 * 1. 多开检测 true
 * 2. 检测失败($unknown)
 * 检测多开, 若可访问规定目录则为正常,否则为多开环境
 * @return
 */
int moreOpenCheck() {
    // 判断是否支持ls命令
    if (exists("/system/bin/ls")) {
        char packageName[BUF_SIZE_64] = UNKNOWN;
        if (getPackageName(packageName) != 0) {
            return 2;
        }
        char path[BUF_SIZE_128];
        sprintf(path, "ls /data/data/%s", packageName);
        FILE *f = NULL;
        f = popen(path, "r");
        if (f == NULL) {
            LOGD("file pointer is null.");
            return 2;
        } else {
            // 读取 shell 命令内容
            char buff[BUF_SIZE_32];
            if (fgets(buff, BUF_SIZE_32, f) == NULL) {
                LOGD("ls data error: %s", strerror(errno));
                pclose(f);
                return 1;
            }
            LOGD("ls data: %s", buff);
            if (strlen(buff) == 0) {
                pclose(f);
                return 1;
            } else {
                pclose(f);
                return 0;
            }
        }
    } else {
        return 2;
    }
}

内部存储目录路径检测

检测当前内部存储目录路径是否是标准路径,目前只有 360 分身大师绕过。

/**
 * 判断当前私有路径是否是标准路径
 *
 * @param context
 * @return
 */
public static boolean pathCheck(Context context) {
        // 获取内部存储目录路径
    String filesDir = context.getFilesDir().getAbsolutePath();
    String packageName = context.getPackageName();
    String normalPath_one = "/data/data/" + packageName + "/files";
    String normalPath_two = "/data/user/0/" + packageName + "/files";
    // 当前存储目录路径和正常存储目录路径比对
    if (!normalPath_one.equals(filesDir) && !normalPath_two.equals(filesDir)) {
        return true;
    }
    return false;
}

Debug检测

检测 TracerPid 若不为 0 则为debug 状态

int tracerPidCheck() {

    ptraceCheck();

    FILE *f = NULL;
    char path[BUF_SIZE_64];
    sprintf(path, "/proc/%d/status", getpid());

    char line[BUF_SIZE_512];

    f = fopen(path, "r");
    while (fgets(line, BUF_SIZE_512, f)) {
        if (strstr(line, "TracerPid")) {
            LOGI("TracerPid line: %s", line);
            int statue = atoi(&line[10]);
            LOGW("TracerPid: %d", statue);
            if (statue != 0) {
                return 1;
            } else {
                return 0;
            }
        }
    }

    return 0;

}

wchan 内容为 ptrace_stop 则为调试状态

int wchanCheck() {
    FILE *f = NULL;
    char path[BUF_SIZE_64];
    sprintf(path, "/proc/%d/wchan", getpid());

    char line[BUF_SIZE_512];
    f = fopen(path, "r");
    while (fgets(line, BUF_SIZE_512, f)) {
        LOGI("wchan line: %s", line);
    }

    return 0;
}

参考链接:

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注