前言
学习逆向的真正目的是为了防护,之所以要学习破解,是为了了解黑客会使用什么手段破解,而我们又该怎么针对性的防护。没有一款应用是绝对安全的,防护的意义在于提高破解的难度,使得破解的成本远大于所得利益。攻防更像是一场游戏,如果你隐藏的够深,让别人猜不透,那你就赢了。根据上一篇文章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环境下关闭Ptrace,Release环境下开启Ptrace防护。 - 由于使用
Ptrace防护的现象特别明显,这就相当于暴露给别人知道你使用了Ptrace,那么黑客就很容易破解,比如使用fishhook拦截Ptrace函数,或者更暴力一点直接修改二进制文件把ptrace干掉,那么Ptrace防护就毫无意义。
Ptrace破解
fishhook破解 文章Hook原理和动态调试详细讲解了如何使用fishhook拦截系统函数,这里就不再详细讲解,关键代码如下。
MonkeyAPP就默认使用了fishhook拦截了Ptrace函数,可以看到不仅拦截了Ptrace函数还拦截了dlsym、syscall、sysctl。
- 修改二进制破解
使用
IDA(也可以使用Hopper)工具打开添加了ptrace防护的demo,全量搜索ptrace标签
定位到ptrace函数如下
光标定位BL _ptrace这行,IDA->Edit->patch program->change type修改前四位指令为NOP(NOP表示空代码,会跳到下一行执行。怎么知道NOP=1F 20 03 D5?IDA全量搜索一下工程中的NOP标签,然后显示二进制数据就知道了)
修改好后,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()可以提前执行进行防护,但是sysctl和ptrace一样,依然是一个外部符号,可以很暴力的像上面那样修改二进制,把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符号断点的方式来分析函数调用栈,如下所示
符号断点断住了
sysctl,sbt查看函数调用栈,定位到Guess.framework中的"isAttached"函数调用了,并且拿到该函数内存地址为0x100bdfe10。lldb image list查询可执行文件的镜像列表如下
Guess.framework的MachO首地址为0x100bd8000,那么"isAttached"函数在Guess.framework中的偏移地址为0x100bdfe10-0x100bd8000=0x7e10,IDA打开Guess.framework中的可执行文件,修改“isAttached”为“NOP”,保存后就可以破解sysctl防护。
汇编防护
其实上面那种隐藏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寄存器中的函数即sysctl,x0~x5寄存器中的值是sysctl函数所需的参数,参数传递涉及汇编知识,这里不做详解,x16寄存器中函数的数值可以在#import <sys/syscall.h>头文件中查看,如触发sysctl是202、触发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呢?那么一切就又回到了原点
汇编防护进阶
如果可以写一个小功能检测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就看不到任何调用堆栈,破解的话会很闷逼,如下:
静态防护
除了动态分析应用,我们往往还可以通过IDA、Hopper、class-dump、MachOView等等工具静态分析程序的逻辑,定位到关键函数或者关键的常量进行破解,所以我们需要保护应用中的关键函数或者关键静态常量,尽可能的增加黑客破解的难度。
代码混淆
在静态分析应用的时候,常常会使用class-dump导出应用的头文件,通过头文件中的函数名或变量名猜测这些函数的功能,然后进行Hook动态分析。如果我们让这些方法名、变量名、类名从名称上看没有任何意义,那么就能从一定程度干扰黑客猜测,这种方法称为代码混淆。写个登录按钮,点击登录AES加密字符串然后发送给后端服务器,demo如下:
EncryptionTools为AES加密的类,loginaction为点击事件,使用class-dump导出应用的头文件看下ViewController.h和EncryptionTools.h
通过头文件很容易猜测
"loginaction"这个名字应该是登录事件,"decryptString"应该是加密函数,"EncryptionTools"应该是加密的类,猜测到这些关键函数或类,就可以Hook动态验证破解了。写个pch头文件,通过预编译指令给类名、方法名添加混淆如下:
#define loginaction qsign_asdfas2134sfs
#define EncryptionTools qsign_tsdfsfsacs
#define encryptString qsign_ds23sdfasasf
添加完预编译混淆之后再dump头文件如下:
混淆后的效果还是很明显的,已经不能从命名的定义上猜测出功能了,静态分析这些像乱码一样的函数会很懵逼。注意:最好只混淆应用中的关键函数、关键类、关键变量,不要利用脚本全量混淆整个应用,否则上架APPStroe很可能被打回。
字符串常量隐藏
通过分析应用的MachO可执行文件,我们可以在常量区找到应用中定义的常量,比如上面的demo,AES加密的key为"abc",我们用MachOView分析这个demo如下:
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
使用自己编译的OLLVM混淆工程,需要在Xcode中添加如下混淆指令:
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反汇编没添加混淆的代码如下:
test()函数的结果编译器直接优化了,IDA反汇编后代码非常透明。再看下使用混淆编译的代码,IDA反汇编后的结果
效果还是很明显的,字符串被加密了,逻辑流程也复杂了。