之前写过一篇深入浅出ELF ,作为姊妹篇这次就来聊聊MacOS的可执行文件格式MachO。
在之前的文章 中我们介绍过,可执行文件的使命有两个,一是方便开发者在编译、链接时提供可扩展的封装结构;二是在执行时能给操作系统(内核)提供内存映射信息。MachO也不例外。
MachO本身没有什么特别的含义,就是Mach object
的简写,而Mach 是早期的一个微内核。和ELF一样,MachO也极具拓展性,从全局视角来看,MachO文件可以分为三个部分,分别是:
这里的segment可以理解为一段连续的内存空间,拥有对应的读/写/执行权限,并且在内存中总是页对齐的。每个segment由一个或者多个section组成,section表示特定含义数据或者代码的集合(不需要页对齐)。在macOS中,通常约定segment的名称为双下划线加全大写字母(如__TEXT
),section的名称为双下划线加小写字母(如__text
)。
下面对这三个部分进行分别介绍。
注: MachO文件结构的表示通常分为32位和64位两种,本文以64位为例,毕竟这是历史的进程。
文件头信息参考mach-o/loader.h 中的定义如下:
filetype
表示类型,常见的有:
flags
为不同的文件标签的组合,每个标签占一个位,可以用位或来进行组合,常见的标签有:
另外一个值得关注的就是ncmds
和sizeofcmds
,分别指定了 LOAD_COMMAND 的个数以及总大小,从这里也大概能猜到,每个 command 的大小是可变的。
LOAD_COMMAND
是体现MachO文件拓展性的地方,每个 command 的头两个word分别表示类型和大小,如下:
不同的cmd
类型都会有其对应的结构体来描述其内容,cmdsize
表示的是整个cmd的大小,即包括头部和内容。也就是说在处理的时候当前cmd的位置加上cmdsize就是下一个cmd的位置。注意每个command的大小(即cmdsize)需要word对齐,对于32位CPU来说是4字节,64位则是8字节;同时对齐末尾的填充部分必须是0。
loader.h
中绝大部分的篇幅就是用来定义各种不同command类型的结构体了,这里挑选一些比较常见的来进行介绍。
LC_SEGMENT
/LC_SEGMENT64
可以说是最重要的一个command。表示当前文件的一部分将会映射到目标进程(task)的地址空间中,包括程序运行所需要的所有代码和数据。假设当前MachO文件的起始地址为begin,则映射的内容为:
其中vmsize >= filesize
,如果有多出来的部分需要填充为零。segment_command的结构体表示如下:
maxprot/initprot表示对应segment虚拟地址空间的RWX权限。如果segment包含一个或者多个section,那么在该segment结构体之后就紧跟着对应各个section头,总大小也包括在cmdsize之中,其结构如下:
每个section头对应一个section,位置在相对文件起始地址 的offset 处,大小为size 字节,对应的虚拟地址为addr 。这里的align 对齐指的是在虚拟地址空间中的对齐,实际上在文件中是连续存放的,因为有size指定大小。reloff和nreloc与符号的重定向有关,在下面的加载过程一节中再进行介绍。
从这里可以看出,section的内容和segment是不连续存放的,只是section header在对应segment之后。而segment的vmsize 实际上会大于segment+section_header的大小(即cmdsize),猜测多出来的空间是内核加载MachO时将对应section内容填充进去,后面将会对这一猜测进行验证。
__TEXT
段包含__text
、__stubs
、__stub_helper
、__cstring
等section,一般用来存放不可修改的数据,比如代码和const字符串,可以用otool
查看对应的section内容:
在实际的MachO可执行文件中观察发现TEXT的fileoff为0,也就是说TEXT段映射的时候会将当前文件头部分也映射到进程空间中。
上面例子中__TEXT
段的的vm_size和file_size都是0x1000
,这个大小在文件中正好是第一个__DATA
section的起始地址:
__PAGEZERO
是一个特殊的段,主要目的是将低地址占用,防止用户空间访问。个人理解这是对空指针引用类型漏洞的一种缓解措施,Linux内核中也有mmap_min_addr 来限制用户可以mmap映射的最低地址。
__DATA
段则包含__got
、__nl_symbol_ptr
、__la_symbol_ptr
等section,一般包括可读写的内容。
另外一个重要的段为__LINKEDIT
,其中包含需要被动态链接器使用的信息,包括符号表、字符串表、重定位项表、签名等。该段和PAGEZERO
一样的是末尾没有额外的section信息,所以cmdsize都等于72(sizeof(struct segment_command_64)
)。其内容即begin + fileoff
指向的地方保存linkedit command的内容,这个内容的格式根据具体cmd的不同而不同。LINKEDIT可以理解为元数据,值得一提的是,经过观察,fileoff +filesize
即为MachO文件末尾,也就是等于文件的大小。
那么LINKEDIT块中的内容是什么格式呢?其实大部分有其专门的格式,比如对Dynamic Loader Info
来说是字节码,对于符号表来说是符号表结构体,对于函数地址项来说是uleb128
编码的地址值,……因此LINKEDIT可谓包罗万象,需要具体问题具体分析,下面介绍的几个command就是其中几个例子。
Signature Command指定当前文件的签名信息,没有单独的结构体,而是使用下面这个结构来表示:
cmd/cmdsize和前面LC_SEGMENT的含义类似,只不过cmdsize是个常数,等于当前结构体的大小。dataoff 表示前面信息在LINKEDIT数据中的偏移,注意这里不是相对文件头的偏移;datasize 则表示签名信息的大小。
苹果的签名数据格式并不是常规类型,对其详细介绍超过了本文的范围,对于具体的签名实现有兴趣的可以参考Jonathan大神的*OS Internal
或者Code Signing – Hashed Out 。使用jtool工具可以打印出详细的签名信息,如下所示:
当然官方的codesign -d
也可以。
这个Command的信息主要是提供给动态链接器dyld
的,其结构如下:
虽然看起来很复杂,但实际上它的目的就是为了给dyld提供能够加载目标MachO所需要的必要信息: 因为可能加载到随机地址,所以需要rebase信息;如果进程依赖其他镜像的符号,则绑定需要bind信息;对于C++程序而言可能需要weak bind实现代码/数据复用;对于一些外部符号不需要立即绑定的可以延时加载,这就需要lazy bind信息;对于导出符号也需要对应的export信息。
为了描述这些rebase/bind信息,dyld定义了一套伪指令,用来描述具体的操作(opcode)及其操作数据。以延时绑定为例,操作符看起来是这样:
其表达的实际含义用中文来描述就是:
其中0x1018/0x1020地址在__DATA
段,更准确来说是在__la_symbol_ptr
这个section中,可以自行编译验证。
LC_LOAD_{,WEAK_}DYLIB
用来告诉内核(实际上是dyld)当前可执行文件需要使用哪些动态库,而其结构如下:
动态库(filetype为MH_DYLIB
)中会包含 LC_ID_DYLIB
command 来说明自己是个什么库,包括名称、版本、时间戳等信息。需要注意的是lc_str
并不是字符串本身,而是字符串的偏移值,字符串信息在command的内容之后,该偏移指的是距离command起始位置 的偏移。
LC_REEXPORT_DYLIB
表示加载并重新导出dylib
除了上面的介绍,还有许多其他类型的 command ,比如LC_FUNCTION_STARTS
表示函数入口地址,LC_MAIN
表示主函数地址,LC_ENCRYPTION_INFO
表示加密的segment段等等,可以在遇到的时候用查看loader.h
的定义,这里就不再赘述了。
MachO的加载和ELF的加载过程没有太大区别,还是系统调用->内核处理->返回执行
的一般流程,对于静态链接程序返回执行是直接返回到程序入口地址,而动态链接程序则需要在程序开始执行之前进行重定向,因此这里也按照这个顺序介绍。
内核空间的主要任务是创建新taks并初始化内存页和对应的权限,我们主要关注MachO文件的处理部分,即parse_machfile
函数,文件为[bsd/kern/mach_loader.c][src],其主要功能为检查header以及cmdsize等长度符合预期,然后通过4次循环来处理不同的信息,如下:
这里重点关注pass2,关键代码如下:
其中很多command比如LC_LOAD_DYLIB
、LC_DYLD_INFO_ONLY
等不在内核态中进行处理,直接进入default分支忽略。
这个函数主要负责加载segment到内存中,实现有几个值得一提的点:
对于映射的地址和大小,都需要是4k页对齐的,并且最终使用map_segment
进行映射:
根据对代码的分析,内核中并未对section的内容 进行解析和映射。
MachO和ELF的一个最大不同点,或者说XNU和Linux的不同点是前者原生支持了对可执行文件的签名认证,文件的签名信息保存在LINKEDIT数据段,在前面我们已经介绍过了LC_CODE_SIGNATURE
的内容
load_main
函数主要用来处理LC_MAIN
命令,这里面包括了可执行文件的入口地址以及栈大小信息。但是在内核中并不需要关心main函数信息,而只需要关心入口信息(entry_point)。因此在load_main中只对栈和线程进行初始化,并且修改对应的result信息:
此时result->entry_point
还是0(MACH_VM_MIN_ADDRESS)。
另外一个能决定入口地址的command是LC_UNIXTHREAD
,类似于UNIX中直接将start
符号导出,该符号应该是在crt1.o
里的,但苹果并不默认提供。也就是说如果想要静态编译,需要自己下载源文件自己去编译,或者自己链接并导出这个符号。苹果不支持静态编译的原因是出于兼容性的考虑。
在load_main结束后,需要加载动态链接器:
动态链接器就是dyld
,在LC_LOAD_DYLINKER
命令中指定,通常是/usr/lib/dyld
。load_dylinker内部也同样调用了parse_machfile
函数,因此大部分操作是类似的。注意到这里其实涉及到了递归调用,因此需要在该函数中加depth参数表示递归层数。
dyld文件中有LC_UNIXTHREAD
命令,因此其result->entry_point
将被设置,在原先的parse_mach返回到load_machfile后,则初始化新的内核task并将执行流交还给用户空间,对于大部分程序而言,就是跳转到dyld的起始地址执行。
从内核回到用户空间,便跳转到目标的入口地址开始执行。对于静态链接链接程序,实际上执行的是dyld中的指令,该程序的源码可以参考opensource-apple/dyld 。
dyld的起始地址固定为0x1000
,这个地址对应的符号是__dyld_start
,文件定义在src/dyldStartup.s
。这部分代码和crt0.o
中的代码是一样的,主要是用来初始化C Runtime,唯一的不同点是有个额外的参数用来指定MachO文件头的地址。
初始化完成后调用call __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
,demangle之后为下面的函数:
所以dyld真正的入口地址是dyld::_main
,该函数的功能主要有:
最终目标程序正常执行,就像自己直接启动一样。下面挑几个比较关键的点进行深入分析。
链接是dyld的主要功能,执行实际动态链接功能的是link函数,除了链接待执行的目标程序,还链接所有插入的其他动态库:
而dyld:link
使用的是具体ImageLoader的link多态实现:
sMainExecutable的实现在开源代码中并没有给出,不过参考基类的默认实现如下:
主要做的就是这几步:
根据名字不难看出其作用,其中大部分函数名称带recursive,这是因为动态库本身也可能会依赖其他的动态库,因此需要递归进行处理(当然循环依赖会有对应的处理)。其中recursiveUpdateDepth
不太直观,其实作用只是为了对镜像进行排序,被依赖的库会出现在依赖者之前。
在上面第4步中说到要加载共享缓存库,这是个什么东西呢?这一步的目的其实是为了加速动态库的加载过程。对于我们自己编译的macOS命令行程序可能还好,但是对于图形界面应用来说,每个应用启动时需要加载的动态库可能有上百个,而其中很大一部分是系统库,比如UIKit、Foundation等。因此苹果就事先把这些常用的库打包成缓存,程序启动时候直接映射到内存中,而无需逐个执行繁琐的处理和解析。
映射共享缓存库的函数为mapSharedCache
,首先检查共享缓存库是否已经映射过:
294号系统调用定义在内核中(bsd/kern/syscalls.master):
内核中的实现也比较简单,忽略错误检查,关键的代码如下:
其内部实现姑且不管,继续回到用户空间,所返回的地址可以强制转换为dyld_cache_header
格式:
检查共享缓存空间存在则直接复制其UUID到进程的sharedCacheUUID
中,然后直接使用该缓存。
如果不存在,就需要进行创建,创建的过程如下:
mapping信息如下:
和之前提到的segment信息类似,没有feilsize,因为不存在padding。
_shared_region_map_and_slide_np
函数分别处理每个mapping,并最终使用mmap
来完成cache到内存的映射操作。
每个mapping info对应一个struct shared_file_mapping_np
,但是这个结构体的定义在开源代码中没找到,并且在苹果文档中也进行了隐藏,见: https://developer.apple.com/documentation/kernel/shared_file_mapping_np
本文通过对MachO文件的文件格式研究,介绍了MacOS和iOS中可执行文件的加载过程,从内核中的处理一直到动态连接器dyld的代码分析。可以看出MachO与ELF相比实现方式各有千秋,但是在内核中原生增加了对代码的签名和加密,其实ELF也很容易实现类似的功能,但开放系统需要更多考虑兼容性的问题,不像苹果可以大刀阔斧的随便改。
对于MachO的深入理解其实也有助于日常的相关研究,比如Apple Store的加密实现以及代码签名的大致原理,还有针对dyld_cache的处理等,其中每一项都值得去深入挖掘。而且本文也没有介绍到全部的MachO特性,比如Objective-C相关的段,具体的实战部分后面有时间会再去整理一下。
/*
* The 64-bit mach header appears at the very beginning of object files for
* 64-bit architectures.
*/
struct mach_header_64 {
uint32_t magic; /* mach magic number identifier */
cpu_type_t cputype; /* cpu specifier */
cpu_subtype_t cpusubtype; /* machine specifier */
uint32_t filetype; /* type of file */
uint32_t ncmds; /* number of load commands */
uint32_t sizeofcmds; /* the size of all the load commands */
uint32_t flags; /* flags */
uint32_t reserved; /* reserved */
};
/* Constant for the magic field of the mach_header_64 (64-bit architectures) */
#define MH_MAGIC_64 0xfeedfacf /* the 64-bit mach magic number */
#define MH_CIGAM_64 0xcffaedfe /* NXSwapInt(MH_MAGIC_64) */
struct load_command {
uint32_t cmd; /* type of load command */
uint32_t cmdsize; /* total size of command in bytes */
};
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* LC_SEGMENT_64 */
uint32_t cmdsize; /* includes sizeof section_64 structs */
char segname[16]; /* segment name */
uint64_t vmaddr; /* memory address of this segment */
uint64_t vmsize; /* memory size of this segment */
uint64_t fileoff; /* file offset of this segment */
uint64_t filesize; /* amount to map from the file */
vm_prot_t maxprot; /* maximum VM protection */
vm_prot_t initprot; /* initial VM protection */
uint32_t nsects; /* number of sections in segment */
uint32_t flags; /* flags */
};
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 2020-9-6 20:53
被evilpan编辑
,原因: