iOS之武功秘籍⑦: dyld加载流程 -- 应用程序的加载

iOS之武功秘籍 文章汇总

写在前面

我们平时编写的程序的入口函数都是main.m文件里面的main函数,但是这就是App的生命起点了吗?玩过逆向的iOSer都知道可以往+load方法注入代码来进行安全攻防,而+load方法先于main函数执行,那么main函数之前都发生了哪些有趣的事呢?本文就将带着大家来揭开这片神秘面纱!

本节可能用到的秘籍Demo

一、编译过程与动静态库

我们先来看个🌰:

  • 创建一个project,在ViewController中重写了load方法,在main.m中加了一个C++方法,即cjFunc,请问它们的打印先后顺序是什么?

  • 运行程序,查看 loadcjFuncmain的打印顺序,下面是打印结果,通过结果可以看出其顺序是 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 框架中的方法和变量,但是这些框架跟我们的代码并不在一个目标文件中,这就需要链接器将它们与我们自己的代码链接起来

FoundationUIKit这种可以共享代码、实现代码的复用统称为——它是可执行代码的二进制文件,可以被操作系统写入内存,它又分为静态库动态库

② 静态库

静态库是指链接时完整的拷贝到可执行文件,多次使用多次拷贝,造成冗余,使包变的更大

.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方法,其核心是返回值调用了dyldmain函数,其中macho_headerMach-O的头部,而dyld加载的文件就是Mach-O类型的,即Mach-O类型可执行文件类型,由四部分组成:Mach-O头部Load CommandsectionOther 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)获取程序架构

只要设置了这两个环境变量参数,在App启动时就会打印相关参数、环境变量信息(自行尝试研究)

③.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_PATHLD_LIBRARY_PATHDYLD_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的初始化必须先运行

可以通过测试程序的堆栈信息来验证,在C++方法出加一个断点

走到这里,还是没有找到_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=参数2sNotifyObjcInit()调用,形成了一个闭环

也可以简单的理解为sNotifySingle这里是添加通知即addObserver_objc_init中调用_dyld_objc_notify_register相当于发送通知,即push,而sNotifyObjcInit相当于通知的处理函数,即selector.

③.9 寻找主程序入口

  • ①在测试程序中汇编调试,可以看到显示来到+[ViewController load]方法
  • ②继续执行,来到cjFuncC++函数
  • ③点击stepover,继续往下,跑完了整个流程,会回到_dyld_start,然后调用main()函数,通过汇编完成main的参数赋值等操作
  • dyld汇编源码实现

最后注意:main是写定的函数,写入内存,读取到dyld,如果修改了main函数的名称,会报错

所以,综上所述,最终dyld加载流程,如下图所示,图中也诠释了前文中的问题:为什么是load-->Cxx-->main的调用顺序

写在后面

和谐学习,不急不躁.我还是我,颜色不一样的烟火.

推荐阅读更多精彩内容