在上篇文章 XNU、dyld 源码分析Mach-O和动态库的加载过程(上)中,我们已经完成了一个 Mach-O
及其和 dyld
加载过程的分析,接下来我们来进行dyld
的源码分析,了解动态库的加载过程,如果有一些名词大家忘记了,可以翻看上一篇文章的名词解释。
dyld 源码分析
首先动态库也是一个静态的文件,文件格式也是Mach-O
,那么它本身是不能够直接运行的,就需要一个加载器将其加载进内存空间,那么这个动态链接加载器就是 dyld
了,它的路径是 /usr/lib/dyld
,如果你没有一台越狱设备的话,你也可以在你Mac电脑的这个路径下看到你Mac设备的 dyld
, dyld
承担了将动态库以镜像的方式映射进内存的工作,文中分析的 dyld
版本为 dyld- 421.2
。
下面这张图是通过 dlopen
加载动态库到 mmap
分配可执行的虚拟内存的一个断点调用栈,这个调用栈的函数调用过程将在下文中一一展开。
dyld
加载动态库过程
首先我们来看__dyld_start
,__dyld_start
是kernel
加载完dyld
之后,dyld
的入口函数。
引导程序 dyldbootstrap::start
,此函数的调用会完成动态库加载的一系列过程,并返回主程序 main 函数入口,也就是我们App的main函数地址,保存在x0寄存器。
dyld
的main函数,主要完成了上下文的建立、初始化必要的参数、解析环境变量、主程序分配 imageLoader
、映射共享的系统动态库、调用加载依赖的动态库、链接动态库、初始化主程序、查找并返回主程序main函数等。
instantiateFromLoadedImage
为主程序初始化imageLoader
,用于后续的链接等过程,主程序作为dyld
的第一个被addimage
的镜像,所以我们总是能够通过_dyld_get_image_header(0)
或者_dyld_get_image_name(0)
等,索引到第一个image
镜像为主程序的相关信息。接下来就是mapSharedCache
,它函数负责将/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64
中的共享动态库加载进内存空间,这也是不同的App实现动态库(UIKit、Foundation
等等系统动态库)共享的机制,不同App的虚拟内存中的共享动态库会通过系统的vm_map
来映射同一块物理内存,从而实现共享动态库。
继续回到dyld
的加载流程中,共享的动态库映射完毕后,就会把环境变量DYLD_INSERT_LIBRARIES
中的动态库调用loadInsertedDylib
进行加载,DYLD_INSERT_LIBRARIES
本身并未做太多工作,主要工作都在load
函数中,dlopen
也会调用load
函数来进行动态库的加载。
dlopen
是运行时加载动态库的一个重要方法,当然系统还提供了运行时加载Bundle的函数,最终也会调用load
函数进行动态库加载。
load
函数是一系列查找动态库的入口。
loadPhase0
会遍历环境变量DYLD_ROOT_PATH
,生成加载路径,调用loadPhase1
,如果没有DYLD_ROOT_PATH
,使用原路径。
loadPhase1...5
函数都是类似的环境变量路径查找等,代码省略,loadPhase5
会调用loadPhase5load
函数,loadPhase5load
会先用查找到的路径在之前加载完成的共享动态库中查找image,如果已经存在则返回,如果不存在则调用loadPhase5stat
函数。
loadPhase5stat
做的事情比较简单,为了防止动态库改名引起的多次加载,会调用findLoadedImage
,用符号链接进行查找image,如果还是没有找到,调用loadPhase5open
打开文件。
loadPhase6
进行文件读取和mach-o文件解析,然后调用ImageLoaderMachO::instantiateFromFile
。
ImageLoaderMachO::instantiateFromFile
会先进行文件解析,然后根据文件解析得到这个文件是否被压缩过,根据是否压缩过走不通的初始化逻辑,并把解析过的结果传入。
现在的mach-o都是被压缩过的,所以我们以ImageLoaderMachOCompressed::instantiateFromFile
为例,主要完成了以下几个工作:
签名验证
沙盒验证(iOS10
及以上系统,iOS10
以下的系统是不会校验沙盒MainBundle
权限的,可以加载沙盒MainBundle
以外的动态库,但是它的安全机制靠签名验证来保证)
分配可执行内存
loadCodeSignature
函数核心逻辑是调用fcntl
这个系统调用函数,将文件句柄和siginfo
传入,我看了下fcntl
在kernel中的实现,最终调用了fcntl_nocancel
函数,fcntl_nocancel
是一个1900行的函数,完全没有每一行读完的欲望,大概看了下逻辑,确实没有发现主程序的签名和动态库签名一致性校验的过程,只发现了校验签名相关blob
的信息,在此不再展开fcntl_nocancel
。
validateFirstPages
函数会预分配第一页(4K)可执行内存,验证签名一致性和动态库是否来自沙盒。
沙盒验证检测,传入动态库path
,最终调用的是sandbox_check
函数,sandbox_check
是一个后台deamon进程sandboxd
提供的检测服务.
上文已经提过,在iOS10
以上的系统才会验证动态库沙盒权限,那么我们使用iOS10.3
系统的iPhone进行验证,代码如下:
这段示例代码,运行结果如下:
我们可以看到file system sandbox blocked mmap() of
这个报错信息和validateFirstPages
这个函数中的沙盒校验的报错信息是一致的,由此我们可以得知mmap
映射可执行虚拟内存的时候会校验沙盒权限和代码签名,具体如何校验,可以另起一个文章结合源码进行分析。
mapSegments
函数就是在沙盒权限和代码签名验证过后,进行每个Segment
调用mmap
进行虚拟内存的可执行性分配,当然在每次mmap
的时候同样也会再次校验沙盒权限和代码签名,mapSegments
在此不再展开。
mapSegments
之后进行一些其他的检查和处理,就会返回image
,也就是我们完成了一个动态库的加载过程,加载完成之后就是链接过程,无论是主程序Mach-o
加载过程,还是运行时通过dlopen
加载一个动态库,对于dyld
来说都是一个镜像,都会调用ImageLoader::link
来进行链接,以加载Mach-O
为例,还记得上面我们的main
函数嘛,我们接上上面的流程,再次回到dyld
的main
函数。
无论是主程序还是动态库,对于dyld
来说,都是一个镜像,在dyld
中都是以ImageLoader
来进行表示的,我们可以看到link
函数在进行了一些必要的检测和处理之后,调用image->link
来完成链接。
ImageLoader::link
函数主要完成了以下几个工作:
recursiveLoadLibraries
递归加载所有依赖库
recursiveRebase
递归修正自己和依赖库的基地址,因为ASLR(上文中已经提到过)的原因,需要根据随机slide
修正基地址。
recursiveBind
对于nolazy
的符号进行递归绑定,lazy
的符号会在运行时动态绑定。
weakBind
弱符号绑定,比如未初始化的全局变量就是弱符号。
recursiveGetDOFSections
和 registerDOFs
递归获取和注册程序的DOF节区,dtrace
会用其动态跟踪
接下来就会调用初始化主程序函数initializeMainExecutable()
,开始的时候我们已经提到过,runtime
里面注册了dyld
的回调通知,会调用load_images
,然后去调用各个类的+load方
法等,这也是为何+load会
在主程序main函数之前执行的根本原因,初始化之后就是寻找主程序main
函数地址,并作为结果返回,在上述的汇编文件dyldStartup.s
中会将结果从x0
寄存器保存到x16
寄存器,经过参数准备等之后,执行br x16
来进行主程序App的main函数调用。
查找中会加载LoadCommand
中的LC_MAIN
所指向的偏移地址,即主程序main
函数地址。
到这里,我们就完成了一个 Mach-O
及其动态库的整个加载过程的分析,这时候我们做一些总结。
首先很多文章中大家都提到说dyld
加载了主程序和动态库,这个理解明显是错误的,我们在XNU
加载Mach-O
和dyld
过程中已经可以看到,是内核加载了主程序,dyld
只会负责动态库的加载,虽然主程序也会作为镜像形式被dyld
来管理起来。我猜想可能因为我们能够通过_dyld_get_image_header(0)
或者_dyld_get_image_name(0)
等,索引到的第一个镜像为主程序,所以造成很多同学误解了dyld
来加载主程序,如果我们结合XNU
和dyld
源码来分析,自然就会知道是内核加载了主程序。
关于App的虚拟内存分布我们已经在第一篇文章中进行了总结,在此不再重复。
下面我们来重点总结一下沙盒权限和代码签名。
试想一下,如果我们想绕过这些沙盒权限和代码签名策略,在了解了文章中的知识之后,大概有几个思路:
我们是否可以Hookdyld
用户态沙盒权限检测和签名检测的函数呢?答案当然是否定的,内核态的检测在发现异常时就会抛出error,所以我们没有在用户态进行Hook的时机。
dyld
是运行在用户态的,dyld
可以使用mmap
分配可执行内存,那么我们主程序也是在用户态,我们是不是也可以呢?我们从validateFirstPages
这个函数分析中得知,mmap
映射可执行虚拟内存的时候会校验沙盒权限和代码签名,dyld
虽然加载了沙盒MainBundle
以外的系统动态库(目录/System/Library/和/usr/lib/
),但是系统的动态库在mmap
时kernel会辨别是否是系统的动态库,并且它的签名也是正确的,所以能够通过验证,mmap
的具体实现是在内核态,未越狱情况下无法进行访问和更改,我们自己加载动态库的Documents
目录是无法通过验证的,当然签名也是无法和Appstore的签名一致的,Appstore的签名有自己的私钥,我们无法拿到这个私钥进行签名,也无法绕过内核态的mmap
。
既然我们无法直接调用mmap
分配可执行内存,那么我们是否可以获取到dyld
在用户态调用mmap
的内存地址,借助它的手去调用mmap
,这个方法确实可以借助它的手调用mmap
,但是上面已经说过了,内核态的mmap
还是会校验动态库的沙盒权限和代码签名,所以还是无法绕过,这也是我们可以用dlopen
加载一个系统库,甚至一个私有库,但是却无法加载MainBundle
以外沙盒内动态库的原因。
dyld
是否存在漏洞可以利用呢,之前版本的dyld
中确实存在一些漏洞,使App能够绕过代码签名,例如dyld-353.2.1
版本,漏洞编号CVE-2015-5876
,漏洞存在于Mach-O头的处理过程中,一个畸形的Mach-O文件可以导致内存段被替换,从而导致任意代码执行。当然目前dyld
的已知漏洞都已修复,我们想要获得提权的漏洞可能需要更长时间的挖掘才能发现,即使发现漏洞,苹果一旦修复,我们也就失去了相关提权能力。
XNU
控制内核态可执行内存分配过程中的检测并抛出异常,那么我们的策略也就都失效了。不过经过对XNU
和dyld
的马拉松长跑似的分析,我们学习到了很多东西,XNU
是一个复杂而设计精巧的内核系统,之后也会对XNU
、launchd
、sandboxd
等做进一步的分析。