越狱开发4-越狱开发防护与破解

左右互搏,anti vs anti-anti

2016-11-15 | 阅读

App Store

Appstore发布的应用,同时被开发者和Apple进行签名。 二进制文件是加密的,需要用密钥将文件解密成可读的版本。当iOS运行应用时, 密钥将二进制文件解密成一个可读的状态,然后加载到内存中执行, 而破壳指的就是在这一段过程中,将内存中的内容dump下来,以输出一个完整的破壳后的二进制文件。iOS在 load command 中的LC_ENCRYPTION_INFO 结构来标记加密的状态,非0的值表示该二进制文件被加密了。

破解应用,即在解密二进制文件时,将内存中的解密后的数据导出。 破解应用一般不会签名,所以只能在越狱机型上使用。或者通过企业证书打出的企业版本APP来进行越狱APP的分发。

所有的保护都是提高破解的难度,并不能完全地阻止破解。 攻击者通过 Clutch或者其他方式,拿到完整的二进制文件,然后就可以随意的修改二进制文件的内容,我们所做的防护在越狱环境下,都是解决不了根本问题的,根本问题是在越狱环境下,用户获取了完整的二进制的文件控制权,可以随意修改二进制文件。

但是,我们可以通过一系列的防护,去提高安全等级,提高破解APP的难度,以过滤一大部分的中低级攻击者。

strip symbols

Xcode上设置 strip ,编译时去除一些符号:

一般在Release版本设置Strip.

减少OC函数的使用

尽量少用Objective-C代码,直接使用Objective-C代码,会将OC代码的函数名,类名等信息记录在二进制文件中,极易被通过函数类名来进行分析逆向。

解决方法就是尽量把代码写成C或者C++的代码,且使用static声明 ,这种代码编译后会是这样 :

字符串加密

动态生成字符串,字符串加密保存,而不是明文保存。攻击者通过全局搜索字符串,而找到指定的需要的内容,然后进行修改。

使用Build Phases来在编译之前修改所有原文件,将进行标记的字符串,进行加密.加密在编译之前,解密在运行期间,而静态分析将无法得到明文字符串,提高破解难度。加密算法使用O(n)的算法,因为定义字符串在系统中是很多,很常用的,所以尽量不要影响到系统性能。

于是自行实现了OCSM ,一个简单的编译前进行字符串加密的工具, 开源放在Github上,点这里

越狱检测

越狱检测有很多种, 如检测cydia是否存在 :

struct stat stat_info;
BOOL jailCheck1 = 0 == stat("/Applications/Cydia.app", &stat_info);

或者通过URLScheme来访问Cydia

 BOOL jailCheck2 = [[UIApplication sharedApplication] canOpenURL:[NSURL URLWithString:@"cydia://"]];

但是攻击者可能会篡改函数,所以我们尽量使用C函数来进行检测,提高篡改难度,如上面使用函数 stat来检测文件是否存在。

反调试

一般我们使用

ptrace(PT_DENY_ATTACH, 0, 0, 0);

来反调试。 PT_DENY_ATTACH是一个苹果专门声明的常量,在内核级别去阻止调试器。 一般开发者所编写的反调试函数如下 :

typedef int (*ptrace_ptr_t)(int _request, pid_t _pid, caddr_t _addr, int _data);
#if !defined(PT_DENY_ATTACH)
#define PT_DENY_ATTACH 31
#endif  // !defined(PT_DENY_ATTACH)

void disable_gdb() {
    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);
}

这个函数很容易修改,几种破解方式 :

  1. 运行时期,断点ptrace,直接返回 :

     (lldb) br set -n ptrace
     Breakpoint 2: where = libsystem_kernel.dylib`__ptrace, address = 0x00000001966af2d4
     (lldb) br command add 2
     Enter your debugger command(s).  Type 'DONE' to end.
     > thread return
     > c
     > DONE
    
  2. 通过tweak,替换disable_gdb函数。
  3. 在二进制文件中 ,修改 PT_DENY_ATTACH31,改成 任意一个值,如PT_ATTACH 0.

我们知道ptrace函数是在通过syscall 26来实现的,所以我们可以使用汇编代码实现这种功能 :

static __attribute__((always_inline)) void antiDebug()
{
#ifndef DEBUG
#ifdef __arm64__
    __asm__("mov X0, #31 \t\n"
            "mov X1, #0 \t\n"
            "mov X2, #0 \t\n"
            "mov X3, #0 \t\n"
            "mov w16, #26 \t\n" // ip1 指针。
            "svc #0x80"
            );
#elif __arm__
    __asm__(
            "mov r0, #31 \t\n"
            "mov r1, #0 \t\n"
            "mov r2, #0 \t\n"
            "mov r3, #0 \t\n"
            "mov ip, #26 \t\n"
            "svc #0x80"
            );
#endif
#endif
    return;
}

这里,我们在OC代码中, 使用混编了汇编代码,使用svc也就是 syscall 去调用ptrace,这样就无法通过函数替换来实现反调试了, 必须通过修改二进制文件中的汇编代码才能反调试。攻击者可以使用nop来移除掉命令svc #0x80。但是这种破解难度就比前面的要高很多。我们可以在APP多处调用这个函数,以加大这种攻击者的搜索替换的难度。

支付宝现在做到的是通过inline函数调用ptrace, 然后我们讨论支付宝的反调试的破解方法 , 运行时破解,通过断点ptrace,然后跳过函数执行,以在单次调试中处理掉ptrace

而如果想要通过修改函数来攻击的话,就比较麻烦,ptrace函数在libsystem_kernel.dylib这个动态库中,使用时才进行加载,不是静态放在本地的,所以我们不能简单地去tweak ptrace函数。所以我们分析一下其代码逻辑,一般这种反调试,代码是这么写的 :

void disable_gdb() {
    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);
}

所以,我们可以通过hook dlsym,当其调用时,如果是获取ptrace函数,则我们返回一个假的ptrace函数回去 , tweak如此编写 :

#import <substrate.h>
#import <mach-o/dyld.h>
#import <dlfcn.h>


int fake_ptrace(int request, pid_t pid, caddr_t addr, int data){
	return 0;
}

void *(*old_dlsym)(void *handle, const char *symbol);

void *my_dlsym(void *handle, const char *symbol){
	if(strcmp(symbol,"ptrace") == 0){
		return (void*)fake_ptrace;
	}

	return old_dlsym(handle,symbol);
}

%ctor{
	MSHookFunction((void*)dlsym,(void*)my_dlsym,(void**)&old_dlsym);
}

然后就可以跳过支付宝的反调试功能了。

反动态库注入

other linker flag中添加 -Wl,-sectcreate,__RESTRICT,__restrict,/dev/null, 添加后,我们可以在machoview中发现一个新的section如下 :

dylib中,有这样一段函数 :

switch (sRestrictedReason) {
                        case restrictedNot:
                                break;
                        case restrictedBySetGUid:
                                dyld::log("main executable (%s) is setuid or setgid\n", sExecPath);
                                break;
                        case restrictedBySegment:
                                dyld::log("main executable (%s) has __RESTRICT/__restrict section\n", sExecPath);
                                break;
                        case restrictedByEntitlements:
                                dyld::log("main executable (%s) is code signed with entitlements\n", sExecPath);
                                break;
                }

在这三种情况下,通过DYLD_INSERT_LIBRARIES运行时引入动态库,会被阻止:

  1. 可执行文件被setuidsetgid了;
  2. 可执行文件含有__RESTRICT/__restrict这个section
  3. 可执行文件被签了某个特殊选项entitlements

使用__RESTRICT/__restrict的破解方式,是修改section的名字 ,如这样:

将所有的restrict进行改名。 但是修改二进制文件后,导致签名无法通过,所以注意,修改二进制文件后,要对app重签名, 否则安装会失败(如果安装了AppSync,安装会成功,但是运行时崩溃。)。

但是这样还是可以继续被攻击。

所以,这里我们再进一步去防护,我们判断是否有人篡改了这个Section :

#include <mach-o/getsect.h>
void *secstart = getsectbyname("__RESTRICT", "__restrict");

通过这个函数,来查找section,如果没有查到这个section,标志着我们的第一层的反动态库,被人给破解了,这时我们退出应用。

这种情况下,攻击方式应该是替换getsectbyname ,然后我们又需要去考虑校验函数是否被替换(攻击与反制。。。)

阻止Cycript的注入。

阻止Cycript的注入,我们知道其动态库是 libcycript.dylib ,所以,我们通过以下代码来检测程序中是否加载了该动态库 :

uint32_t count = _dyld_image_count();
    BOOL hasInjected = NO;
    for (uint32_t i = 0; i < count; i++) {
        if (strstr( _dyld_get_image_name(i),"libcycript.dylib")) {
            hasInjected = YES;
            break;
        }
    }

但是这里只能检测到初始化的时候的注入,即以cycript启动应用,而在运行期间的阻止,我们通过_dyld_register_func_for_add_image来实现 :

void func(const struct mach_header* mh, intptr_t vmaddr_slide){
    Dl_info info;
    if (dladdr(mh, &info) == 0) {
        return;
    }
    if (strstr(info.dli_fname,"libcycript.dylib")) {
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            exit(1);
        });
    }
}
_dyld_register_func_for_add_image(func);

我们在检测到程序中有动态库cycript注入时,关闭应用。

检测app信息

校验APP信息, 对于攻击者来说,iOS的逆向,最大的方向就是篡改后重签名 , 所以我们可以通过检测APP的一些信息来进行防护与上报。我们可以检测加密状态和签名信息:

加密状态

提交到AppStore加密后的文件,会带上一个LC_ENCRYPTION_INFOmacho load command ,如下:

在ARM64中,是LC_ENCRYPTION_INFO_64, 所以,我们可以通过检测这个值,来判断当前APP是否被破壳 :

bool isBinaryEncrypted()
{
    // checking current binary's LC_ENCRYPTION_INFO
    const void *binaryBase;
    struct load_command *machoCmd;
    const struct mach_header *machoHeader;

    NSString *path = [[NSBundle mainBundle] executablePath];
    NSData *filedata = [NSData dataWithContentsOfFile:path];
    binaryBase = (char *)[filedata bytes];

    machoHeader = (const struct mach_header *) binaryBase;

    if(machoHeader->magic == FAT_CIGAM)
    {
        unsigned int offset = 0;
        struct fat_arch *fatArch = (struct fat_arch *)((struct fat_header *)machoHeader + 1);
        struct fat_header *fatHeader = (struct fat_header *)machoHeader;
        for(uint32_t i = 0; i < ntohl(fatHeader->nfat_arch); i++)
        {
            if(sizeof(int *) == 4 && !(ntohl(fatArch->cputype) & CPU_ARCH_ABI64)) // check 32bit section for 32bit architecture
            {
                offset = ntohl(fatArch->offset);
                break;
            }
            else if(sizeof(int *) == 8 && (ntohl(fatArch->cputype) & CPU_ARCH_ABI64)) // and 64bit section for 64bit architecture
            {
                offset = ntohl(fatArch->offset);
                break;
            }
            fatArch = (struct fat_arch *)((uint8_t *)fatArch + sizeof(struct fat_arch));
        }
        machoHeader = (const struct mach_header *)((uint8_t *)machoHeader + offset);
    }
    if(machoHeader->magic == MH_MAGIC)    // 32bit
    {
        machoCmd = (struct load_command *)((struct mach_header *)machoHeader + 1);
    }
    else if(machoHeader->magic == MH_MAGIC_64)   // 64bit
    {
        machoCmd = (struct load_command *)((struct mach_header_64 *)machoHeader + 1);
    }
    for(uint32_t i=0; i < machoHeader->ncmds && machoCmd != NULL; i++){
        if(machoCmd->cmd == LC_ENCRYPTION_INFO)
        {
            struct encryption_info_command *cryptCmd = (struct encryption_info_command *) machoCmd;
            return cryptCmd->cryptid;
        }
        if(machoCmd->cmd == LC_ENCRYPTION_INFO_64)
        {
            struct encryption_info_command_64 *cryptCmd = (struct encryption_info_command_64 *) machoCmd;
            return cryptCmd->cryptid;
        }
        machoCmd = (struct load_command *)((uint8_t *)machoCmd + machoCmd->cmdsize);
    }
    return FALSE; // couldn't find cryptcmd
}

签名信息

重签名后,一般是攻击者使用企业证书来进行签名,而我们要保护的是发布到AppStore不是用企业证书打包的内容,所以我们可以通过多种方式来校验,这个签名是否被篡改:

  1. 检测这个embedded.mobileprovision文件是否存在,对于企业证书签名,必定会有这个证书存在。
  2. bundleID : 重签名必定会修改这个值。通过[[NSBundle mainBundle] bundleIdentifier] 来获取bundleID,进行校验。

macho文件中,可以获取到签名信息(坑,待填)。

地址检测

上面提到了一系列的检测函数,这些函数我们要保证其安全性,所以检测函数要是一个inline函数,确保攻击者不能简单地替换该函数来跳过检测。 使用 __attribute__((always_inline)) ,表示强制编译器进行inline编译,因为单纯的声明inline,可能编译器并不一定会内联,所以需要强制声明。

由于我们的检测一般会调用系统函数,而攻击者会动态替换函数,以使跳过检测,或者返回错误的返回结果。所以我们有时候要检测函数的地址,以避免函数被hook :

#import <dlfcn.h>  	  
Dl_info info2;
IMP imp2 = class_getMethodImplementation([MeViewController class], @selector(tableView:didSelectRowAtIndexPath:));
if (dladdr(imp2, &info2)) {
    printf("dli_fname: %s\n", info2.dli_fname);
    printf("dli_sname: %s\n", info2.dli_sname);
    printf("dli_fbase: %p\n", info2.dli_fbase);
    printf("dli_saddr: %p\n", info2.dli_saddr);
} else {
    printf("error: can't find that symbol.\n");
}

通过校验dli_fname来保证函数没有被篡改。我们对上述所有的检测中的系统函数,进行地址校验,同时对检测函数进行内联,并结合多种检测,保证了APP的安全性。 所以做到这一步时, 简单的hook函数已经无法处理我们的APP了,攻击者必须通过修改汇编代码的形式以达成自己的目的。

交叉检测

攻击者选择手动修改这些检测内容,以确保自己能重新注入与调试。而我们为了增加攻击者这样操作的难度,我们选择交叉重复检测,即将检测放置在多个入口处,多次检测,以加大攻击者篡改二进制文件的难度 (攻击者需要搜索到所有的检测代码,然后一个一个地去篡改)。而检测的数量和强度也要控制,在增加检测的强度的同时,确保不会对普通用户产生太大影响, 最好在关键业务处进行针对性检测, 然后一旦检测到用户当前处于调试状态或者动态库注入的情况下,直接进行结束进程,防止攻击者继续调试。

结束进程避免直接使用exit以被攻击者直接hook,而是使用汇编代码编写,如下 :

static __attribute__((always_inline)) void sysexit()
{
#ifdef __arm64__
    __asm__("mov X0, #1 \t\n"
            "mov w16, #1 \t\n" // ip1 指针。
            "svc #0x80"
            );
#elif __arm__
    __asm__(
            "mov r0, #1 \t\n"
            "mov ip, #1 \t\n"
            "svc #0x80"
            );
#endif
    return;
}

通过系统调用来关闭程序。 这里跟ptrace调用相似,这里记录了苹果的系统调用 , 所以我们可以直接通过函数来调用一些功能,而不是C函数,以避免被直接篡改。

再进一步

函数结构体混淆

攻击者会篡改汇编代码,将必要的函数执行或判断给篡改,以避免检测的正确执行。 由于强大的反编译工具,如idahopper导致阅读汇编代码更加容易。 所以,我们要将汇编代码也进行混淆,以极大地提高攻击者阅读汇编代码的难度。

开源方案 obfuscator-llvm , 已停止更新维护,但世面上的商业方案,都是基于这个思路来实现的。

OC代码混淆

iOS中混淆函数和类名,由于攻击者可以比较轻松的Dump到所有OC的头文件,所以我们需要进行代码混淆,以混淆函数名和类名,降低可以Dump的信息,以提高分析的难度。

开源方案 ios-class-guard , 不够强大, 问题很多,暂时未见世面上的商业方案。

总结

在这一节中,我们针对常见的攻击,设计了防护的思路,确保了APP能够在中等以上程度地去防范 调试,动态库注入,Cycript注入和重签名。

暂时无法完成的工作,难度较大的工作,有两个,函数体混淆和代码混淆,两者都有开源方案,但是开源方案都有很多问题,所以我们不能使用,而要去试用和采购商业方案。但由于iOS本身的安全性和上述一些安全措施的实施,导致采购并不急切。