Hook原理

什么是hook

HOOK,中文译为“挂钩”或“钩子”。在iOS逆向中是指改变程序运行流程的一种技术。
例如,一个正常的程序运行流程是A->B->C,通过hook技术可以让程序的执行变成A->我们自己的代码->B->C。在这个过程中,我们的代码可以获取到A传递B的数据,对其进行修改或利用再传递给B,而A,B是不会感知到这个过程的。所以,通过hook可以让别人的程序执行自己所写的代码。在逆向中经常使用这种技术。在学习过程中,我们重点要了解其原理,这样能够对恶意代码进行有效的防护。在iOS系统中有以下三种方式可以实现hook,这篇文章主要讲究fishhook的使用及其原理。

iOS中hook的三种方式

rumtime

利用OC的runtime api,动态改变SEL和IMP的对应关系,达到方法调用流程改变的目的。主要用于OC方法

fishhook

它是Facebook开源的一个动态修改链接Mach-O文件的工具。利用dyld加载Mach-O文件的原理,通过修改懒加载和非懒加载两个表的指针达到hook系统动态库函数的目的。主要用于系统库的C函数。

Cydia Substrate

Cydia Substrate原名为Mobile Substrate,它的主要作用是针对OC方法,C函数以及函数地址进行Hook操作。并且它并不是仅仅针对iOS设计的,在安卓平台一样可以使用。官方地址www.cydiasubstrate.com

rumtime hook

很多文章里介绍说是使用Method Swizzling来进行方法交换的,但其实吧,这两单词翻译过来就是方法交换的意思。代入原句就很别扭了,明明就是使用runtime提供的api来达到Mehtod Swizzling方法交换的目的。其实runtime里面除了使用method_exchangeImplementations()来实现方法的交换以外,还可以使用class_replaceMethod()方法替换实现,也可以使用method_getImplementation()method_setImplementation搭配使用实现。这个部分的内容在我前面的文章代码注入中已经讲过了,感兴趣的读者可以自行前往查看。

方法交换在正向开发中可用于埋点,数据监控统计,防止崩溃等,在iOS逆向工程中可以通过对某个方法进行拦截和修改达到修改逻辑和数据的目的,在后面的实战中会大量使用该技术。

fishhook

fishhook利用了dyld加载Mach-O文件的原理,dyld从iOS13开始,从dyld2更新到了dyld3,目前看来,对fishhook的影响不是很大,依然可以正常使用。这里贴上github对这个问题的讨论
fishhook with dyld 3.0 #43

fishhook的使用

fishhook交换NSLog()

从github下载fishhook源码,可以看到fishhook源码就一个.c和.h加起来不到300行代码。新建一个工程,并添加以下代码:
image.png
这里需要讲一下fishhook提供的api就两个,都是c语言的函数。rebing_symbols函数需要两个参数,第一个参数是一个rebinding结构体的数组,第二个参数是数组的个数。

rebinding结构体是fishhook提供的

  • name字段表示需要hook的函数的名称。
  • replaced字段这里需要传入一个函数指针,用来保存被hook的函数的原始实现。
  • replacement传入咱们自己实现,用来替换的的函数。

点击屏幕,发现我们的输出带上了后缀,表示hook成功了!!!
image.png

fishhook交换自定义的函数

image.png
没有交换成功,为什么fishhook可以交换系统的C函数,而无法交换我们自定义的C函数呢,请看下面的原理

fishhook 原理

iOS工程师们经常会听到说Objective-C是一门动态语言,而C是一门静态语言,这里说的动态和静态,具体是指什么呢?主要区别在于编译时确定,还是运行时确定。那么这个确定,是指确定什么呢,比如变量的具体类型,函数的具体实现等…下面举个例子,在工程中声明一个OC方法,不写定义代码,和声明一个C方法,同样不写定义代码。编译一下,查看编译器是否通过?
image.png
虽然Xcode给出了一个警告⚠️test方法定义未找到,但还是编译通过了。
image.png
而C语言声明的一个函数func,Xcode提示编译未通过,报错Undefined symbol:_func未定义的符号_func,这里系统自动给func加上下划线。Objective-C的动态特性使我们可以使用runtime来hook,而C的静态特性决定了它的函数实现在编译期就确定了,是无法进行hook的,那么为什么我们iOS系统的C函数能够被fishhook交换呢?

共享缓存

iOS中使用了共享缓存技术,每个APP进程都会用到的系统库,比如UIKit,Foundation…都会被放到共享缓存库中,在我的上篇文章dyld中讲到过,感兴趣的同学可以前往查看

位置无关代码 (position-independent code,PIC)

因为C函数是静态的,在编译的时刻,就需要有一个C函数的实现地址。而iOS由于有了共享缓存机制,使得我们APP内调用的系统函数,通过dyld加载进内存的时候,才会绑定系统函数在共享缓存中的地址。这里存在一个矛盾,编译器在编译C函数的时候,必须要一个地址,但共享缓存的存在,让我们实际的地址只有在运行的时刻才能知道。所以苹果使用PIC技术。

根据当前APP中调用到了的系统库函数的符号(比如NSLog),在Mach-O的Data段(Data段可读写)建立了了懒加载表和非懒加载表,在编译的时候,就使用对应的符号地址,这个时候的地址是内存中的随机值,仅仅是为了通过编译。在程序启动dyld执行完绑定时,这个时候才将共享缓存中真正的实现地址找到并赋值给我们的符号。

理论讲了那么多,怎么验证我们讲的是不是对的?

根据经验我们知道NSLog符号是懒加载的,那我们就以NSLog符号举例。我们可以新建一个崭新的工程,什么代码也不写,直接查看Mach-O文件的懒加载符号指针如下图,是找不到NSLog的。
image.png
然后我们在任何位置,添加一句NSLog打印代码:
image.png
之后再次查看Mach-O文件的懒加载符号指针:
image.png
发现多了一个地址,这个就是我们NSLog符号在我们Mach-O文件中的地址。这里证明了系统确实是根据我们项目里面用到的库函数,来建立的符号表的。

我们回到交换NSLog函数的代码,在调用fishhook重绑定前,添加一行NSLog输出代码,再分别在NSLog打印前,打印后,和fishhook重绑定后打上三个断点:
Snip20210708_66.png
再次运行,到第一个断点,使用LLDB的image list命令查看我们当前程序加载的镜像以及地址,我们只需要第一个镜像,也就是我们当前APP自己的Mach-O的内存地址
image.png
再打开Mach-O文件查看NSLog符号的地址
image.png
需要注意的是,Mach-O文件中NSLog符号的地址是相当于当前文件的偏移,再加上当前Mach-O文件在内存中的地址,就是上一步得到的。相加的值才是NSLog符号在内存中的地址。使用LLDB命令memory read 地址可以查看内存中的值,我这里加起来是0x1024F0000,再使用dis -s 地址查看反汇编,发现现在啥也不是。
image.png
这个时候,放掉第一个断点,来到第二个断点,此时NSLog就已经打印一次了,那么我们NSLog符号的地址里面存放的应该就是Foundation中NSLog的实现。验证一下:
image.png
没有问题,我们来到最后一个断点,这个时候fishhook执行了重绑定代码,那么我们看看现在NSLog符号是不是指向了我们的实现:
image.png

可以看到,fishhook其实就是修改懒加载符号表,非懒加载符号表中符号指向的地址,从而达到hook的目的。

fishhook 源码分析

fishhook的实现代码不过200多行,分析这200多行代码,需要对Mach-O文件有一定的理解。如果有兴趣的可以查看我之前的文章Mach-O文件,如果不了解Mach-O文件的话,那这200多行的代码就有点像天书…

先看一下fishhook的头文件
image.png
头文件非常简单,一个rebinding结构体,两个C函数,都是用来重绑定的,其中一个不需要指定镜像和ASLR的偏移,另一个需要。一般推荐还是使用不需要指定镜像和偏移的,毕竟这两个参数我们也不太好弄到…(可以使用dyld提供的一些api获取,但不是很方便,dyld的api在mach-o/loader.h,使用Xcode快捷键command + shift + o打开),而且我在使用指定镜像的这个api的时候,会出现只绑定成功一次的情况…

再来看实现代码,这里就只分析int rebind_symbols(struct rebinding rebindings[], size_t rebindings_nel)这个不需要指定镜像的。
image.png
首先调用prepend_rebindings()来做一些准备工作,rebind_symbols使用链表来存储每一次调用自己时,传入的参数,每次调用的时候和参数一起构成新的一张表,新表的next指向旧的表,这样每次调用的参数都保存下来了…
image.png
根据_rebindings_head->next是否有值,就可以判断出是否是第一次调用rebind_symbols,如果是第一次调用,就调用注册监听函数_dyld_register_func_for_add_image(),已经被dyld加载的镜像会立刻执行回调,之后加载的镜像,会在dyld加载的时候触发回调。如果不是第一次调用了,就不需要重复注册回调了,直接遍历所有镜像进行重绑定。
image.png
回调函数_rebind_symbols_for_image里面调用自己的重绑定函数。

接下来看rebind_symbols_for_image的实现

    Dl_info info;
    if (dladdr(header, &info) == 0) {
        return;
    }
复制代码

首先是一段判断逻辑,不太理解是做什么的,但不影响对后面整体流程的理解,就放过。。。

    //定义好几个变量,准备从MachO里面去找!
    segment_command_t *cur_seg_cmd;
    segment_command_t *linkedit_segment = NULL;
    struct symtab_command* symtab_cmd = NULL;
    struct dysymtab_command* dysymtab_cmd = NULL;
    
    uintptr_t size = sizeof(mach_header_t);
    uintptr_t cur = (uintptr_t)header + size;
    // 循环遍历Mach-O文件的Load Commands,找到上面3个需要的Load Command
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
        cur_seg_cmd = (segment_command_t *)cur;
        if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
            if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
                linkedit_segment = cur_seg_cmd;
            }
        } else if (cur_seg_cmd->cmd == LC_SYMTAB) {
            symtab_cmd = (struct symtab_command*)cur_seg_cmd;
        } else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
            dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
        }
    }
    //如果刚才获取的,有一项为空就直接返回,dysymtab_cmd->nindirectsyms意思LC_DYSYMTAB加载命令中 间接符号表个数的意思 小于0意思没有就不执行后面的代码了
    if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment || !dysymtab_cmd->nindirectsyms) {
        return;
    }
复制代码

这一步就是从Mach-O文件中的Load Commands中找到想要的加载命令,分别是LC_SYMTAB,LC_DYSYMTAB和__LINKEDIT段,下一步根据这几个Load Command分别找到符号表,字符串表和间接符号表的地址

    // 镜像文件头在内存中的地址简称基址 = __LINKEDIT.VM_Address -__LINKEDIT.File_Offset + silde的改变值
    uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
    
    //符号表的地址 = 基址 + 符号表偏移量
    nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
   
    //字符串表的地址 = 基址 + 字符串表偏移量
    char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);
    
    //动态符号表地址 = 基址 + 动态符号表偏移量
    uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);
复制代码

接着,又是遍历一遍Load Commands,找到我们的非懒加载符号表,和懒加载符号表,这个过程判断比较多,因为非懒加载符号表和懒加载符号表在__DATA_CONST段的__got节和__DATA段的__la_symbol_ptr节中

    cur = (uintptr_t)header + sizeof(mach_header_t);
    // 又是遍历一遍Load Commands,如果是LC_SEGMENT_64或LC_SEGMENT加载命令,那么找到名字为__DATA_CONST或__DATA的segment
    for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
        cur_seg_cmd = (segment_command_t *)cur;
        if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
            //找到名字为__DATA_CONST或__DATA的segment
            if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 && strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
                continue;
            }
            
            // 找到section为S_LAZY_SYMBOL_POINTERS或者S_NON_LAZY_SYMBOL_POINTERS的section
            for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
                section_t *sect = (section_t *)(cur + sizeof(segment_command_t)) + j;
                //找懒加载表
                if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
                    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
                }
                //非懒加载表
                if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
                    perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
                }
            }
        }
    }
复制代码

LC_SEGMENT_ARCH_DEPENDENT是一个针对不同架构的宏,对应的是普通的段或者64位架构的段

#ifdef __LP64__
typedef struct mach_header_64 mach_header_t;
typedef struct segment_command_64 segment_command_t;
typedef struct section_64 section_t;
typedef struct nlist_64 nlist_t;
#define LC_SEGMENT_ARCH_DEPENDENT LC_SEGMENT_64
#else
typedef struct mach_header mach_header_t;
typedef struct segment_command segment_command_t;
typedef struct section section_t;
typedef struct nlist nlist_t;
#define LC_SEGMENT_ARCH_DEPENDENT LC_SEGMENT
#endif
复制代码

找到了懒加载表或者非懒加载表之后,就开始执行真正的重绑定逻辑了perform_rebinding_with_section()

    //__got和__la_symbol_ptr section中的reserved1字段指明对应的indirect_symbol table起始的index
    uint32_t *indirect_symbol_indices = indirect_symtab + section->reserved1;
    
    //slide + section->addr 就是符号对应的存放函数实现的数组
    //也就是我相应的__got和__la_symbol_ptr相应的函数指针都在这里面了,所以可以去寻找到函数的地址
    void **indirect_symbol_bindings = (void **)((uintptr_t)slide + section->addr);
复制代码

上面两个变量,一个用来寻找符号,一个用来寻找符号对应的函数实现地址,然后是遍历两个表里面的每一个符号,每一个符号都跟链表里面的我们传入的name匹配,如果一致就说明找到了要hook的符号,然后将符号对应的原始函数实现地址,赋值给我们用来保存的变量replaced,再将我们自定义函数的地址赋值给符号保存;这样,我们APP代码调用符号函数的时候,就先来到了我们自定义函数的逻辑,如果我们在自定义的函数逻辑里,调用保存的原始函数实现,就实现了hook,代码如下

    //遍历section里面的每一个符号
    unsigned long long count = section->size / sizeof(void *);
    for (uint i = 0; i < count; i++) {
        //找到符号在Indrect Symbol Table表中的值
        //读取indirect table中的数据
        uint32_t symtab_index = indirect_symbol_indices[i];
        if (symtab_index == INDIRECT_SYMBOL_ABS || symtab_index == INDIRECT_SYMBOL_LOCAL ||
            symtab_index == (INDIRECT_SYMBOL_LOCAL | INDIRECT_SYMBOL_ABS)) {
            continue;
        }
        //以symtab_index作为下标,访问symbol table
        uint32_t strtab_offset = symtab[symtab_index].n_un.n_strx;
        //获取到symbol_name,可以打印出每个符号
        char *symbol_name = strtab + strtab_offset;
        //判断是否函数的名称是否有两个字符,为啥是两个,因为C函数前面有个_,所以函数的名称最少要1个
        bool symbol_name_longer_than_1 = symbol_name[0] && symbol_name[1];
        //遍历最初的链表,来进行hook
        struct rebindings_entry *cur = rebindings;
        while (cur) {
            for (uint j = 0; j < cur->rebindings_nel; j++) {
                struct rebinding one = cur->rebindings[j];
                //这里if的条件就是判断从symbol_name[1],从1开始去掉了_,两个函数的名字是否都是一致的
                if (symbol_name_longer_than_1 && strcmp(&symbol_name[1], one.name) == 0) {
                    //判断replaced的地址不为NULL以及原始方法的实现和rebindings[j].replacement的方法不一致
                    if (one.replaced != NULL && indirect_symbol_bindings[i] != one.replacement) {
                        //让rebindings[j].replaced保存indirect_symbol_bindings[i]的函数地址
                        *(one.replaced) = indirect_symbol_bindings[i];
                    }
                    //将替换后的方法给原先的方法,也就是替换内容为自定义函数地址,
                    indirect_symbol_bindings[i] = one.replacement;
                    goto symbol_loop;
                }
            }
            cur = cur->next;
        }
    symbol_loop:;
    }
复制代码

Cydia Substrate

MobileHooker

顾名思义用于HOOK。它定义一系列的宏和函数,底层调用objc的runtime和fishhook来替换系统或者目标应用的函数。其中有两个函数:

  • MSHookMessageEx 主要作用于Objective-C方法
  • MSHookFunction 主要作用于C和C++函数,Logos语法的%hook就是对此函数做了一层封装。

MobileLoader

MobileLoader用于加载第三方dylib在运行的应用程序中。启动时MobileLoader会根据规则把指定目录的第三方的动态库加载进去,第三方的动态库也就是我们写的破解程序。

safe mode

破解程序本质是dylib,寄生在别人进程里。系统进程一旦出错,可能导致整个进程崩溃,崩溃后就会造成iOS瘫痪。所以CydiaSubstrate引入了安全模式,在安全模式下所有基于Cydia Substratede的三方dylib都会被禁用,便于查错与修复。

反hook初探

利用fishhook修改runtime的相关api,比如上面所讲的method_exchangeImplementations等等,但需要最先加载,否则无效,放在工程的Framework中最好,这样别人无法使用第三方Framework插入的方式进行代码注入了,使用过yololib工具注入的同学会发现,插入的Framework只能放在Load Commands的最后一条,那样我们自己的Framework肯定在前面,这样就可以屏蔽恶意代码注入了。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享