Files
knowledge-kit/Chapter1 - iOS/1.68.md
2024-07-15 20:03:01 +08:00

15 KiB
Raw Blame History

守护你的App安全

从 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 来禁止调试。

结果:可以看到对 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) 自动结束进程。

更安全的版本

隐藏符号名称

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

最后的效果:

完整代码可以这里: