写在前面
我们平时编写的程序的入口函数都是main.m
文件里面的main
函数,但是这就是App
的生命起点了吗?玩过逆向的iOSer
都知道可以往+load
方法注入代码来进行安全攻防,而+load
方法先于main
函数执行,那么main
函数之前都发生了哪些有趣的事呢?本文就将带着大家来揭开这片神秘面纱!
一、编译过程与动静态库
我们先来看个🌰:
-
创建一个
project
,在ViewController
中重写了load
方法,在main.m
中加了一个C++
方法,即cjFunc
,请问它们的打印先后顺序是什么? -
运行程序,查看
load
、cjFunc
、main
的打印顺序,下面是打印结果,通过结果可以看出其顺序是load --> C++方法 --> main()
为什么是这么一个顺序?按照常规的思维理解,main
不是入口函数吗?为什么不是main
最先执行?
下面根据这个问题,我们来探索在走到main
函数之前,到底还做了什么.
在探索分析app
启动之前,我们需要先了解iOS中App代码的编译过程
以及动态库
和静态库
.
① 编译过程
在日常开发过程中,开发者会使用成千上万次的Command + B/R
进行开发调试,但可能很少有人关注过这个过程中 Xcode
帮我们做了哪些事情(iOS开发者往往会吐槽Xcode越来越难用了,但不得不承认它越来越强了)
事实上,这个过程分解为4个步骤,分别是预处理(Prepressing)、编译(Compilation)、汇编(Assembly)和链接(Linking).------ 摘自《程序员的自我修养-- 链接、装载与库》
在以上4个步骤中,IDE
主要做了以下几件事:
-
预编译
:处理代码中的# 开头
的预编译指令,比如删除#define
并展开宏定义,将#include
包含的文件插入到该指令位置等(即替换宏,删除注释,展开头文件,产生.i文件
) -
编译
:对预编译处理过的文件进行词法分析、语法分析和语义分析,并进行源代码优化,然后生成汇编代码(即将.i文件
转换为汇编语言,产生.s文件
) -
汇编
:通过汇编器将汇编代码转换为机器可以执行的指令,并生成目标文件.o文件
-
链接
:将目标文件链接成可执行文件.这一过程中,链接器将不同的目标文件链接起来,因为不同的目标文件之间可能有相互引用的变量或调用的函数,如我们经常调用Foundation
框架和UIKit
框架中的方法和变量,但是这些框架跟我们的代码并不在一个目标文件中,这就需要链接器将它们与我们自己的代码链接起来
Foundation
和UIKit
这种可以共享代码、实现代码的复用统称为库
——它是可执行代码的二进制文件,可以被操作系统写入内存,它又分为静态库
和动态库
② 静态库
静态库
是指链接时完整的拷贝到可执行文件,多次使用多次拷贝,造成冗余,使包变的更大
如.a
、.lib
都是静态库
③ 动态库
动态库
是指链接时不复制,程序运行时由系统加在到内存中,供系统调用,系统只需加载一次,多次使用,共用节省内存.
如.dylib
、.framework
都是动态库
二、dyld
① dyld简介
dyld
(The dynamic link editor
)是苹果的动态链接器
,负责程序的链接及加载工作,是苹果操作系统的重要组成部分,存在于MacOS
系统的(/usr/lib/dyld
)目录下.在应用被编译打包成可执行文件格式的Mach-O
文件之后 ,交由dyld
负责链接,加载程序.
所以 App
的启动流程图如下
② dyld_shared_cache
由于不止一个程序需要使用UIKit
系统动态库,所以不可能在每个程序加载时都去加载所有的系统动态库.为了优化程序启动速度和利用动态库缓存,苹果从iOS3.1
之后,将所有系统库(私有与公有)编译成一个大的缓存文件,这就是dyld_shared_cache
,该缓存文件存在iOS系统下的/System/Library/Caches/com.apple.dyld/
目录下
三、dyld加载流程
在前文的Demo
中,在load方法
和main方法
处加一个断点
点击函数调用栈/使用LLDB——bt
指令打印,都能看到最初的起点_dyld_start
接下来怎么去研究dyld
呢,我们将通过dyld源码展开分析
① 1._dyld_start
在源码中全局搜索_dyld_start
,会发现它是由汇编实现的
在arm64
中,_dyld_start
调用了一个看不懂的方法
从注释中得出可能是dyldbootstrap::start
方法(其实在“函数调用栈”那张图中汇编代码已经把这个方法暴露出来了)
② dyldbootstrap::start
其实dyldbootstrap::start
是指dyldbootstrap
这个命名空间作用域里的 start
函数
源码中搜索dyldbootstrap
找到命名作用空间
.
再在这个文件中查找start
方法,其核心是返回值调用了dyld
的main
函数,其中macho_header
是Mach-O
的头部,而dyld
加载的文件就是Mach-O类型
的,即Mach-O类型
是可执行文件类型
,由四部分组成:Mach-O头部
、Load Command
、section
、Other Data
,可以通过MachOView
查看可执行文件信息
在start()
函数中主要做了一下几件事:
- 根据
dyldsMachHeader
计算出slide
, 通过slide
判定是否需要重定位;这里的slide
是根据ASLR
技术 计算出的一个随机值,使得程序每一次运行的偏移值都不一样,防止攻击者通过固定地址发起恶意攻击 -
mach_init()
初始化(允许dyld使用mach消息传递) - 栈溢出保护
- 计算
appsMachHeader
的偏移,调用dyld::_main()
函数
③ dyld::_main()
dyld::_main()主要流程为:
-
环境变量配置
:根据环境变量设置相应的值以及获取当前运行架构 -
共享缓存
:检查是否开启了共享缓存,以及共享缓存是否映射到共享区域,例如UIKit、CoreFoundation
等 -
主程序的初始化
:调用instantiateFromLoadedImage
函数实例化了一个ImageLoader
对象 -
插入动态库
:遍历DYLD_INSERT_LIBRARIES
环境变量,调用loadInsertedDylib
加载 link 主程序
link 动态库
弱符号绑定
执行初始化方法
-
寻找主程序入口
即main
函数
③.1 环境变量配置
- 平台,版本,路径,主机信息的确定
- 从环境变量中获取主要可执行文件的
cdHash
-
checkEnvironmentVariables(envp)
检查设置环境变量 -
defaultUninitializedFallbackPaths(envp)
在DYLD_FALLBACK
为空时设置默认值 -
getHostInfo(mainExecutableMH, mainExecutableSlide)
获取程序架构
③.2 共享缓存
-
checkSharedRegionDisable
检查是否开启共享缓存(在iOS中必须开启) -
mapSharedCache
加载共享缓存库,其中调用loadDyldCache
函数有这么几种情况:- 仅加载到当前进程
mapCachePrivate
(模拟器仅支持加载到当前进程) - 共享缓存是第一次被加载,就去做加载操作
mapCacheSystemWide
-
共享缓存不是第一次被加载,那么就不做任何处理
- 仅加载到当前进程
③.3 主程序的初始化
- ①调用
instantiateFromLoadedImage
函数实例化了一个ImageLoader
对象
- ②进入
instantiateFromLoadedImage
源码,其中创建一个ImageLoader
实例对象,通过instantiateMainExecutable
方法创建
- ③进入
instantiateMainExecutable
源码,其作用是为主可执行文件创建映像,返回一个ImageLoader
类型的image
对象,即主程序
.其中sniffLoadCommands
函数会获取Mach-O
类型文件的Load Command
的相关信息,并对其进行各种校验
③.4 插入动态库
遍历DYLD_INSERT_LIBRARIES
环境变量,调用loadInsertedDylib
加载,通过该环境变量我们可以注入自定义的一些动态库代码从而完成安全攻防,loadInsertedDylib
内部会从DYLD_ROOT_PATH
、LD_LIBRARY_PATH
、DYLD_FRAMEWORK_PATH
等路径查找dylib
并且检查代码签名,无效则直接抛出异常
③.5 link 主程序
③.6 link 动态库
③.7 弱符号绑定
③.8 执行初始化方法
先回顾一下函数调用栈- ①进入
initializeMainExecutable
源码,主要是循环遍历,都会执行runInitializers
方法
- ②全局搜索
runInitializers(cons
,找到如下源码,其核心代码是processInitializers
函数的调用为初始化做准备
- ③进入
processInitializers
函数的源码实现,其中对镜像列表调用recursiveInitialization
函数进行递归实例化
- ④全局搜索
recursiveInitialization(cons
函数,其作用获取到镜像的初始化,其源码实现如下
在这里,需要分成两部分探索,一部分是notifySingle
函数,一部分是doInitialization
函数,首先探索notifySingle
函数
- ⑤全局搜索
notifySingle(
函数,其重点是(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());
这句.
- ⑥全局搜索
sNotifyObjCInit
,发现没有找到实现,有赋值操作
- ⑦搜索
registerObjCNotifiers
在哪里调用了,发现在_dyld_objc_notify_register
进行了调用,这个函数只在运行时提供给objc
使用
注意:_dyld_objc_notify_register
的函数需要在libobjc
源码中搜索
- ⑧在
objc4
源码中搜索_dyld_objc_notify_register
,发现在_objc_init
源码中调用了该方法,并传入了参数,所以sNotifyObjCInit
的赋值的就是objc
中的load_images
,而load_images
会调用所有的+load
方法.所以综上所述,notifySingle是一个回调函数
都到这了,那就顺便看看load函数的加载吧
下面我们进入load_images
的源码看看其实现,以此来证明load_images
中调用了所有的load
函数
- 通过
objc源码
中_objc_init
源码实现,进入load_images
的源码实现
- 进入
call_load_methods
源码实现,可以发现其核心是通过do-while
循环调用+load
方法
- 进入
call_class_loads
源码实现,了解到这里调用的load方法
证实我们前文提及的类的load方法
所以,load_images
调用了所有的load
函数,以上的源码分析过程正好对应堆栈的打印信息
那么问题又来了,_objc_init
是什么时候调用的呢?请接着往下看
- ⑨ 走到
objc
的_objc_init
函数,发现走不通了,我们回退到recursiveInitialization
递归函数的源码实现,发现我们忽略了一个函数doInitialization
,进入doInitialization
函数的源码实现
这里也需要分成两部分,一部分是doImageInit
函数,一部分是doModInitFunctions
函数
-
⑩进入
doImageInit
源码实现,其核心主要是for循环
加载方法的调用,这里需要注意的一点是,libSystem
的初始化必须先运行 -
⑪进入
doModInitFunctions
源码实现,这个方法中加载了所有Cxx
文件,这里需要注意的一点是,libSystem
的初始化必须先运行
走到这里,还是没有找到_objc_init
的调用?怎么办呢?放弃吗?当然不行,我们还可以通过_objc_init
加一个符号断点来查看调用_objc_init
前的堆栈信息
- ⑫在
libsystem
中查找libSystem_initializer
,查看其中的实现
- ⑬根据前面的堆栈信息,我们发现走的是
libSystem_initializer
中会调用libdispatch_init
函数,而这个函数的源码是在libdispatch
开源库中的,在libdispatch
中搜索libdispatch_init
- ⑭进入
_os_object_init
源码实现,其源码实现调用了_objc_init
函数
结合上面的分析,从初始化_objc_init
注册的_dyld_objc_notify_register
的参数2,即load_images
,到sNotifySingle --> sNotifyObjCInie=参数2
到sNotifyObjcInit()
调用,形成了一个闭环
也可以简单的理解为sNotifySingle
这里是添加通知即addObserver
,_objc_init
中调用_dyld_objc_notify_register
相当于发送通知,即push
,而sNotifyObjcInit
相当于通知的处理函数,即selector
.
③.9 寻找主程序入口
- ①在测试程序中汇编调试,可以看到显示来到
+[ViewController load]
方法
- ②继续执行,来到
cjFunc
的C++
函数
- ③点击
stepover
,继续往下,跑完了整个流程,会回到_dyld_start
,然后调用main()
函数,通过汇编完成main
的参数赋值等操作
- ④
dyld
汇编源码实现
最后注意:main
是写定的函数,写入内存,读取到dyld
,如果修改了main
函数的名称,会报错
所以,综上所述,最终dyld
加载流程,如下图所示,图中也诠释了前文中的问题:为什么是load-->Cxx-->main
的调用顺序
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.