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