一、前言

学习了frida检测原理后,打算找个样本练手。起初是仿照着东方玻璃师傅的文章复现,但是在找最后一个检测点遇到了一些阻碍,导致最后一个执行流没找出来所以暂且先放放。后来在误打误撞下发现了一个新的样本,该样本有不少检测,故借此样本分析一下。最终完成检测的绕过,以及其中各类检测方法实现的分析还原。

声明

  1. 本文所述内容仅为技术研究与学习交流之目的, 所分析的 App 版权归其所属公司所有
  2. 作者未对任何 App 进行非法篡改, 破解, 数据窃取或商业利用, 亦不鼓励或支持任何违反法律法规的行为
  3. 读者不得将本文内容用于任何非法用途, 由此产生的一切法律责任由使用者自行承担
  4. 请遵守《中华人民共和国网络安全法》及相关法律法规

文章知识点

阅读这篇文章,可能能让你掌握这些知识点:

  • 检测点的定位思路
  • pthread_create函数调用链
  • 一些反调试、反frida的手段

二、检测点定位

首先就是检测点定位,这里直接使用两种方式进行frida注入的测试。这里直接使用frida的纯交互模式进行测试:

frida -U -f com.douban.frodo

进入frida之后,发现frida马上就退出了,而程序正常运行:

说明可能在某些so,或者子线程当中会有检测,接下来在app成功运行的情况下,使用attach进行附加,发现等待几秒钟后app直接退出, 退出之后app又重新启动了:

那么已经可以推测出来,这个app在子线程当中一定有对frida的检测。接下来需要定位一下这个这个检测点到底在哪里,这里对dlopen进行hook,脚本如下:

function frida_check(){
    function hook_dlopen(addr){
        Interceptor.attach(addr,{
            onEnter: function(args){
                this.path = "";
                this.soPath = args[0];
                if(this.soPath !== null && this.soPath !== undefined){
                    this.path = this.soPath.readCString();
                    console.log("\x1b[96m[dlopen onEnter]\x1b[0m " + this.path);
                }
            }, onLeave:function(retval){
                if(this.path !== null && this.path !== undefined){
                    console.log("\x1b[92m[dlopen onLeave]\x1b[0m " + this.path);
                }
            }
        });
    }  

    console.log("[+] 开始定位 frida 检测位置");
    var android_dlopen_ext = Module.findExportByName("libdl.so","android_dlopen_ext");
    hook_dlopen(android_dlopen_ext);
}
frida_check();

onEnteronLeave的位置输出一下提示,由于这个frida检测是通过退出自己的进程来打断frida注入的话,那么这个过程中检测的so可能会有下面的行为特征:

  • 只有onEnter的输出,没有onLeave的输出,说明可能在这个so加载的过程中就检测到然后退出了。
  • 有这两个位置都有输出,但是没有了其他so加载的输出提示,进程也退出了。

经过hook发现,进行检测的地方在这个libmsaoaidsec.so的so当中。而且可以看到的是在dlopen onLeave之前,这个进程就已经退出了,可以初步判断这个检测的位置大致是在init_procinit_array执行过程当中,因为JNI_Onload的调用时机是在dlopen之后,所以需要对这个so当中的init_procinit_array进行主要分析

三、so分析

确定了目标,就可以开始着手分析了。这里将so从apk当中取出来后,丢到IDA,接着想要通过段来找到init_array,这个结构,但是这个so有点不同,只有这三个段

这里直接在IDA View窗口搜索:能够搜到 DT_INITDT_INIT_ARRAY,对应的就是.init_proc.init_array了。

这里为了快速定位到究竟是.init_proc还是.init_array当中的某个函数检测并退出进程的,就直接对call_function进行hook,将对应的函数偏移答应出来,如果遇到只进不出的就说明是在这个函数当中进行了检测行为了。对应的hook代码如下:

function frida_check(){
    function hook_call_function(){
        if(isHook) return;
        isHook = true;
        var symbols = Process.getModuleByName("linker64").enumerateSymbols();
        var call_function = null;
        for(let i = 0; i < symbols.length; i++ )
        {
            var symbol = symbols[i];            if(symbol.name.indexOf("__dl__ZL13call_functionPKcPFviPPcS2_ES0_.__uniq.331521225453620004837736674378903349473") != -1)    // call_function
            {
                call_function = symbol.address;
                break;
            }
        }
        console.log("call function: ",call_function);
        Interceptor.attach(call_function,{
            onEnter: function(args){
                this.realpath = args[2].readCString();
                if(this.realpath.indexOf("libmsaoaidsec.so") !== -1){
                    this.base = Process.getModuleByName("libmsaoaidsec.so").base;
                    this.addr = args[1];
                    console.log("\x1b[96m[call Function onEnter]\x1b[0m " + ptr(this.addr).sub(this.base));
                }
            },onLeave: function(retval){
                if(this.realpath.indexOf("libmsaoaidsec.so") !== -1){
                    console.log("\x1b[92m[call Function onEnter]\x1b[0m " + ptr(this.addr).sub(this.base));
                }
            }
        });
    }
    function hook_dlopen(addr, soName){
        Interceptor.attach(addr,{
            onEnter: function(args){
                this.path = "";
                this.soPath = args[0];
                if(this.soPath !== null && this.soPath !== undefined){
                    this.path = this.soPath.readCString();
                    if(this.path.indexOf(soName) !== -1) hook_call_function();
                    console.log("\x1b[96m[dlopen onEnter]\x1b[0m " + this.path);
                }
            }, onLeave:function(retval){
                if(this.path !== null && this.path !== undefined){
                    console.log("\x1b[92m[dlopen onLeave]\x1b[0m " + this.path);
                }
            }
        });
    }
    console.log("[+] 开始定位 frida 检测位置");
    var android_dlopen_ext = Module.findExportByName("libdl.so","android_dlopen_ext");
    hook_dlopen(android_dlopen_ext,"libmsaoaidsec.so");
    var isHook = false;
}

frida_check();

运行之后,发现是在这个0x14400函数退出进程的,因为并没有从这个函数onLeave,也就是检测在这个.init_proc函数当中。

如果后续想要更进一步的了解这个so的检测的话就需要这个.init_proc进行深度分析了。这里先不深入进行分析,上面定位检测点的时候猜测是有通过子线程进行检测的,这里可以先尝试对pthread_create,进行hook,然后替换掉线程函数,看看能否绕过再说。

四、子线程绕过

结合上述的分析结果可以判断,也是有子线程检测的。还有一种可能是,在.init_proc当中启动的子线程检测。这里就先尝试一下,通过替换子线程的线程函数来看看能否绕过检测。而想要对子线程的线程函数进行替换,就需要先找到到底是使用了哪一个函数,而想要做到这一步,就需要对pthread_create这个函数进行hook。既然提及到了这个pthread_create,那么顺带的也来整理一下这个函数的调用链,以及是怎么调用线程函数的。

pthread_create调用链分析

Android分析有一点好处就是可以直接对照着源码,再和IDA的反编译进行比对分析。那么首先就先进行源码的分析。(源码可以使用在线网站,这里使用的是XRefAndroid - Support Android 16.0 & OpenHarmony 6.0 (AndroidXRef/AospXRef)

函数声明

int pthread_create(
    pthread_t* thread_out,           // 输出参数,返回新线程的线程句柄
    pthread_attr_t const* attr,      // 线程属性,传 NULL 表示默认属性
    void* (*start_routine)(void*),   // 线程函数
    void* arg                        // 线程函数参数
);

相关结构体

  • pthread_attr_t
typedef struct {
  uint32_t flags;			// 标志位
  void* stack_base;			// 栈基址
  size_t stack_size;		// 栈大小
  size_t guard_size;		// 保护页大小
  int32_t sched_policy;		// 调度策略
  int32_t sched_priority;	// 调度优先级

#ifdef __LP64__
  char __reserved[16];		// 64位下的保留字段,用于对齐
#endif
} pthread_attr_t;

pthread_internal_t

class pthread_internal_t {
 public:
  class pthread_internal_t* next;			// 指向上一个线程环境块
  class pthread_internal_t* prev;			// 指向下一个线程环境块

  pid_t tid;			// 线程ID

 private:
  uint32_t cached_pid_ : 31;			// 高31位缓存PID
  uint32_t vforked_ : 1;				// 最后1位标记这个线程是否通过vfork()创建的

 public:
  bool is_vforked() { return vforked_; }

  // ==========================================================
  // 3. PID/TID 缓存管理方法
  // ==========================================================
  pid_t invalidate_cached_pid() {
    pid_t old_value;
    get_cached_pid(&old_value);
    set_cached_pid(0);
    return old_value;
  }

  void set_cached_pid(pid_t value) {
    cached_pid_ = value;
  }

  bool get_cached_pid(pid_t* cached_pid) {
    *cached_pid = cached_pid_;
    return (*cached_pid != 0);
  }

  // ==========================================================
  // 4. 线程基本属性与状态
  // ==========================================================
  pthread_attr_t attr;						// 线程基本属性

  _Atomic(ThreadJoinState) join_state;

  __pthread_cleanup_t* cleanup_stack;

  void* (*start_routine)(void*);			// 函数指针,这个线程的执行函数
  void* start_routine_arg;					// 函数参数指针
  void* return_value;						// 线程函数返回值
  sigset64_t start_mask;

  void* alternate_signal_stack;

  // ==========================================================
  // 5. 安全防护机制 (Shadow Call Stack - 针对 ARM64)
  // ==========================================================
  void* shadow_call_stack_guard_region;

  uintptr_t stack_top;

  _Atomic(bool) terminating;

  // ==========================================================
  // 6. 同步与内存管理
  // ==========================================================
  Lock startup_handshake_lock;

  void* mmap_base;
  size_t mmap_size;

  void* mmap_base_unguarded;
  size_t mmap_size_unguarded;
  char vma_name_buffer[32];

  // ==========================================================
  // 7. 线程局部存储 (TLS) 与错误处理
  // ==========================================================
  thread_local_dtor* thread_local_dtors;

  char* current_dlerror;
#define __BIONIC_DLERROR_BUFFER_SIZE 512
  char dlerror_buffer[__BIONIC_DLERROR_BUFFER_SIZE];

  bionic_tls* bionic_tls;

  int errno_value;
};

在这个pthread_internal_t当中,对于找线程函数来说比较重要的就是这几个成员,他们的名称以及结构体偏移大致如下:

  • start_routine:线程函数地址,偏移为0x60
  • start_routine_arg:线程函数的参数,偏移为0x68
  • return_value:线程函数的返回值,偏移为0x70

调用链分析

有了上面的了解之后就能够来分析pthread_create的调用链了。这个函数可以在源码当中的/bionic/libc/bionic/pthread_create.cpp当中找到:

pthread_create首先会调用__allocate_thread将新线程所需要的内存空间申请好,并且划分不同的功能区。然后返回tcb指针,后续会通过tcb来获取pthread_internal_t*指针。接着就是对thread进行初始化,包括线程回调函数,以及函数参数的初始化。

完成上述准备之后,就进入到了最关键的一步,调用clone:以当前进程为模板,克隆出来一个新的执行流。这里需要注意的这几个参数:参数一(__pthread_start),参数四(thread)。

跟进函数,继续分析:

可以看到函数声明:fn就是__pthread_startarg就是thread,接着往下分析:下面会判断fn是否为空,如果有值则调用__bionic_clone,来进行系统调用进行创建线程。

到这里会发现无法跟进__bionic_clone,这里需要配合ida来分析libc.so代码。首先在libc.soclone当中找到调用__bionic_clone的位置,标记一下参数传递所使用的寄存器。这里标记了几个比较重要的参数:

这里是对照着源码的参数进行标记的。完成后进入__bionic_clone进行分析,汇编代码分析如下

这里系统调用之后如果返回的是子线程的空间的话,sp已经变成了子线程的栈帧。然后回进入__start_thread这个函数当中。此时x0pthread_start的函数地址,x1是结构体地址。

接着继续看看__start_thread这个函数作用是什么。在这个函数当中,我们主要关注的是threadphread_start相关的寄存器即可。

最后会调用pthread_start这个函数。这个函数可以在源码当中看到是什么情况:

可以看到传入的arg参数就是thread,接着会在后面调用thread当中的回调函数,也就是pthread_create传入的回调函数地址。综上所述,pthread_create的调用链大致如下(在其他不同版本的设备中可能会不太一样):

pthread_create --> clone --> __bionic_clone  --> __start_thread ---> pthread_start

而且在这个分析过程中可以发现,有非常多的点可以通过pthread_internal_t结构体拿到对应的线程函数,例如clone__bionic_clone__start_thread等函数当中。有了上面的知识就可以来试试找出通过创建线程来检测frida的函数在哪里了。

获取线程函数及绕过

有了上面的知识就可以通过frida来写脚本,获取线程函数地址了:

var pthread_hooked = false;
function hook_pthread_create(soName){
    if(pthread_hooked) return;
    pthread_hooked = true;
    var pthread_create = Module.findExportByName("libc.so","pthread_create");
    console.log("pthread_create addr : ",pthread_create);
    var observer = Interceptor.attach(pthread_create,{
        onEnter: function(args){
            this.thread_func_addr = args[2];
            if(this.thread_func_addr !== null && this.thread_func_addr !== undefined){
                var module = Process.findModuleByAddress(this.thread_func_addr);
                if(module.name.indexOf(soName) !== -1){
                    console.log(`pthread_create thread func : ${module.name} +\x1b[38;5;208m0x${(this.thread_func_addr - module.base).toString(16)}\x1b[0m`);
                }
            }
        }, onLeave:function(retval){}
    });
}
hook_pthread_create("libmsaoaidsec.so");

Spawn方式注入,输出如下,可以发现能够找到对应so当中相关的线程函数了。

接着就来试一下将这三个函数给替换掉

var pthread_hooked = false;
function hook_pthread_create(soName){
    if(pthread_hooked) return;
    pthread_hooked = true;
    var pthread_create = Module.findExportByName("libc.so","pthread_create");
    console.log("[*] pthread_create addr : ",pthread_create);
    Interceptor.attach(pthread_create,{
        onEnter: function(args){
                this.thread_func_addr = args[2];
                if(this.thread_func_addr !== null && this.thread_func_addr !== undefined){
                    var module = Process.findModuleByAddress(this.thread_func_addr);
                    if(module.name.indexOf(soName) !== -1){
                        let offset = this.thread_func_addr - module.base;
                       if (offset == 0x26E5C) {
                            args[2] = fake_thread_26E5C;
                            console.log("[replace] sub_26E5C -> fake_thread_26E5C");
                        } else if (offset == 0x1B8D4) {
                            args[2] = fake_thread_1B8D4;
                            console.log("[replace] sub_1B8D4 -> fake_thread_1B8D4");
                        } else if (offset == 0x1C544) {
                            args[2] = fake_thread_1C544;
                            console.log("[replace] sub_1C544 -> fake_thread_1C544");
                        }
                    }
                }
        }, onLeave:function(retval){}
    });
}

var fake_thread_26E5C = new NativeCallback(function (arg) {
    console.log("[fake_thread_26E5C] called, arg =", arg);
    return ptr(0);
}, 'pointer', ['pointer']);

var fake_thread_1B8D4 = new NativeCallback(function (arg) {
    console.log("[fake_thread_1B8D4] called, arg =", arg);
    return ptr(0);
}, 'pointer', ['pointer']);

var fake_thread_1C544 = new NativeCallback(function (arg) {
    console.log("[fake_thread_1C544] called, arg =", arg);
    return ptr(0);
}, 'pointer', ['pointer']);

只不过替换线程函数之后就进程就直接崩溃了,命令行当中也打印了崩溃转储信息(这个可能需要多重复操作几次才会打印):

通过转储信息可以看到一个是fault addr = 0x000b0202000015b2,另一个是backtrace,当中的调用堆栈。现在就需要去IDA当中通过调用堆栈回溯一下发生这种问题的点可能在哪里。

解决崩溃问题

在IDA当中直接跳转到0x20d10的位置:

可以看到这里是将W8,这个寄存器当中的值存放到x20+0x188的位置,通过转储信息可以看到此时的 x20 = 000b02020000142a而加上0x188之后就是这个fault addr了,那么此时需要找到这个x20,到底是从哪里来的。

这里将x20高亮一下,然后再函数开头发现是从这个 [X8,#off_4B728@PAGEOFF] 中取出来的,接着找这个标签:

最后发现是调用了sub_2082C这个函数的返回值。现在得分析一下这个sub_2082C这个函数到底是做什么的:

进来看到好几个带有linker64的字符串,接着就是 .symtab.strtabsolist

根据这些特征猜测这个函数是解析elf,根据solist这个符号获取solist的结构,用ai分析了一下确实是:通过解析 linker64 的 ELF 符号表,定位 Android linker 内部的 solist 全局变量,从而拿到当前进程加载模块链表的头指针。

那么接下来hook一下这个函数,看看返回值是什么:

function hook_sub_2082C(target, offset){
    if(!target) return;
    console.log(`[hooked] sub_2082C is hooked`);
    Interceptor.attach(target, {
        onEnter: function(args){
            console.log(`\x1b[38;5;208m[sub_2082C onEnter]\x1b[0m`);
        }, onLeave:function(retval){
            console.log(`\x1b[93m[sub_${offset.toString(16)} onLeave]\x1b[0m retval --> ${retval.toString(16)}`);
        }
    })
}

通过对代码流程分析可以得出:如果这个函数返回值为0的话,就不会执行到0x20D10的位置,这里将返回值改为 0,验证 solist/soinfo 路径是否参与检测,顺便hook一下调用sub_2082C的函数,有没有正常退出(onLeave)。

这个sub_20bdc就是上层的函数,可以看到能够正常退出了,而且很惊喜的发现这个dlopen也成功的退出了,则就说明剩下的init_array没有检测函数了。可惜这里的进程还是退出了,这里hook了一下JNI_OnLoad,发现JNI_Onload能够正常退出,但是进程仍然退出,并且也没有新的so加载,那么可能就是在java层调用了检测函数了:

到这里就不再细究java层了。经过资料查找发现,有不少app都有使用了这个so来进行检测,这里找到最新版的某艺进行测试,依旧是使用一模一样的frida脚本,然后进行hook,发现是可以完成Frida的注入的:

可以发现是能够注入了。

五、思路总结

在以前旧版本的检测当中,只需要将启动线程的函数replace即可,但是在新版当中由于线程相关逻辑缺失,导致某个后续流程使用了未初始化/无效状态。在进行了nop这个线程函数,或者是替换线程函数的情况下,需要再另外对目标函数进行处理。满足以上两点才能够进行frida的检测绕过

六、混淆手段

这个so当中主要的混淆手段就是ollvm,还有就是字符串的加密,以及ShellCode的加密及使用。ollvm就不需要说了,这里就来讲讲后面两种:

字符串加密

这里找到一段反编译后的代码看看:

大致的特征就是这种:先计算长度,然后异或解密这种。这种特征貌似不只是在这个so当中出现过,之前分析壳的时候好像也见过类似的。解决办法: 目前针对这种解密字符串没想到有什么比较好的方法,只能一个个写脚本解密。有比较好的想法的师傅可以指点一下

ShellCode使用

可能会有的师傅会想通过hook exit这类函数来定位一下进程是在哪里退出的。恐怕这个想法实现不了(可以试一下,主包这里没试过)。这里拿一个检测函数当中调用的退出函数看看:

这里会先对数据进行异或解密,解密手法就是之前字符串的解密方法,然后通过mmap将解密出来的机器码拷贝到这个 RWX 的匿名内存当中,然后通过函数指针调用这个ShellCode,而这段ShellCode解密脚本以及反汇编之后的效果如下:

enc = [ 0x91, 0xA7, 0x29, 0x4B, 0xA6, 0xA9, 0x99, 0x73, 0x69, 0x9A,
  0xF8, 0x7F, 0x86, 0x87, 0xAA, 0x4C, 0xB8, 0x89, 0x9A, 0x72,
  0xB6, 0xB9, 0xA4, 0x7C, 0x86, 0x87, 0xAA, 0x4C]
code = []
key = [0x99,0xa7,0xa9]

for i in range(len(enc)):
    code.append(enc[i] ^ key[i % 3])

code_1 = code[0] | (code[1] << 8) | (code[2] << 16) | (code[3] << 24)
code_1 += 3008
code[0] = code_1 & 0xFF
code[1] = (code_1 >> 8) & 0xFF
code[2] = (code_1 >> 16) & 0xFF
code[3] = (code_1 >> 24) & 0xFF

from capstone import*
md = Cs(CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN)
for insn in md.disasm(bytes(code),0x1000):
    print(f"0x{insn.address:x}:\t{insn.mnemonic}\t{insn.op_str}")

可以看到这个ShellCode直接通过系统调用,结束整个进程。所以hook exit这类函数的方法可能行不通

七、检测思路

那么既然已经绕过了Frida的检测,现在可以来好好看看这个so当中使用到的检测手法了。这里拿了几个出来分析,如果有兴趣的话可以自己分析一下其他的。

线程函数

那么首当其冲的肯定就是通过创建线程来进行检测的函数了。分别有以下三个:

0x1C544
0x1B8D4
0x26E5C

这里我对0x1b8d4做了一个比较详细的分析,0x26e5c0x1c544大致粗略看了一下。

sub_1b8d4

这个函数进入之后看起来还是很清爽的,代码如下(这里是已经分析过,然后修复了不少函数名,变量名的结果):

大致就是通过get_tracePID来获取TracerPid,来检测是否处于调试状态。另外一个就是通过check_stat()来判断线程状态是否是T (stopping),挂起状态。如果是的话就会退出循环,然后进程退出。来看看这个tracePid的获取方法:

就是通过打开proc/self/status这个文件,然后取出TracePid,这个字段的值。这里的字符串通过反编译能看到都不是硬编码,基本上都是加密的。这个问题后面再说。check_stat这个函数原理大致也差不多,这里就不展开了。所以这个线程函数其实是通过TracePid和stat,来反调试。

sub_26E5C

接下来就是26E5C这个函数了。

进来首先是一个小型的ollvm,重点只有这个部分,如果sub_1678c返回值最低位为0的话,就会走到这个exit_0当中,接着看看这个sub_1678c

发现是打开了/proc/self/maps这个文件,然后就是获取maps当中的start_addr等信息进行检测,这里不再赘述。分析方法就是先按照上面混淆手段提及到的字符串解密,将字符串解密之后就会比较容易分析了。

sub_1C544

进入后发现很多字符串解密的位置,解密了头几个后发现这些特征:gum-js-loopgmainlinjector....这种很明显就是frida特征了,猜测这个线程函数主要就是进行的frida检测。

其他检测函数

sub_23AD4

这个函数是在init_proc当中的,功能就是用来检测模拟器:

通过maps,查看是否有x86这种路径的模块,这种模块路径一般是在模拟器当中

检测总结

这个so当中检测的手法远不止于此,还有类似adb检测,crc完成性检测等等,但是对这些函数进行hook之后发现并没有调用,也就没有写出来了。感兴趣的可以去看看

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐