iOS应用安全5 -- main函数调用以前作了些什么?

前言

老规矩,先说一下本篇文章说的内容。有两大部分:分别是MachO文件和DYLD
主要要说的是DYLD加载image(不是图片,是镜像)的总体流程。由于这两部分都是概念性的知识且MachO部分的内容相对来讲不算太多,因此就将两部分知识点用这一篇文章归纳算咯🐶。编程

标题是属于第二大块DYLD里面的内容,若是是冲着标题来的能够直接跳到DYLD去看。bootstrap

MachO文件

Mach-O为Mach object文件格式的缩写,它是一种用于可执行文件、目标代码、动态库的文件格式。缓存

常见的MachO文件有哪些?

  • 目标文件.o
  • 库文件.a | .dylib | xxx.framework/xxx
  • 可执行文件
  • 符号表文件.dsym

能够经过终端file + 文件路径来查看文件的类型信息。bash

通用二进制(Universal binary)

苹果公司提出的一种程序代码,可以同时兼容多种架构的二进制文件。如果不了解什么是架构,看下面:
arm6四、arm64e、armv七、armv7s这些就是不一样的架构,具体有什么区别与联系这里再也不多说。 架构

架构
通用二进制文件除了可以兼容多种架构以外还具备如下特色:

  1. 可以为不一样的架构提供最理想的性能。
  2. 由于要存储多种架构的代码,通用二进制程序包要比单一架构的二进制程序包大。
  3. 由于多种架构只是代码不一样但资源相同,通用二进制的资源只有一份,因此并不会比单一架构的程序包大小多一倍。
  4. 运行时也只执行对应架构的代码,运行时不会占用多余的内存。

lipo命令

写过SDK的童鞋对此应该比较熟悉,在合并真机包和模拟器包的时候会使用如下指令:app

lipo -create [真机编译路径/xxx.framework/xxx] 
[模拟器编译路径/xxx.framework/xxx] -output [合并后输出的文件路径]
复制代码

想起来了吧?这个命令就是为了合并不一样架构(真机/模拟器)的二进制文件。ide

合并的想起来了,别急,下面还有一个拆分的命令:函数

// 从通用二进制文件拆分出不一样的架构
lipo [通用二进制文件路径] -thin [要拆的架构] -output [拆出的二进制输出的路径]
复制代码

MachO的文件结构

首先来整一张图。
从图中能够看出,MachO文件能够分为Header、Load commands、Data三部分。post

MachO结构
下面对这三部分进行解释:

  1. Header包含了MachO文件的概要信息。如:魔数、cpu架构类型、文件类型等。
  2. 相似于MachO文件的目录,里面指定了每块区域对应的起始位置、符号表、要加载的动态库等信息。附上LoadCommands参数解释:
    lm
  3. MachO文件中最大的一部分,主要包括segment的具体数据。

DYLD

回到标题,"main函数是整个程序的入口"这句话想必你们从开始学习编程的那一刻就听到过了吧!可是有没有想过main函数为何是程序的入口?main函数在哪里调用的呢?以及main函数调用以前作了哪些事呢?性能

想知道上面问题的答案,慢慢往下看,dyld。

dyld(the dynamic link editor)是苹果的动态连接器,是苹果操做系统 一个重要组成部分,在系统内核作好程序准备工做以后,交由dyld负责余下的工做。并且它是开源的,任何人能够经过苹果官网下载它的源码来阅读理解它的运做方式,了解系统加载动态库的细节。dyld源码

load方法

咱们知道,每个类都有一个load方法,而且这个load方法的调用时机特别的早,比程序入口main函数调用的还要早。
包括上篇文章的代码注入之因此写到load方法中也是由于这个缘由,在真正的代码逻辑执行以前就交换了某些类的方法,则在代码逻辑执行过程使用的方法就都是交换过之后的方法了。

为了探究dyld,咱们先在任意类的load方法上打一个断点,运行,能够看到在load方法执行前有9个调用堆栈。

load

探究dyld源码

点击调用堆栈中的第一个_dyld_start可查看到汇编代码。注意断点的前一行dyldbootstrap::start,和左边调用堆栈中的第二步相同,说明就是执行了这句代码跳到了堆栈第二步。(查询汇编bl指令的含义就可验证这个猜测)。

start

另外若是对C++语言有所了解的话,应该就知道dyldbootstrap是C++中的一个命名空间,start是这个命名空间的一个函数。
接下来咱们就在下载的dyld源码中全局搜索这个命名空间dyldbootstrap而且查找里面是否是有一个start函数。

命名空间
能够看到这个命名空间里面确实是有start函数的,说明咱们找对地方了。哈哈😄
start 函数
先分析一下这个start函数,start函数主要作了如下工做:

  1. rebaseDyld dyld重定位。
  2. __guard_setup 栈溢出保护。
  3. 调用_main函数并将结果返回。

接下来咱们回过头看刚刚的调用堆栈,第三步是dyld::_main,仔细一看这不正是咱们start函数最后返回值处调用的一个方法吗?

_main函数

看到dyld::_main,好了,main函数找到了,main函数调用以前也没干啥事啊?
哈哈😂,先别急说我是标题党,这个_main并不是程序入口main。跳进方法实现一看,这个方法实现600多行,还真很多。

600多行
下面就简单分析一下这个 _main函数的代码实现吧。

Step1: 设置运行环境。
主要是设置主程序的运行参数、环境变量等。
将参数mainExecutableMH赋值给了sMainExecutableMachHeader,这是一个macho_header结构体,表示的是当前主程序的MachO头部信息

// 将主程序的MachO头部信息赋值给sMainExecutableMachHeader
sMainExecutableMachHeader = mainExecutableMH;   // MH = MachOHeader
// 同理,保存主程序的内存地址偏移值
sMainExecutableSlide = mainExecutableSlide;	
复制代码

接着调用setContext()设置上下文信息,包括一些回调函数、参数、标志信息等。设置的回调函数都是dyld模块自身实现的,如loadLibrary()函数实际调用的是libraryLocator(),负责加载动态库。

// 设置上下文信息
setContext(mainExecutableMH, argc, argv, envp, apple);
复制代码

配置进程是否受限、检查环境变量

// 配置进程是否受限
configureProcessRestrictions(mainExecutableMH, envp);
··· ···
// 检查环境变量
checkEnvironmentVariables(envp);
// 在Scheme中配置对于的环境变量可打印对应环境变量的值
// 配置相应的环境变量(DYLD_PRINT_OPTS/DYLD_PRINT_ENV)便可打印对应的信息
if (sEnv.DYLD_PRINT_OPTS) {
    printOptions(argv);
}
if ( sEnv.DYLD_PRINT_ENV ) {
    printEnvironmentVariables(envp);
}
复制代码

Step2: 加载共享缓存
这里要说一下iOS里的共享库、动态库和静态库的区别。

  1. 共享库,例如Foundation、UIKit等系统库,几乎全部App都会使用到这些库,如果每一个App都将这些库从磁盘加载到内存中一次,不但会使加载时间变成,占用内存也会多不少。因此就有了共享库的存在,这些共享库只会在首次使用时加载到内存而且将已加载的库的地址信息缓存在一个缓存区里。后续其余App使用时直接从缓存区里查看是否已加载,若已经加载直接从缓存区将内存地址拷贝并保存起来,若没有加载则从磁盘加载到内存。
  2. 动态库,在其余不少操做系统中,上面说的共享库就是动态库。而在iOS系统中,这里的动态库实质上就是被阉割的共享库,将共享这个特色给阉割了,由于iOS系统为了让每一个App进程相互独立,不容许开发者本身建立共享库(真正的动态库)
  3. 静态库,静态库就简单了,其实静态库就相似于一个"文件夹",把某些功能及所用到的资源文件所有放到这个"文件夹"里面了,在程序编译连接期间静态库的代码会被编译到主程序的MachO文件中。

通过上面的解释,想必已经知道加载共享缓存是作什么的了吧?我认为就是读取共享库的缓存区,将App使用到的系统共享库没加载到内存的加载到内存,已经加载的记录一下内存地址。

// 检查共享缓存是不是禁用状态
checkSharedRegionDisable((dyld3::MachOLoaded*)mainExecutableMH, mainExecutableSlide);
··· ···
// 映射/加载共享缓存
mapSharedCache();
复制代码

检查共享缓存禁用状态那个方法跳进去看源码能够知道,iOS系统共享缓存没法被禁用。

Step3: 实例化主程序
听名字就知道这一步是作什么的。操做系统自己也是一个应用,只不过这个应用是用来管理其余应用的。既然是应用,那么这个应用内确定会有变量/对象,很明显,这一步就是经过主程序的相关信息建立一个主程序对象。此时的App相对于操做系统来讲就是其中的一个变量。

// 实例化主程序,建立主程序对象sMainExecutable
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH,  
mainExecutableSlide, sExecPath);
复制代码

跳到这个方法实现里面能够发现,这个方法就是建立来一个ImageLoader类型的对象image(这里不是图片,是指主程序的镜像)而且添加到了某个地方保存起来。

static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path) {
    // try mach-o loader
    if ( isCompatibleMachO((const uint8_t*)mh, path) ) {
    	// 建立image对象,image指的是主程序
    	ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
        // 添加主程序镜像
        addImage(image);
        return (ImageLoaderMachO*)image;
    }
    throw "main executable not a known format";
}
复制代码

Step4: 加载插入的库
这里插入的库是dyld源码中注释的直译,我认为这里"插入的库"应该指的就是动态库,包括App开发中用到的动态库以及咱们代码注入时注入的动态库。
从代码上看是遍历DYLD_INSERT_LIBRARIES环境变量指向的那个连续的空间,取出所有的插入的库,依次加载。

// load any inserted libraries
// 加载插入的库到内存。 所谓插入的库,其实就是非共享的动态库,由于静态库在编译期间就会变成主程序的一部分
if( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
    for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
    	// 加载所有的动态库
    	loadInsertedDylib(*lib);
}
// record count of inserted libraries so that a flat search will look at 
// inserted libraries, then main, then others.
sInsertedDylibCount = sAllImages.size()-1;
复制代码

点进loadInsertedDylib(*lib);方法发现里面又调用了一个load(path, context, cacheIndex);方法,在这个方法中一层层的调用了loadPhase0、loadPhase一、loadPhase二、loadPhase三、loadPhase四、loadPhase五、loadPhase6等方法,具体实现没有详细研究,大概看了一下发现有些对加载的库进行一些签名验证、cryptid判断等操做。(有兴趣的能够本身研究研究源码)

Step5: 连接主程序和插入的库

// 连接主程序
link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL), -1);
··· ···
// 连接插入的库
for(unsigned int i=0; i < sInsertedDylibCount; ++i) {
	ImageLoader* image = sAllImages[i+1];
	// 连接插入的库
	link(image, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL), -1);
	image->setNeverUnloadRecursive();
}
复制代码

连接主程序和插入的库是分两次调用的,在AllImages中第一个image是主程序的image,后面才是插入的库的image。
经过link函数连接主程序和插入的库,在link函数中会递归的将当前image进行符号绑定。注意:这里符号绑定只会绑定nolazy的库,对于lazy标记的库会在运行时动态进行绑定连接。

Step6: 初始化主程序
前面那些步骤已经将须要配置和加载的东西都完成了,接下来就须要初始化咱们的主程序,相似于建立对象的alloc已经执行完了,接下来就是init了。

// 初始化主程序
initializeMainExecutable(); 
复制代码

点进去查看方法实现以下:

void initializeMainExecutable() {
    // record that we've reached this step
    gLinkContext.startedInitializingMainExecutable = true;
    
    // run initialzers for any inserted dylibs
    // 先初始化所有的插入库
    ImageLoader::InitializerTimingList initializerTimes[allImagesCount()];
    initializerTimes[0].count = 0;
    const size_t rootCount = sImageRoots.size();
    if ( rootCount > 1 ) {
    	// 从1开始,由于第0个是主程序的image
    	for(size_t i=1; i < rootCount; ++i) {
    		sImageRoots[i]->runInitializers(gLinkContext, initializerTimes[0]);
    	}
    }
    
    // run initializers for main executable and everything it brings up
    // 执行主程序的初始化方法
    sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);
    
    // register cxa_atexit() handler to run static terminators in all loaded images when this process exits
    if ( gLibSystemHelpers != NULL ) 
    	(*gLibSystemHelpers->cxa_atexit)(&runAllStaticTerminators, NULL, NULL);
    
    // dump info if requested
    // 若是配置了这两个环境变量,则会打印想对应的状态信息
    if ( sEnv.DYLD_PRINT_STATISTICS )
    	ImageLoader::printStatistics((unsigned int)allImagesCount(), initializerTimes[0]);
    if ( sEnv.DYLD_PRINT_STATISTICS_DETAILS )
    	ImageLoaderMachO::printStatisticsDetails((unsigned int)allImagesCount(), initializerTimes[0]);
}
复制代码

追踪调用堆栈

其实看到这个initializeMainExecutable()方法,就会发现这个方法就是最开始那个调用堆栈里面第四步,也就是说调用堆栈的后续步骤须要从这个函数继续追踪了。那么接下来就开始追踪吧。

找到堆栈下一步runInitializers,恰好代码中就有这个函数的调用

runInitializers
继续下一步 processInitializers
processInitializers
接着调用 recursiveInitialization
recursiveInitialization
再跳,哎?咋跳到声明文件了?实现文件不让看吗?
别急,复制函数名,command + shift + o,搜索这个函数名
搜索
找到函数实现了
load_images
若是当前执行的是主程序 image,在 doInitialization方法前会发送 statedyld_image_state_dependents_initialized的通知,这个通知会调用 libobjcload_images,最后去依次调用各个OC类的load方法以及分类的load方法。(这里就是load方法的调用时机)。

接下来咱们跳进notifySingle里面看看,哎?又跳到声明了,老方法

notifySingle
跳进方法实现以后,咱们看调用堆栈,下一步是 load_images,可是咱们找遍了也没找到哪里有调用 load_images
load_images
既然要找这个回调方法的实现,那咱们就得先找找这个回调是在哪赋的值?
下一个符号断点 load_images,运行
命中
从这里能够看出 load_imageslibobjc里面的,所以咱们能够下载 runtime源码
runtime
从这个调用堆栈里面能够发现 load_images_objc_init函数里面的,所以咱们在runtime源码里面全局搜索 _objc_init
能够看到 load_images_dyld_objc_notify_register函数的第二个参数,很明显 _dyld_objc_notify_register是dyld库里面的方法。在dyld源码里面搜索 _dyld_objc_notify_register
再跳进 registerObjCNotifiers方法实现,能够看到 objc传过来的 load_images在dyld中赋值给力 sNotifyObjCInit,而咱们上面的那个回调函数就是 sNotifyObjCInit。说明那一步回调实质上调用的就是 objc里面的 load_images函数。
说了这么多,还不知道 load_images函数到底长啥样,在 objc源码中搜索 load_images找到方法实现。
在跳进 call_load_methods()函数,函数实现以下

void call_load_methods(void) {
    static bool loading = NO;
    bool more_categories;

    loadMethodLock.assertLocked();

    // Re-entrant calls do nothing; the outermost call will finish the job.
    if (loading) return;
    loading = YES;

    void *pool = objc_autoreleasePoolPush();

    do {
        // 1. Repeatedly call class +loads until there aren't any more
        while (loadable_classes_used > 0) {
            // 循环调用
            call_class_loads();
        }

        // 2. Call category +loads ONCE
        more_categories = call_category_loads();

        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);

    objc_autoreleasePoolPop(pool);

    loading = NO;
}
复制代码

这里的call_class_loads();方法内部就是调用每一个类的load方法。

返回主程序入口

找到了load方法的调用时机,还没完,再回到刚开始的_main函数中,初始化主程序这一步算是分析完了,可是下面还有代码,继续分析。

// find entry point for main executable
// 找到主可执行程序的入口点
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
if (result != 0) {
	// main executable uses LC_MAIN, we need to use helper in libdyld to call into main()
	if ((gLibSystemHelpers != NULL) && (gLibSystemHelpers->version >= 9))
		*startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
	else
		halt("libdyld.dylib support not present for LC_MAIN");
}
else {
	// main executable uses LC_UNIXTHREAD, dyld needs to let "start" in program set up for main()
	result = (uintptr_t)sMainExecutable->getEntryFromLC_UNIXTHREAD();
	*startGlue = 0;
}
复制代码

getEntryFromLC_MAIN()方法找到了主可执行程序的入口了,也就是那个程序的入口main()函数,非刚刚分析的_main()函数。
又通过一系列的配置,最后_main()函数的返回值处返回了咱们找到的程序入口main()函数。

总结

又到了总结的时刻,总结了如下六点......

哈哈,写这篇文章快要累死我了,可是收获也是不少的,学到了App加载过程当中经历了哪些步骤,后续文章咱们可能就会利用这些东西的加载顺序的不一样来破解或者防御App。

真正的总结
MachO文件

  1. MachO文件是什么?
  2. 常见的MachO文件有哪些?
  3. 通用二进制(多种架构的MachO文件)。
  4. 拆分和合并MachO文件。
  5. MachO文件的格式。

dyld,main函数以前都作了什么?

  1. 程序从_dyld_start开始执行,进入_main函数。
  2. 设置运行环境。
  3. 加载共享缓存。
  4. 实例化主程序。
  5. 加载插入的库。
  6. 连接主程序和插入的库。
  7. 初始化主程序。通过一系列的调用堆栈,最终会调用到每一个类的load方法。
  8. doModInitFunctions函数,会调用带有__attribute__((constructor))的C函数。
  9. _main()调用结束返回程序入口main()函数,开始进入主程序的main()函数。

文章地址

相关文章
相关标签/搜索