汽車之家APP基於Mach-O的探索與實踐

語言: CN / TW / HK

關注“之家技術”,獲取更多技術乾貨

總篇156篇 2022年第31篇

Mach-O 簡介:

Mach-O 檔案全稱 Mach Object ,是在 Mac O S iOS iPad OS 上的可執行檔案,類似於 Windowds PE 檔案。支援的 CPU 架構型別主要有 x86_64 armv 7 ar m64

Mach - O 檔案的生成過程 原始碼 --> 預處理 --> 詞法分析 --> 語法分析 --> 語義分析 --> 中間程式碼 --> 生成目的碼 --> 彙編 --> 機器碼 --> 靜態連結 --> Mach - O 檔案

Mach-O能做什麼

瞭解 Mach - O 格式的結構和載入過程,可以幫助我們更容易的理解 APP 的啟動過程、 C 函式的 hook 動態庫的懶載入 原理。常見的應用場景有:

C rash 的符號化

B it c ode 分析

APP 啟動速度的優化

優化 APP 包體積

方法呼叫鏈分析

接下來,本文針對第 5 種應用場景,介紹下工作中用到的兩個實踐專案。

1. 基於 Mach-O 檔案的動態庫與靜態庫的歸屬方案。

2. 基於 Mach-O API 掃描方案。

由於 目前 APP Store 上基本已廢棄對 armv7 的支援,所以接下來介紹的方案都是基於 arm64 架構的 Mach - O 分析。

實踐專案 一:基於 Mach-O 檔案 的動態庫與靜態庫 歸屬方案

背景: 大部分的 APP 都會包含多個動態庫與靜態庫,之家 APP 也一樣, 並且 隨著 業務的增長, APP 內整合的功能越來越多 ,靜態庫和動態庫的數量也 不斷增加。 為了提升使用者體驗,之家 APP 進行了多個維度的 資料 採集,如:網路、崩潰、卡頓、秒開、圖片效能等 ,而採集後的資料如何精準、快速的分發到研發人員進行解決 ,一直是 我們 面對的難題。

基於 Mach-O 結構與 Runtime 的原理 ,我們通過不斷 實踐 ,實現了 一套 自定義 歸屬方案,此 方案 可以對效能資料進行庫的歸屬劃分, 再通過庫歸屬找到開發人員,從而解決分發難題

下面對此方案做一個詳細說明。

首先,歸屬劃分主要涉及 動態庫與靜態庫兩種場景:

(1) 執行時建立的某個物件歸屬於哪個庫(通過類查詢庫)。

(2) 公共庫的某個方法被哪個庫所呼叫(通過堆疊查詢庫)。

因為動態庫其本身就是程式碼隔離的,查詢非常方便,而靜態庫最終會被編譯到主程式的二進位制檔案中,或者多個靜態庫被包到一個動態庫中,所以無法直接進行類和堆疊的歸屬劃分。

接下來本段落會先介紹下動態庫的歸屬方法,然後重點介紹靜態庫的歸屬方法。

動態庫:

類定位:先用物件查詢 isa 指標獲取類,然後使用類查詢所在的可執行檔案即可

NSBundle *bundle = [NSBundle bundleForClass:objClass];
堆疊定位:獲取的堆疊可以直接區分出所歸屬的 動態庫

如圖:

NSArray<NSNumber *> *callAddresses = [NSThread callStackReturnAddresses];
long long callStackAddress = [callAddresses[i] longLongValue];
Dl_info info = {0};
dladdr((void *)callStackAddress, &info); //獲取堆疊地址對應的可執行檔案資訊
NSString *dliFname = [NSString stringWithFormat:@"%s",info.dli_fname]; //取出庫名

靜態庫:

名詞解釋:

Mac 伺服器 :用於編譯 APP dSYM 的符號解析,以下簡稱 Mac 伺服器。

日誌伺服器 :記錄線上 APP 上報的效能資料,以下簡稱日誌伺服器。

ASLR :全稱 Address spce layout randomization 地址空間佈局隨機化,通過對堆、棧、共享庫等關鍵資料區域的地址空間隨機化,防止攻擊者直接定位程式碼位置來篡改程式。 這種技術會使得 APP 或者 動態 庫每次執行載入到記憶體中時的基地址都是隨機的

靜態庫歸屬 常見 的方案 基於 dSYM 的符號解析, 主要流程:

   1、打包時在 Mac 服務 器上儲存所有庫的 dSYM 檔案。

   2、線上 APP 上報執行檔名稱、釋出版本、偏移量等資訊。

   3、日誌 服務端收到 APP 的上報資訊 通過版本號、執行檔名稱查詢快取的 dSYM 最後 Mac 伺服器上 使用偏移量 進行 dSYM 符號 解析 ,返回靜態庫名

這種方案需要在 Mac 伺服器上 所有 動態庫 所有 版本 dSYM 進行快取,增大了伺服器的儲存成本 ,並且 由於需要三端的互動才能完成靜態庫的歸屬 ,穩定性 較差,同時 APP 執行時無法 做到 直接定位,可讀性 也不高  

針對 dSYM 符號解析的問題,我們通過分析 Mach -O 的結構與原理,探索出了基於 Mach-O 的靜態庫歸屬方案, 具體 如下:

在編譯期通過解析 Mach-O Link Map 檔案,生成 靜態庫 地址區間 和彙編程式碼段 地址區間 ,在執行時根據 isa 指標 ( 指向了 Mach-O 中的類宣告地址 ) 和程式碼偏移地址解析出 靜態庫 名。

先解析 Mach- O 檔案的結構, Mach - O 的頭部 開始 Header 中包含了二進位制檔案的大小、支援的 CPU 型別、 Load Commands 的數量和大小, Segment 中包括了各 Section 和符號表等的偏移位置和大小。

Mach -O 的結構比較複雜, Segment 現在已知的型別有 50 多種,不同型別職責不同。

該方案只用到了程式碼段和類列表,所以只 __text __objc_classlist 進行解析

解析 Mach - O 頭部, 關鍵程式碼如下

mach_header_64 mhHeader;
//讀取頭部資訊
[fileData getBytes:&mhHeader range:NSMakeRange(0, sizeof(mach_header_64))];
for (int i = 0; i < mhHeader.ncmds; i++) {
load_command* cmd = (load_command *)malloc(sizeof(load_command));
//讀取Load_command
[fileData getBytes:cmd range:NSMakeRange(currentLcLocation, sizeof(load_command))];
if (cmd->cmd == LC_SEGMENT_64) {
segment_command_64 segmentCommand;
[fileData getBytes:&segmentCommand range:NSMakeRange(currentLcLocation, sizeof(segment_command_64))];
NSString *segName = [NSString stringWithFormat:@"%s",segmentCommand.segname];
//提取彙編程式碼 __TEXT
if ([segName isEqualToString:SEGMENT_TEXT] || [segName isEqualToString:SEGMENT_BD_TEXT]) {
section_64 sectionHeader;
[fileData getBytes:§ionHeader range:NSMakeRange(currentSecLocation, sizeof(section_64))];
NSString *secName = [[NSString alloc] initWithUTF8String:sectionHeader.sectname];
}else if ([segName isEqualToString:SEGMENT_DATA]) {
//提取指定DATA sectionHeader資訊
unsigned long long currentSecLocation = currentLcLocation + sizeof(segment_command_64);
}
//符號表
}else if (cmd->cmd ==LC_SYMTAB){
symtab_command tsymtabcommand;
[fileData getBytes:&tsymtabcommand range:NSMakeRange(currentLcLocation, sizeof(segment_command_64))];
symtabcommand = tsymtabcommand;
}else if(cmd->cmd == LC_FUNCTION_STARTS){
[fileData getBytes:&funcStartHeader range:NSMakeRange(currentLcLocation, sizeof(linkedit_data_command))];
}
}

如果對每個類都標記 庫名 會使生成的 C lassMap 檔案會過大, 對包體積影響較大 通過 分析 APP 的編譯過程, 發現 靜態庫是順序編譯,在每個 Section 下靜態庫也都是分段的,所以 最終 通過計算每個靜態庫的偏移 地址 和大小來生成靜態庫的位置標記。

  • 靜態庫類的標記:   解析 Mach - O C lassList 段,藉助 Link Map 檔案,反向解析出每個靜態庫的類宣告位置,找到第一個類的宣告地址為起始地址,最後一個類的宣告地址 + 類宣告的位元組 大小 為類宣告的結束地址。

  • 靜態庫程式碼段標記:原理與查詢類宣告類似,結合 __TEXT Symbol Table Func tion Starts, 找到靜態庫第一個類的第一個方法的起始地址作為庫的程式碼段 起始 地址,找到靜態庫的最後一個類的最後一個方法的結束地址,作為靜態庫程式碼段的結束地址。

通過上面兩步生成 C lassMap T ext Map 匯入到 ipa 中,檔案小於 1kb ,對 APP 大小 幾乎 無影響。

生成 指令碼的位置要在 L ink   Binary With Libraries 之後, Copy  bundle Resources 之前。

前面介紹了編譯期的工作,下面介紹下執行時的定位原理。

1. 執行時獲取物件對應的 isa 指標,找到 class ,再通過 class 指標地址減去動態庫載入到記憶體的起始地址,算出對應的偏移量( 上面提到的 ASLR ,會 使 每次執行 APP 的記憶體基地址 發生 改變,所以需要計算偏移地址 ),然後使用偏移量去 C lassMap 中找到對應的 靜態庫 名。

NSString *imageName = [[NSString alloc] initWithUTF8String:_dyld_get_image_name(i)];
//找到APP主二進位制
if (_dyld_get_image_header(i)->filetype == MH_EXECUTE &&
[imageName containsString:mainBundle.executablePath]) {
mainExecuteAddress = _dyld_get_image_vmaddr_slide(i);
break;
}
uintptr_t os = (uintptr_t)objClass;
//計算出類的偏移地址
uintptr_t classInBundleAddress = os-mainExcuteAddress;
NSDictionary *classMap = [self pluginAllAddressWithClass];
for (NSString * key in classMap.allKeys) {
NSDictionary *addressDic = classMap[key];
if (addressDic && [addressDic[@"end"] longLongValue]>classInBundleAddress && classInBundleAddress>=[addressDic[@"start"] longLongValue]) {
return key;
}
}

2. 基於 堆疊 定位靜態庫 首先 獲取到無符號的堆疊 陣列 ,然後找到上一個呼叫的 callback 地址, 檢索 到堆疊地址所在動態庫起始地址,使用棧地址 - 動態庫起始地址得到偏移量,再用偏移量去 T extMap 中找到對應的 靜態庫 名。

NSBundle *mainBundle = [NSBundle mainBundle];
Dl_info info = {0};
//獲取堆疊地址對應的外掛資訊
dladdr((void *)callStackAddress, &info);
//獲取二進位制名
NSString *dliFname = [NSString stringWithFormat:@"%s",info.dli_fname];
//獲取偏移量
uintptr_t callStackBundleAddress = callStackAddress-[self mainStartAddress];
NSDictionary *textMap = [self pluginAllAddressWithText];
for (NSString * key in textMap.allKeys) {
NSDictionary *addressDic = textMap[key];
//查詢所屬靜態庫
if ([addressDic[@"end"] longLongValue]>callStackBundleAddress &&callStackBundleAddress>=[addressDic[@"start"] longLongValue])
{
return key;
}
}

這樣靜態庫的歸屬便可以在執行時完成,耗時小於 1ms ,可以大範圍應用於 APP 各種場景中。

小結 基於 Mach-O 檔案 的動態庫與靜態庫 歸屬方案 介紹完了, 在靜態庫歸屬上,相比之前 dSYM 的方案, 它的 複雜度更低、易用性更高,可以在執行時實時解析,而且更容易遷移到其它 APP 上使用。

實踐專案 二:基於 Mach -O API 的掃描

背景: 在實際工作 中,由於 經常需要 定位 APP 中呼叫過的 API ,為了 減少重複的工作 我們 實現了一套自動掃描的工具。

API 掃描 常見的 方案是基於 語法樹 掃描, 程式碼在編譯時會生成語法樹,通過遍歷語法樹可以實現 API 的掃描。

由於 語法樹掃描存在以下缺點,無法滿足使用需求。

1、 支援黑盒掃描,語法樹是在編譯時才能生成,所以無法掃描三方 SDK

2、 掃描速度 太慢 ,語法樹掃描的功能強大但是在掃描效能上較低,對 2 萬行程式碼樹的掃描在優化的情況下也需要 1 分鐘左右的時間

為了解決上面的兩個問題,我們實現了基於 Mach-O API 掃描方案。

實踐專案一 中介紹的 Mach-O 結構,本段落 還會繼續 用到

__objc_classrefs 類引用列表

__objc_selrefs 方法引用列表

Mach - O 解析 主要步驟:

1、 首先解析 __objc_classrefs 讀取所有呼叫的外部 API 檢索被掃描 API 的類地址記錄為 class_addr

2、 解析 __objc_selrefs 檢索被掃描 API 的方法地址記錄為 method _addr

3、 把二進位制機器碼解析出彙編程式碼 找到所有的 bl 指令,計算 bl 指令最近的 x1 暫存器 的值 ,對比 x1 暫存器與 method_addr 是否相等,相等則記錄 bl 指令的位置。

4、 bl 指令的位置向上 檢索是否有要找的類 地址 class_addr 一直檢索到 最近的函式入口 找到則輸出結果,未找到進入第 5

5、 找到呼叫者類的起始和結束地址,檢索起始與結束地址的所有彙編指令是否存在被掃描 API 的類,出現則輸出結果。

API 掃描流程圖

反彙編程式碼:

vm:(unsigned long long)vm {
mach_header_64 mhHeader;
//解析頭部
[fileData getBytes:&mhHeader range:NSMakeRange(0, sizeof(mach_header_64))];
// 獲取彙編程式碼的偏移地址和大小
char *ot_sect = (char *)[fileData bytes] + begin;
uint64_t ot_addr = vm + begin;
csh cs_handle = 0;
cs_insn *cs_insn = NULL;
cs_err cserr;
if ((cserr = cs_open(CS_ARCH_ARM64, CS_MODE_ARM, &cs_handle)) != CS_ERR_OK ) {
NSLog(@"未能初始化: %d, %s.", cserr, cs_strerror(cs_errno(cs_handle)));
return NULL;
}
// 設定解析模式
cs_option(cs_handle, CS_OPT_DETAIL, CS_OPT_ON);
cs_option(cs_handle, CS_OPT_SKIPDATA, CS_OPT_ON);
// 反彙編
size_t disasm_count = cs_disasm(cs_handle, (const uint8_t *)ot_sect, size, ot_addr, 0, &cs_insn);
if (disasm_count < 1 ) {
NSLog(@"彙編指令解析不符合預期!");
return NULL;
}
return cs_insn;
}

檢索方法的範圍:

//檢索方法偏移範圍
do {
@autoreleasepool {
unsigned long long index = (end - textList.addr) / 4;
char *dataStr = s_cs_insn[index].mnemonic;
//查詢是否是函式跳轉指令,記錄方法的開始和結束地址
if (strcmp(dataStr, "b")== 0|| strcmp(dataStr, "ret")==0) {
unsigned long long nextSymoble = end + 4;
MethodHelper *nextMethodHelper = [objectSymbolMap objectForKey:[NSNumber numberWithUnsignedLong:nextSymoble]];
if (nextMethodHelper && ![nextMethodHelper.className isEqualToString:className]){
//找到類的最後一個函式地址作為類的結束地址
callClassHelper.end = end;
return callClassHelper;
}
}
end += 4;
}
} while (end <= textList.addr + textList.size);

查詢 objc_msgSend 呼叫位置,檢索呼叫的外部方法名。

依據 r untime 的原理, objc_msgSend 呼叫時 x1 暫存器是 存放的 方法地址,所以主要查詢 bl 指令前的 x 1 暫存器 資訊

在掃描過程中發現,在呼叫 o bjc_msgSend 時的暫存器有時是需要 ldr 指令計算得出 這裡涉及到 彙編中高低位地址的查詢。

彙編指令中 把地址拆分為高低位,所以檢索時要注意 x 1 暫存器值的計算過程。如上圖所示,需要計算 x8 暫存器 + 低位地址 0 x 908 然後對比被檢測 API 的地址與 x1 暫存器是否相等,最後再向上查詢被檢測 API 的類,如果匹配則輸出結果

小結: 至此基於 Mach-O API 掃描介紹完了, Mach - O 的掃描方式相比傳統的語法樹掃描,在本質上脫離了程式碼,可以對任意的動態庫和靜態庫 進行 掃描 而且掃描過程可以採用 多執行緒分段掃描 進行提速,能夠在 2 秒內完成對 3 萬行程式碼編譯產物的掃描 相比語法樹掃描在速度上有 20 倍以上的提升

在語言上 除了 OC 方法的掃描,還 支援 S wiftC 方法的掃描,原理與 OC 掃描類似,這裡就不做詳細介紹。

總結與展望 :

以上是基於 Mach -O 結構的動態庫與靜態庫的歸屬和 API 的掃描方案,已應用於生產環境,助力團隊降本提效。

未來我們還會持續在以下方面進行探索 實踐:

1、 擴充套件歸屬檢索的範圍,如 類別、 block 常量、 C 方法等。

2、 API 使用規範的掃描,基於 Mach-O 生成簡易的呼叫關係圖來檢視 API 的規範情況 ,可 掃描衝突 分類 方法 提前發現隱藏的 bug

3、 安全方面:防止反編譯、動態注入。

參考文件:

Overview of the Mach-O Executable Format (apple.com)

Introduction (apple.com)

作者簡介

汽車之家

杜沛

使用者產品中心-APP技術部

2015年加入汽車之家,主要負責APP架構相關工作。

閱讀更多:

▼ 關注「 之家技術 」,獲取更多技術乾貨