从 Web 安全一样,所有的攻防离不开一句话“在合理范围内保证 App 安全,让攻击者增加破解成本,让一部分人三思而后行战术性放弃”。
在iOS系统中,ptrace
被用于防止应用程序被调试。ptrace
函数提供了一种机制,允许一个进程监听和控制另一个进程,并且可以检测被控制进程的内存和寄存器中的数据。在iOS开发中,ptrace
可以用于实现断点调试和系统调用跟踪,但它也常被用于反调试措施
通过传递 PT_DENY_ATTACH
标志,它允许应用程序设置一个标志,以防止其他调试器附加。如果其他调试器尝试附加,则进程将终止。
可以使用类似下面的代码来防止别人破解、逆向。
#import <dlfcn.h>
__BEGIN_DECLS
int ptrace(int _request, pid_t _pid, caddr_t _addr, int _data);
__END_DECLS
void disable_gdb(void) {
ptrace(PT_DENY_ATTACH, 0, 0, 0);
}
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
#if DEBUG
// 非 DEBUG 模式下禁止调试
disable_gdb();
#endif
// Setup code that might create autoreleased objects goes here.
appDelegateClassName = NSStringFromClass([AppDelegate class]);
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
但上述方式安全吗?一个简单的 fishhook 都可以破解掉。
第一步:创建一个 AppHook 的动态库,和一个 AppHookProtoctor 的 iOS App
第二步:AppHook 里面在 +load
方法里使用 fishhook 对 ptrace 进行 hook,判断 PT_DENY_ATTACH
则绕过
int hooked_ptrace(int _request, pid_t _pid, caddr_t _addr, int _data) {
if (_request == PT_DENY_ATTACH) {
return 0;
}
return ptrace_pointer(_request, _pid, _addr, _data);
}
第三步:在 App 的 main.m 中调用 disable_gdb 来禁止调试。
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/FishhookCrackedPtrace.png" style="zoom:30%" />
结果:可以看到对 ptrace 使用 fishhook hook 之后,ptrace 并没有让 App 进程结束掉。也就是 ptrace 失效了,并不安全。
我们知道 fishhook 的原理是根据符号表进行 rebind 的,那是不是可以通过该原理绕开?
ptrace
是系统函数,dyld 会在启动阶段进行 rebase、rebind,遍历 Mach-O 文件的 __DATA
段中的 __nl_symbol_ptr
和 __la_symbol_ptr
两个 section。通过 Indirect Symbol Table、Symbol Table 和 String Table 的配合,Fishhook 能够找到需要替换的函数,并修改其地址。想了解 fishhook 详细工作原理可以查看这篇文章:fishhook 原理
我们禁止 fishhook,对 Xcode 添加一个符号断点 ptrace
,如下所示。
我们可以看到 ptrace 位于 libsystem_kernel.dylib
动态库中。lldb 模式下通过 image list
查看所有的 image 信息。
可以看到当前电脑模拟器运行情况下,libsystem_kernel.dylib 位于 /usr/lib/system/libsystem_kernel.dylib
路径。这个路径是我电脑调试环境下的路径。真机路径不一样。
通过 dlopen、dlsym 的方式来找到 ptrace 符号地址,再去执行,这种方式的本质是没有走符号表的流程。
Demo 如下:
#ifndef DEBUG
// 非 DEBUG 模式下禁止调试
char *ptraceLibPath = "/usr/lib/system/libsystem_kernel.dylib";
void *handler = dlopen(ptraceLibPath, RTLD_LAZY);
int (*ptrace_pointer)(int _request, pid_t _pid, caddr_t _addr, int _data);
ptrace_pointer = dlsym(handler, "ptrace");
if (ptrace_pointer) {
ptrace_pointer(PT_DENY_ATTACH, 0, 0, 0);
}
#endif
工程运行后会发现,App 启动后立马 crash 结束进程。说明这种(通过 dlsym 找到符号地址) ptrace 的防护是有效的
对代码进行修改,整洁一些,如下所示:
typedef int (*ptrace_ptr_t)(int _request, pid_t _pid, caddr_t _addr, int _data);
void disable_gdb(void) {
// 简易版:容易被 FishHook 进行符号表的修改,从而破解 ptrace 的拦截
// ptrace(PT_DENY_ATTACH, 0, 0, 0);
// 安全版本
void *handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW);
ptrace_ptr_t ptrace_ptr = dlsym(handle, "ptrace");
ptrace_ptr(PT_DENY_ATTACH, 0, 0, 0);
dlclose(handle);
}
该方式通过 dlopen、dlsym 的方式延迟、动态的找到 ptrace 的符号地址,没有走符号表的逻辑,避开了 fishhook 的工作流程,从而更安全一些。
sysctl
函数是一个系统调用,用于获取或设置系统相关的信息。这个函数提供了一种机制来查询或修改系统的状态信息,比如系统配置参数、统计数据等。
在 Linux 和类 Unix 系统中用于查看和修改内核参数。然而,在 iOS 逆向工程中,sysctl
也常被用于检测应用程序是否正在被调试
int sysctl(int *, u_int, void *, size_t *, void *, size_t);
参数解释:
name
: 一个指向整数数组的指针,数组中的每个元素代表一个级别的OID(对象标识符),用于指定要查询或设置的系统信息。namelen
: name
数组的长度,即OID的级别数。oldp
: 一个指向缓冲区的指针,用于接收查询到的现有值。如果设置值,这个参数可以是NULL。oldlenp
: 一个指向 size_t
的指针,用于指定 oldp
缓冲区的大小,并在调用后返回实际读取的数据大小。newp
: 一个指向新值的指针,用于设置系统信息。如果只是查询,这个参数可以是NULL。newlen
: 新值的大小返回值:
其中传递的结构体引用,info.kp_proc.p_flag
字段,用于判断进程是否处于调试状态。是二进制的0、1。第12位,为1代表处于调试状态。反之不是。
思考:如何正确二进制判断某一位是0还是1?用特定位置填充1,其他位填充0来处理。按位与之后,特定位置为1,说明之前是1,否则就是0.
#define P_TRACED 0x00000800 /* Debugged process being traced */
info.kp_proc.p_flag
判断系统提供了一个 P_TRACED
,按位与用来判断是否是调试模式。
使用
bool isInDebugMode(void) {
int name[4];
name[0] = CTL_KERN; // 内核
name[1] = KERN_PROC; // 查询进程
name[2] = KERN_PROC_PID; // 通过进程 id 来查找
name[3] = getpid();
struct kinfo_proc info; // 接收查询信息,利用结构体传递引用
size_t infoSize = sizeof(info);
int resultCode = sysctl(name, sizeof(name)/sizeof(*name), &info, &infoSize, 0, 0);
assert(resultCode == 0);
return info.kp_proc.p_flag & P_TRACED;
}
结合定时器,每间隔1秒进行检查一下,运行起来发现处于 debug 模式,则调用 exit(0)
结束进程。
为什么调用 exit
而不是 abort
?
atexit
注册的函数。exit(0)
表示程序正常退出,返回状态码为0。exit(0)
更适合在程序正常结束时使用,而 abort
更适合在发生不可恢复的错误时使用。使用 abort
可以快速停止程序,但可能会导致资源泄漏等问题,因为它不会执行任何清理操作。
不过我们的逻辑是,在非 debug 模式才进行这样的检测。所以用 #ifndef DEBUG
包装
#ifndef DEBUG
timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_global_queue(0, 0));
dispatch_source_set_timer(timer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 0 * NSEC_PER_SEC);
dispatch_source_set_event_handler(timer, ^{
if (isInDebugMode()) {
NSLog(@"在调试模式");
exit(0);
} else {
NSLog(@"不在调试模式");
}
});
dispatch_resume(timer);
#endif
sysctl
是系统函数,存在间接符号表,所以可以用 fishhook 进行 hook。
继续用动态库 + App 的形式验证能否 hook 成功。
第一步:注册一个函数指针,用来保存 sysctl 的函数地址
// sysctl 函数指针,保存原始 sysctl 函数地址
int (*sysctl_pointer)(int *, u_int, void *, size_t *, void *, size_t);
第二步:写替换后的 sysctl 函数实现
int hooked_sysctl(int *name, u_int namelen, void *info, size_t *infosize, void *newInfo, size_t newInfoSize) {
int resultCode = sysctl_pointer(name, namelen, info, infosize, newInfo, newInfoSize);
if (namelen == 4 && name[0] == CTL_KERN && name[1] == KERN_PROC && name[2] == KERN_PROC_PID && info) {
struct kinfo_proc *myInfo = (struct kinfo_proc *)info;
if (myInfo->kp_proc.p_flag & P_TRACED) {
// 异或取反。设置调试判断位为0.
myInfo->kp_proc.p_flag ^= P_TRACED;
}
return resultCode;
}
return resultCode;
}
第三步:调用 fishhook rebind_symbols 完成系统符号 sysctl
的 hook
第四步:验证 hook 是否成功。如果成功,则 App 运行起来,处于 debug 模式下,还是会输出 不在调试模式
结果如下:
可以看到 fishhook 也可以 hook sysctl。所以不安全。
修改思路参考上面的 ptrace,知道 fishhook 的原理,绕开懒加载符号表,绕开 dyld 修正符号和填充地址这个过程。
不再赘述,核心代码如下图所示:
效果就是在 fishhook hook 的情况下,App 检测到处于 debug 模式下,调用 exit(0)
自动结束进程。
更安全的是不让分析者在 MachO 中显示的看到 ptrace、sysctl 符号名称。所以采用异或运算一个固定的 key,再根据指针指向字符串初始值,再次异或,得到原始字符串。
隐藏 ptrace 符号名称的方法,如下所示
void disable_gdb_via_hidden_ptrace(void) {
// 使用一个 char 数组拼接一个 ptrace 字符串 (此拼接方式可以让逆向的人在使用工具查看汇编时无法直接看到此字符串)
unsigned char funcName[] = {
(KEY ^ 'p'),
(KEY ^ 't'),
(KEY ^ 'r'),
(KEY ^ 'a'),
(KEY ^ 'c'),
(KEY ^ 'e'),
(KEY ^ '\0'),
};
unsigned char * p = funcName;
// 再次异或之后恢复原本的值
while (((*p) ^= KEY) != '\0') p++;
void *handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW);
ptrace_ptr_t ptrace_ptr = dlsym(handle, (const char *)funcName);
if (ptrace_ptr) {
ptrace_ptr(PT_DENY_ATTACH, 0, 0, 0);
}
dlclose(handle);
}
隐藏 sysctl 符号的方法如下
bool isInDebugModeViaHiddenSysctl(void) {
// 使用一个 char 数组拼接一个 sysctl 字符串 (此拼接方式可以让逆向的人在使用工具查看汇编时无法直接看到此字符串)
unsigned char funcName[] = {
(KEY ^ 's'),
(KEY ^ 'y'),
(KEY ^ 's'),
(KEY ^ 'c'),
(KEY ^ 't'),
(KEY ^ 'l'),
(KEY ^ '\0'),
};
unsigned char * p = funcName;
//再次异或之后恢复原本的值
while (((*p) ^= KEY) != '\0') p++;
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 infoSize = sizeof(info);
void *handle = dlopen(0, RTLD_GLOBAL | RTLD_NOW);
sysctl_ptr_t sysctl_ptr = dlsym(handle, (const char *)funcName);
sysctl_ptr(name, sizeof(name)/sizeof(*name), &info, &infoSize, 0, 0);
dlclose(handle);
return info.kp_proc.p_flag & P_TRACED;
}
会发现隐藏符号后,可以实现防止 hook 效果的。骚操作来还原符号,保证安全。
函数调用都可以利用 syscall
的方式调用。
syscall(SYS_ptrace,PT_DENY_ATTACH,0,0);
// 等价于
syscall(26,31,0,0,0);
volatile
代表不优化此汇编代码
asm volatile(
"mov x0,#26\n"
"mov x1,#31\n"
"mov x2,#0\n"
"mov x3,#0\n"
"mov x16,#0\n" //这里就是syscall的函数编号
"svc #0x80\n" //这条指令就是触发中断(系统级别的跳转)
);
ptrace(PT_DENY_ATTACH, 0, 0, 0);
等价于
asm volatile(
"mov x0,#31\n" //参数1
"mov x1,#0\n" //参数2
"mov x2,#0\n" //参数3
"mov x3,#0\n" //参数4
"mov x16,#26\n"//中断根据x16 里面的值,跳转ptrace
"svc #0x80\n" //这条指令就是触发中断去找x16执行(系统级别的跳转!)
);
还可以对 exit(0)
进行汇编混合,自定义符号 quit_process
static __attribute__((always_inline)) void quit_process () {
#ifdef __arm64__
asm(
"mov x0,#0\n"
"mov x16,#1\n" //这里相当于 Sys_exit,调用exit函数
"svc #0x80\n"
);
return;
#endif
#ifdef __arm__
asm(
"mov r0,#0\n"
"mov r16,#1\n" //这里相当于 Sys_exit
"svc #80\n"
);
return;
#endif
exit(0);
}
最后的效果:
完整代码可以这里: