深入剖析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閱讀 227,428評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,024評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,285評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,548評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,328評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,878評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,971評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,098評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,616評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,554評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,725評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,243評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,971評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,361評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,613評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,339評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,695評論 2 370

推薦閱讀更多精彩內容