深入剖析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 Libraries和iOS 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
中可以获取到。
我们从创建一个可以在各函数之间复用的遍历函数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_header
和mach_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_UUID
command并获取uuid_t
一旦寻找到它。然后_print_image
将uuid_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是否为__TEXT
segment。如果是,它就获取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 Signing和iOS Hacker’s Handbook中获取到了很多帮助。
虽然这是一个非常吸引人的话题,但限于偏于我这里不会展开细讲。
在内核中,当发生代码签名时,一个Mach-O文件将会包含一个LC_CODE_SIGNATURE
load 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 Logger和Dynamic iOS找到源代码。
Reference:
Dynamic Linking