iOS 逆向 - 应用安全攻防(越狱与非越狱)

7,444 阅读27分钟

iOS 逆向篇章目录 :

前言

逆向篇章从前导知识到工具使用和原理分析我们都已经讲述完毕了 , 也结合了实际案例来进行巩固 . 那么接下来 , 进入到我们学习逆向的最重要的目标篇章 , 应用安全攻防 .

这是一个大篇章 , 文章如果过长会分两篇讲述 .

学习逆向最重要的就是知道如何防护 , 本文会列举一些目前市面上较为常见的逆向攻击方式应该讲讲如何防护 , 这些做法并不唯一也不一定最好 , 如有心得欢迎交流 .

关于防护

关于应用防护 , 我们首先要有几个前提概念要清楚 .

  • 1️⃣ : 没有绝对安全的程序 .

    没有绝对安全的应用 , 我们所要做的就是尽最大可能混淆 or 浪费攻击者的时间 , 加大攻击成本 .

  • 2️⃣ : 针对检测到调试和注入时的操作 , 尽量不要做非常明显的退出应用 or 提示 等操作 .

    • 对于一个经验比较丰富 ( 相对防护者来说 ) 的逆向工程师 , 当发现调试 / 注入代码工程运行时与正版应用正式运行有明显的区别时 , 很容易顺藤摸瓜找到相应的防护或者监测处理逻辑和技术 .

    • ( 比如经常也有一些同学问到 , 为什么重签名了之后工程一运行就闪退 , 正版应用就没问题 , 那么就很容易得知很可能是利用了 ptrace 或者引入包监测 / 包名监测之类的处理 ) .

    • 相对而言目前市面上许多大厂所使用的的 , 监测到此行为会记录设备 / 账户等信息进行上报和封号等措施则是在无形之中做到了较为有效的防护 .

1. 动态调试

进攻 :

对于非越狱环境来说 , 重签名进行动态调试 , yololib 修改 mach-oLoad CommandsDYLD 加载攻击者所编写的动态库从而进行 hook 来完成代码注入是最为常见的了 . ( 这里不会详细讲述进攻过程和效果 , 不熟悉的同学可以翻一翻目录中 2,3,4 这三篇文章有详细的操作演示 ) .

利用重签名工程的特性 , 其实这种解决方案有特别多 , 这里挑几个比较有代表性的防护措施列举一下 .

防护方式 1 : 干掉 lldb - ptrace

利用重签名工程运行调试特性 , 我们可以通过禁止开发环境使用 lldb 来实现重签名工程运行闪退的效果 .

lldb 的原理这里提一点 :

  • lldb 本质上是一个 GUI 断点和命令收集工具 + debug server 通过进程附加到当前运行进程中来实现的 .

( Xcode 自带的 Debug - Attach to process 就是根据 DebugServer 来实现的 )

ptrace

ptrace 是 命令行工程以及 Mac OS 工程里的 <sys/ptrace.h> 提供的一个函数 , 可以用来来控制进程附加管理 , 它可以实现禁止应用程序进程被附加的效果 . 在 iOS 中并没有暴露出来 , 但是 iOS 是可以使用的 .

笔者这里将该头文件导出 , ( 链接 - 密码: iaqp ) 下载后导入工程中就可以使用了 .

使用如下 :

/**
 arg1: ptrace要做的事情: PT_DENY_ATTACH 表示要控制的是当前进程不允许被附加
 arg2: 要操作进程的PID , 0就代表自己
 arg3: 地址 取决于第一个参数要做的处理不同传递不同
 arg4: 数据 取决于第一个参数要做的处理不同传递不同
 */
ptrace(PT_DENY_ATTACH, 0, 0, 0);

处理后效果

代码添加完毕后 :

  • 运行工程 , 程序闪退 .
  • 从手机点开应用 , 应用正常 .
  • 使用Xcode 自带的 Debug - Attach to process 发现附加失败 .

防护手段评估

  • 1️⃣ : 越狱环境下 lldb-debug server 同样可以防护 .
  • 2️⃣ : 这种做法比较暴力 , 而且影响本身正向开发 , 只能在正向开发时不使用 , 打包上架时再打开 .
  • 3️⃣ : 这种做法效果比较明显 , 很容易推断出来使用了这个机制 , 一个符号断点就能轻松查到 .
  • 4️⃣ : 破解起来也比较简单 , 使用 fishhook 可以很轻易的 hookptrace 这个函数 .

( 坊间传闻早期支付宝有使用过这种方式 , 不过并没有实据 , 全当一听 )

提示

Cycript 本身是从正在运行的进程中读取数据 , 并不是进程附加的原理 , 并不能通过 ptrace 防护 .

防护手段破解

破解这个也有较多方式 , 比如直接修改二进制汇编代码 ( bl -> nop ) , hookptrace 等等 .

最简单的方式就是插入一个动态库 , 在这个库的 load 中使用 fishhook 直接把 ptrace hook 掉即可 . ( 关于 fishhook 参阅 fishHook 原理与符号表 这篇文章有从使用到原理解释的完整内容 , 这里就不再演示了 ) .

Monkeydev 中默认就已经使用了 fishhook 交换了 ptrace .

防护方式 2 : sysctl

sysctl ( system control ) 是由 <sys/sysctl.h> 提供的一个函数 , 它有很多作用 , 其中一个是可以监测当前进程有没有被附加 . 但是因为其特性 , 只是监测当前时刻应用有没有被附加 .

因此正向开发中我们往往结合定时器一起使用 , 或者 定时 / 定期 / 在特定时期 去使用 .

使用如下 :

#import "ViewController.h"
#import <sys/sysctl.h>
@interface ViewController ()
@end

@implementation ViewController
BOOL isDebug(){
    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) 就是0x800, 即可获取第12位
    */
    return ((info.kp_proc.p_flag & P_TRACED) != 0);
}

static dispatch_source_t timer;
void debugCheck(){
    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.0 * NSEC_PER_SEC, 0.0 * NSEC_PER_SEC);
    dispatch_source_set_event_handler(timer, ^{
        if (isDebug()) {//在这里写你检测到调试要做的操作
            NSLog(@"调试状态!");
        }else{
            NSLog(@"正常!");
        }
    });
    dispatch_resume(timer);
}

- (void)viewDidLoad {
    [super viewDidLoad];
    debugCheck();
}

处理后效果

这个显示层面的效果根据你自定义的结果决定 , 你检测到后决定做上报还是闪退都可以 .

防护手段评估

  • 1️⃣ : 越狱环境下 lldb-debug server 同样可以检测到 , 但同样会影响本身正向开发 .
  • 2️⃣ : 这种做法比较灵活 , 可以自定义处理结果 , 上报很容易做到无形 .
  • 3️⃣ : 破解起来也相对简单 , 使用 fishhook hooksysctl 这个函数 ( 但是因为用户使用 sysctl 获取到返回结果 , 因此这里注意 hook 之后不能直接啥也不做 , 需要找到返回的 flag12 位改为 0 , 参考下图 ) . 但是得益于针对使用层面效果可做到无形 , 也被一些公司在使用 .
  • 4️⃣ : 由于其特性 , 检测当时当刻 , 需要针对需求去操作何时检测 or 检测多久的逻辑 .

( 坊间传闻它又来了 . 传说早期抖音团队就是使用了这个检测 , 检测到之后直接 exit 同样没有有实据 , 全当一听.. )

提示

这里并不推荐检测到有调试之后直接 exit .

因为逆向过程中通过对 exit 添加符号断点查看函数调用栈就可以查看到调用 exit 的函数地址 , 再减去通过 image list 指令获取 mach-o 首地址就可以获取到函数偏移量 , 然后在 Hopper 中很容易就可以拿到调用 exit 的函数了 , 那攻击人员就能找到这个函数中你是通过 sysctl 来监测的 .

因此 , 我们说防护最好是不要有明显的痕迹给攻击者 , 否则就是给他们提供一个很好的线索和思路去找到你的防护逻辑 .

防护手段破解

sysctlhook 做法如下 :

同样 , Monkeydev 中默认就已经使用了 fishhook 交换了 sysctl ..

问题

可能大家都注意到了 , 上面所说这两种解决动态调试的方案似乎都差了点意思 , 破解成本也比较小 . 只要保证注入代码在 sysctl 检测代码或者 ptrace 调用之前就能够完全解决这个防护手段 .

解决上述动态调试防护方案问题其实有很多方案 , 例如 :

  • 保证 sysctl 检测代码或者 ptrace 的执行在 hook 注入代码之前 .
  • 禁用掉 fishhook .

防护方式 3 : ptrace / sysctl 优化版 - 提前执行

原理讲述

根据 ld 以及 llvm 的编译特性以及 dyld 的加载逻辑 . 实际上当我们在正向开发使用 framework 中的 load 中编写防护代码时 , 是会比 yololib 注入的动态库更早被执行的 .

  • 之前文章中我们也提到过 , Mach-O__DATA 段后面是有空余内存位置的 . yololib 添加的 hook framework 是添加在这个空缺位置中的 , load commands 也会往后插入.
  • 也就是说 , 用户自己引入的 framework 是会在 注入的 framework 之前被链接的 . ( 越狱环境 Insert Library 跟这个是两个东西 , 后面我们会讲述越狱插件的工作原理 ) .

具体做法

那么利用这个特性 , 我们可以自己添加一个 framework 在它的 load 中编写防护代码 . 这些防护代码会比注入的更早被执行 . 也就是说 , 这时已经检测到有被调试了 . sysctl 后续虽然会被注入代码 hook 掉但是我们已经完成监测 .

而且由于是单独的 framework , 如果是通过符号断点获取函数地址去计算基于首地址偏移量还会有所不同 , 因为每个 framework 都是一个独立的 mach-o , 需要去找对应的文件首地址而非主程序的首地址 , 分析的 mach-O 也应该是 framework 而非主程序 . 也对攻击人员产生了额外的时间消耗 .

( 经验丰富的逆向人员实际上也很容易忘记这一点 , 通常会以为自己算错了 .. )

防护手段评估

这种方式适用于非越狱环境重签名调试 , 已经做到比较好的效果了.

攻击人员针对在 frameworkload 中所做的防护措施 , 由于永远比注入代码早被执行而相对更安全一些 , 想要攻击也只能通过静态汇编分析逻辑修改汇编了 . 而这种就相较于动态调试会更加复杂和耗时 .

( 同样 , 越狱插件跟这个不是一个原理 , 并不能防护到 , 后面我们会将越狱插件如何防护 )

提示

  • 建议不要为了防护专门开一个 framework , 并且取一个很明显的名字 , 这样攻击人员可以很轻易地看出这个 framework 就是为了防护写的 , 再结合 load 方法汇编分析 , 发现你只做了一个 sysctl , 他静态修改也是很简单的 . load 方法直接 ret 就行了 .
  • 可以放到一个有实际功能逻辑的库中添加这个防护逻辑 , 这样能达到更好地防护效果 .
  • 基于这个机制 ( 我们的防护代码可以比注入代码更早被执行 ) , 我们可以做很多处理 , 比如 runtime 的方法交换屏蔽 , fishhook 屏蔽 , 这个后面我会补充一下 .

防护方式 4 : 函数地址保存绕过 fishhook

绕过 fishhook 调用系统函数

我们有这样一个设想 , 在我工程开始我就获取 ptrace / sysctl 的地址 , 后面直接使用地址调用这个函数 . 实际上是可行的 , 利用 dlsym 这个函数 .

上述 防护方式3 在我们看来 , 虽然能保证防护代码的执行优先权 , 但似乎看起来仍然是治标不治本 . 那么如何能保证我们需要用的系统函数不会被 fishhook 干掉呢 ?

启动优化之Clang插桩实现二进制重排 文章中我们提到过通过符号获取函数地址 ( dladdr 函数 ) , 并且使用了通过函数内部地址找到函数符号 ( dlsym 函数 ) .

使用展示

#import "MyPtraceHeader.h"
#import <dlfcn.h>
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
   
    //这里做法是隐藏常量字符串
    unsigned char str[] = {
        ('a' ^ 'p'),
        ('a' ^ 't'),
        ('a' ^ 'r'),
        ('a' ^ 'a'),
        ('a' ^ 'c'),
        ('a' ^ 'e'),
        ('a' ^ '\0')
    };
    unsigned char * p = str;
    while (((*p) ^= 'a') != '\0') p++;
    
    void * handle = dlopen("/usr/lib/system/libsystem_kernel.dylib", RTLD_LAZY);

    int (*ptrace_p)(int _request, pid_t _pid, caddr_t _addr, int _data);
    //获取ptrace函数指针
    ptrace_p = dlsym(handle,(const char *)str);
    if (ptrace_p) {
        //如果有值,就可以顺利调用
        ptrace_p(PT_DENY_ATTACH,0,0,0);
    }
}

上述做法中首先使用的是常量字符串的隐藏 , 本文第三章节有详细讲述 .

如果我们希望自己使用的系统函数不会被 hook , 就可以采用这种方式 .

提示

( 实际上 MonkeyDev 同样就已经对 dlsym 进行了 hook ) , 因此可以结合 防护方法3 公同使用 , 也就是保证获取地址方法提前执行即可 , 这里不多提了.

防护手段评估

这种防护方式间接的绕过了 fishhook , 但也不是绝对的 . 很明显 , 我们同样使用了 dlopendlsym 这两个系统函数 , 那他们就同样有被 fishhook 干掉的可能 .

不着急 , 后续我会继续讲如何更好地解决这个问题 .

防护方式 5 : 绕过符号断点 syscall

syscall 是系统级别的调用函数的一个函数 . 例如我们希望调用 ptrace , 但是又不希望符号断点可以段住 ptrace . 那么你不用导入任何头文件就可以直接使用 syscall 函数来调用 ptrace .

使用演示

/**
 1.参数 是函数编号 26 指的是 ptrace 函数 
 2.其他的就是参数顺序. 31 指的是 ptrace 的参数 PT_DENY_ATTACH
 */
syscall(26,31,0,0);

关于每个序号对应什么函数 , 你可以导入一下 #import <sys/syscall.h> 即可查看 .

除了 ptrace , 还有 exit 等等 , 当你需要绕过符号断点就可以使用这个函数 .

当然 , syscall 也是一个系统函数 , 因此除了汇编 , 你可以结合 防护方式 4防护方式 3 来解决会被 fishhook 干掉的问题 . 而且添加 syscall 的符号断点是同样可以断住的 , 然后可以读寄存器查看你是否调用了 ptrace , exit 等等 .

怎么解决呢 ? 接着往下看 .

防护方式 6 : 汇编调用 ( 推荐版本 )

上述所写的这么多种防护方式 , 看起来都不是很完美 , 还是存在 fishhook 可能对各种你使用的方法进行 hook 的风险 ( 虽然你可能使用了 framework 提前执行 , 绕过符号断点 , 使用系统级别的调用 , 但仍然会留下一些 '蛛丝马迹' ) .

该如何解决呢 ?

我们可以通过汇编来直接调用 syscall .

使用演练

- (void)viewDidLoad {
    [super viewDidLoad];
    //使用汇编调用syscall调起ptrace
    #ifdef __arm64__
    asm volatile(
                 "mov x0,#26\n"
                 "mov x1,#31\n"
                 "mov x2,#0\n"
                 "mov x3,#0\n"
                 "mov x16,#0\n"
                 "svc #0x80\n"//这条指令就是触发中断(系统级别的跳转!)
    );
    #endif
    
    //使用汇编直接调用 ptrace
    #ifdef __arm64__
    asm volatile(
                 "mov x0,#31\n"
                 "mov x1,#0\n"
                 "mov x2,#0\n"
                 "mov x16,#26\n"
                 "svc #0x80\n"
                 );
    #endif
}

x16 寄存器就放调用 syscall 需要调用的函数对应编号就可以 . 当然 , 不同架构寄存器指令不同 , 例如调用 exit 我们可以这么写 :

#ifdef __arm64__
    asm volatile(
                 "mov x0,#0\n"
                 "mov x16,#1\n"
                 "svc #0x80\n"
                 );
#endif
#ifdef __arm__//32位下
    asm volatile(
                 "mov r0,#0\n"
                 "mov r16,#1\n"
                 "svc #80\n"
                 );
#endif

防护方案评估

使用汇编指令调用 syscall

  • 可以防止系统函数被 fishhook 干掉 .
  • 添加符号断点并不能断住 .
  • 攻击者静态分析也比较难以查找 .

比较推荐这种方式 .

动态调试其他防护方式

动态调试除了上述防护方式以外 , 还有许多方案 , 这里列举几个供大家参考 .

  • 引入动态库监测 . 使用白名单监测自己工程当前引入三方库 , 查找是否有未知库注入 , 获取引入库写法如下 .
    • 注意: 由于程序本身 mach-o 这里也能监测出来 , 而且是第一个 , 因此 , 循环应该从 1 开始 , 也就是剔除本身 mach-o .
    • 而且该方式同样可以监测越狱环境 DYLD_INSERT_LIBRARIES 动态注入的插件 .
    • 此方法可以有效地检测到 Cycript 越狱与非越狱的调试 .
  • Bundle ID 检测 , 重签名工程是需要修改包名的 , 可以以此监测 .

2. 代码混淆

由于砸壳后应用恢复符号后使用 lldb , class-dump 或者 Mach-O 的一些查看工具很轻易的可以看到我们的类名与方法名 , 而通常贯彻了代码规范的正向开发人员会已标准的驼峰和英文来命名 , 这就为攻击人员提供了极大的便利性和可寻性 .

代码混淆可以在不影响正向开发人员的情况下对方法名和类名进行混淆 , 使生成的二进制文件中没有去除符号的类和方法也变的没有可参考性 .

使用方式

宏定义的机制可以帮助我们很好地满足混淆的需求 . 对于一个已经开发完成的工程我们可以很方便的来对其进行混淆 .

例如代码如下 :

- (void)viewDidLoad {
    [super viewDidLoad];
    LBObjectClass * objc = [LBObjectClass new];
    [objc LBObjectTestFunc];
    BOOL result = [objc checkIsVipWithkeyString:@"12311" Token:@"jfkfqwe"];
    NSLog(@"%d",result);
}

@interface LBObjectClass : NSObject
- (void)LBObjectTestFunc;
- (BOOL)checkIsVipWithkeyString:(NSString *)string Token:(NSString *)token;
@end

@implementation LBObjectClass
- (void)LBObjectTestFunc{
    NSLog(@"this is a confusion func");
}

- (BOOL)checkIsVipWithkeyString:(NSString *)string Token:(NSString *)token{
    if ([string containsString:@"111"] && (token != nil) ) return YES;
    return NO;
}
@end

如上案例中我们 , 有一个类名叫 LBObject , 他有两个方法 , 一个无参无返回值 , 一个有参有返回值 .

如何进行混淆呢 ?

做法很简单 . 为其定义对应的 即可 . 我这里在 pch 文件中添加如下 :

#define LBObject HDJSNWOIJNWPKFWD
#define LBObjectTestFunc LKNWFMWJFNJMSLW
#define checkIsVipWithkeyString IWRNWKJNDS
#define Token NFKAOWRL

添加完毕后发现类名和方法名称颜色发生了改变 . 原本代码无须进行任何修改 .

效果展示

MachOView 查看混淆前 :

MachOView 查看混淆后 :

class-dump 查看混淆前 :

提示

class-dump 是对他人的已脱壳应用从 mach-O 中读取头文件 , 可以获得类以及其方法 and 属性的定义 . ( 使用方法参考 重签应用调试与代码修改 ) .

class-dump 查看混淆后 :

Hopper 中查看方法汇编实现如果做了混淆也是一样的效果 . 这里就不再展示了 .

宏定义代码混淆方案评估

优点 :

  • 1️⃣ : 代码混淆可以很有效的大量增加攻击者的分析耗时 , 增大干扰性 .
  • 2️⃣ : 代码混淆可以很轻松的对已有工程进行处理 .
  • 3️⃣ : 加入混淆后对正向人员正常开发基本没有影响 .
  • 4️⃣ : 完全可以利用宏定义的机制 , 灵活处理 , 例如类名方法名每次运行都是随机字符串 .

缺点

  • 1️⃣ : 代码混淆利用宏定义的机制 , 因此在预编译阶段会增加一定耗时 , 但对运行期间也就是用户来说没有影响 , 可以针对部分重要代码 or 功能增加混淆.
  • 2️⃣ : 使用宏定义需要注意工程中有其他同样字符串产生影响 . ( 例如上述案例中我用了一个 Token 的宏 , 那么一定要注意其他使用 Token 字样的地方 , 尽量使用唯一字符串代替 . )

网上也有很多利用编译器来实现的代码混淆的三方库 , 自动实现的 , 思路大体上都是如此 , 宏定义是手动做的 . 但也因此比较灵活和简单 , 自定义程度较高 .

3. 字符串常量隐藏

我们工程中的一些常量字符串在逆向开发中 , 使用 hopper 查看汇编时会直接以注释的方式写在汇编指令之后 , 这种注释会给攻击人员提供极大的线索和引导作用 .

比如如下代码 :

#define STRING_ENCRYPT_KEY "demo_AES_key"
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    
    LBObject * objc = [LBObject new];
    BOOL result = [objc checkIsVipWithkeyString:@"12311" Token:@"jfkfqwe"];
    lbCFunc(STRING_ENCRYPT_KEY);
}

void lbCFunc(const char * str){
    printf("%s",str);
}
@end

当采用了字符串常量 or 宏定义的做法 , 攻击人员使用 hopper 查看汇编时如下 :

( 注意 : 笔者这里 hopper 查看的已经是生产环境的包 )

甚至 hopper 自带的汇编还原伪代码可以做到如下 :

细思极恐 ..

那么如何做到隐藏常量字符串呢 ?

其实简单做法就是把字符串常量换为一个方法 , 在这个方法中返回需要的字符串即可 , 例如 :

比如很多同学的项目中 , 对称性加密的 key 可能是写在本地的 , 一些重要的 key / secret / token 可能也是 ( 这里只是举个例子 , 为了说明一些比较重要的常量字符串 , 现在类似 key 这种目前普遍都是服务器下发了 ) , 那么针对这种比较明显又比较重要的字符串 , 我们最好是对其做一下隐藏处理的 .

简单做法演示

#define STRING_ENCRYPT_KEY @"demo_AES_key"
@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
    //[self uploadDataWithKey:STRING_ENCRYPT_KEY]; //使用宏/常量字符串
    [self uploadDataWithKey:AES_KEY()]; //使用函数代替字符串
}

- (void)uploadDataWithKey:(NSString *)key{
    NSLog(@"%@",key);
}

static NSString * AES_KEY(){
    unsigned char key[] = {
        'd','e','m','o','_','A','E','S','_','k','e','y','\0',
    };
    return [NSString stringWithUTF8String:(const char *)key];
}
@end

效果展示

使用常量字符串 / 宏

使用函数

可以看到已经没有显示的字符串直接被书写出来了 , 当然 , 配合上方法混淆会更好 .

但是可能有同学会问了 , 如果攻击者静态分析定位到我们返回 key 这个函数怎么办 .

函数隐藏字符串升级版

#define STRING_ENCRYPT_KEY @"demo_AES_key"
#define ENCRYPT_KEY 0xAC
@interface ViewController ()
@end

@implementation ViewController
- (void)viewDidLoad {
    [super viewDidLoad];
//    [self uploadDataWithKey:STRING_ENCRYPT_KEY]; //使用宏/常量字符串
    [self uploadDataWithKey:AES_KEY()]; //使用函数代替字符串
}

- (void)uploadDataWithKey:(NSString *)key{
    NSLog(@"%@",key);
}

static NSString * AES_KEY(){
    unsigned char key[] = {
        (ENCRYPT_KEY ^ 'd'),
        (ENCRYPT_KEY ^ 'e'),
        (ENCRYPT_KEY ^ 'm'),
        (ENCRYPT_KEY ^ 'o'),
        (ENCRYPT_KEY ^ '_'),
        (ENCRYPT_KEY ^ 'A'),
        (ENCRYPT_KEY ^ 'E'),
        (ENCRYPT_KEY ^ 'S'),
        (ENCRYPT_KEY ^ '_'),
        (ENCRYPT_KEY ^ '\0'),
    };
    unsigned char * p = key;
    while (((*p) ^= ENCRYPT_KEY) != '\0') {
        p++;
    }
    return [NSString stringWithUTF8String:(const char *)key];
}
@end

采用这样的方式,这些字符不会进入字符常量区 . 编译器直接换算成异或结果 .

4. 越狱环境防护

在越狱环境下 , 最为出名的就是越狱插件 - tweak 的使用了 . Monkey 也提供了 Xcode 的插件可以很轻易地编写一个自己的插件 . ( 感兴趣的同学可以阅读一下笔者在实战篇-钉钉打卡插件 中有完整的逆向探索和编写流程 ) .

插件的工作原理

在笔者 iOS 底层 - 从头梳理 dyld 加载流程 篇中 , 我们知道一个环境变量 DYLD_INSERT_LIBRARIES 的存在 . dyld 会由此标识来决定是否加载插入动态库 .

  • 越狱环境下 , Cydia 的基石:MobileSubstrate 会将 SpringBoard[FBApplicationInfo environmentVariables]函数做 hook,将环境变量 DYLD_INSERT_LIBRARIES 设置添加需要加载的插件 ( 动态库 ) . 而应用的二进制包无须做任何改变 , 但是 dyld 在加载应用的时候就会因为 DYLD_INSERT_LIBRARIES 的机制 , 会去加载指定的插入库 .

  • 插件在开发时就需要指定需要附加的进程 , 因此就可以知道加载哪个应用时需要插入这个插件 .

越狱环境的插件就是此原理 . tweak 插件编译经过 .o 等中间产物其实最终会生成一个 dylib , 最终打包生成一个 .deb 的包 , 将其修改为 zip 你会看到我们的 dylib .

Monkey 以及 theos 中会将生成的 dylib 通过 openssh 拷贝到手机 /Library/MobileSubstrate/DynamicLibraries 里 , 等待 dyld 去加载附加 . 这也是为什么插件安装了之后会杀掉进程重新启动的原因 . 因为需要 dyld 再次工作将插件动态加载进来 .

它与重签名应用的代码注入 , 也就是通过修改应用 mach-oload commands , 虽然都是通过动态库注入 , 但可以说原理上完全是两个东西 . 越狱环境插件并不需要修改目标文件 .

知道了越狱环境插件的原理 , 那我们再来谈谈插件如何防护 .

越狱环境防护方法 1 : __RESTRICT

原理分析

dyld 源码中 , 我们发现了如下代码 :

if ( gLinkContext.processIsRestricted ) {
    pruneEnvironmentVariables(envp, &apple);
    // set again because envp and apple may have changed or moved
    setContext(mainExecutableMH, argc, argv, envp, apple);
}

查看后发现 , 当 processIsRestrictedtrue , 会删除相应的环境变量 , 也就意味着 DYLD_INSERT_LIBRARIES 可以被忽略掉 . 继续搜索查看到如下 :

if ( issetugid() || hasRestrictedSegment(mainExecutableMH) ) {
    gLinkContext.processIsRestricted = true;
}

也就是说 hasRestrictedSegment 时 , processIsRestricted 这个标识会被设置为 true .

点进去方法中 , 如上图 , 发现其实很简单 , 当我们的 mach-o 中有 __RESTRICT 段 以及 __restrict 节 时 , 这个函数就会返回 true . 也就意味着我们只剩下一个问题 , 如何给我们的应用添加 __RESTRICT 段 以及 __restrict 节 .

操作演示

操作很简单 , 在 Other Linker Flags 中添加 : -Wl,-sectcreate,__RESTRICT,__restrict,/dev/null 即可 ( 这个指令不能写错 , 写错会直接影响越狱插件能否注入成功 ) .

添加后编译 , 查看 mach-o .

添加完毕后 , 所有越狱插件都会添加无效 , 笔者这里就不演示了 .

防护手段评估

  • 该方案能有效地屏蔽越狱环境下基本所有插件 .
  • 该方案效果比较明细 , 也因此比较容易被查出 .
  • 该方案存在可能因为 dyld 随着系统更新修改了这个机制 , 那么防护手段就失效的风险 .

防护手段破解

由于这个方案可以屏蔽所有的插件 , 因此对于攻击人员来说 , 很容易会想到是利用了 __RESTRICT 的机制来防护的 . 打开 mach-o 直接查看你有没有这个段和节就能查找到 .

而由于 dyld 加载的机制 , 只要段与节不叫这个名字 , 就不认为是受限制的进程 . 因此 , 只需要改一下二进制即可 . 修改二进制的方式很多 , 有专门的二进制修改器 Synalize It! , 甚至 MachOView , Hopper , IDER 等可视化工具都可以直接修改 .

修改完毕发现如下 :
此时 , 重新打开你要逆向的工程 , 你会发现直接闪退 . 网上各种论坛里特别多这个问题 .. 问为什么修改了会闪退 .

实际上我们在 应用签名原理及重签名 (重签微信应用实战) 中讲过 iOS 应用签名和验证的原理 , 上述问题的根本在于 当我们修改了二进制 , 那么就需要对应用进行重签名 , 否则 iOS 的验签中应用被修改 , hash 值一定变化 , 那么验签一定过不去 .

重签名的过程我们就不讲了 , 上述文章里有详细演示 . 下面我们讲讲如何防护这种做法 .

破解手段防护

fishhook 源码 , 与 阿里开源的组件化框架 BeeHive 中的启发 , 我们可以自己去干 dyld 在做的事 . 换句话说 , 在我们已经添加了 __RESTRICT 段和节的前提下 , 我们可以自己在运行时去读 mach-o 中段和节的名称 , 以达到检查 __RESTRICT 段__restrict 节 有没有被修改的需求 .

不知道大家这个思路理清楚了没 , 解释一下 :

为了防护我们的二进制中 __RESTRICT 段__restrict 节 被修改掉 . 我们在运行时自己去检查一遍 .

  • 有则代表没有被修改 .
  • 没有则代表已经被修改 . ( 因为我明明添加了这个段和节 , 却没有查到 )
防护手段使用演示
#import <mach-o/loader.h>
#import <mach-o/dyld.h>

#if __LP64__
#define LC_SEGMENT_COMMAND        LC_SEGMENT_64
#define LC_SEGMENT_COMMAND_WRONG LC_SEGMENT
#define LC_ENCRYPT_COMMAND        LC_ENCRYPTION_INFO
#define macho_segment_command    segment_command_64
#define macho_section            section_64
#define macho_header            mach_header_64
#else
#define macho_header            mach_header
#define LC_SEGMENT_COMMAND        LC_SEGMENT
#define LC_SEGMENT_COMMAND_WRONG LC_SEGMENT_64
#define LC_ENCRYPT_COMMAND        LC_ENCRYPTION_INFO_64
#define macho_segment_command    segment_command
#define macho_section            section
#endif

@implementation ViewController

+(void)load
{
    //imagelist 里第0个是我们自己的可执行文件
    const struct mach_header * header = _dyld_get_image_header(0);
    
    if (hasRestrictedSegment(header)) {
        NSLog(@"没问题!");
    }else{
        NSLog(@"检测到!!");
        // 退出程序  ,  可以上报 or 记录 ..
        #ifdef __arm64__
            asm volatile(
                         "mov x0,#0\n"
                         "mov x16,#1\n"
                         "svc #0x80\n"
                         );
        #endif
        #ifdef __arm__//32位下
            asm volatile(
                         "mov r0,#0\n"
                         "mov r16,#1\n"
                         "svc #80\n"
                         );
        #endif
    }
}

static bool hasRestrictedSegment(const struct macho_header* mh)
{
    const uint32_t cmd_count = mh->ncmds;
    const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(struct macho_header));
    const struct load_command* cmd = cmds;
    for (uint32_t i = 0; i < cmd_count; ++i) {
        switch (cmd->cmd) {
            case LC_SEGMENT_COMMAND:
            {
                const struct macho_segment_command* seg = (struct macho_segment_command*)cmd;
                
                if (strcmp(seg->segname, "__RESTRICT") == 0) {
                    const struct macho_section* const sectionsStart = (struct macho_section*)((char*)seg + sizeof(struct macho_segment_command));
                    const struct macho_section* const sectionsEnd = &sectionsStart[seg->nsects];
                    for (const struct macho_section* sect=sectionsStart; sect < sectionsEnd; ++sect) {
                        if (strcmp(sect->sectname, "__restrict") == 0)
                            return true;
                    }
                }
            }
                break;
        }
        cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
    }
    
    return false;
}
@end

当然 , 同样为了防止我们的 hasRestrictedSegment 代码被 hook , 你可以结合混淆 , 提前执行机制 ( 本文的动态调试-防护方式 3 : ptrace / sysctl 优化版 - 提前执行) 来共同使用 .

同样还是那句话 , 不建议直接退出应用 , 不要留下明显的防护痕迹 , 否则攻击人员首先不会怀疑自己有没有哪里没有理清楚 , 而是会针对这个点展开分析 .

吃瓜环节

这个防护手段是从哪出来的呢 ? 不知道大家有没有人还知道 '念茜' , 支付宝的逆向工程师 , CSDN 里活跃的一位前辈 , 14 年初就开始分享逆向知识 , 也很感谢这些前辈为我们铺下了路 , 让我们站在巨人的肩膀上 .

防护方法 2 : 监测环境变量

使用和原理都比较简单 , 我们都讲述过了 . 就是监测 DYLD_INSERT_LIBRARIES . 因此就可以检测到有没有越狱插件 , 或者说是不是越狱环境了 ( 因为越狱环境默认就有 Mobile Substrate 等插件了 ) .

//越狱检测
char * dlname = getenv("DYLD_INSERT_LIBRARIES");
if (dlname) {
    NSLog(@"越狱手机,关闭部分功能");
}else{
    NSLog(@"正常手机!");
}

防护方式 3 : image list

这个防护手段其实就是 我们本文 动态调试其他防护方式 中的第一种 . 使用白名单监测自己工程当前引入三方库 , 查找是否有未知库注入 .

由于 DYLD_INSERT_LIBRARIES 的原理和特性 , 其本质上也是动态库注入 , 只不过是由 dyld 动态去加载实现的动态注入 , 无须修改二进制文件本身 .

我们同样可以使用如下 :

来查看是否有其他动态库注入 , 当发现

这些库时 , 你基本就可以断定这个用户是在越狱环境中了 .

总结

防护技巧 :

  • 单一的防护手段往往不足以保证安全 , 多重防护手段结合使用 , 能起到更好效果 .
  • 不要给防护代码留下明显的痕迹 , 尤其在 UI 层面 ( 例如闪退 , 弹框等等 ) .
  • 没有绝对安全的防护 , 增加攻击者的查找和分析花费即可达到防护的目的 .
  • 不懂进攻 , 何谈防护 .