Android逆向-基础与实践 (六) 反调试

1. root检测

反制手段
1.算法助手、对话框取消等插件一键hook
2.分析具体的检测代码
3.利用IO重定向使文件不可读
4.修改Andoird源码,去除常见指纹

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。

2.模拟器检测

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"

模拟器检测对抗

3.应用反调试检测

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

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

3.2 debuggable属性

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

3.3 ptrace检测

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

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

3.4 调试进程名检测

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

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

4. Frida反调试

4.1 文件名检测、端口检测、双进程保护

*文件名检测

/data/local/tmp路径下是否有frida特征文件

应对:修改frida服务端文件名
*端口检测

默认端口是27042

应对:指定端口转发
*双进程保护

app创建子进程挂载自身

应对:使用spawn方式
*so检测

将上面几种方法写到so中进行检测

应对:

hook android_dlopen_ext,通过观察输出在哪个so停止输出就是哪个so在检测,找到目标so后进行patch或hook操作

4.2 检测maps

/proc/self/maps

self为对应进程id,注入frida之后maps文件中就会存在 frida-agent-64.so、frida-agent-32.so 文件

应对:

1.hook strstr、strcmp函数,让检测 frida、agent、REJECT等字符串方法失效

2.重定向maps

3.ebpf hook

用eBPF来hook系统调用并修改参数实现目的,使用bpf_probe_write_user向用户态函数地址写内容直接修改参数

char placeholder[] = "/data/data/com.zj.wuaipojie/maps";
bpf_probe_write_user((void*)addr, placeholder, sizeof(placeholder));

# 自定义 openat 调用 svc 检测 /proc/self/maps

bool anti_anti_maps() {
    // 定义一个足够大的字符数组line,用于存储读取的行
    const int buf_size = 512;
    char buf[buf_size];
    int fd;  // 文件描述符
    // 使用 my_openat 打开当前进程的内存映射文件 /proc/self/maps 进行读取
    // AT_FDCWD 表示当前工作目录,"r" 表示只读方式打开
    fd = my_openat(AT_FDCWD, "/proc/self/maps", O_RDONLY | O_CLOEXEC, 0);
    if (fd != -1) {
        // 如果文件成功打开,循环读取每一行
        while ((read_line(fd, buf, buf_size)) > 0) {
            // 使用strstr函数检查当前行是否包含"frida"字符串
            if (strstr(buf, "frida") || strstr(buf, "gadget")) {
                // 如果找到了"frida",关闭文件并返回true,表示检测到了恶意库
                close(fd);
                return true; // Evil library is loaded.
            }
        }
        // 遍历完文件后,关闭文件
        close(fd);
    } else {
        // 如果无法打开文件,记录错误。这可能意味着系统状态异常
        // 注意:这里的代码没有处理错误,只是注释说明了可能的情况
    }
    // 如果没有在内存映射文件中找到"frida",返回false,表示没有检测到恶意库
    return false; // No evil library detected.
}

ENTRY(my_openat)  // 定义函数入口,标签my_openat
    mov     x8, __NR_openat  // 将openat系统调用号(__NR_openat)移动到x8寄存器,x8用于存储系统调用号
    svc     #0               // 触发系统调用异常,进入操作系统执行系统调用
    cmn     x0, #(MAX_ERRNO + 1)  // 将函数返回值(存储在x0寄存器)与MAX_ERRNO + 1进行无符号比较
    cneg    x0, x0, hi       // 如果上面的比较结果大于或等于零(即没有错误),则将x0的符号位取反(如果原来是负则变正)
    b.hi    __set_errno_internal  // 如果上面的比较结果大于或等于零(即发生了错误),则跳转到__set_errno_internal进行错误处理
    ret                       // 从函数返回,继续执行调用者代码
END(my_openat)  // 标记函数结束

#anti svc调用
#重定向maps

function anti_svc(){
    let target_code_hex;  // 用于搜索特定汇编指令序列的十六进制字符串
    let call_number_openat;  // 系统调用号对应的数值,openat
    let arch = Process.arch;  // 获取当前进程的架构

    if ("arm" === arch){  // 如果架构是ARM
        target_code_hex = "00 00 00 EF";  // ARM架构下svc指令的十六进制表示
        call_number_openat = 322;  // openat在ARM架构中的系统调用号
    }else if("arm64" === arch){  // 如果架构是ARM64
        target_code_hex = "01 00 00 D4";  // ARM64架构下svc指令的十六进制表示
        call_number_openat = 56;  // openat在ARM64架构中的系统调用号
    }else {
        console.log("arch not support!");  // 如果架构不支持,打印错误信息
    }

    if (arch){  // 如果成功获取了架构信息
        console.log("\nthe_arch = " + arch);  // 打印当前架构
        // 枚举进程的内存范围,寻找只读内存段
        Process.enumerateRanges('r--').forEach(function (range) {
            if(!range.file || !range.file.path){  // 如果内存段没有文件路径,跳过
                return;
            }
            let path = range.file.path;  // 获取内存段的文件路径
            // 如果文件路径不是以"/data/app/"开头或不以".so"结尾,跳过
            if ((!path.startsWith("/data/app/")) || (!path.endsWith(".so"))){
                return;
            }
            let baseAddress = Module.getBaseAddress(path);  // 获取so库的基址
            let soNameList = path.split("/");  // 通过路径分割获取so库的名称
            let soName = soNameList[soNameList.length - 1];  // 获取so库的名称
            console.log("\npath = " + path + " , baseAddress = " + baseAddress + 
                        " , rangeAddress = " + range.base + " , size = " + range.size);
            // 在so库的内存范围内搜索target_code_hex对应的指令序列
            Memory.scan(range.base, range.size, target_code_hex, {
                onMatch: function (match){
                    let code_address = match;  // 获取匹配到的指令地址
                    let code_address_str = code_address.toString();  // 转换为字符串
                    // 如果地址的最低位是0, 4, 8, c中的任意一个,说明可能是svc指令
                    if (code_address_str.endsWith("0") || code_address_str.endsWith("4") || 
                        code_address_str.endsWith("8") || code_address_str.endsWith("c")){
                        console.log("--------------------------");
                        let call_number = 0;  // 初始化系统调用号
                        if ("arm" === arch){
                            // 获取svc指令后面的立即数,作为系统调用号
                            call_number = (code_address.sub(0x4).readS32()) & 0xFFF;
                        }else if("arm64" === arch){
                            call_number = (code_address.sub(0x4).readS32() >> 5) & 0xFFFF;
                        }else {
                            console.log("the arch get call_number not support!");  // 如果架构不支持,打印错误信息
                        }
                        console.log("find svc : so_name = " + soName + " , address = " + code_address + 
                                    " , call_number = " + call_number + " , offset = " + code_address.sub(baseAddress));
                        // 如果匹配到的系统调用号是openat,挂钩该地址
                        if (call_number_openat === call_number){
                            let target_hook_addr = code_address;
                            let target_hook_addr_offset = target_hook_addr.sub(baseAddress);
                            console.log("find svc openat , start inlinehook by frida!");
                            Interceptor.attach(target_hook_addr, {
                                onEnter: function (args){  // 当进入挂钩函数时
                                    console.log("\nonEnter_" + target_hook_addr_offset + " , __NR_openat , args[1] = " + 
                                                  args[1].readCString());
                                    // 修改openat的第一个参数为指定路径
                                    this.new_addr = Memory.allocUtf8String("/data/user/0/com.zj.wuaipojie/maps");
                                    args[1] = this.new_addr;
                                    console.log("onEnter_" + target_hook_addr_offset + " , __NR_openat , args[1] = " + 
                                                  args[1].readCString());
                                }, 
                                onLeave: function (retval){  // 当离开挂钩函数时
                                    console.log("onLeave_" + target_hook_addr_offset + " , __NR_openat , retval = " + retval)
                                }
                            });
                        }
                    }
                }, 
                onComplete: function () {}  // 搜索完成后的回调函数
            });
        });
    }
}

上面内容提到hook strstr方式来应对检测,如果应用自定义strstr,因为hook strstr是系统的,对于自定义的方法来说这个应对方法就无效了

1.hook strstr、strcmp函数,让检测 frida、agent、REJECT等字符串方法失效

对于这种自定义strstr的方式,可以通过hook修改程序,让代码在执行自定义strstr之前直接返回

4.3 检测status(线程名)

ls /proc/pid/task 列出线程id
cat /proc/pid/task/线程id/status

应对:

hook strstr、strcmp函数,让检测 frida 特征相关字符串方法失效

4.4 检测inlinehook

通过frida查看一个函数hook之前和之后的机器码,以此来判断是否被frida的inlinehook注入

比如通过检测 libc.so前 N 个字节是否被修改就可以确定是否被Hook

通常inline hook第一条指令是mov 常数到寄存器,然后第二条是一个br 寄存器指令。检查第二条指令高16位是不是0xd61f,就可以判断目标函数是否被inline hook了


应对:

要检测 libc.so 就需要使用到 fread等读取文件函数,对这个函数进行hook并进行判断,如果读取的内容是 N 个字节表示是检测方在调用,直接返回 libc.so 原文件 N 个字节即可

4.5 魔改frida-server服务端

*so检测

将上面几种方法写到so中进行检测

应对:

hook android_dlopen_ext,通过观察输出在哪个so停止输出就是哪个so在检测,找到目标so后进行patch或hook操作

4.6 其他检测方法

1.检测方法签名信息,frida在hook方法的时候会把java方法转为native方法

检测点icon-default.png?t=N7T8https://github.com/frida/frida-gum/blob/8d9f4578b58c03025aef63652ec4defa19f8061c/gum/backend-linux/gumandroid.c#L876

 

2.Frida在attach进程注入SO时会显式地校验ELF_magic字段,不对则直接报错退出进程,可以手动在内存中抹掉SO的magic,达到反调试的效果。

 

if (memcmp (GSIZE_TO_POINTER (start), elf_magic, sizeof (elf_magic)) != 0)
    return FALSE;
FILE *fp=fopen("/proc/self/maps","r");
while (fgets(line, sizeof(line), fp)) {
    if (strstr(line, "linker64") ) {
          start = reinterpret_cast<int *>(strtoul(strtok(line, "-"), NULL, 16));
          *(long*)start=*(long*)start^0x7f;
          }
     }

3.Frida源码中多次调用somain结构体,但它在调用前不会判断是否为空,只要手动置空后Frida一附加就会崩溃
检测点icon-default.png?t=N7T8https://github.com/frida/frida-gum/blob/8d9f4578b58c03025aef63652ec4defa19f8061c/gum/backend-linux/gumandroid.c#L1078

 

somain = api->solist_get_somain ();
gum_init_soinfo_details (&details, somain, api, &ranges);
api->solist_get_head ()
gum_init_soinfo_details (&details, si, api, &ranges);
int getsomainoff = findsym("/system/bin/linker64","__dl__ZL6somain");
*(long*)((char*)start+getsomainoff)=0;

4.还可以去hook加固壳,现在很多加固厂商都antifrida了,从壳中的代码去分析检测思路

 

5. Frida持久化

 5.1 免ROOT 方案

Frida的Gadget是一个共享库,用于免root注入hook脚本。
官方文档
思路:将APK解包后,通过修改smali代码或patch so文件的方式植入frida-gadget,然后重新打包安装。
优点:免ROOT、能过掉一部分检测机制
缺点:重打包可能会遇到解决不了的签名校验、hook时机需要把握

​
基于obejction的patchapk功能
官方文档
命令:

​
$ objection patchapk -V 14.2.18 -c config.txt -s demo.apk(注意路径不要有中文)
-V 指定gadget版本
-c 加载脚本配置信息
-s 要注入的apk

注意的问题:
objection patchapk命令基本上是其他几个系统命令的补充,可尽可能地自动化修补过程。当然,需要先安装并启用这些命令。它们是:

ps:这几个环境工具,aapt、jarsigner都是Android Studio自带的,所以在配置好as的环境即可,abd的环境配置网上搜一下就行,apktool则需要额外配置

另外会遇到的问题,patchapk的功能在patch的时候会下载对应版本的gadget的so,但是网络问题异常慢,所以建议根据链接去下载好,然后放到这个路径下并重命名

C:\Users\用户名\.objection\android\arm64-v8a\libfrida-gadget.so

5.2 ROOT方案

方法一:
思路:可以patch /data/app/pkgname/lib/arm64(or arm)目录下的so文件,apk安装后会将so文件解压到该目录并在运行时加载,修改该目录下的文件不会触发签名校验。
Patch SO的原理可以参考Android平台感染ELF文件实现模块注入
优点:绕过签名校验、root检测和部分ptrace保护。
缺点:需要root、高版本系统下,当manifest中的android:extractNativeLibs为false时,lib目录文件可能不会被加载,而是直接映射apk中的so文件、可能会有so完整性校验
使用方法

python LIEFInjectFrida.py test.apk ./ lib52pojie.so -apksign -persistence
test.apk要注入的apk名称
lib52pojie.so要注入的so名称

然后提取patch后是so文件放到对应的so目录下

方法二:
思路:基于magisk模块方案注入frida-gadget,实现加载和hook。寒冰师傅的FridaManager
优点:无需重打包、灵活性较强
缺点:需要过root检测,magsik检测

方法三:
思路:基于jshook封装好的fridainject框架实现hook
JsHook

5.3 源码定制方案

原理:修改aosp源代码,在fork子进程的时候注入frida-gadget
ubuntu 20.04系统AOSP(Android 11)集成Frida
AOSP Android 10内置FridaGadget实践01
AOSP Android 10内置FridaGadget实践02(完)|

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值