深刻iOS系统底层之程序映像

绿树阴浓夏日长,楼台倒影入池塘。--《唐高骈·山亭夏日》   c++

mach-o文件和进程的映像(image)

iOS系统生成的可执行程序或者动态库文件的存储布局格式被称之为mach-o格式。文件中存放着程序的代码和数据,而程序运行时系统会为其创建一个进程,以及分配虚拟内存空间。同时会把程序文件中的内容加载到虚拟内存地址空间中去,这种加载的方法通常采用内存映射文件的技术来实现。所谓的映像能够理解为将一个程序文件的内容加载到进程虚拟内存中的内容,也就是说进程的映像就是程序磁盘文件在内存中的一个副本。 通常来讲一个进程中映像的内容和内存布局结构会和程序文件的内容以及存储布局结构一致,映像的首地址是一个struct mach_header的结构体指针。映像中内容的排列布局和程序文件都是以段(Segment)为单位进行排列的。可是有一些状况映像的内存布局和内容可能会和程序文件的内存布局和内容不一致:git

  1. 映像中的数据段部分,由于数据段部分大可能是能够被读写访问的,也就是说能够在运行时被修改,或者某些信息会进行rebase处理。所以数据段不能被进程之间共享,而是每一个进程单独维护一份。固然为了效率和性能系统会采用一种称之为Copy on write的技术来实现单独副本的拷贝的。一般只有不可变的代码段部分才会是内存和文件中的内容保持一致,而且多进程共享。一个很常见的例子就是进程中加载的动态库和框架中的代码段部分一般都是全部进程共享。github

  2. 即便是代码段也有可能映像中的内容和程序文件中的内容不一致。有一些映像中的某些段的内容会是系统中缓存的段,而不是程序文件对应的段。一个颇有表明性的例子就是CoreLocation这个库,当这个库被加载时你就会发现其映像中的有一些代码段的内容实际上是系统缓存的内容而不是程序文件中的内容。缓存

因此说程序文件和程序被加载后在内存中映像之间并非一一对应的。程序文件和映像之间的关系就如程序和进程之间的关系是同样的。在程序运行后对其在进程中全部的mach-o数据结构的访问都是基于映像而不是基于程序文件的。安全

Slide机制

构建一个程序时为了方便计算和处理会为这个程序设定一个默认在内存中加载的基地址。这样在程序中全部涉及到地址存储的代码中的地址变量都是以这个基地址为标准的。好比咱们在代码中有变量保存一个函数的地址或者在rumtime中的OC类的方法结构体:struct method_t中的imp保存的函数的地址等等。正常状况下若是咱们的程序加载时也是按照程序中指定的基地址加载到虚拟内存中对应的地址时则一切都正常并且也不须要作任何的改变。但实际状况则不一样:bash

  1. 任何一个库或者可执行程序在构建时都会指定一个加载的基地址,可是却没法保证这个基地址的惟一性。和没法保证程序映像的地址区间不产生重叠。所以有可能出现多个库加载到内存时的重叠覆盖的状况。
  2. iOS系统为保证的应用安全采用了一种称之为**ASLR(Address space layout randomization)**的技术。这种技术会使得每一个程序或者库每次运行加载到内存中时的基地址都不是固定而是随机的,这种机制会增长黑客的破解难度。

上面的两种状况代表一个程序或者库加载到内存时的真实的基地址和程序构建时指定的基地址是不同的。系统会为可执行程序和每一个库选择不重叠的区域进行加载。可是这样就会出如今程序中全部以构建时基地址为标准的那些地址指针出现访问异常,由于这些地址值并非真实在内存中的地址值。数据结构

为了解决这个问题系统会在构建的程序或库中添加一个特殊的load command命令:LC_DYLD_INFO或者LC_DYLD_INFO_ONLY。这部分信息用来记录全部须要进行地址调整的位置。这样当程序被加载到内存时,加载器就会将须要调整的地址分别进行调整处理,以便转化为真实的内存地址。这个过程称之为基地址重定向(rebase)。框架

假设程序构建时指定的基地址为A,程序中某处保存的一个函数指针地址为x,而程序被加载到内存时的真实基地址为B。也就是说真实的基地址和构建时的基地址的偏移差就是B-A。咱们称这个偏移差值为Slide值。所以真实的地址x被调整后应该是: x + (B - A)了。dom

一个程序在构建时的基地址值能够在程序的第一个名为__TEXT的代码段描述结构体struct segment_command中的vmaddr数据成员中获取,而程序被加载后的获得的映像的mach-o头部结构体struct mach_header指针则是映像被加载的真实的基地址,所以:ide

映像的Slide值 = 映像的mach_header结构体指针 - 映像的第一个__TEXT代码段描述结构体struct segmeng_command中的vmaddr数据成员的值。

固然系统也提供了接口API来获取可执行程序或者库的映像的Slide值。这个将会在下面介绍。

段(Segment)和节(Section)

mach-o文件由诸多的load command组成,每一个load command所表明的是一种数据类型。好比有的load command是用来存放程序代码和全局变量数据,有的load command是用来存放符号表,有的load command是用来存放代码签名信息等。每种load command都是结构体struct load_command的扩展结构体。其中的cmd字段用来描述这种load command的类型。

类型为LC_SEGMENT或者为LC_SEGMENT_64的load command被称之为段(Segment)。一个可执行程序中的代码和全局变量数据都保存在段中。描述段的信息是一个struct segment_command结构体。一个程序中能够存在着不少的段,每一个段有一个惟一的段名(segment name)。好比一个可执行程序中全部的代码都保存在名字为:__TEXT的代码段中,而全部的数据都保存在名字为:__DATA的数据段中。段以页为边界进行对齐。

每一个段则由多个节(Section)组成。节是内容分类的最小管理单元。每一个节的描述信息是一个称之为:struct section的结构体。每一个节有一个惟一的名称用来标识这个节。好比代码段中有一个名为:__text的节用来保存程序中用户编写的源代码对应的机器指令,而一个名为:__stub_helper的节则保存全部调用的外部函数的桩代码。下面的一张图展现的就是程序中的段和节的结构布局:

mach-o文件中的段和节信息

进程映像(Image)操做API

对映像进行操做的API都在<mach-o/dyld.h>中声明。你能够import这个头文件来使用里面定义的函数。下面我会分别介绍这些函数。

1.获取当前进程中加载的映像的数量
//函数返回当前进程中加载的映像的数量
uint32_t  _dyld_image_count(void) 
复制代码
2.获取某个映像的mach-o头部信息结构体指针
const struct mach_header*   _dyld_get_image_header(uint32_t image_index) 
复制代码

函数的入参为映像在进程当中的索引号,函数返回的值是一个映像的mach-o头部信息struct mach_header结构体指针,若是是64位系统则返回的是struct mach_header_64结构体指针。你能够经过这个函数返回的映像的头部结构体来遍历和访问映像中的全部信息和数据。

一个映像的头部信息结构体指针其实就是映像在内存中加载的基地址。

通常状况下索引为0的映像是dyld库的映像,而索引为1的映像就是当前进程的可执行程序映像。

系统还提供一个没有在头文件中声明的函数:

const struct mach_header* _NSGetMachExecuteHeader()
复制代码

这个函数返回当前进程的可执行程序映像的头部信息结构体指针。由于这个函数没有在某个具体的头文件中被声明,因此当你要使用这个函数时须要在源代码文件的开头进行声明处理:

extern const struct mach_header* _NSGetMachExecuteHeader();
复制代码
3.获取进程中某个映像加载的Slide值
intptr_t   _dyld_get_image_vmaddr_slide(uint32_t image_index) 
复制代码

函数的入参为映像在进程当中的索引号,函数的返回值是映像加载的Slide值。关于Slide值的介绍已经在上面有详细说明。在mach-o格式程序中的结构体描述信息中凡是涉及到指针字段都应该加上这个值才是真实的内存地址。

4.获取进程中某个映像的名称
const char*  _dyld_get_image_name(uint32_t image_index)
复制代码

函数的入参为映像在进程当中的索引号,函数的返回值是映像对应库的全路径名称,返回的字符串咱们不能修改也没必要去销毁它。

5.注册映像加载和卸载的回调通知函数
void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))
void _dyld_register_func_for_remove_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))
复制代码

若是你经过函数_dyld_register_func_for_add_image注册了一个映像被加载时的回调函数时,那么每当后续一个新的映像被加载但未初始化前就会调用注册的回调函数,回调函数的两个入参分别表示加载的映像的头结构和对应的Slide值。若是在调用_dyld_register_func_for_add_image时系统已经加载了某些映像,则会分别对这些加载完毕的每一个映像调用注册的回调函数。

若是你经过函数_dyld_register_func_for_remove_image注册了一个映像被卸载时的回调函数时,那么每当一个映像被卸载前都会调用注册的回调函数,回调函数的两个入参分别表示卸载的映像的头结构和对应的Slide值。

这两个函数的做用一般用来作程序加载映像的监控以及一些统计处理。

6.获取某个库连接时和运行时的版本号
//获取库运行时的版本号
int32_t NSVersionOfRunTimeLibrary(const char* libraryName)
//获取库连接时的版本号
int32_t NSVersionOfLinkTimeLibrary(const char* libraryName)
复制代码

咱们在XCODE工程中连接一些系统动态库时,有时候会选择某个具体版本的动态库,可是有些操做系统可能不必定会提供对应版本的动态库,这样就会致使程序运行时加载的动态库版本和连接时指定的动态库的版本不一致。还有一种场景就是工程中并无连接对应的动态库,可是由于其余库会连接对应的动态库,就会出现虽然没有直接连接对应的动态库可是仍是会加载对应的动态库的状况。 所以系统提供了这两个API能够获取某个动态库连接和加载运行时的版本号。这两个函数的入参都是动态库的名称,这个名称是不带路径和扩展名以及不带lib前缀的库名称。函数返回库对应的版本号,若是库不存在或者没有被加载或者没有被连接则返回-1。好比下面的代码:

//这里的名称c++实际上是指的libc++.dylib这个库。
    uint32_t v1 =  NSVersionOfRunTimeLibrary("c++");
    uint32_t v2 =  NSVersionOfLinkTimeLibrary("c++");
复制代码

若是咱们的程序并无显示的连接libc++.dylib则后者函数会返回-1。而前者则通常都会返回一个对应的libc++的版本号。

这两个函数的主要用来作一些库分析和运行监测等功能,好比能够检测某个库是不是一个在运行时被加载而不是显示连接进来的动态库。

7.获取当前进程可执行程序的路径文件名
int _NSGetExecutablePath(char* buf, uint32_t* bufsize)
复制代码

函数的入参buf和bufsize指明保存可执行文件路径名的缓存和缓存的尺寸,其中的bufsize是要指明缓存的尺寸,而且会输出可执行文件路径名称的真实尺寸。若是函数调用返回正确则返回0,不然返回-1。就好比下面的例子:

char buf[256];
uint32_t bufsize = sizeof(buf)/sizeof(char);
_NSGetExecutablePath(buf, &bufsize);

复制代码
8.注册当前线程结束时的回调函数
void _tlv_atexit(void (*termFunc)(void* objAddr), void* objAddr)
复制代码

有时候咱们想监控线程的结束事件,那么就能够用这个函数来实现。这个函数用来监控当前线程的结束,当线程结束或者终止时就会调用注册的回调函数,_tlv_atexit函数有两个参数:第一个是一个回调函数指针,第二个是一个扩展参数,做为回调函数的入参来使用。

不明白为何这个函数会放在<mach-o/dyld.h>中声明,彻底不搭界!

段(Segment)和节(Section)操做API

对段和节进行操做的API都在import <mach-o/getsect.h>中声明。你能够import这个头文件来使用里面定义的函数。固然若是你了解mach-o的文件格式的话能够不用这些API,而是直接根据映像的头部结构体struct mach_header来遍历和访问这些段和节。不过既然系统已经提供相关的API,那么仍是优先考虑用它们最合适了。下面我会分别介绍这些函数。

段和节操做的API在系统的libmacho.dylib库中实现,这个库暂时尚未开源出来。

1. 获取进程中映像的某段中某个节的非Slide的数据指针和尺寸
//获取进程中可执行程序映像的某个段中某个节的数据指针和尺寸。
 char *getsectdata(const char *segname, const char *sectname, unsigned long *size) 

//获取进程加载的库的segname段和sectname节的数据指针和尺寸。
 char *getsectdatafromFramework(const char *FrameworkName, const char *segname, const char *sectname, unsigned long *size);
复制代码

这两个函数返回进程中可执行程序映像或者某个加载的动态库中的某个段中某个节的数据指针和尺寸。这两个函数其实就是返回对应的节描述信息结构struct section中的addr和size两个数据成员的值。须要注意的是返回的地址值是没有加上Slide值的指针,所以当咱们要在进程中访问真实的地址时须要加上对应的Slide值,下面就是一个实例代码:

//通常索引为1的都是可执行文件映像
intptr_t  slide = _dyld_get_image_vmaddr_slide(1);
unsigned long size = 0;
char *paddr = getsectdata("__TEXT", "__text", &size);
char *prealaddr = paddr + slide;  //这才是真实要访问的地址。
复制代码

getsectdata函数的代码实现以下:

//假设是64位的系统
char *getsectdata(const char *segname, const char *sectname, unsigned long *size)
{
    const struct mach_header_64 *mhp =  _NSGetMachExecuteHeader();
    //这个函数会在下面介绍到。
    return  getsectdatafromheader_64(mhp, segname, sectname, size);
}

复制代码

我的不建议用这个函数而是用下面会介绍到的getsectiondata函数更合适。

2.获取段和节的边界信息
//获取当前进程可执行程序映像的最后一个段的数据后面的开始地址。 
unsigned long get_end(void);
//获取当前进程可执行程序映像的第一个__TEXT段的__text节的数据后面的开始地址。
 unsigned long get_etext(void);
//获取获取当前进程可执行程序映像的第一个_DATA段的__data节的数据后面的开始地址
 unsigned long get_edata(void);
复制代码

这几个函数主要用来获取指定段和节的结束位置,以及用来肯定某个地址是否在指定的边界内。须要注意的是这几个函数返回的边界值是并未加Slide值的边界值。下面是这几个函数的内部实现:

unsigned long get_end()
{
   unsigned long end = 0;
   const struct mach_header_64 *mhp =  _NSGetMachExecuteHeader();
   struct segment_command_64 *psegcmd = mhp + 1;
   for (int i = 0; i < mhp->ncmds; i++)
   {
       if (psegcmd->cmd != LC_SEGMENT_64)
            break;
       end = psegcmd->vmaddr + psegcmd->vmsize;
       psegcmd += 1;
   }
   return end;
}

unsigned long get_etext()
{
   const struct section_64 *sec = getsectbyname("__TEXT","__text");
   return psection->addr + psection->size;
}

unsigned long get_edata()
{
   const struct section_64 *sec = getsectbyname("__DATA","__data");
   return psection->addr + psection->size;
}

复制代码
3.获取进程中可执行程序映像的段描述信息
//获取进程中可执行程序映像的指定段名的段描述信息
const struct segment_command *getsegbyname(const char *segname)
//上面函数的64位版本
const struct segment_command_64 *getsegbyname(const char *segname)
复制代码

这两个函数返回进程中可执行程序映像的某个段的段描述信息。段描述信息是一个struct segment_command或者struct segment_command_64结构体。

好比下面代码返回进程中可执行程序映像代码段__TEXT的段信息。

const struct segment_command_64 *psegment = getsegbyname("__TEXT");
复制代码
4.获取进程中可执行程序映像的某个段中某个节的描述信息
//获取进程中可执行程序映像的某个段中某个节的描述信息。
const struct section *getsectbyname(const char *segname,  const char *sectname)
//上面对应函数的64位系统版本
const struct section_64 *getsectbyname(const char *segname, const char *sectname)
复制代码

这两个函数分别返回32位系统和64位系统中的进程中可执行程序映像的segname段中的sectname节的描述信息。节的描述信息是一个struct section或者struct section_64的结构体。好比下面的代码返回代码段__TEXT中的代码节__text的描述信息:

struct section_64 *psection = getsectbyname("__TEXT","__text");
复制代码
5.获取进程中映像的段的数据
//获取指定映像的指定段的数据。
uint8_t *getsegmentdata(const struct mach_header *mhp, const char *segname, unsigned long *size)

//上面函数的64位版本
uint8_t *getsegmentdata(const struct mach_header_64 *mhp, const char *segname, unsigned long *size)
复制代码

函数返回进程内指定映像mhp中的段segname中内容的地址指针,而整个段的尺寸则返回到size所指的指针当中。这个函数的内部实现就是返回段描述信息结构struct segment_command中的vmaddr数据成员的值加上映像mhp的slide值。而size中返回的就是段描述信息结构中的vmsize数据成员。

由于在前面讲过由于映像加载时的slide值的缘故,因此映像中的各类mach-o结构体中涉及到地址的数据成员的值都须要加上slide值才能获得映像在内存中的真实加载地址。

进程中每一个映像中的第一个__TEXT段的数据的地址其实就是这个映像的mach_header头结构的地址。这是一个比较特殊的状况。

下面的代码演示的是获取进程中第0个索引位置映像的__DATA段的数据。

struct mach_header_64 *mhp = _dyld_get_image_header(0);
unsigned long size = 0;
uint8_t *pdata = getsegmentdata(mhp,  "__DATA", &size);
复制代码
6.获取进程映像的某段中某节的数据
//获取进程映像中的某段中某节的数据地址和尺寸。
uint8_t *getsectiondata(const struct mach_header *mhp, const char *segname, const char *sectname, unsigned long *size)
//上面函数的64位版本
uint8_t *getsectiondata(const struct mach_header_64 *mhp, const char *segname, const char *sectname, unsigned long *size)
复制代码

函数返回进程内指定映像mhp中的段segname中sectname节中内容的地址指针,而整个节的尺寸则返回到size所指的指针当中。这个函数的内部实现就是返回节描述信息结构struct section中的addr数据成员的值加上映像mhp的slide值。而size中返回的就是段描述信息结构中的size数据成员的值。

由于在前面讲过由于映像加载时的slide值的缘故,因此映像中的各类mach-o结构体中涉及到地址的数据成员的值都须要加上slide值才能获得映像在内存中的真实加载地址。

下面的例子获取进程中第0个映像的"__TEXT"段中的"__text"节的数据地址指针和尺寸:

struct mach_header_64 *mhp = _dyld_get_image_header(0);
unsigned long size = 0;
uint8_t *pdata = getsectiondata(mhp,  "__TEXT", "__text", &size);
复制代码
7.获取mach-O文件中的某个段中某个节的描述信息
//获取指定mach-o文件中的某个段中某个节中的描述信息
const struct section *getsectbynamefromheader(const struct mach_header *mhp, const char *segname, const char *sectname)

//获取指定mach-o文件中的某个段中某个节中的描述信息。fSwap传NXByteOrder枚举值。
const struct section *getsectbynamefromheaderwithswap(struct mach_header *mhp, const char *segname, const char *sectname, int fSwap)

//上面对应函数的64位系统版本
const struct section_64 *getsectbynamefromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname)

//上面对应函数的64位系统版本
const struct section *getsectbynamefromheaderwithswap_64(struct mach_header_64 *mhp, const char *segname, const char *sectname, int fSwap)
复制代码

这一系列函数分别返回32位系统和64位系统的mach-o文件的节的描述信息。每一个函数都有segname和sectname分别指明要获取的段名和节名。参数mhp则代表mach-o文件的头部结构指针。对于有一些系统或者mach-o文件中的数值采用big-endian来编码,所以对于这些采用big-endian编码的结构来讲就须要传递fSwap来肯定是否交换这些编码。

这一系列函数中的mhp结构不局限于进程中的映像的头部结构,针对mach-o文件的头部结构也适用,若是你不了解映像和文件的区别则请看文章中的开头的介绍。

由于不论是进程中的映像的Section的排列以及mach-o文件中的Section的排列都是一致的,所以其实上述的getsectbyname的实现就是借助本节提供的函数实现的,其实现的代码以下:

const struct section_64 *getsectbyname(
    const char *segname,
    const char *sectname)
{
   const struct mach_header_64 *mhp =  _NSGetMachExecuteHeader();
   return getsectbynamefromheader_64(mhp, segname, sectname);
}
复制代码
8.获取mach-o文件中的某段中的某个节的数据指针和尺寸
//获取指定mach-o文件中的某个段中的某个节的数据指针和尺寸
char *getsectdatafromheader(const struct mach_header *mhp, const char *segname, const char *sectname, uint32_t *size)

//64位系统函数
char *getsectdatafromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname, uint64_t *size)
复制代码

这两个函数返回32位系统或者64位系统中的某个mach-o文件中的某个段中某个节的数据指针和尺寸。这两个函数其实就是返回对应的节描述信息结构struct section中的addr值和size值。由于这两个函数是针对mach-o文件的,可是也能够用在对应的库映像中,当应用在库映像中时就要记得对返回的结果加上对应的slide值才是真实的节数据所对应的地址!

一个很是有用的DEMO

iOS系统提供了所谓方法交换(method swizzling)的黑魔法机制。它能够在运行时替换掉某个类的某个方法的默认实现。然而技术有两面性,对于越狱系统来讲,恶意开发人员能够经过动态库注入并利用方法交换的技巧来改变程序运行的原有逻辑,从而能够跨过一些常规检测而谋取非法利益。

凡事有攻就有守,经过本文中介绍的API函数就能够在必定程度上检测某个类中的某个方法是否被非法HOOK。以可执行程序中的某个类的实例方法为例。可执行程序中定义的类的实例方法的实现地址老是在可执行程序映像的地址区间范围内,即便是这个方法被可执行程序中的其余方法HOOK了,这个HOOK的方法地址仍然是在可执行程序的映像地址区间范围内,咱们仍然认为这是一个合法的HOOK。若是可执行程序中的类的实例方法被恶意攻击者经过动态库注入并以方法交换的形式来HOOK原有方法的实现时,由于HOOK的方法地址是在恶意注入的动态库映像的地址区间范围内,因此咱们就能够经过检测这个类的实例方法的实现地址是否在可执行程序的映像的地址区间范围内来判断这个方法是否被恶意HOOK了。下面就是这种检测的具体实现代码,建议检测的代码用C函数来实现而不是用OC类的方法来实现,不然这个检测逻辑也有可能被HOOK。

//Author by 欧阳大哥
#import <mach-o/dyld.h>
#import <mach-o/getsect.h>

BOOL checkMethodBeHooked(Class class, SEL selector)
{
    //你也能够借助runtime中的C函数来获取方法的实现地址
    IMP imp = [class instanceMethodForSelector:selector];
    if (imp == NULL)
         return NO;

    //计算出可执行程序的slide值。
    intptr_t pmh = (intptr_t)_NSGetMachExecuteHeader();
    intptr_t slide = 0;
#ifdef __LP64__
    const struct segment_command_64 *psegment = getsegbyname("__TEXT");
#else 
    const struct segment_command *psegment = getsegbyname("__TEXT");
#endif
    intptr_t slide = pmh - psegment->vmaddr

    unsigned long startpos = (unsigned long) pmh;
    unsigned long endpos = get_end() + slide;
    unsigned long imppos = (unsigned long)imp;
    
    return (imppos < startpos) || (imppos > endpos);
}
复制代码

👉【返回目录

欢迎你们访问个人github地址

相关文章
相关标签/搜索