深入剖析iOS动态链接库

iOS不支持动态链接库的特性总是被人诟病。不管你赞不赞同这一点,去弄清楚其中的why和how还是很有趣的一件事情。在这篇文章里我们将会看到库是什么,如何在实践中用到,它们怎么运作(如果它们被iOS全面支持),以及是什么导致我们不能够加载一个库到IOS应用中。

关于库的链接

应用很少会直接直接构建成一个很大的可执行文件,而是通过不同的模块组装而成,即库libraries。从实践的角度来看,一个库可以看成一个由可执行代码和一些公开的头文件和一些资源组合而成,以被应用链接和使用。
虽然这个广泛的定义适合大部分的库类型,它们在一个方面上还是有根本性的区别:被链接时。基于点库一共有两个类别:动态和静态的。这里我会大概给出这两者的区别,但如果你想知道更具体的,我推荐阅读Apple官网的教程:Dynamic Library Programming Topics

静态库

静态库可以看成是一堆对象文件(object files)的归档。当链接这样一个库到应用中时,静态链接器static linker将会从库中收集这些对象文件并把它们和应用的对象代码一起打包到一个单独的二进制文件中。这意味着应用的可执行文件大小将会随着库的数目增加而增长。另外,当应用启动时,应用的代码(包含库的代码)将会一次性地导入到程序的地址空间中去。

动态库

动态库允许一个应用在实际需要的时候加载一段代码到它的地址空间中去,这既可以在应用启动时或者运行时完成。动态库并不是应用的二进制文件的一部分。
当一个app启动后,app的代码最先被加载到进程的地址空间,然后动态链接器dynamic loader - 在苹果的平台上即是dyld,接管进程并加载相关的库。这里面包括解析他们在文件系统上的位置(基于他们安装时候的名字),并解析app需要的未定义的外部符号external symbols。在运行时dynamic loader也将会加载哪些被请求的其他库。

Framework

在苹果的定义中,一个Framework指包含一个动态库,头文件和资源的包bundle(package)。Frameworks以一个非常整洁的方式来将相关的资源整合到一个包package中,包里提供了一个可执行文件和公开的头文件。
需要注意的是虽然一个Framework可能需要包含一个动态库,创建一个iOS上的静态的Framework还是非常容易的。这里我就不展开细讲了,推荐阅读iPhone Framework Support - Shipping LibrariesiOS Static Libraries Are, Like, Really Bad, And Stuff

iOS上的动态库

动态库真得不能在iOS上使用?事实上,这里有多多少少的误解。每一个你链接到你的app的苹果的Framework都包含一个动态共享库dynamic shared library。如果你必须静态链接UIKit和其他frameworks到每一个单独的app,你将无法想象可执行文件将会有多大。
事实上,动态库在iOS上被广泛使用。当你的代码执行到applicationDidFinishLaunching:时,dyld已经加载了超过150个库!

如果我们能够弄清楚当app运行时哪些库正在被加载就再好不过了。幸运的是dyld提供了一些钩子hooks,使得当一个镜像image在加载时或移除时你的app能够得到这些通知。让我们创建一个LLImageLogger类,以在这个类载入时设置一些回调函数。这些代码你都可以在Github上找到,其中包括了iOS和MacOS上的应用例子。

加载动态库时打日志

mach-o/dyld.h 声明了两个非常有用的函数:_dyld_register_func_for_add_image_dyld_register_func_for_remove_image。这两个函数的文档如下:

The following functions allow you to install callbacks which will be called by dyld whenever an image is loaded or unloaded.
During a call to `_dyld_register_func_for_add_image()` the callback func is called for every existing image. Later, it is called as each new image is loaded and bound (but initializers not yet run).
The callback registered with `_dyld_register_func_for_remove_image()` is called after any terminators in an image are run and before the image is un-memory-mapped.

我们很容易地在我们的类在load时添加一些回调:

#import <mach-o/dyld.h>

@implementation LLImageLogger

+ (void)load
{
    _dyld_register_func_for_add_image(&image_added);
    _dyld_register_func_for_remove_image(&image_removed);
}
@end

现在我们需要实现这两个函数。注意到回调函数的签名如下:

void callback_function(const struct mach_header *mh, intptr_t vmaddr_slide);

我们要做点什么呢?不如打印一些关于已加载的image的日志到控制台中吧。最熟悉的方式是尝试模仿一个crash report的格式。一个crash report总是有一个image的列表,包含了可执行文件的路径,基地址base address,可执行文件文本段text section大小(或者末地址)和image UUID。这些信息在还原一个crash report是非常有用的。

0x2fd23000 - 0x2ff0dfff Foundation armv7s  <b75ca4f9d9b739ef9b16e482db277849> /System/Library/Frameworks/Foundation.framework/Foundation

0x31c2c000 - 0x3239ffff UIKit armv7s  <f725ad0982673286911bff834295ec99> /System/Library/Frameworks/UIKit.framework/UIKit

注意到回调函数的第一个参数是一个指向Mach-O的头部mach_header *mh的指针,那么获取到完整的信息将会是非常容易的事情。现在我们来实现这两个回调函数。首先实现一个共同的函数,通过一个额外的参数来标识image是在被加载还在移除。

#import <mach-o/loader.h>

static void image_added(const struct mach_header *mh, intptr_t slide)
{
    _print_image(mh, true);
}

static void image_removed(const struct mach_header *mh, intptr_t slide)
{
    _print_image(mh, false);
}

现在我们只需关注_print_image的实现。Mach-O头部的大部分信息可以通过定义在dlfcn.h的函数dladdr来获取到。通过传递指针给Mach-O头部Mach-O header并引用一个Dl_info结构体,我们可以取到一些关于image的关键的信息。Dl_info结构体包含如下成员变量:

typedef struct dl_info {
    const char  *dli_fname;     /* Pathname of shared object */
    void        *dli_fbase;     /* Base address of shared object */
    const char  *dli_sname;     /* Name of nearest symbol */
    void        *dli_saddr;     /* Address of nearest symbol */
} Dl_info;

记住这些,现在我们看下_print_image的实现了:

#import <dlfcn.h>

static void _print_image(const struct mach_header *mh, bool added)
{
    Dl_info image_info;
    int result = dladdr(mh, &image_info);
    
    if (result == 0) {
        printf("Could not print info for mach_header: %p\n\n", mh);
        return;
    }
    
    const char *image_name = image_info.dli_fname;
    
    const intptr_t image_base_address = (intptr_t)image_info.dli_fbase;
    const uint64_t image_text_size = _image_text_segment_size(mh);
    
    char image_uuid[37];
    const uuid_t *image_uuid_bytes = _image_retrieve_uuid(mh);
    uuid_unparse(*image_uuid_bytes, image_uuid);
    
    const char *log = added ? "Added" : "Removed";
    printf("%s: 0x%02lx (0x%02llx) %s <%s>\n\n", log, image_base_address, image_text_size, image_name, image_uuid);
}

正如你所看到的,这里面并没有太多玄幻的东西。我们从获取Mach-O头部的Dl_info结构体入手,然后计算出我们需要的其他信息。虽然基地址base address和image路径可以直接从结构体中得到,我们仍然需要从二进制中手动获取image的文本段text segment的大小和image的UUID。这些正是_image_retrieve_uuid_image_text_segment_size做到事情。
对于这两个函数,我们将会简单过一下Mach-O文件的加载命令load commands。这里推荐阅读苹果官方的OS X ABI Mach-O File Format Reference来对Mach-O文件格式有个概览。在内核中,一个Mach-O文件由头部header,一系列的加载命令load commands和多个segment组成的data组成。关于segment的信息(比如他们的偏移offset和大小)在segment load commands中可以获取到。

Mach-O 格式

我们从创建一个可以在各函数之间复用的遍历函数visitor function开始。

static uint32_t _image_header_size(const struct mach_header *mh)
{
    bool is_header_64_bit = (mh->magic == MH_MAGIC_64 || mh->magic == MH_CIGAM_64);
    return (is_header_64_bit ? sizeof(struct mach_header_64) : sizeof(struct mach_header));
}

static void _image_visit_load_commands(const struct mach_header *mh, void (^visitor)(struct load_command *lc, bool *stop))
{
    assert(visitor != NULL);
    
    uintptr_t lc_cursor = (uintptr_t)mh + _image_header_size(mh);
    
    for (uint32_t idx = 0; idx < mh->ncmds; idx++) {
        struct load_command *lc = (struct load_command *)lc_cursor;
        
        bool stop = false;
        visitor(lc, &stop);
        
        if (stop) {
            return;
        }
        
        lc_cursor += lc->cmdsize;
    }
}

这个函数的接收一个指向Mach-O头部的指针和一个用于遍历的block闭包,然后对每个找到的load command调用block。注意到获取Mach-O头部大小的辅助函数,我们将结合它来寻找第一个load command。这是因为Mach-O头部有两种不同的结构体:mach_headermach_header_64,基于平台的架构architecture是否是64位的。幸运的是头部的第一个字段magic number给出了关于架构的信息。
结合这个辅助函数现在我们应该能够实现_image_retrieve_uuid_image_text_segment_size了:

static const uuid_t *_image_retrieve_uuid(const struct mach_header *mh)
{
    __block const struct uuid_command *uuid_cmd = NULL;
    
    _image_visit_load_commands(mh, ^ (struct load_command *lc, bool *stop) {
        if (lc->cmdsize == 0) {
            return;
        }
        if (lc->cmd == LC_UUID) {
            uuid_cmd = (const struct uuid_command *)lc;
            *stop = true;
        }
    });
    
    if (uuid_cmd == NULL) {
        return NULL;
    }
    
    return &uuid_cmd->uuid;
}

这个函数也非常简单。它查找LC_UUIDcommand并获取uuid_t一旦寻找到它。然后_print_imageuuid_t通过uuid_unparse转换成一个string。
最后,这里是函数_image_text_segment_size的实现:

static uint64_t _image_text_segment_size(const struct mach_header *mh)
{
    static const char *text_segment_name = "__TEXT";
    
    __block uint64_t text_size = 0;
    
    _image_visit_load_commands(mh, ^ (struct load_command *lc, bool *stop) {
        if (lc->cmdsize == 0) {
            return;
        }
        if (lc->cmd == LC_SEGMENT) {
            struct segment_command *seg_cmd = (struct segment_command *)lc;
            if (strcmp(seg_cmd->segname, text_segment_name) == 0) {
                text_size = seg_cmd->vmsize;
                *stop = true;
                return;
            }
        }
        if (lc->cmd == LC_SEGMENT_64) {
            struct segment_command_64 *seg_cmd = (struct segment_command_64 *)lc;
            if (strcmp(seg_cmd->segname, text_segment_name) == 0) {
                text_size = seg_cmd->vmsize;
                *stop = true;
                return;
            }
        }
    });
    
    return text_size;
}

这里也没有太多玄幻的东西。遍历block仅仅查找segment commands(32位上是LC_SEGMENT,64位上是LC_SEGMENT_64)并检查当前的load segment是否为__TEXTsegment。如果是,它就获取vmsize并作为text size返回它。
通过运行以上在iOS的模拟器中,打印出来的日志如下:

Added: 0x10000b000 (0x2a8000) /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator7.1.sdk/System/Library/Frameworks/Foundation.framework/Foundation <C299A741-488A-3656-A410-A7BE59926B13>
…
Added: 0x110527000 (0x385000) /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator7.1.sdk/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox <57B61C9C-8767-3B3A-BBB5-8768A682383A>

数数看,一共有147个image在启动一个非常简单的iOS应用时被加载了!
通过以上我们证明了动态库确实被加载到了我们的IOS应用中。但IOS上不支持动态库到底是几个意思?好,就让我们来构建一个试试并看看会发生神马!

�构建一个IOS上的动态库

接下来我们将尝试构建3个在Mac上常见但iOS上不支持的products:

  • 一个简单的被应用链接的动态库
  • 一个framework(一个合法的,包含一个动态共享库)
  • 一个插件plugin(i.e. 一个包含一个可执行文件的bundle,不与app打包在一起但在runtime加载)
应用截图

和之前一样,你可以在[Github]上找到这些代码。

iOS上的动态库
iOS上的Framework
iOS上的插件

�运行在真机上会怎样呢

已链接的动态库

dyld: Library not loaded: @executable_path/Library.dylib  
Referenced from: /var/mobile/Applications/DC816A37-F0D4-4F72-9EC8-A642A03C0ABC/Dynamic.app/Dynamic  
Reason: no suitable image found.  
Did find: /var/mobile/Applications/DC816A37-F0D4-4F72-9EC8-A642A03C0ABC/Dynamic.app/Library.dylib: code signature invalid for '/var/mobile/Applications/DC816A37-F0D4-4F72-9EC8-A642A03C0ABC/Dynamic.app/Library.dylib'

运行时加载的Plugin

让我们来看看当加载我们的插件时会发生什么。当尝试在运行时加载插件,app很有可能crash,堆栈如下:

Exception Type:  EXC_CRASH (SIGKILL - CODESIGNING)

Thread 0 Crashed:
0   dyld 0x2be50c40 ImageLoaderMachO::crashIfInvalidCodeSignature + 72
1   dyld 0x2be5557a ImageLoaderMachOCompressed::instantiateFromFile + 286
2   dyld 0x2be50b44 ImageLoaderMachO::instantiateFromFile + 204
3   dyld 0x2be48036 dyld::loadPhase6 + 390
4   dyld 0x2be4b9b0 dyld::loadPhase5stat + 296
5   dyld 0x2be4b7c6 dyld::loadPhase5 + 390
6   dyld 0x2be4b61c dyld::loadPhase4 + 128
7   dyld 0x2be4b53c dyld::loadPhase3 + 1000
8   dyld 0x2be4afd0 dyld::loadPhase1 + 108
9   dyld 0x2be47e0a dyld::loadPhase0 + 162
10  dyld 0x2be47bb4 dyld::load + 208
11  dyld 0x2be4d1b2 dlopen + 790
12  libdyld.dylib 0x3a09a78a dlopen + 46
13  CoreFoundation 0x2f392754 _CFBundleDlfcnLoadBundle + 120
14  CoreFoundation 0x2f3925a4 _CFBundleLoadExecutableAndReturnError + 328
15  Foundation 0x2fd7f674 -[NSBundle loadAndReturnError:] + 532
16  Foundation 0x2fd8f51e -[NSBundle load] + 18
17  Dynamic 0x000f64be -[LLViewController _loadPluginAtLocation:]

app被杀是在当dyld尝试加载bundle时。我们这里只能看见用户态的东西,但stack中最顶部的函数给了我们一些思路:ImageLoaderMachO::crashIfInvalidCodeSignature。值得注意的是我们复制到Documents文件中的插件是没有进行代码签名的。在尝试对它进行代码签名前,我们来简单分析下什么导致程序在加载插件时被杀掉了。

在用户态中

幸运的是,dyld是开源的。我们可以简单看下函数ImageLoaderMachO::crashIfInvalidCodeSignature的实现来弄清楚到底发生了什么。问题中的文件是ImageLoaderMachO.cpp,它的实现是非常简单的:

int ImageLoaderMachO::crashIfInvalidCodeSignature()
{
// Now that segments are mapped in, try reading from first executable segment
// If code signing is enabled the kernel will validate the code signature
// when paging in, and kill the process if invalid
    for (unsigned int i = 0; i < fSegmentsCount; ++i) {
        if ((segFileOffset(i) == 0) && (segFileSize(i) != 0)) {
        // return read value to ensure compiler does not optimize away load
            int* p = (int*)segActualLoadAddress(i);
            return *p;
        }
    }
    return 0;
}

这是一个非常直接的来检查签名并让app崩溃如果签名是非法或不存在的方式:从可执行文件的第一个segment开始尝试读取,如果在这个过程中发现了不能被审核的签名,让内核杀掉该进程。

在内核态中

内核同样也是开源的。我们可以简单看下并弄清楚签名是在哪里和如何被验证的。阅读内核代码是在是件不舒服的事情,但我从Don’t Hassle The Hoff: Breaking iOS Code SigningiOS Hacker’s Handbook中获取到了很多帮助。
虽然这是一个非常吸引人的话题,但限于偏于我这里不会展开细讲。
在内核中,当发生代码签名时,一个Mach-O文件将会包含一个LC_CODE_SIGNATUREload command,它引用了一个二进制中的code signature segment。我们可以通过工具otool来验证一个已经签名的二进制:

> otool -l Plugin.llplugin/Plugin
…
Load command 17
      cmd LC_CODE_SIGNATURE
  cmdsize 16
  dataoff 9968
 datasize 9616
…

在内核中,Mach-O文件被加载并在函数parse_machfile中被解析,签名在函数load_code_signature中被加载,这两个函数都在mach_loader.c中。最后签名将会被检查,它的合法性将存储在进程的内核结构体proc
的成员变量csflags上。
之后不管任何时候一个page fault发生时,vm_fault.c中的函数vm_fault会被调用。page fault时如果有需要签名会被验证。当一个page映射到用户态时,如果该page属于一个已签名的对象,或如果该page将会是可写的,或如果它之前还没有被验证过,签名也会被验证。验证发生在vm_fault.c中的函数vm_page_validate_cs(验证过程和这个规则如何持续执行不仅仅在加载的时候是非常有趣的,详细参考Charlie Miller的书)。
如果某种原因某page不能被验证,内核会检测flagCS_KILL是否被设置,然后如果有必要的话杀掉进程。iOS和MacOS看待这个flag有一个很重大的区别。对于所有的进程,iOS上都会有这个flag,而在MacOS上,虽然代码签名被检验,这个flag并不是全体都有设置,因而代码签名也没有被保证。
在我们的这个场景里,我们可以安全的假设:代码签名不能被验证导致内核杀掉进程。

对plugin签名的场景

结论

  • 被苹果签名的动态库可以(会)iOS应用加载
  • 一个简单的iOS应用在启动时会加载超过150个动态库
  • Xcode不支持创建iOS上的动态库,frameworks或者插件,但解决这些还是非常容易的事情
  • 如果没有代码签名,我们将可以像在MacOS上在iOS上加载动态库,frameworks和在运行时加载插件
  • 在实践中内核将会杀掉哪些尝试加载一个未签名或者签名不能被审核的动态库
  • 一个要上架的动态库同样需要被同一个用来上架AppStore应用的证书签名
  • 最后AppStore的政策绝不运行动态库,即使技术是做到了,它也通不过AppStore的审核。
    你可以在Image LoggerDynamic iOS找到源代码。

Reference:
Dynamic Linking

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容