前言
学习逆向的真正目的是为了防护
,之所以要学习破解,是为了了解黑客会使用什么手段破解,而我们又该怎么针对性的防护。没有一款应用是绝对安全的,防护的意义在于提高破解的难度
,使得破解的成本远大于所得利益。攻防更像是一场游戏,如果你隐藏的够深,让别人猜不透,那你就赢了。根据上一篇文章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
,Releas
e环境下开启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的话,这个白名单列表也能灵活维护。