3
头图
从 Web 安全一样,所有的攻防离不开一句话“在合理范围内保证 App 安全,让攻击者增加破解成本,让一部分人三思而后行战术性放弃”。

ptrace 简易版本

在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);
}

ptrace 安全吗

但上述方式安全吗?一个简单的 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 失效了,并不安全。

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 简易版本

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: 新值的大小

返回值:

  • 如果成功,返回0
  • 如果失败,返回 -1

其中传递的结构体引用,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

  • exit(0):函数是C标准库中的一个函数,用于正常退出程序。当调用时,程序会执行清理操作,比如关闭打开的文件、释放分配的资源等。它允许程序在退出前执行一些清理工作,比如调用 atexit 注册的函数。exit(0) 表示程序正常退出,返回状态码为0。
  • abort: 函数也是C标准库中的一个函数,但它用于异常或紧急情况下的退出。当调用时,程序会立即终止,不会进行任何清理工作,比如关闭文件或释放资源。会导致程序发送SIGABRT信号给自身,这通常用于调试目的,以便在发生严重错误时立即停止程序。表示程序是非正常退出的。

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 安全吗

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。所以不安全。

sysctl 安全版本

修改思路参考上面的 ptrace,知道 fishhook 的原理,绕开懒加载符号表,绕开 dyld 修正符号和填充地址这个过程。

不再赘述,核心代码如下图所示:

效果就是在 fishhook hook 的情况下,App 检测到处于 debug 模式下,调用 exit(0) 自动结束进程。

syscall 简易版本

int syscall(int, ...) syscall函数是一种用于调用系统调用的方法。系统调用是用户空间程序请求操作系统内核服务的一种机制。

在用户空间和内核空间之间,有一个叫做 Syscall (系统调用, system call )的中间层,是连接用户态和内核态的桥梁。这样即提高了内核的安全型,也便于移植,只需实现同一套接口即可。Linux系统,用户空间通过向内核空间发出 syscall,产生软中断,从而让程序陷入内核态,执行相应的操作。对于每个系统调用都会有一个对应的系统调用号,比很多操作系统要少很多。

引入头文件 #import <sys/syscall.h>

syscall(26, 31, 0, 0);
// 等价于 syscall(SYS_ptrace, PT_DENY_ATTACH, 0, 0);

syscall 调用的时候第一个参数是调用函数的名称,后面的参数是调用函数的参数。

syscall 安全吗

syscall 本质也是系统函数,最后还是躲不开 fishhook 的追杀。所以是不安全的。有没有什么办法可以解决?

这里就不再去写一遍 fishhook 的代码了,很重复...

syscall 安全性改进

  • 隐藏符号,还原符号
  • 使用 dlopen、dlsym 的方式,找到 syscall 符号的地址
  • syscall 发起系统调用,调用 sysctl 能力
  • GCD 定时器检测,判断是否处于调试模式
  • 如果处于调试模式,调用汇编 quit_process 结束进程

可以看到:即使 fishhook hook 了 ptrace、sysctl 绕过 hook,但是这种方式还是可以对非法调试 App 的行为进行了保护,立马会结束进程。

svc 调用

SVC指令在ARM体系中被归于异常处理类指令,该指令能允许用户程序调用内核,其格式如下:

SVC{cond} #imm // Supervisor call, allows application program to call the kernel (EL1)

传统 arm中使用 svc 0 表示中断,在 xnu 中使用的是 svc 0x80。具体的看下面的例子

更安全的版本

隐藏符号名称

更安全的是不让分析者在 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);
}

最后的效果:

一些其他的思路

符号混淆

做 iOS 开发的同学,在类名、方法名等命名上,都会做到见名知意,这点在开发阶段、日常维护阶段是好事情。但是站在黑客和攻击者角度来看的话,这对他们来说也是一件好事,但对 App 的安全来讲就是一件坏事。所以需要做符号混淆

在静态分析应用的时候,常常会使用 class-dump 导出应用的头文件,通过头文件中的函数名或变量名 猜测这些函数的功能,然后进行 Hook 动态分析窥探大概逻辑。如果让这些方法名、变量名、类名从名称上看没有任何意义,那么就能从一定程度干扰攻击者猜测,这叫做代码混淆

如果一个登陆注册如下所示(伪代码):

@interface LoginViewController: UIViewController 
- (void)handleLoginAction;
@end

这样的代码上传到 App Store 后,攻击者利用 class-dump 还原后,还是很清晰的,见名知意,一下子就可以判断这是登陆事件的处理函数。如果对符号进行混淆,如下所示

@interface $38wiewh81_Controller: UIViewController 
- (void)0jjd1;
@end

攻击者看到这样的符号,无疑会增大破解难度,至少不会像以前的一样,代码做到裸奔。

动态库白名单检测

除了保护应用中的关键代码,还可以通过代码检测应用中动态库是否是合法的。无论是越狱环境还是非越狱环境,如果要入侵除了修改二进制就是注入动态库,所以可以写段逻辑判断 App 中除了我们自己项目中的动态库,是否还存在入侵的动态库。

通过 dyld API 函数获取应用中的动态库名称,把这些字符串名称合并作为一个白名单,如果发现动态库不在白名单中,则结束进程。

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)){ // 不在白名单中
                    quit_process();
        }
    }
}

当然,更严谨的做法就是 App 启动完成,主页加载显示完毕后,拉取接口,服务端告诉当前版本的 App 有哪些库,App 内存保存这些数据,GCD 定时器检测,发现有库不在该白名单内存中,则结束进程。

服务端白名单数据怎么来?App 打出 release 模式的包,然后运行上述代码,包库的名称数组,上传到服务端。

逻辑混淆

一些核心逻辑还是有必要混淆的。编译工程时使用这个自定义的 OLLVM 工具可以编写 Pass 来混淆 IR

具体查看 obfuscator 这个 Repo。具体使用这里不展开。

完整代码可以这里:


杭城小刘
1.1k 声望5.1k 粉丝

95 - iOS - 养了4只布偶猫