iOS應用安全5 -- main函數調用以前作了些什麼?

前言

老規矩,先說一下本篇文章說的內容。有兩大部分:分別是MachO文件和DYLD
主要要說的是DYLD加載image(不是圖片,是鏡像)的總體流程。由於這兩部分都是概念性的知識且MachO部分的內容相對來講不算太多,因此就將兩部分知識點用這一篇文章歸納算咯🐶。編程

標題是屬於第二大塊DYLD裏面的內容,若是是衝着標題來的能夠直接跳到DYLD去看。bootstrap

MachO文件

Mach-O爲Mach object文件格式的縮寫,它是一種用於可執行文件、目標代碼、動態庫的文件格式。緩存

常見的MachO文件有哪些?

  • 目標文件.o
  • 庫文件.a | .dylib | xxx.framework/xxx
  • 可執行文件
  • 符號表文件.dsym

能夠經過終端file + 文件路徑來查看文件的類型信息。bash

通用二進制(Universal binary)

蘋果公司提出的一種程序代碼,可以同時兼容多種架構的二進制文件。如果不瞭解什麼是架構,看下面:
arm6四、arm64e、armv七、armv7s這些就是不一樣的架構,具體有什麼區別與聯繫這裏再也不多說。 架構

架構
通用二進制文件除了可以兼容多種架構以外還具備如下特色:

  1. 可以爲不一樣的架構提供最理想的性能。
  2. 由於要存儲多種架構的代碼,通用二進制程序包要比單一架構的二進制程序包大。
  3. 由於多種架構只是代碼不一樣但資源相同,通用二進制的資源只有一份,因此並不會比單一架構的程序包大小多一倍。
  4. 運行時也只執行對應架構的代碼,運行時不會佔用多餘的內存。

lipo命令

寫過SDK的童鞋對此應該比較熟悉,在合併真機包和模擬器包的時候會使用如下指令:app

lipo -create [真機編譯路徑/xxx.framework/xxx] 
[模擬器編譯路徑/xxx.framework/xxx] -output [合併後輸出的文件路徑]
複製代碼

想起來了吧?這個命令就是爲了合併不一樣架構(真機/模擬器)的二進制文件。ide

合併的想起來了,別急,下面還有一個拆分的命令:函數

// 從通用二進制文件拆分出不一樣的架構
lipo [通用二進制文件路徑] -thin [要拆的架構] -output [拆出的二進制輸出的路徑]
複製代碼

MachO的文件結構

首先來整一張圖。
從圖中能夠看出,MachO文件能夠分爲Header、Load commands、Data三部分。post

MachO結構
下面對這三部分進行解釋:

  1. Header包含了MachO文件的概要信息。如:魔數、cpu架構類型、文件類型等。
  2. 相似於MachO文件的目錄,裏面指定了每塊區域對應的起始位置、符號表、要加載的動態庫等信息。附上LoadCommands參數解釋:
    lm
  3. MachO文件中最大的一部分,主要包括segment的具體數據。

DYLD

回到標題,"main函數是整個程序的入口"這句話想必你們從開始學習編程的那一刻就聽到過了吧!可是有沒有想過main函數爲何是程序的入口?main函數在哪裏調用的呢?以及main函數調用以前作了哪些事呢?性能

想知道上面問題的答案,慢慢往下看,dyld。

dyld(the dynamic link editor)是蘋果的動態連接器,是蘋果操做系統 一個重要組成部分,在系統內核作好程序準備工做以後,交由dyld負責餘下的工做。並且它是開源的,任何人能夠經過蘋果官網下載它的源碼來閱讀理解它的運做方式,瞭解系統加載動態庫的細節。dyld源碼

load方法

咱們知道,每個類都有一個load方法,而且這個load方法的調用時機特別的早,比程序入口main函數調用的還要早。
包括上篇文章的代碼注入之因此寫到load方法中也是由於這個緣由,在真正的代碼邏輯執行以前就交換了某些類的方法,則在代碼邏輯執行過程使用的方法就都是交換過之後的方法了。

爲了探究dyld,咱們先在任意類的load方法上打一個斷點,運行,能夠看到在load方法執行前有9個調用堆棧。

load

探究dyld源碼

點擊調用堆棧中的第一個_dyld_start可查看到彙編代碼。注意斷點的前一行dyldbootstrap::start,和左邊調用堆棧中的第二步相同,說明就是執行了這句代碼跳到了堆棧第二步。(查詢彙編bl指令的含義就可驗證這個猜測)。

start

另外若是對C++語言有所瞭解的話,應該就知道dyldbootstrap是C++中的一個命名空間,start是這個命名空間的一個函數。
接下來咱們就在下載的dyld源碼中全局搜索這個命名空間dyldbootstrap而且查找裏面是否是有一個start函數。

命名空間
能夠看到這個命名空間裏面確實是有start函數的,說明咱們找對地方了。哈哈😄
start 函數
先分析一下這個start函數,start函數主要作了如下工做:

  1. rebaseDyld dyld重定位。
  2. __guard_setup 棧溢出保護。
  3. 調用_main函數並將結果返回。

接下來咱們回過頭看剛剛的調用堆棧,第三步是dyld::_main,仔細一看這不正是咱們start函數最後返回值處調用的一個方法嗎?

_main函數

看到dyld::_main,好了,main函數找到了,main函數調用以前也沒幹啥事啊?
哈哈😂,先別急說我是標題黨,這個_main並不是程序入口main。跳進方法實現一看,這個方法實現600多行,還真很多。

600多行
下面就簡單分析一下這個 _main函數的代碼實現吧。

Step1: 設置運行環境。
主要是設置主程序的運行參數、環境變量等。
將參數mainExecutableMH賦值給了sMainExecutableMachHeader,這是一個macho_header結構體,表示的是當前主程序的MachO頭部信息

// 將主程序的MachO頭部信息賦值給sMainExecutableMachHeader
sMainExecutableMachHeader = mainExecutableMH;   // MH = MachOHeader
// 同理,保存主程序的內存地址偏移值
sMainExecutableSlide = mainExecutableSlide;	
複製代碼

接着調用setContext()設置上下文信息,包括一些回調函數、參數、標誌信息等。設置的回調函數都是dyld模塊自身實現的,如loadLibrary()函數實際調用的是libraryLocator(),負責加載動態庫。

// 設置上下文信息
setContext(mainExecutableMH, argc, argv, envp, apple);
複製代碼

配置進程是否受限、檢查環境變量

// 配置進程是否受限
configureProcessRestrictions(mainExecutableMH, envp);
··· ···
// 檢查環境變量
checkEnvironmentVariables(envp);
// 在Scheme中配置對於的環境變量可打印對應環境變量的值
// 配置相應的環境變量(DYLD_PRINT_OPTS/DYLD_PRINT_ENV)便可打印對應的信息
if (sEnv.DYLD_PRINT_OPTS) {
    printOptions(argv);
}
if ( sEnv.DYLD_PRINT_ENV ) {
    printEnvironmentVariables(envp);
}
複製代碼

Step2: 加載共享緩存
這裏要說一下iOS裏的共享庫、動態庫和靜態庫的區別。

  1. 共享庫,例如Foundation、UIKit等系統庫,幾乎全部App都會使用到這些庫,如果每一個App都將這些庫從磁盤加載到內存中一次,不但會使加載時間變成,佔用內存也會多不少。因此就有了共享庫的存在,這些共享庫只會在首次使用時加載到內存而且將已加載的庫的地址信息緩存在一個緩存區裏。後續其餘App使用時直接從緩存區裏查看是否已加載,若已經加載直接從緩存區將內存地址拷貝並保存起來,若沒有加載則從磁盤加載到內存。
  2. 動態庫,在其餘不少操做系統中,上面說的共享庫就是動態庫。而在iOS系統中,這裏的動態庫實質上就是被閹割的共享庫,將共享這個特色給閹割了,由於iOS系統爲了讓每一個App進程相互獨立,不容許開發者本身建立共享庫(真正的動態庫)
  3. 靜態庫,靜態庫就簡單了,其實靜態庫就相似於一個"文件夾",把某些功能及所用到的資源文件所有放到這個"文件夾"裏面了,在程序編譯連接期間靜態庫的代碼會被編譯到主程序的MachO文件中。

通過上面的解釋,想必已經知道加載共享緩存是作什麼的了吧?我認爲就是讀取共享庫的緩存區,將App使用到的系統共享庫沒加載到內存的加載到內存,已經加載的記錄一下內存地址。

// 檢查共享緩存是不是禁用狀態
checkSharedRegionDisable((dyld3::MachOLoaded*)mainExecutableMH, mainExecutableSlide);
··· ···
// 映射/加載共享緩存
mapSharedCache();
複製代碼

檢查共享緩存禁用狀態那個方法跳進去看源碼能夠知道,iOS系統共享緩存沒法被禁用。

Step3: 實例化主程序
聽名字就知道這一步是作什麼的。操做系統自己也是一個應用,只不過這個應用是用來管理其餘應用的。既然是應用,那麼這個應用內確定會有變量/對象,很明顯,這一步就是經過主程序的相關信息建立一個主程序對象。此時的App相對於操做系統來講就是其中的一個變量。

// 實例化主程序,建立主程序對象sMainExecutable
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH,  
mainExecutableSlide, sExecPath);
複製代碼

跳到這個方法實現裏面能夠發現,這個方法就是建立來一個ImageLoader類型的對象image(這裏不是圖片,是指主程序的鏡像)而且添加到了某個地方保存起來。

static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path) {
    // try mach-o loader
    if ( isCompatibleMachO((const uint8_t*)mh, path) ) {
    	// 建立image對象,image指的是主程序
    	ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
        // 添加主程序鏡像
        addImage(image);
        return (ImageLoaderMachO*)image;
    }
    throw "main executable not a known format";
}
複製代碼

Step4: 加載插入的庫
這裏插入的庫是dyld源碼中註釋的直譯,我認爲這裏"插入的庫"應該指的就是動態庫,包括App開發中用到的動態庫以及咱們代碼注入時注入的動態庫。
從代碼上看是遍歷DYLD_INSERT_LIBRARIES環境變量指向的那個連續的空間,取出所有的插入的庫,依次加載。

// load any inserted libraries
// 加載插入的庫到內存。 所謂插入的庫,其實就是非共享的動態庫,由於靜態庫在編譯期間就會變成主程序的一部分
if( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
    for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
    	// 加載所有的動態庫
    	loadInsertedDylib(*lib);
}
// record count of inserted libraries so that a flat search will look at 
// inserted libraries, then main, then others.
sInsertedDylibCount = sAllImages.size()-1;
複製代碼

點進loadInsertedDylib(*lib);方法發現裏面又調用了一個load(path, context, cacheIndex);方法,在這個方法中一層層的調用了loadPhase0、loadPhase一、loadPhase二、loadPhase三、loadPhase四、loadPhase五、loadPhase6等方法,具體實現沒有詳細研究,大概看了一下發現有些對加載的庫進行一些簽名驗證、cryptid判斷等操做。(有興趣的能夠本身研究研究源碼)

Step5: 連接主程序和插入的庫

// 連接主程序
link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL), -1);
··· ···
// 連接插入的庫
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();
}
複製代碼

連接主程序和插入的庫是分兩次調用的,在AllImages中第一個image是主程序的image,後面纔是插入的庫的image。
經過link函數連接主程序和插入的庫,在link函數中會遞歸的將當前image進行符號綁定。注意:這裏符號綁定只會綁定nolazy的庫,對於lazy標記的庫會在運行時動態進行綁定連接。

Step6: 初始化主程序
前面那些步驟已經將須要配置和加載的東西都完成了,接下來就須要初始化咱們的主程序,相似於建立對象的alloc已經執行完了,接下來就是init了。

// 初始化主程序
initializeMainExecutable(); 
複製代碼

點進去查看方法實現以下:

void initializeMainExecutable() {
    // record that we've reached this step
    gLinkContext.startedInitializingMainExecutable = true;
    
    // run initialzers for any inserted dylibs
    // 先初始化所有的插入庫
    ImageLoader::InitializerTimingList initializerTimes[allImagesCount()];
    initializerTimes[0].count = 0;
    const size_t rootCount = sImageRoots.size();
    if ( rootCount > 1 ) {
    	// 從1開始,由於第0個是主程序的image
    	for(size_t i=1; i < rootCount; ++i) {
    		sImageRoots[i]->runInitializers(gLinkContext, initializerTimes[0]);
    	}
    }
    
    // run initializers for main executable and everything it brings up
    // 執行主程序的初始化方法
    sMainExecutable->runInitializers(gLinkContext, initializerTimes[0]);
    
    // register cxa_atexit() handler to run static terminators in all loaded images when this process exits
    if ( gLibSystemHelpers != NULL ) 
    	(*gLibSystemHelpers->cxa_atexit)(&runAllStaticTerminators, NULL, NULL);
    
    // dump info if requested
    // 若是配置了這兩個環境變量,則會打印想對應的狀態信息
    if ( sEnv.DYLD_PRINT_STATISTICS )
    	ImageLoader::printStatistics((unsigned int)allImagesCount(), initializerTimes[0]);
    if ( sEnv.DYLD_PRINT_STATISTICS_DETAILS )
    	ImageLoaderMachO::printStatisticsDetails((unsigned int)allImagesCount(), initializerTimes[0]);
}
複製代碼

追蹤調用堆棧

其實看到這個initializeMainExecutable()方法,就會發現這個方法就是最開始那個調用堆棧裏面第四步,也就是說調用堆棧的後續步驟須要從這個函數繼續追蹤了。那麼接下來就開始追蹤吧。

找到堆棧下一步runInitializers,恰好代碼中就有這個函數的調用

runInitializers
繼續下一步 processInitializers
processInitializers
接着調用 recursiveInitialization
recursiveInitialization
再跳,哎?咋跳到聲明文件了?實現文件不讓看嗎?
別急,複製函數名,command + shift + o,搜索這個函數名
搜索
找到函數實現了
load_images
若是當前執行的是主程序 image,在 doInitialization方法前會發送 statedyld_image_state_dependents_initialized的通知,這個通知會調用 libobjcload_images,最後去依次調用各個OC類的load方法以及分類的load方法。(這裏就是load方法的調用時機)。

接下來咱們跳進notifySingle裏面看看,哎?又跳到聲明瞭,老方法

notifySingle
跳進方法實現以後,咱們看調用堆棧,下一步是 load_images,可是咱們找遍了也沒找到哪裏有調用 load_images
load_images
既然要找這個回調方法的實現,那咱們就得先找找這個回調是在哪賦的值?
下一個符號斷點 load_images,運行
命中
從這裏能夠看出 load_imageslibobjc裏面的,所以咱們能夠下載 runtime源碼
runtime
從這個調用堆棧裏面能夠發現 load_images_objc_init函數裏面的,所以咱們在runtime源碼裏面全局搜索 _objc_init
能夠看到 load_images_dyld_objc_notify_register函數的第二個參數,很明顯 _dyld_objc_notify_register是dyld庫裏面的方法。在dyld源碼裏面搜索 _dyld_objc_notify_register
再跳進 registerObjCNotifiers方法實現,能夠看到 objc傳過來的 load_images在dyld中賦值給力 sNotifyObjCInit,而咱們上面的那個回調函數就是 sNotifyObjCInit。說明那一步回調實質上調用的就是 objc裏面的 load_images函數。
說了這麼多,還不知道 load_images函數到底長啥樣,在 objc源碼中搜索 load_images找到方法實現。
在跳進 call_load_methods()函數,函數實現以下

void call_load_methods(void) {
    static bool loading = NO;
    bool more_categories;

    loadMethodLock.assertLocked();

    // Re-entrant calls do nothing; the outermost call will finish the job.
    if (loading) return;
    loading = YES;

    void *pool = objc_autoreleasePoolPush();

    do {
        // 1. Repeatedly call class +loads until there aren't any more
        while (loadable_classes_used > 0) {
            // 循環調用
            call_class_loads();
        }

        // 2. Call category +loads ONCE
        more_categories = call_category_loads();

        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);

    objc_autoreleasePoolPop(pool);

    loading = NO;
}
複製代碼

這裏的call_class_loads();方法內部就是調用每一個類的load方法。

返回主程序入口

找到了load方法的調用時機,還沒完,再回到剛開始的_main函數中,初始化主程序這一步算是分析完了,可是下面還有代碼,繼續分析。

// find entry point for main executable
// 找到主可執行程序的入口點
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
if (result != 0) {
	// main executable uses LC_MAIN, we need to use helper in libdyld to call into main()
	if ((gLibSystemHelpers != NULL) && (gLibSystemHelpers->version >= 9))
		*startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
	else
		halt("libdyld.dylib support not present for LC_MAIN");
}
else {
	// main executable uses LC_UNIXTHREAD, dyld needs to let "start" in program set up for main()
	result = (uintptr_t)sMainExecutable->getEntryFromLC_UNIXTHREAD();
	*startGlue = 0;
}
複製代碼

getEntryFromLC_MAIN()方法找到了主可執行程序的入口了,也就是那個程序的入口main()函數,非剛剛分析的_main()函數。
又通過一系列的配置,最後_main()函數的返回值處返回了咱們找到的程序入口main()函數。

總結

又到了總結的時刻,總結了如下六點......

哈哈,寫這篇文章快要累死我了,可是收穫也是不少的,學到了App加載過程當中經歷了哪些步驟,後續文章咱們可能就會利用這些東西的加載順序的不一樣來破解或者防禦App。

真正的總結
MachO文件

  1. MachO文件是什麼?
  2. 常見的MachO文件有哪些?
  3. 通用二進制(多種架構的MachO文件)。
  4. 拆分和合並MachO文件。
  5. MachO文件的格式。

dyld,main函數以前都作了什麼?

  1. 程序從_dyld_start開始執行,進入_main函數。
  2. 設置運行環境。
  3. 加載共享緩存。
  4. 實例化主程序。
  5. 加載插入的庫。
  6. 連接主程序和插入的庫。
  7. 初始化主程序。通過一系列的調用堆棧,最終會調用到每一個類的load方法。
  8. doModInitFunctions函數,會調用帶有__attribute__((constructor))的C函數。
  9. _main()調用結束返回程序入口main()函數,開始進入主程序的main()函數。

文章地址

相關文章
相關標籤/搜索