iOS底層從頭梳理dyld加載流程

NO IMAGE

前言

瞭解 dyld 的加載流程可以幫我們更系統的瞭解 iOS 應用的本質 . 無論是在逆向方向或者在底層研究方面 , dyld 都是必不可少的領域 . 對流程梳理清楚可以幫助我們更好地瞭解一些基礎原理 . 例如我們之前講
分類底層原理詳細研究流程 , load方法調用機制解析 , 都不可避免的提到 dyld .

本篇文章就整個加載流程進行梳理分析 , 並不會特別細 , 畢竟整個流程太多 , 需要提點的都會有所介紹 .

提示 : 瞭解本文前先請對 Mach-O 文件有所瞭解 .

1、dyld

1.1 簡介

dyld 全名 The dynamic link editor . 它是蘋果的動態鏈接器,是蘋果操作系統一個重要組成部分 ,在應用被編譯打包成可執行文件格式的 Mach-O 文件之後 ,交由 dyld 負責鏈接 , 加載程序 。

dyld 是開源的,我們可以通過 官網 下載它的源碼來閱讀理解它的運作方式,瞭解系統加載動態庫的細節 。

我這裡下載的是 dyld-635.2 .

1.2 共享緩存

解讀 dyld 有一個必不可少的東西 – 共享緩存 .

由於 iOS 系統中 UIKit / Foundation 等庫每個應用都會通過 dyld 加載到內存中 , 因此 , 為了節約空間 , 蘋果將這些系統庫放在了一個地方 : 動態庫共享緩存區 (dyld shared cache) . ( Mac OS 一樣有 ) .

因此 , 類似 NSLog 的函數實現地址 , 並不會也不可能會在我們自己的工程的 Mach-O 中 , 那麼我們的工程想要調用 NSLog 方法 , 如何能找到其真實的實現地址呢 ?

其流程如下 :

  • 在工程編譯時 , 所產生的 Mach-O 可執行文件中會預留出一段空間 , 這個空間其實就是符號表 , 存放在 _DATA 數據段中 ( 因為 _DATA 段在運行時是可讀可寫的 )

  • 編譯時 : 工程中所有引用了共享緩存區中的系統庫方法 , 其指向的地址設置成符號地址 , ( 例如工程中有一個 NSLog , 那麼編譯時就會在 Mach-O 中創建一個 NSLog 的符號 , 工程中的 NSLog 就指向這個符號 )

  • 運行時 : dyld將應用進程加載到內存中時 , 根據 load commands 中列出的需要加載哪些庫文件 , 去做綁定的操作 ( 以 NSLog 為例 , dyld 就會去找到 FoundationNSLog 的真實地址寫到 _DATA 段的符號表中 NSLog 的符號上面 )

這個過程被稱為 PIC 技術 . ( Position Independent Code : 位置代碼獨立 )

瞭解了系統函數的整個加載過程 , 我們來看 fishhook 的函數名稱 :

rebind_symbols :: 重綁定符號 也就簡單明瞭了.

fishhook 原理就是 :

將編譯後系統庫函數所指向的符號 , 在運行時重綁定到用戶指定的函數地址 , 然後將原系統函數的真實地址賦值到用戶指定的指針上.

2、dyld 加載流程

新建一個空 app 工程 , 在 ViewController 中添加 load 方法 .

+ (void)load{
NSLog(@"load 來了");
}

load 方法添加斷點 . 運行程序 . 查看函數調用棧 .

iOS底層從頭梳理dyld加載流程

通過 lldb : bt + up / down 指令來到入口 _dyld_start 處 .

iOS底層從頭梳理dyld加載流程

2.1 _dyld_start

上圖第 11 行 : call 就是調用函數的指令 , ( 同 bl ) . 這個函數也就是我們 app 開始的地方 .

當我們點開一個應用 , 系統內核會開啟一個進程 , 然後由 dyld 開始加載這個可執行文件 .

2.1.1 dyldbootstrap :: start

dyldbootstrap::start 就是指 dyldbootstrap 這個命名空間作用域裡的 start 函數 .

來到源碼中 , 搜索 dyldbootstrap , 然後找到 start 函數 .

cmd + shift + j 可以定位文件位置

uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[], 
intptr_t slide, const struct macho_header* dyldsMachHeader,
uintptr_t* startGlue)
{
slide = slideOfMainExecutable(dyldsMachHeader);
bool shouldRebase = slide != 0;
#if __has_feature(ptrauth_calls)
shouldRebase = true;
#endif
if ( shouldRebase ) {
rebaseDyld(dyldsMachHeader, slide);
}
mach_init();
const char** envp = &argv[argc+1];
const char** apple = envp;
while(*apple != NULL) { ++apple; }
++apple;
__guard_setup(apple);
#if DYLD_INITIALIZER_SUPPORT
runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
#endif
uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);
return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
}

這個函數首先有兩個參數我們要說明一下 :

  • 1️⃣、const struct macho_header* appsMachHeader , 這個參數就是 Mach-Oheader . 關於這個 header , Mach-O文件 這篇文章中 Mach-O 文件結構 裡有詳細描述 .

  • 2️⃣、intptr_t slide , 這個其實就是 ALSR , 說白了就是通過一個隨機值 ( 也就是我們這裡的 slide ) 來實現地址空間配置隨機加載 .

    • 當某個特定進程,在存儲器中所能夠使用與控制的地址空間在運行時隨機進行分配 , 可以使某些攻擊者無法事先獲知地址 ,令攻擊者難以通過固定地址獲取函數或者內存值進行攻擊 .

    • Mac OS X Lion10.7 開始所有的應用程序均提供了 ASLR 支持 .

  • 3️⃣、 物理地址 = ALSR + 虛擬地址 ( 偏移 ) .

那麼接下來 , 這個函數到底做了什麼呢 ?

流程如下 :

  • 首先 , 根據計算出來的 ASLRslide 來重定向 macho .

  • 初始化 , 允許 dyld 使用 mach 消息傳遞 .

  • 棧溢出保護 .

  • 初始化完成後調用 dyldmain 函數 ,dyld::_main .

2.1.2 dyld::_main

直接點擊跳轉到 dyldmain 函數中 . 該函數是加載 app 的主要函數.

uintptr_t
_main(const macho_header* mainExecutableMH, uintptr_t mainExecutableSlide, 
int argc, const char* argv[], const char* envp[], const char* apple[], 
uintptr_t* startGlue)
{
// *函數太長 , 這裡就不貼了.*/
}

這個函數主要流程如下 :

2.1.2.1 準備工作
  • 1️⃣ : 配置相關環境變量 .
  • 2️⃣ : 設置上下文信息 setContext .
  • 3️⃣ : 檢測進程是否受限 , 在上下文中做出對應處理 configureProcessRestrictions , 檢測環境變量 checkEnvironmentVariables.
    • 熟悉越獄插件的同學應該都很清楚 , 某些環境變量會直接影響該庫是否會被加載 , 有些防護操作就是基於這個原理來做的 . ( 後續更新越獄篇章攻防會詳細講述和演示 )
  • 4️⃣ : 根據環境變量配置打印信息 , DYLD_PRINT_OPTSDYLD_PRINT_ENV , 大家可以在如下圖中配置玩一玩 .
    iOS底層從頭梳理dyld加載流程
  • 5️⃣ : 獲取程序架構 getHostInfo .
2.1.2.2 加載共享緩存庫

該流程主要步驟如下 :

  • 1️⃣ : 檢測共享緩存禁用狀態 checkSharedRegionDisable . ( iOS 下不會被禁用 ) .

  • 2️⃣ : 加載共享緩存庫 , mapSharedCache -> loadDyldCache .這裡加載共享緩存有幾種情況 :

    • 1、僅加載到當前進程 mapCachePrivate , ( 模擬器僅支持加載到當前進程 ) .
    • 2、共享緩存是第一次被加載 , 就去做加載操作 mapCacheSystemWide .
    • 3、共享緩存不是第一次被加載 , 那麼就不做任何處理 .
      iOS底層從頭梳理dyld加載流程
2.1.2.3 reloadAllImages
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);

實例化主程序 , 檢測可執行程序格式 .

static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path)
{
// try mach-o loader
if ( isCompatibleMachO((const uint8_t*)mh, path) ) {
ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
addImage(image);
return (ImageLoaderMachO*)image;
}
throw "main executable not a known format";
}

isCompatibleMachO 裡就會通過 header 裡的 magic , cputype , cpusubtype 去檢測是否兼容 .

iOS底層從頭梳理dyld加載流程

檢測通過 , 就會通過 instantiateMainExecutable 實例化這個 image , 並添加到 static std::vector<ImageLoader*> sAllImages; 這個全局的鏡像列表中去 , 設置好上下文 .

iOS底層從頭梳理dyld加載流程

instantiateMainExecutable 裡 , 真正實例化主程序是用 sniffLoadCommands 這個函數去做的 . 有的同學可能對這個函數比較熟悉了 . 我們來稍微看一下 .

還是 ImageLoaderMachO 這個作用域裡的 sniffLoadCommands 函數 .

void ImageLoaderMachO::sniffLoadCommands(const macho_header* mh, const char* path, bool inCache, bool* compressed,
unsigned int* segCount, unsigned int* libCount, const LinkContext& context,
const linkedit_data_command** codeSigCmd,
const encryption_info_command** encryptCmd)
{
*compressed = false;
*segCount = 0;
*libCount = 0;
*codeSigCmd = NULL;
*encryptCmd = NULL;
/*
...省略掉.
*/
// fSegmentsArrayCount is only 8-bits
if ( *segCount > 255 )
dyld::throwf("malformed mach-o image: more than 255 segments in %s", path);
// fSegmentsArrayCount is only 8-bits
if ( *libCount > 4095 )
dyld::throwf("malformed mach-o image: more than 4095 dependent libraries in %s", path);
if ( needsAddedLibSystemDepency(*libCount, mh) )
*libCount = 1;
}

這個函數就是根據 Load Commands 來加載主程序 .

這裡幾個參數我們稍微說明下 :

  • compressed -> 根據 LC_DYLD_INFO_ONYL 來決定 .
  • segCount 段命令數量 , 最大不能超過 255 個.
  • libCount 依賴庫數量 , LC_LOAD_DYLIB (Foundation / UIKit ..) , 最大不能超過 4095 個.
  • codeSigCmd , 應用簽名 , 在 應用簽名原理及重簽名 (重籤微信應用實戰) 這篇文章中有非常詳細的講述 , 建議讀一讀 .
  • encryptCmd , 應用加密信息 , ( 我們俗稱的應用加殼 , 我們非越獄環境重簽名都是需要砸過殼的應用才能調試 , 關於應用的砸殼 , 後續逆向文章越獄篇裡會實際操作演練 ) .

經過以上步驟 , 主程序的實例化就已經完成了 .

2.1.2.4 加載插入動態庫
if ( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib) 
loadInsertedDylib(*lib);
}

熟悉越獄插件的同學應該很清楚這個機制了 . 根據 DYLD_INSERT_LIBRARIES 環境變量來決定是否需要加載插入的動態庫 .

越獄的插件就是基於這個原理來實現只需要下載插件 , 就可以影響到應用 . 有部分防護手段就用到了這個環境變量 ( 後續逆向文章會帶著大家自己寫一個越獄插件 , 這個很簡單 , 然後會講一講越獄環境插件如何防護 . ) .

sInsertedDylibCount = sAllImages.size()-1;

記錄插入動態庫的數量 .

2.1.2.5 鏈接主程序
// link main executable
gLinkContext.linkingMainExecutable = true;
link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL), -1);
sMainExecutable->setNeverUnloadRecursive();
if ( sMainExecutable->forceFlat() ) {
gLinkContext.bindFlat = true;
gLinkContext.prebindUsage = ImageLoader::kUseNoPrebinding;
}
if ( sInsertedDylibCount > 0 ) {
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();
}
for(unsigned int i=0; i < sInsertedDylibCount; ++i) {
ImageLoader* image = sAllImages[i+1];
image->registerInterposing(gLinkContext);
}
}

點擊進入 link 函數 , link 函數中有一系列 recursiveLoadLibraries , recursiveBindWithAccounting -> recursiveBind , 也就是遞歸進行符號綁定的過程 .

link 函數執行完畢之後 , dyld :: main 會調用 sMainExecutable->weakBind(gLinkContext); 進行弱綁定 , 懶加載綁定 , 也就是說弱綁定一定發生在 其他庫鏈接綁定完成之後 .

綁定的過程就是我們上述 1.2 章節中所講的共享緩存綁定的過程 .

走到了這裡 , 主程序已經實例化完畢 , 但還沒有加載 , framework 已經加載完畢了 , 那講到這插一句題外話 , 不同 framework , 誰先會被加載 ? 其實根據二進制順序有關 , Xcode 中可以自由調整 .

iOS底層從頭梳理dyld加載流程

拖動就可以自己調整順序了 , 編譯順序就會根據這個順序來 , 同樣你可以使用 MachOView 來查看二進制順序 .

iOS底層從頭梳理dyld加載流程

至此 , 配置環境變量 -> 加載共享緩存 -> 實例化主程序 -> 加載動態庫 -> 鏈接動態庫 就已經完成了 .

繼續往 dyld :: main 下面找 , 我們會看到

initializeMainExecutable();

那麼我們回到函數調用棧看下 .

iOS底層從頭梳理dyld加載流程

2.1.3 運行主程序

通過查看源碼查看 , 結合函數調用棧 , 我們跟進去調用流程 .
initializeMainExecutable -> runInitializers -> processInitializers -> 遞歸調用 recursiveInitialization .

到了這裡 , 直接點擊 進不去了 , 同理 , cmd + shift + o, 搜索 recursiveInitialization . 來到函數實現 , 找到如下代碼 :

// let objc know we are about to initialize this image
uint64_t t1 = mach_absolute_time();
fState = dyld_image_state_dependents_initialized;
oldState = fState;
context.notifySingle(dyld_image_state_dependents_initialized, this, &timingInfo);
// initialize this image
bool hasInitializers = this->doInitialization(context);
// let anyone know we finished initializing this image
fState = dyld_image_state_initialized;
oldState = fState;
context.notifySingle(dyld_image_state_initialized, this, NULL);

調用 notifySingle 函數 .

⚠️ : 重頭戲來了 .
根據函數調用棧我們發現 , 下一步是調用 load_images , 可是這個 notifySingle 裡並沒有找到 load_images 的影子 . 但是我們看到了這麼個東西 :

(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());

這是個回調函數的調用 , sNotifyObjCInit 上面判斷了並不會為空 , 那就代表一定是有值的 . 那我們搜索一下 sNotifyObjCInit , 看看什麼時候被賦的值 .

直接本文件搜索 , 看到如下 :

void registerObjCNotifiers(_dyld_objc_notify_mapped mapped, _dyld_objc_notify_init init, _dyld_objc_notify_unmapped unmapped)
{
// record functions to call
sNotifyObjCMapped	= mapped;
sNotifyObjCInit		= init;
sNotifyObjCUnmapped = unmapped;
// call 'mapped' function with all images mapped so far
try {
notifyBatchPartial(dyld_image_state_bound, true, NULL, false, true);
}
catch (const char* msg) {
// ignore request to abort during registration
}
// <rdar://problem/32209809> call 'init' function on all images already init'ed (below libSystem)
for (std::vector<ImageLoader*>::iterator it=sAllImages.begin(); it != sAllImages.end(); it++) {
ImageLoader* image = *it;
if ( (image->getState() == dyld_image_state_initialized) && image->notifyObjC() ) {
dyld3::ScopedTimer timer(DBG_DYLD_TIMING_OBJC_INIT, (uint64_t)image->machHeader(), 0, 0);
(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());
}
}
}

也就是說 , 這個函數調用 , 其第二個參數賦值給了 sNotifyObjCInit , 然後在 notifySingle 裡被執行 .

那麼我們搜索一下 registerObjCNotifiers , 看看其在什麼時候被調用的 , 搜索發現 :

void _dyld_objc_notify_register(_dyld_objc_notify_mapped    mapped,
_dyld_objc_notify_init      init,
_dyld_objc_notify_unmapped  unmapped)
{
dyld::registerObjCNotifiers(mapped, init, unmapped);
}

再繼續搜索 , 沒啥結果了 . 那麼怎麼辦 , 不著急 , 我們來到測試工程裡下一個符號斷點 _dyld_objc_notify_register , 運行來到斷點 , 看函數調用棧 .

iOS底層從頭梳理dyld加載流程

.
至此 , 我們看到的就是 runtime 被加載的整個流程 , 來到 objc 750 的代碼中直接搜索 _objc_init .

2.1.4 _objc_init

void _objc_init(void)
{
static bool initialized = false;
if (initialized) return;
initialized = true;
// fixme defer initialization until an objc-using image is found?
environ_init();
tls_init();
static_init();
lock_init();
exception_init();
_dyld_objc_notify_register(&map_images, load_images, unmap_image);
}

來到這裡 , 我們就看到了 _dyld_objc_notify_register 被調用 , 傳遞了三個參數 , 這三個分別代表 在 分類底層原理詳細研究 中我們也有詳細講述過 .

  • map_images : dyldimage 加載進內存時 , 會觸發該函數.
  • load_images : dyld 初始化 image 會觸發該方法. ( 我們所熟知的 load 方法也是在此處調用 ) .
  • unmap_image : dyldimage 移除時 , 會觸發該函數 .

當然 , 你可以通過 lldb 驗證一下 .

iOS底層從頭梳理dyld加載流程

那麼這個 load_images , 就調用了各個類的 load 方法 ( call_load_methods ) . 關於這個請看
分類底層原理詳細研究 load方法調用機制解析 這兩篇文章 .

要聲明一下的是 :

iOS底層從頭梳理dyld加載流程

那麼也就是說 :

  • 1️⃣、 當 dyld 加載到開始鏈接主程序的時候 , 遞歸調用 recursiveInitialization 函數 .
  • 2️⃣、 這個函數第一次執行 , 進行 libsystem 的初始化 . 會走到 doInitialization -> doModInitFunctions -> libSystemInitialized .
  • 3️⃣、 Libsystem 的初始化 , 它會調用起 libdispatch_init , libdispatchinit 會調用 _os_object_init , 這個函數裡面調用了 _objc_init .
  • 4️⃣、_objc_init 中註冊並保存了 map_images , load_images , unmap_image 函數地址.
  • 5️⃣ : 註冊完畢繼續回到 recursiveInitialization 遞歸下一次調用 , 例如 libobjc , 當 libobjc 來到 recursiveInitialization 調用時 , 會觸發 libsystem 調用到 _objc_init 裡註冊好的回調函數進行調用 . 就來到了 libobjc , 調用 load_images.

跟我們上面截圖的函數調用棧一模一樣 .

2.1.5 doInitialization

dyld 來到 doInitialization 時 ,

bool ImageLoaderMachO::doInitialization(const LinkContext& context)
{
CRSetCrashLogMessage2(this->getPath());
// mach-o has -init and static initializers
doImageInit(context);
doModInitFunctions(context);
CRSetCrashLogMessage2(NULL);
return (fHasDashInit || fHasInitializers);
}

doModInitFunctions 中 , 值得一提的是會調用 c++ 的構造方法 .

演示如下 :

iOS底層從頭梳理dyld加載流程

打印結果 :

iOS底層從頭梳理dyld加載流程

這種 c++ 構造方法存儲在 __DATA 段 , __mod_init_func 節中.

iOS底層從頭梳理dyld加載流程

2.1.6 找到主程序的入口

// find entry point for main executable
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();

找到真正 main 函數入口 並返回.

總結 :

iOS底層從頭梳理dyld加載流程

以上便是 dyld 加載應用的完整流程 . 建議大家仔細探索 .

相關文章

關於幾道React前端面試題記錄

Redux的中間件,Axios的攔截器、Vuex的插件讓你迷惑嗎?實現一個精簡版的就徹底搞懂了。

最近從0學習Git,詳細分類總結了這份Git命令寶典

Redis6.0新特性之集群代理