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

2,398 阅读14分钟

前言

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

标题是属于第二大块DYLD里面的内容,如果是冲着标题来的可以直接跳到DYLD去看。

MachO文件

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

常见的MachO文件有哪些?

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

可以通过终端file + 文件路径来查看文件的类型信息。

通用二进制(Universal binary)

苹果公司提出的一种程序代码,能够同时兼容多种架构的二进制文件。若是不了解什么是架构,看下面:
arm64、arm64e、armv7、armv7s这些就是不同的架构,具体有什么区别与联系这里不再多说。

架构
通用二进制文件除了能够兼容多种架构之外还具有以下特点:

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

lipo命令

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

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

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

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

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

MachO的文件结构

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

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、loadPhase1、loadPhase2、loadPhase3、loadPhase4、loadPhase5、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()函数。

文章地址https://juejin.cn/post/6844904110857142279