ios 逆向攻防

·  阅读 4756

前言

学习逆向的真正目的是为了防护,之所以要学习破解,是为了了解黑客会使用什么手段破解,而我们又该怎么针对性的防护。没有一款应用是绝对安全的,防护的意义在于提高破解的难度,使得破解的成本远大于所得利益。攻防更像是一场游戏,如果你隐藏的够深,让别人猜不透,那你就赢了。根据上一篇文章Hook原理和动态调试,我们从两个纬度探索攻防,动态调试静态分析

动态调试

我们破解一款APP时,往往会先动态调试它。比如破解微信抢红包,首先会用Reveal或者Cycript分析UI视图,然后lldb动态调试页面,定位到抢红包按钮所在视图控制器以及函数调用栈。在越狱手机上我们很难限制别人使用Reveal或者Cycript分析我们的应用,但是我们可以限制lldb动态调试,少一种分析渠道就增加一分破解的难度。

Ptrace防护

lldb可以附加进程进行动态调试的关键就是ptrace函数,它可以提供一个进程监察控制另一个进程(本质是通过debugServer附加APP进程),并且可以读取和改变被控制进程的内存和寄存器里的值。我们可以在APP中设置此函数禁止我们的APP被调试。ios中使用ptrace()函数需要导入头文件,该头文件在Mac工程中是存在的,可以Xcode新建Mac工程->打开<sys/ptrace.h>头文件->复制头文件内容到ios工程。

//参数1:PT_DENY_ATTACH 表示当前进程不允许被附加
//参数2:进程id,0就是本进程
//参数3:地址,根据参数1而定,这里传0
//数据4:数据,根据参数1而定,这里传0
ptrace(PT_DENY_ATTACH, 0, 0, 0);
复制代码

APP中调用如上ptrace()函数,比如在Main函数中调用,那么lldb断点调试APP时就会奔溃,而正常点开APP却可以运行。所以一般看到lldb附加调试奔溃而手动打开APP却正常的现象大概率是添加了ptrace防护。

破解思路

  • 添加了Ptrace防护,那么lldb调试就会奔溃,正常开发的时候这很不友好,所以需要根据环境变量,在Debug环境下关闭PtraceRelease环境下开启Ptrace防护。
  • 由于使用Ptrace防护的现象特别明显,这就相当于暴露给别人知道你使用了Ptrace,那么黑客就很容易破解,比如使用fishhook拦截Ptrace函数,或者更暴力一点直接修改二进制文件把ptrace干掉,那么Ptrace防护就毫无意义。

Ptrace破解

  • fishhook破解 文章Hook原理和动态调试详细讲解了如何使用fishhook拦截系统函数,这里就不再详细讲解,关键代码如下。

自定义.png MonkeyAPP就默认使用了fishhook拦截了Ptrace函数,可以看到不仅拦截了Ptrace函数还拦截了dlsym、syscall、sysctl

image.png

  • 修改二进制破解 使用IDA(也可以使用Hopper)工具打开添加了ptrace防护的demo,全量搜索ptrace标签

image.png

定位到ptrace函数如下

image.png

光标定位BL _ptrace这行,IDA->Edit->patch program->change type修改前四位指令为NOP(NOP表示空代码,会跳到下一行执行。怎么知道NOP=1F 20 03 D5?IDA全量搜索一下工程中的NOP标签,然后显示二进制数据就知道了) image.png

修改好后,IDA->Edit->patch program->Apply patchs to input file保存修改后的可执行文件。利用脚本或Monkey重签名可执行文件,这时候再lldb附加进程就不会奔溃了,这种直接暴力修改二进制文件的方式几乎无解,因为直接修改了程序,我们能做的就是竟可能的隐藏我们的防护

防护思路

  • 如果黑客通过fishhook破解ptrace,那么肯定是通过插入动态库进行Hook。非越狱环境下,通过修改可执行文件的Load Commands,让dyld加载需要插入的动态库,越狱环境下是通过修改DYLD_INSERT_LIBRARYS环境变量,让dyld自动加载插入的动态库。如果我们能在这个插入的动态库之前就进行ptrace防护,那么就可以避开fishhook破解。我们知道自己工程中的Framework执行顺序优先于别人注入的Framework,我们可以新建一个Framework,然后在里面添加ptrace防护。
  • 如果黑客通过修改二进制文件破解ptrace,那么我们能否隐藏这个ptrace符号标签呢?
  • 由于ptrace防护直接奔溃的现象太过明显,哪怕我们在自己的Framework里提前执行ptrace防护,但是如果黑客定位到这个Framework并修改这个ptrace标签呢?所以防护Framework在命名时最好别让人猜出来这个Framework就是用来防护的,然后把ptrace换成另外一个函数,比如sysctl(),我们可以在这个函数里自定义行为,比如不让程序直接奔溃,而是检测到lldb动态调试后,把用户的IP、设备信息等等数据上报服务器,让服务器封IP或者让APP直接断网,总之尽可能的避免提供一个很强烈的你干了啥的信号

sysctl防护

由于ptrace防护特征太明显,我们可以使用sysctl()函数代替,这个函数可以查询进程是否被附加,但是不会直接奔溃。根据上面的分析,我们新建一个Guess.framework,添加上sysctl函数如下:

+(void)load{
    if(isAttached()){
        NSLog(@"检测到附加,收集手机信息等数据给后台");
    }
}
//检测是否被附加 Yes表示被附加了
BOOL isAttached(void){
    int name[4];             //里面放字节码。查询的信息
    name[0] = CTL_KERN;      //内核查询
    name[1] = KERN_PROC;     //查询进程
    name[2] = KERN_PROC_PID; //传递的参数是进程的ID
    name[3] = getpid();      //获取当前进程ID
    struct kinfo_proc info;  //接受查询结果的结构体
    size_t info_size = sizeof(info);  //结构体大小
    if(sysctl(name,4, &info, &info_size, 0, 0)){
        NSLog(@"查询失败");
        return NO;
    }
    /*
     查询结果看info.kp_proc.p_flag 的第12位,如果为1,表示调试附加状态。
     info.kp_proc.p_flag & P_TRACED 即可获取第12位
    */
    return ((info.kp_proc.p_flag & P_TRACED) != 0);
}
复制代码

破解思路

  • 虽然在Framework里添加sysctl()可以提前执行进行防护,但是sysctlptrace一样,依然是一个外部符号,可以很暴力的像上面那样修改二进制,把sysctl直接修改成NOP,具体步骤就不贴图了。也有人使用syscall()函数屏蔽sysctl符号,这里不推荐,第一syscall()这个函数在ios10以后已经弃用了,第二syscall本身也是一个符号。

dlopen动态调用防护

由于sysctl符号可以通过IDA等工具静态分析并修改,所以我们需要使用动态调用的方式隐藏sysctl符号。这里使用dlopen+dlopen动态调用,注意需要导入#import <dlfcn.h>头文件,代码如下

//是否被附加
BOOL isAttached(void){
    int name[4];             //里面放字节码。查询的信息
    name[0] = CTL_KERN;      //内核查询
    name[1] = KERN_PROC;     //查询进程
    name[2] = KERN_PROC_PID; //传递的参数是进程的ID
    name[3] = getpid();      //获取当前进程ID
    struct kinfo_proc info;  //接受查询结果的结构体
    size_t info_size = sizeof(info);  //结构体大小

    //A异或B等到C,C再异或A得到B,隐藏sysctl
     unsigned char str[] = {
             ('q' ^ 's'),
             ('q' ^ 'y'),
             ('q' ^ 's'),
             ('q' ^ 'c'),
             ('q' ^ 't'),
             ('q' ^ 'l'),
             ('q' ^ '\0')
      };
      unsigned char * p = str;

      while (((*p) ^= 'q') != '\0') p++;
      int (*m_sysctl)(int *, u_int, void *, size_t *, void *, size_t);
     void* handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW);//获得具柄
       m_sysctl=dlsym(handle,(const char *)str);//动态查找sysctl符号
      if(m_sysctl(name,4, &info, &info_size, 0, 0)){
            NSLog(@"查询失败");
            return NO;
      }
      /*
     查询结果看info.kp_proc.p_flag 的第12位,如果为1,表示调试附加状态。
     info.kp_proc.p_flag & P_TRACED 即可获取第12位
    */
    return ((info.kp_proc.p_flag & P_TRACED) != 0);
复制代码

破解思路

  • sysctl函数名通过异或运算符隐藏并动态调用异或运算符隐藏字符串的方式很常用),虽然这种方式利用IDA等工具已经找不到sysctl外部符号了,但是我们依然可以通过下sysctl符号断点的方式来分析函数调用栈,如下所示

image.png 符号断点断住了sysctlsbt查看函数调用栈,定位到Guess.framework中的"isAttached"函数调用了,并且拿到该函数内存地址为0x100bdfe10。lldb image list查询可执行文件的镜像列表如下

image.png Guess.framework的MachO首地址为0x100bd8000,那么"isAttached"函数在Guess.framework中的偏移地址为0x100bdfe10-0x100bd8000=0x7e10IDA打开Guess.framework中的可执行文件,修改“isAttached”“NOP”,保存后就可以破解sysctl防护。

image.png

汇编防护

其实上面那种隐藏sysctl符号然后dlopen动态调用的防护已经很好了,首先去掉了sysctl符号,再者是在自定义的framework中调用的,黑客要想破解的话首先得先定位到是哪个动态库添加了防护。唯一不足的是还是可以通过sysctl函数调用栈定位到调用逻辑进而直接修改二进制文件。所以这个隐藏还不是很彻底,再优化如下

//是否被附加
BOOL isAttached(void){
    int name[4];             //里面放字节码。查询的信息
    name[0] = CTL_KERN;      //内核查询
    name[1] = KERN_PROC;     //查询进程
    name[2] = KERN_PROC_PID; //传递的参数是进程的ID
    name[3] = getpid();      //获取当前进程ID
    struct kinfo_proc info;  //接受查询结果的结构体
    size_t info_size = sizeof(info);  //结构体大小
    
  #ifdef __arm64__
    asm volatile(
                 "mov x0,%[name_p]\n"
                 "mov x1,#4\n"
                 "mov x2,%[info_p]\n"
                 "mov x3,%[infozize_p]\n"
                 "mov x4,#0\n"
                 "mov x5,#0\n"
                 "mov x16,#202\n" //202代表 sysctl
                 "svc #0x80"   //触发软中断
                 :
                 : [name_p] "r"(name),[info_p] "r"(&info),[infozize_p]  "r"(&info_size)
                 );
  #else //32位下
    asm volatile(
                 "mov r0,%[name_p]\n"
                 "mov r1,#4\n"
                 "mov r2,%[info_p]\n"
                 "mov r3,%[infozize_p]\n"
                 "mov r4,#0\n"
                 "mov r5,#0\n"
                 "mov r16,#202\n" //202代表 sysctl
                 "svc #0x80"   //触发软中断
                 :
                 : [name_p] "r"(name),[info_p] "r"(&info),[infozize_p]  "r"(&info_size)
                 );
   #endif
      /*
     查询结果看info.kp_proc.p_flag 的第12位,如果为1,表示调试附加状态。
     info.kp_proc.p_flag & P_TRACED 即可获取第12位
    */
    return ((info.kp_proc.p_flag & P_TRACED) != 0);
复制代码

这里用汇编触发sysctl函数,汇编分32位64位两种指令集。"svc #0x80"汇编指令会触发x16寄存器中的函数即sysctlx0~x5寄存器中的值是sysctl函数所需的参数,参数传递涉及汇编知识,这里不做详解,x16寄存器中函数的数值可以在#import <sys/syscall.h>头文件中查看,如触发sysctl202、触发ptrace就是26,触发exit就是1。这里提醒一下,虽然我们不建议在检测到入侵时直接让程序奔溃,比如调用exit(0),但是如果一定要这么做得话建议使用汇编指令svc的方式触发。

#define SYS_syscall        0
#define SYS_exit           1
//....
#define SYS_setuid         23
#define SYS_getuid         24
#define SYS_geteuid        25
#define SYS_ptrace         26
#define SYS_recvmsg        27
#define SYS_sendmsg        28
#define SYS_recvfrom       29
//...
#define SYS_sysctl         202
复制代码

破解思路

  • 通过汇编指令svc触发软中断确实隐藏了符号,也不能下符号断点,但是如果直接修改二进制文件,把svc指令像上面那样直接置为NOP呢?那么一切就又回到了原点

image.png

汇编防护进阶

如果可以写一个小功能检测SVC指令是否可以正常使用,那么就可以避免这种SVC被patch修改的风险,比如写一个SVC指令获取进程ID,如果可以获取到说明SVC指令正常,如果获取不到说明SVC被修改成NOP了,所以可以优化如下。

BOOL isAttached(void){
    int pid = 0;
    //svc获取pid,检测svc是否可用
    asm volatile(
                 "mov x0,#0\n"
                 "mov x16,#20\n" //20为获取pid
                 "svc #0x80\n"   
                 "cmp x0,#0\n" 
                 "b.ne #24\n" //不等于0,那么久执行 mov result代码

                 "mov x1,#0\n"  //这段汇编很细节,清除堆栈
                 "mov sp,x1\n"
                 "mov x29,x1\n"
                 "mov x30,x1\n"
                 "ret\n"

                 "mov %[result],x0\n"
                 : [result] "=r"(pid)
                 :
                 :
                 );
    int name[4];             //里面放字节码。查询的信息
    name[0] = CTL_KERN;      //内核查询
    name[1] = KERN_PROC;     //查询进程
    name[2] = KERN_PROC_PID; //传递的参数是进程的ID
    name[3] = getpid();      //获取当前进程ID
    struct kinfo_proc info;  //接受查询结果的结构体
    size_t info_size = sizeof(info);  //结构体大小
    
  #ifdef __arm64__
    asm volatile(
                 "mov x0,%[name_p]\n"
                 "mov x1,#4\n"
                 "mov x2,%[info_p]\n"
                 "mov x3,%[infozize_p]\n"
                 "mov x4,#0\n"
                 "mov x5,#0\n"
                 "mov x16,#202\n" //202代表 sysctl
                 "svc #0x80"   //触发软中断
                 :
                 : [name_p] "r"(name),[info_p] "r"(&info),[infozize_p]  "r"(&info_size)
                 );
  #else //32位下
    asm volatile(
                 "mov r0,%[name_p]\n"
                 "mov r1,#4\n"
                 "mov r2,%[info_p]\n"
                 "mov r3,%[infozize_p]\n"
                 "mov r4,#0\n"
                 "mov r5,#0\n"
                 "mov r16,#202\n" //202代表 sysctl
                 "svc #0x80"   //触发软中断
                 :
                 : [name_p] "r"(name),[info_p] "r"(&info),[infozize_p]  "r"(&info_size)
                 );
   #endif
      /*
     查询结果看info.kp_proc.p_flag 的第12位,如果为1,表示调试附加状态。
     info.kp_proc.p_flag & P_TRACED 即可获取第12位
    */
    return ((info.kp_proc.p_flag & P_TRACED) != 0);
复制代码

SVC汇编指令获取进程ID的值,X0寄存器存储着进程ID返回值,用进程ID的值和0比较,如果不等于0就跳过24个字节,执行"mov %[result],x0\n",为什么是24个字节?一行汇编指令占4个字节,从 "b.ne #24\n""mov %[result],x0\n"6行指令。如果等于0,说明SVC指令被修改了,那么就用汇编清除堆栈信息,清除堆栈信息后sbt就看不到任何调用堆栈,破解的话会很闷逼,如下:

image.png

静态防护

除了动态分析应用,我们往往还可以通过IDA、Hopper、class-dump、MachOView等等工具静态分析程序的逻辑,定位到关键函数或者关键的常量进行破解,所以我们需要保护应用中的关键函数或者关键静态常量,尽可能的增加黑客破解的难度。

代码混淆

在静态分析应用的时候,常常会使用class-dump导出应用的头文件,通过头文件中的函数名变量名猜测这些函数的功能,然后进行Hook动态分析。如果我们让这些方法名、变量名、类名从名称上看没有任何意义,那么就能从一定程度干扰黑客猜测,这种方法称为代码混淆。写个登录按钮,点击登录AES加密字符串然后发送给后端服务器,demo如下:

image.png EncryptionToolsAES加密的类,loginaction为点击事件,使用class-dump导出应用的头文件看下ViewController.hEncryptionTools.h

image.png 通过头文件很容易猜测"loginaction"这个名字应该是登录事件,"decryptString"应该是加密函数,"EncryptionTools"应该是加密的类,猜测到这些关键函数或类,就可以Hook动态验证破解了。写个pch头文件,通过预编译指令给类名、方法名添加混淆如下:

#define loginaction qsign_asdfas2134sfs
#define EncryptionTools qsign_tsdfsfsacs
#define encryptString qsign_ds23sdfasasf
复制代码

添加完预编译混淆之后再dump头文件如下:

image.png

混淆后的效果还是很明显的,已经不能从命名的定义上猜测出功能了,静态分析这些像乱码一样的函数会很懵逼。注意:最好只混淆应用中的关键函数、关键类、关键变量,不要利用脚本全量混淆整个应用,否则上架APPStroe很可能被打回。

字符串常量隐藏

通过分析应用的MachO可执行文件,我们可以在常量区找到应用中定义的常量,比如上面的demo,AES加密的key为"abc",我们用MachOView分析这个demo如下:

image.png

key ”abc“在字符串常量区很容易的被定位到了,一旦加密的key泄露了应用被入侵的风险就变高了。所以我们需要隐藏这个字符串常量key,可以利用C函数脱符号的特征进行隐藏,如下通过调用C函数的方式返回Key。

static NSString * MyAESKEY(){ 
unsigned char aeskey[] = { 'a','b','c','\0', 
}; 
return [NSString stringWithUTF8String:(const char *)aeskey];
}
复制代码

这样在常量区就没有”abc“这个常量了,同时我们也可以对MyAESKEY()这个函数名做混淆,让它看起来没有意义。但是如果通过动态分析结合静态分析的方式定位到MyAESKEY()函数的话,在这个函数体里面还是会有”abc“字符的,所以我们需要优化如下:

static NSString * MyAESKEY(){ 
unsigned char aeskey[] = { 
('a'^'a'),
('a'^'b'),
('a'^'c'),
('a'^'\0'),
}; 
unsigned char* m=aeskey;
while(((*m)^='a')!='\0'){
    m++
}
return [NSString stringWithUTF8String:(const char *)aeskey]; 
}
复制代码

”abc“异或一个固定的字符串”a“之后,再依次取出来异或”a“还原字符串“abc”(A异或B等于C,C再异或B等于A)。编译的时候会直接把异或结果编译出来,编译后的函数体是没有“abc”字符的。

白名单检测

除了保护应用中的关键代码,还可以通过代码检测应用中的动态库是否是合法的。 无论是越狱环境还是非越狱环境,如果要入侵除了修改二进制就是注入动态库,我们可以写一个小功能检测一下APP中除了我们项目自己的动态库,是否还有入侵的动态库。通过dyld API函数获取应用中的动态库名称并printf打印出来,把这些字符串名称合起来作为一个白名单,如果有动态库名称不在该白名单中,那么该库是非法入侵的动态库。

const char * whitstr=""//省略,通过循环打印获取
void checkWhiteStr(){
    uint32_t count= _dyld_image_count();
    for(int i=1;i<count;i++){
      const char* dyname=_dyld_get_image_name(i);
        //printf(dyname);
        if(!strstr(whitstr, dyname)){//不在白名单中
            //检测到之后的处理
            NSLog([NSString stringWithFormat:@"检测到非白名单:%s",dyname]);
        }
    }

}
复制代码

printf(dyname)循环打印Image name,然后把控制台中的输出复制粘贴到whitstr处,注意循环打印的时候是从0开始,但是strstr()函数判断是否是白名单库需要从1开始,因为第一个镜像文件是跟沙盒路径相关的主工程镜像需要剔除。提醒:此处的白名单列表最好也是从服务器获取,这样APP更新或者有Bug的话,这个白名单列表也能灵活维护。

逻辑混淆

觉得还是有必要把逻辑混淆写进来,一个是因为最近公司APP提交给第三方检测,检测机构反馈应用被破解的风险比较高,第二个是因为市面上的第三方加固机构比如顶象、网易盾等等都是收费的,并且费用不低。所以就有足够动力搞一套免费的逻辑混淆工具,虽然免费的效果没有收费的那么好,但有总比没有强又不要钱。所谓逻辑混淆我理解就是在不改变原有程序逻辑的情况下,注入一些干扰代码,提高反汇编破解难度。废话不多说,介绍一个开源工具OLLVM,我们知道Xcode编译工程使用的是LLVM,OLLVM是一个基于LLVM源码自定义的编译工具,编译工程时使用这个自定义的OLLVM工具可以编写Pass来混淆IR

使用

注意:

  • 官方OLLVM目前只更新到4.0就停止更新了,使用某大哥维护的git库github.com/obfuscator-… 切换到9.0.1的版本。为啥是这个版本?低版本不能用,高版本用不了。
  • 下载cmake工具,可以使用 brew安装,也可以直接安装cmake客户端(安装客户端的话记得配环境变量
  • Xcode10以后的版本,不要使用添加插件的方式,亲测不支持,需要使用添加ToolChain的方式,步骤如下:
# clone 项目 
git clone https://hub.fastgit.org/heroims/obfuscator.git 
# 切换到 9.0.1 
cd obfuscator
git checkout llvm-9.0.1 
# 创建build目录 
mkdir build 
cd build 
# 初始化MakeFile 
cmake -DCMAKE_BUILD_TYPE=Release -DLLVM_CREATE_XCODE_TOOLCHAIN=ON ../../obfuscator/ 
# 开始编译 
make -j7
# 等待一会 我等待了40多分钟,成功之后,在build目录中执行
sudo make install-xcode-toolchain
# 拷贝在usr目录下生产的toolchain到developer目录下
sudo mv /usr/local/Toolchains  /Library/Developer/
复制代码

大功告成,Xcode选中刚刚编译好的Toolchain就可以编译工程了,如果不想混淆就切换到系统默认的,比如Xcode 12.5

image.png

使用自己编译的OLLVM混淆工程,需要在Xcode中添加如下混淆指令:

image.png

OLLVM 9.0.1支持添加的混淆如下

  • fla:控制流扁平化。Pass先实现一个永真循环,然后再在这个循环中放入switch语句,将代码中除了开始块的所有BasicBlock,放入这个switch语句的不同case中,通过修改 switch 的条件,来实现 BasicBlock 之间的跳转。
  • sub:指令替换。所谓指令替换仅仅是对标准二进制运算(比如加、减、位运算)使用更复杂的指令序列进行功能等价替换,当存在多种等价指令序列时,随机选择一种。
  • bcf:虚假控制流程。这种方式通过在当前基本块之前添加一个基本块,来修改函数调用流程图。新添加的基本块包含一个不透明的谓语,然后再跳转到原来的基本块。启用该指令编译会比较慢,自主选择
  • sobf:字符串加密。

写一个非常简单的demo如下,看一下添加混淆和不添加混淆的区别

#define Projectname @"demotest"
@interface ViewController ()
@end
@implementation** ViewController
int test(){
    int a = 20;
    int b = 30;
    if(a > b){
       printf("%@:%d",Projectname,a);
       printf("%@:%d",Projectname,b);
       return a;
     }else{
       printf("%@:%d",Projectname,a);
       printf("%@:%d",Projectname,b);
       return b;
     }
    return 0;
}
- (void)viewDidLoad {
    [super viewDidLoad];
    int m= test();
    NSLog(@"%d",m);
}
复制代码

使用IDA反汇编没添加混淆的代码如下:

image.png test()函数的结果编译器直接优化了,IDA反汇编后代码非常透明。再看下使用混淆编译的代码,IDA反汇编后的结果

image.png

效果还是很明显的,字符串被加密了,逻辑流程也复杂了。

分类:
iOS
标签:
收藏成功!
已添加到「」, 点击更改