浅谈地址无关代码

一、程序的加载与链接

1.1 静态链接与动态链接

  • 程序的代码基本上不可能全部在一个源文件中实现。例如以最经典的helloworld为例,调用的printf函数就没有被我们在源文件中实现,而实际上是通过调用C语言标准库实现。以Linux中为例,库文件是libc。
  • 我们对于这种文件的调用有两种方式,一种是链接过程中加入静态库,例如libc-a-b-c.a。另一种是执行时动态调用动态库,例如libc-a-b-c.so:
    • 其中使用静态库,相当于将静态库文件与我们编写的程序链接在一起,生成成一个可执行文件,在这种情况下我们的可执行文件无需任何依赖即可执行。此时文件大小较使用动态库明显大。
    • 使用动态库则不同,生成的可执行文件并不包含库文件,也就是说可执行文件没有库的情况下是无法执行的。在执行期间,可执行文件被加载进虚拟内存时,可执行文件涉及到的动态库也被加载进内存。此时可执行文件大小较使用静态库明显小很多。

  • 事实上,系统中大部分程序都是需要几个特定的库。倘若我们都使用静态库的方案,当系统运行100个进程时,若这些进程同时执行到库部分的代码,物理内存被占据了100个库的大小。考虑到内存是非常宝贵的,这种方案显然是不合理的。

  • 使用动态链接库也可能会导致一些版本接口改变造成的依赖性问题。但考虑到其相对于静态库节约的内存,该方案在目前被广泛的使用。

1.2 从编译到链接

  • 在日常的开发工作中,我们往往使用的是集成开发环境。一个按键就能够帮我们完成从源码到可执行文件的转换。即使是在Linux的shell下,往往也是gcc一条指令就可以完成。事实上,程序从源码到可执行文件需要有一个复杂的过程。

  • 然而当我们不再将静态库编译进程序,而是动态加载库文件时,问题就出现了。众所周知,汇编语言中控制流转换是需要地址的。例如一条调用printf的API的语句
    printf("IOLI Crackme Level 0x00n");    
...
.text:0804842E       29 C4                     sub     esp, eax
.text:08048430       C7 04 24 68 85 04 08      mov     dword ptr [esp], offset format ; "IOLI Crackme Level 0x00n"
.text:08048437       E8  04 FF FF FF           call    _printf
...

  • 在这段汇编中,我们可以看到call指令很明显涉及到地址。这段汇编代码中的地址是在链接的阶段完成的,而动态库中的API地址是在装载之后才确定的。(实际上部分重要的动态库,例如ntdll等有着几乎是固定的装载地址,但对于大部分dll而言这仍然是个问题,尤其是当ASLR机制被引入之后)这为我们的程序编译造成了非常大的麻烦。

二、地址无关代码(PIC)

2.1 静态共享库

  • 对于这一问题,一开始人们的想法是在编译阶段前,也就是汇编代码生成之前确定API的地址。那么这么一来每个库的装载地址都需要预先确定。
  • 在早期,我们使用动态库的时候曾经有过一种名为静态共享库的解决方案。这与当前使用的动态库不太一样。其主要特点是将模块统一交给操作系统管理,使用的库会被固定加载到某个地址。这样我们就可以在编译的时候确定函数的地址。因为每个函数由于其库是确定的,所以其库在内存中的装载地址也是确定的,由此就可以得到其地址。
  • 然而该方案有几个很严重的问题:
    • 库在升级的过程中不能改变原有函数的位置,不能有API的删减,否则将导致已经生成的可执行文件无法执行。
    • 自己编写的库文件无法以共享库的形式被使用
  • 为了解决这一问题,我们不再强制要求模块在固定地址装载,而是试图在模块装载地址确定之后再修复函数引用的地址。
  • 顺带一提,目前可执行文件的模块装载地址在没有开启ASLR的前提下是固定的。例如Windows下地址为0x00400000,Linux下为0x0804000。

2.2 装载时重定位

  • 我们首先想到的就是重定位。简而言之就是对其他模块的函数的引用在链接时只填写函数相对于其模块的起始地址,到装载时模块基地址确定后再修改为相对地址+基地址。例如foo相对于模块a的代码段起始地址是0x100,先在链接阶段填入,之后模块a装载后确定基地址为0x600000,则修改所有调用foo处地址为0x600100。
  • 这种方案到目前为止都在被广为使用,但是也存在一个问题,那就是指令共享时无法正常使用。
  • 为什么会有指令共享?当前操作系统中,一个进程可以理解为对一个程序的一次执行,当系统多次运行一个程序(数据不同)时,为了节约内存,就会产生多个进程在物理地址中共用一个代码段的情况(在虚拟内存中仍然是各有一份代码,因为进程有独立的地址空间)。
  • 那么在这种情况下,显然重定位修改只能针对一个进程,对于另一个进程,就无法正常使用了。当然,对于动态链接库,此方案仍然可以正常使用。

2.3 地址无关代码

  • 那么有没有一套通用的方案?答案是有的。由于指令部分可能是共享的,所以我们很自然的想到将需要重定位的部分从指令中分离出来,放到数据中。由于每个进程数据是独一无二的,所以这样就可以实现重定位的同时不影响代码的共享。
  • 首先我们先将模块的地址引用分为四种:模块内部的函数调用,模块内部的数据访问,模块外部的函数调用,模块外部的数据访问。

2.3.1 模块内部函数调用或跳转

  • 这种情况可以说是最简单的,由于被调用的函数与调用者处于同一模块,两者之间相对位置固定,所以这种指令不需要定位,一条相对跳转/调用就可以完成。而返回时由于call指令会自动完成push $PC的操作,所以ret不需要任何处理就可以定位到原先call的下一条指令(PC在取完call之后指向下一条指令的地址而不是call的地址)。
0x8048349   :
...
.init:080482FE   E8 81 00 00 00     call    sub_8048384
...

  • 例如上面代码中,e8是call指令的操作码,81 00 00 00实际上是小端序的0x00000081,补码表示,实际上是0x81的偏移。该指令实际上是一条相对调用指令。我们将call下一条指令的地址(0x080482FE+0x5)加上偏移量就得到了指令地址,即0x8048384。

2.3.2 模块内部数据调用

  • 首先,由于模块装载地址是不确定的,所以对于模块内部数据调用我们无法使用直接地址。其次,由于模块内部数据与代码处于同一个模块中,尽管处于不同的段(一般代码处于.text而数据处于.data段),两者之间的相对偏移量可以说是确定的。唯一的问题在于,没有一条相对当前PC地址的访存指令,所以我们需要获得当前地址才能够完成模块内部数据调用。
  • 幸运的是,elf为我们提供了一个很好的解决方案,__i686.get_pc_thunk.xx函数,该函数只有两条语句:
.text:08048515 __i686_get_pc_thunk_bx proc near        ; CODE XREF: __libc_csu_init+8↑p
.text:08048515                 mov     ebx, [esp+0]
.text:08048518                 retn
.text:08048518 __i686_get_pc_thunk_bx endp

  • 由于call指令等效于push $PC,所以mov ebx, [esp+0]相当于取call <__i686.get_pc_thunk.bx>指令的下一条地址指令到ecx,此时就可以通过ecx加上偏移,完成对模块内部数据的寻址。
.text:080484A8                 call    __i686_get_pc_thunk_bx
.text:080484AD                 add     ebx, 1B47h
.text:080484B3                 sub     esp, 1Ch
.text:080484B6                 call    _init_proc
.text:080484BB                 lea     eax, (__CTOR_LIST__ - 8049FF4h)[ebx]
.text:080484C1                 lea     edx, (__CTOR_LIST__ - 8049FF4h)[ebx]

  • 此处我们看到,程序调用call __i686_get_pc_thunk_bx后对ebx(即0x080484AD的地址)进行了加0x1B47的操作,也就是加上了偏移量。之后两条lea指令则是通过一些常量结合先前的地址完成偏移量计算,最终完成访存操作。

2.3.3 模块间数据访问

  • 跨模块间调用显然要麻烦的多,因为我们只有在被调用的代码所在模块装载完成后才能够获得其基地址。
  • 先前我们提到,为了避免影响代码共享的效果,我们必须将与跨模块相关的调用与代码段分离,放在数据段中。那么具体的实现办法就是对于跨模块的调用,我们使用一张表来存储其地址。该表最初是未初始化,模块装载后该表完成初始化。访问变量时,先取出表中的地址,之后通过该地址来完成访问。这也就是GOT表。
  • 由于每个进程有着不同的数据段,因此我们对同一程序的不同进程也可以保持不同的地址访问。

2.3.4 模块间跳转与调用

  • 对于模块之间的跳转与调用,我们可以采用2.3.3的方法来修改,唯一有区别的就是相对于2.3.3访问从got表取得的地址,这里采用的是call这个从got表中获得的地址。
    call        494 <__i686.get_pc_thunk.cx>
    add         $0x118c,%ecx
    mov         0xfffffffc(%ecx),eax
    call        *(%eax)

  • 这种方法非常简单,但存在部分对性能的浪费,实际上ELF采用另一种近似原理的方案。

三、延迟绑定

  • 先前我们在2.3.4中提到模块间相对跳转需要通过GOT表间接完成。由于GOT表是在模块装载后才能确定的,所以说我们需要逐一初始化GOT表表项。对于数据访问相关的项倒还好,毕竟所有项是必定会被访问的。然而对于函数访问,相当多的函数可能从程序执行到结束都不会被访问,这也造成了极大的浪费。
  • 对此,编译器使用了一种延迟绑定的技术,使用PLT(Procedure Linkage Table)方法来实现。即当第一次访问函数时才将GOT表填入。在这种情况下我们就无法通过直接访问GOT表来完成函数调用了。因为如果直接访问GOT表,则在第一次访问时GOT表尚未初始化,就会出现访存错误。编译器对此使用的是不直接访问GOT表,而是通过一个plt来完成跳转。根据情况选择装填GOT表或直接访问GOT表地址。
  • 也因此,为了与原先的GOT表区分,所有的函数填入的表被称之为.plt.got表而不是原先的GOT表。

四、基于PIC的fishHook

fishHook是Facebook提供的一个动态修改链接mach-O文件的工具。利用MachO文件加载原理,通过修改懒加载表(Lazy Symbol Pointers)和非懒加载表(Non-Lazy Symbol Pointers)这两个表的指针达到C函数HOOK的目的。

学习fishHook原理,需要了解MatchO相关知识MachO相关知识以及Dyld加载原理dyld简介及加载过程分析。

4.1 fishHook原理

1.共享缓存库
在iOS or Mac系统中,几乎所有的程序都会用到动态库,而动态库在加载的时候都需要用dyld(位于/usr/lib/dyld)程序进行链接。很多系统库几乎都是每个程序都要用到的,如果在每个程序运行的时候在一个一个将这些动态库都加载进来,不仅耗费内存,而且耗时。为了降低内存,提高性能,苹果引入了共享缓存库,用来存储系统的库。
Mac下的共享缓存库位置:/private/var/db/dyld/
iOS下的共享缓存库位置:/System/Library/Caches/com.apple.dyld/
文件名都是以dyld_shared_cache_开头,再加上这个dyld缓存文件所支持的指令集。

2.PIC技术(位置独立代码)
我们都知道C语言是静态的,也就是说,在编译的时候就已经确定了函数的地址。而系统的函数由于共享缓存库的存在,必须是dyld加载的时候(运行时)才能确定,这明显存在矛盾。为了解决这个问题,苹果针对Mach-O文件提供了一种PIC技术,即在MatchO的_Data段中添加懒加载表(Lazy Symbol Pointers)和非懒加载表(Non-Lazy Symbol Pointers)这两个表,让系统的函数在编译的时候先指向懒加载表(Lazy Symbol Pointers)或非懒加载表(Non-Lazy Symbol Pointers)中的符号地址,这两个表中的符号的地址的指向在编译的时候并没有指向任何地方,app启动,被dyld加载到内存,就进行链接, 给这2个表赋值动态缓存库的地址进行符号绑定。

  1. fishHook使用示例
#import "ViewController.h"
#import "fishhook.h"

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    // Do any additional setup after loading the view, typically from a nib.

    NSLog(@"开始");

    struct rebinding nsLog;
    nsLog.name = "NSLog";
    nsLog.replacement = nNSLog;
    nsLog.replaced = (void *)&oNSLog;
    struct rebinding rebinds[1] = {nsLog};
    rebind_symbols(rebinds, 1);
}
static void (* oNSLog)(NSString *format, ...);
void nNSLog(NSString *format, ...) {
    oNSLog(@"%@",[format stringByAppendingString:@"被HOOK了"]);
}
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
    NSLog(@"测试");
}
@end

首先使用image list指令,查看MatchO的首地址

使用MatchOView查看MatchO懒加载表中的NSLog符号在文件中的偏移值

执行NSLog之后:

执行Hook之后:

分别查看NSLog执行之前,执行了NSLog,被Hook以后这三个时刻,懒加载表中NSLog符号指向的地址。
总结: fishHook其实就是修改懒加载表(Lazy Symbol Pointers)、非懒加载表(Non-Lazy Symbol Pointers)中的符号地址的指向,从而达到hook的目的。

4.2 fishHook的流程图

通过NSLog字符串,fish是如何找到NSLog对应的符号表的呢?

你可能感兴趣的:(浅谈地址无关代码)