iOS之深入理解Image与MachO

可执行文件
  • 进程,其实就是可执行文件在内存中加载得到的结果;
  • 可执行文件必须是操作系统可理解的格式,而且不同系统的可执行文件的格式也是不同的;
不同平台的可执行文件
  • Linux:ELF文件
  • WindowsPE32/PE32+文件
  • OS和iOSMach-O(Mach Object)文件
Mach-O 文件
  • Mach-O文件是iOSiPadOSmacOS平台的可执行文件格式。对应系统通过应用二进制接口(application binary interface,缩写为ABI)来运行该格式的文件
  • Mach-O格式用来替代BSD系统中的a.out格式,保存了在编译和链接过程中产生的机器代码和数据,从而为静态链接和动态链接的代码提供单一文件格式。
  • Mach-O提供了更强的扩展性,以及更快的符号表信息访问速度
  • 可执行文件目标文件或者静态库动态库等都是Mach-O格式

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

iOS系统生成的可执行程序或者动态库文件的存储布局格式被称之为mach-o格式。文件中存放着程序的代码和数据,而程序运行时系统会为其建立一个进程,以及分配虚拟内存空间。同时会把程序文件中的内容加载到虚拟内存地址空间中去

一般来说一个进程中image的内容和内存布局结构会和程序文件的内容以及存储布局结构一致,image的首地址是一个struct mach_header的结构体指针。

image中内容的排列布局和程序文件都是以段Segment为单位进行排列的。但是有一些情况image的内存布局和内容可能会和程序文件的内存布局和内容不一致:

  • image中的数据段部分,因为数据段部分大多是可以被读写访问的,也就是说可以在运行时被修改,或者某些信息会进行rebase(重定向)处理。因此数据段不能被进程之间共享,而是每个进程单独维护一份。
  • 通常只有不可变的代码段部分才会是内存和文件中的内容保持一致,并且多进程共享。一个很常见的例子就是进程中加载的动态库和框架中的代码段部分通常都是所有进程共享
  • 即使是代码段也有可能映像中的内容和程序文件中的内容不一致。有一些image中的某些段的内容会是系统中缓存的段,而不是程序文件对应的段。一个很有代表性的例子就是CoreLocation这个库,当这个库被加载时你就会发现其image中的有一些代码段的内容其实是系统缓存的内容而不是程序文件中的内容

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

Slide机制

构建一个程序时为了方便计算和处理会为这个程序设定一个默认在内存中加载的基地址。这样在程序中所有涉及到地址存储的代码中的地址变量都是以这个基地址为标准的。

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

一个程序或者库加载到内存时的真实的基地址和程序构建时指定的基地址是不一样的。系统会选择不重叠的区域进行加载,但是这样就会出现地址指针访问异常,因为这些地址值并不是真实内存中的地址值。

为了解决这个为题,在程序构建中的时候,会添加一条Load Command命令LC_DYLD_INFO_ONLY,这个命令会记录需要进行调整的位置

当程序被加载到内存的时候,dyld就会将需要调整的地址分别进行调整处理,转换成为真实的内存地址,这个过程也称之为基地址重定向rebase

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

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

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

我们也可以用过_dyld_get_image_vmaddr_slide(uint32_t image_index)来获取到slide

Mach-O文件结构

MachO主要包含三个主要的部分:

  1. Header:头部,描述CPU类型、文件类型、加载命令的条数大小等信息
  2. Load Commands :加载命令,其条数和大小已经在header中被提供
  3. Data:数据段
  4. 还有一些其他信息
  • Dynamic Loader Info:动态库加载信息
  • Function Starts:入口函数
  • Symbol Table:符号表
  • Dynamic Symbol Table: 动态库符号表
  • String Table:字符串表

Mach Header(可执行文件的头部)

查看XNU源码 我们可以看到 header的定义

struct mach_header_64 {
  uint32_t  magic;    /*系统内核用来判断是否是mach-o格式*/
  cpu_type_t  cputype;  /* CPU架构类型,比如ARM */
  cpu_subtype_t cpusubtype; /* CPU架构的具体类型,比如ARM64,ARMV7 */
  uint32_t  filetype; /*mach-o文件类型, 可执行文件、目标文件或者静态库和动态库 */
  uint32_t  ncmds;    /* load commands 的条数 */
  uint32_t  sizeofcmds; /* load commands 的大小 */
  uint32_t  flags;    /* 标志位,用来系统加载或者链接 */
  uint32_t  reserved; /* 保留字段,相比于32位多出来的字段 */
};
复制代码

一般我们可以通过一下方法来获取到header

void getMachHeader(void){
    // 开辟空间
    machHeaderArr = (JYMachHeaderArr *)malloc(sizeof(JYMachHeaderArr));
  
    //_dyld_image_count 获取所有的image的数量
    machHeaderArr->allocLength = _dyld_image_count();
​
    // 获取第一个image的基址
//    intptr_t  base_addr = _dyld_get_image_vmaddr_slide(0);
​
    
    //每个imaged的大小* 总数 
    machHeaderArr->array = (JYMachHeader *)malloc(sizeof(JYMachHeader) * machHeaderArr->allocLength);
   
    for (uint32_t i = 0; i < machHeaderArr->allocLength; i++) {
        JYMachHeader *machHeader = &machHeaderArr->array[i];
        
        //获取image的头
        machHeader->header = _dyld_get_image_header(i);
        
        //获取image的名称
        machHeader->name = _dyld_get_image_name(i);
        
        //获取进程中单个image加载的Slide值
        // Slide 代表默认在内存中加载的基地址
        machHeader->slide = _dyld_get_image_vmaddr_slide(i);
    }
}
复制代码

Load Commands

  1. Load Commands是加载命令的列表,用于描述Data在二进制文件和虚拟内存中的布局信息;
  2. Load Commands记录了很多信息,例如动态链接器的位置、程序的入口、依赖库的信息、代码的位置、符号表的位置等;
  3. Load commands由内核定义,不同版本的command数量不同,其条数和大小记录在header中;
  4. Load commandstype是以LC_为前缀常量,譬如LC_SEGMENTLC_SYMTAB等;
// 其结构为这样
struct load_command {
  uint32_t cmd;   /* load command的类型 例如 LC_SYMTAB、LC_SEGMENT */
  uint32_t cmdsize; /* 加载命令的大小 */
};
复制代码

load commands的类别有很多

#define LC_REQ_DYLD 0x80000000/* Constants for the cmd field of all load commands, the type */
#define LC_SEGMENT  0x1 /* segment of this file to be mapped */
#define LC_SYMTAB 0x2 /* link-edit stab symbol table info */
#define LC_SYMSEG 0x3 /* link-edit gdb symbol table info (obsolete) */
#define LC_THREAD 0x4 /* thread */
#define LC_UNIXTHREAD 0x5 /* unix thread (includes a stack) */
#define LC_LOADFVMLIB 0x6 /* load a specified fixed VM shared library */
#define LC_IDFVMLIB 0x7 /* fixed VM shared library identification */
#define LC_IDENT  0x8 /* object identification info (obsolete) */
#define LC_FVMFILE  0x9 /* fixed VM file inclusion (internal use) */
#define LC_PREPAGE      0xa     /* prepage command (internal use) */
#define LC_DYSYMTAB 0xb /* dynamic link-edit symbol table info */
#define LC_LOAD_DYLIB 0xc /* load a dynamically linked shared library */
#define LC_ID_DYLIB 0xd /* dynamically linked shared lib ident */
#define LC_LOAD_DYLINKER 0xe  /* load a dynamic linker */
#define LC_ID_DYLINKER  0xf /* dynamic linker identification */
#define LC_PREBOUND_DYLIB 0x10  /* modules prebound for a dynamically */
复制代码

而在我们iOS当中,用得最多的就是LC_SEGMENT_64,也就是segment_command_64

struct segment_command_64 { /* for 64-bit architectures */
  uint32_t  cmd;    /* 标志加载命令类型为LC_SEGMENT_64 */
  uint32_t  cmdsize;  /* 表示加载命令大小(还包括了紧跟其后的nsects个section的大小) */
  char    segname[16];  /* 16个字节的段名字 */
  uint64_t  vmaddr;   /* 段的虚拟内存起始地址 */
  uint64_t  vmsize;   /* 段的虚拟内存大小 */
  uint64_t  fileoff;  /* 段在文件中的偏移量 */
  uint64_t  filesize; /* 段在文件中的大小 */
  vm_prot_t maxprot;  /* 段页面所需要的最高内存保护(4 = r,2 = w,1 = x) */
  vm_prot_t initprot; /* 段页面初始的内存保护 */
  uint32_t  nsects;   /* 段中section数量 */
  uint32_t  flags;    /* 标志位 */
};
复制代码

MachOView当中,我们能看到其结构为

我们用得最多的也就是__LINKEDIT以及LC_SYMTAB__TEXT__DATA_CONST__DATA,这些和我们的函数、数据、类、属性都是息息相关

  • __PAGEZERO__PAGEZERO 所指代的内存并没有真正被分配(或占用)。首先,在文件在硬盘上时,PAGEZERO 的 filesize 为 0,即不分配硬盘空间。再者,mach-O 是被加载进入虚拟内存而不是物理内存,物理内存的使用需要通过 MMU 进行印射。因此, PAGEZERO 占用的虚拟内存在物理内存中并没有被分配。如果程序访问该段内存,肯定会直接被系统拦截,报出 BAD_ACCESS 的错误,感兴趣的可以看看这篇文章PAGEZERO的作用
  • __TEXT:代码段、只读数据段、其中有__text__stubs__stub_helper__const_objc_methname__cstring__objc_methtype__cstring__objc_classnmae__objc_methtype__ustring、等等

    就拿_objc_classnmae来说,在Section64 Header(__objc_classname)当中可以看到Address000000010000AD26, Size0000000000000195

我们来到Section64(__TEXT,__objc_classname)可以看到,其实地址就是我们上面的AD26,前面的0x100000000是固定的,我们计算一下起始位置+Size = 0x10000AEBB,这个地址却比下面这个表中最后一个是AEAB,是在这个范围内。

这里需要注意的是,如果我们在程序运行中去拿这些,则是需要计算内存地址的,因为有ASLR的存在,每个image加入到内存中是不一样的,就拿__LINKEDIT来说

// 假设这是我们上面拿到的header
const struct mach_header * header = NULL;
    
struct segment_command_64 * seg_linkedit = NULL;
    
// 因为load_command的地址是和header是紧密连接的,header的地址下面就是
uintptr_t cur = (uintptr_t)(((struct mach_header_64*)header) + 1);
    
// 开始循环load Commands下的条目
for (uint32_t i = 0; i<header->ncmds; i++) {
        
  // 当前条目
   struct load_command * command =  (struct load_command*)cur;
        
  // 判断是否为 LC_SEGMENT_64
   if (command->cmd == LC_SEGMENT_64) {
       struct segment_command_64 * segment_command = (struct segment_command_64*)command;
        
       //因为LC_SEGMENT_64有几种,这里我们需要拿得到是SEG_LINKEDIT
        if (strcmp(segment_command->segname, SEG_LINKEDIT)) {
             seg_linkedit = segment_command;
          }
      }
        // 查看下一个command
        cur = cur + command->cmdsize;
 }
    
复制代码

Data

  • Data中存储了实际的数据与代码,主要包含方法、符号表、动态符号表、动态库加载信息(重定向、符号绑定等)等;
  1. Data中的排布完全按照Load Command中的描述;
  2. DataSegment(段)和 Section (节)的方式来组成,通常,Data拥有多个segment,每个segment可以有零到多个section节;
  3. 不同的segment都有一段虚拟地址映射到进程的地址空间;

几乎所有的Mach-O文件都包含3segment

  1. __TEXT:代码段,只读可执行,存储函数的二进制代码(__text)常量字符串(__cstring)OC的类/方法名等信息
  2. __DATA:数据段, 可读可写,存储OC的字符串(__cfstring),以及运行时的元数据:class/protocol/method,以及全局变量,静态变量等;
  3. __LINKEDIT:只读,存储启动App需要的信息,如 bind & rebase 的地址、函数的名称和地址等信息;

Data区中,Section占了很大的比例,而且在Mach-O中集中体现在__TEXT__DATA两段里。

Section64被定义在loader.h文件中,具体代码如下:

struct section_64 { /* for 64-bit architectures */
  char    sectname[16]; /* section的名称 */
  char    segname[16];  /* section所在的segment名称 */
  uint64_t  addr;   /* 内存中的起始位置 */
  uint64_t  size;   /* section的大小 */
  uint32_t  offset;   /* section的文件偏移 */
  uint32_t  align;    /* 字节大小对齐 */
  uint32_t  reloff;   /* 重定位入口的文件偏移 */
  uint32_t  nreloc;   /* 重定位入口数量 */
  uint32_t  flags;    /* 标志,section的类型和属性*/
  uint32_t  reserved1;  /* 保留(用于偏移量或索引) */
  uint32_t  reserved2;  /* 保留(用于count或sizeof) */
  uint32_t  reserved3;  /* 保留 */
};
复制代码

Image操作API

  • 获取当前进程中加载的映像的数量
uint32_t  _dyld_image_count(void) 
复制代码

  • 获取某个image的mach-o头部信息结构体指针
const struct mach_header*   _dyld_get_image_header(uint32_t image_index) 
复制代码

  • 获取进程中某个image加载的Slide值
intptr_t   _dyld_get_image_vmaddr_slide(uint32_t image_index) 
复制代码

  • 获取进程中某个image的名称
const char*  _dyld_get_image_name(uint32_t image_index)
复制代码

  • 注册image加载和卸载的回调通知函数
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值。

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


  • 获取某个库连接时和运行时的版本
//获取库运行时的版本号 libraryName是不带路径和拓展名以及不带lib前缀的库,例如libc++.dylib
int32_t NSVersionOfRunTimeLibrary(const char* libraryName)
//获取库链接时的版本号
int32_t NSVersionOfLinkTimeLibrary(const char* libraryName)
复制代码

如果库存在,函数返回库对应的版本号,如果库不存在或者没有被加载或者没有被链接则返回-1

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


  • 获取当前进程可执行程序的路径文件名
int _NSGetExecutablePath(char* buf, uint32_t* bufsize)
  
char buf[256];
uint32_t bufsize = sizeof(buf)/sizeof(char);
_NSGetExecutablePath(buf, &bufsize);
复制代码

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

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


Segment与Section操作API

  • 获取进程中映像的某段中某个节的非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;  //这才是真实要访问的地址。
复制代码

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

  • 获取进程中可执行程序映像的段描述信息
//获取进程中可执行程序映像的指定段名的段描述信息
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");
复制代码

  • 获取进程中可执行程序映像的某个段中某个节的描述信息
//获取进程中可执行程序映像的某个段中某个节的描述信息。
const struct section_64 *getsectbyname(const char *segname, const char *sectname)
  
struct section_64 *psection = getsectbyname("__TEXT","__text");
复制代码

  • 获取进程中映像的段的数据
//获取指定映像的指定段的数据
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);
复制代码

  • 获取mach-o文件中的某段中的某个节的数据指针和尺寸
//获取指定mach-o文件中的某个段中的某个节的数据指针和尺寸
char *getsectdatafromheader_64(const struct mach_header_64 *mhp, const char *segname, const char *sectname, uint64_t *size)
复制代码

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


大家如果对MachO感兴趣可以多去看看欧阳大哥的文章 ,一个很厉害的大佬,文章中有很多API都是从他那边抄来的

参考文献

欧阳大哥的深入iOS系统底层之程序映像

Mach-O文件结构

mach-o文件分析

猜你喜欢

转载自juejin.im/post/7053289480131706911