從野指針探測到對iOS 15 bind 的探索

從野指針探測說起

前段時間58旗下本地版APP上出現(xiàn)了較多的野指針崩潰,崩潰堆棧沒有太多有效信息,只是告訴崩潰發(fā)生在自動釋放池釋放對象的時候。

堆棧

相關問題極難復現(xiàn),在開發(fā)階段我們先后開啟了zombiescribbleAddressSanitizer檢測,但是實際經過大量的測試并沒有復現(xiàn)相關崩潰,問題比較難定位。為了定位相關問題,我們部署了線上野指針探測工具,通過相關工具可以捕捉到崩潰發(fā)生時的堆棧以及野指針對象的類型,如果有必要,還可以對象釋放時的輕量堆棧。

輕量堆棧:為了節(jié)省內存,我們只保存了APP的調用軌跡的堆棧,并且通過偏移地址運算將地址從8字節(jié)優(yōu)化為4字節(jié),盡量節(jié)省空間。

線上野指針比較難的一點就在于如何確定到底是哪些類出現(xiàn)了問題,只有明確了發(fā)生的類型才能建立有效的監(jiān)控。如果不能明確過度釋放的類名,那么只能開啟全量監(jiān)控捕捉類名,然后再進一步根據(jù)類名開啟對應的堆棧監(jiān)控。但是全量監(jiān)控會因為監(jiān)控范圍過大影響APP性能和監(jiān)控的有效性。為了先確定野指針發(fā)生的類型,我們犧牲了一部分內存(30MB)作為緩存,廣撒網式地全方位輕量探測,期望在明確類型以后再開啟抓取釋放時的堆棧信息以及野指針發(fā)生時的堆棧信息。因此我們將APP內我們自定義的類以及我們用到的系統(tǒng)類都被納入到監(jiān)控范圍。

監(jiān)控范圍

符號的類型

那如何確定到底我們在項目中用到了哪些系統(tǒng)類呢?

獲取當前APP用到的系統(tǒng)類有多種方式,例如可以通過nm 等命令獲取到符號,并根據(jù)符號類型來判斷內外符號。

nm 輸出示例

細心的同學會發(fā)現(xiàn)nm輸出的符號前會有U S D等字母修飾,其實這些字母就代表了符號的類型。

關于符號類型可以參考下符號的類型說明:

符號類型 說明
A 該符號的值是絕對的,在以后的鏈接過程中,不允許進行改變。這樣的符號值,常常出現(xiàn)在中斷向量表中,例如用符號來表示各個中斷向量函數(shù)在中斷向量表中的位置。
B 該符號的值出現(xiàn)在非初始化數(shù)據(jù)段(bss)中。例如,在一個文件中定義全局static int test。則該符號test的類型為b,位于bss section中。其值表示該符號在bss段中的偏移。一般而言,bss段分配于RAM中
C 該符號為common。common symbol是未初始話數(shù)據(jù)段。該符號沒有包含于一個普通section中。只有在鏈接過程中才進行分配。符號的值表示該符號需要的字節(jié)數(shù)。例如在一個c文件中,定義int test,并且該符號在別的地方會被引用,則該符號類型即為C。否則其類型為B。
D 該符號位于初始話數(shù)據(jù)段中。一般來說,分配到data section中。例如定義全局int baud_table[5] = {9600, 19200, 38400, 57600, 115200},則會分配于初始化數(shù)據(jù)段中。
G 該符號也位于初始化數(shù)據(jù)段中。主要用于small object提高訪問small data object的一種方式。
I 該符號是對另一個符號的間接引用。
N 該符號是一個debugging符號。
R 該符號位于只讀數(shù)據(jù)區(qū)。例如定義全局const int test[] = {123, 123};則test就是一個只讀數(shù)據(jù)區(qū)的符號。注意在cygwin下如果使用gcc直接編譯成MZ格式時,源文件中的test對應_test,并且其符號類型為D,即初始化數(shù)據(jù)段中。但是如果使用m6812-elf-gcc這樣的交叉編譯工具,源文件中的test對應目標文件的test,即沒有添加下劃線,并且其符號類型為R。一般而言,位于rodata section。值得注意的是,如果在一個函數(shù)中定義const char *test = “abc”, const char test_int = 3。使用nm都不會得到符號信息,但是字符串“abc”分配于只讀存儲器中,test在rodata section中,大小為4。
S 符號位于非初始化數(shù)據(jù)區(qū),用于small object。
T 該符號位于代碼區(qū)text section。
U 該符號在當前文件中是未定義的,即該符號的定義在別的文件中。例如,當前文件調用另一個文件中定義的函數(shù),在這個被調用的函數(shù)在當前就是未定義的;但是在定義它的文件中類型是T。但是對于全局變量來說,在定義它的文件中,其符號類型為C,在使用它的文件中,其類型為U。
V 該符號是一個weak object。
W 該符號是弱符號,尚未專門標記為弱對象符號。
- 該符號是a.out格式文件中的stabs symbol。
? 該符號類型沒有定義

符號類型說明摘選自:提米果的博客

我們可以看到如果符號類型為U,則說明該符號為外部符號。比如UIViewController,UIViewController 在我們的二進制文件中沒有對應的實現(xiàn)和定義,它是在UIKit中實現(xiàn)和定義的,那UIViewController對我們來說就是外部符號,因此會用U來修飾。因此通過nm命令結合符號類型說明就可以拿到當前項目中用到的所有系統(tǒng)類。當然這種獲取不包括動態(tài)調用方式。

思考:大家可以思考下linkmap文件是否也可以獲取到APP中用到的類?如何實現(xiàn)呢?

提示:可以借助linkmap中的文件索引來判斷符號的來源。如果文件索引對應的是系統(tǒng)庫,那么就是外部符號,也就是我們用到的系統(tǒng)類等。

如果用命令結合文本分析的方式來獲取系統(tǒng)類,會帶來額外的版本流程??赡茉诿看未虬鼤r我們都要先分析導出一份系統(tǒng)類列表內置到APP中,這顯然令目前本已復雜的打包流程更是雪上加霜。因此思考如何在APP內部運行期拿到所有用到的系統(tǒng)類。

聯(lián)想到了bind

接著上面的例子,既然UIViewController不是在我們的APP中定義的,那么它的地址我們在APP運行前肯定不知道。只有在bind之后我們才能拿到對應的地址。在二進制文件中,如果我們用到了UIViewController,實際上在二進制文件中都是用0x0000000000000000來進行"占位"。在經過bind后,對應的地址才會被替換為UIViewController的真實地址。那系統(tǒng)是如何實現(xiàn)bind的呢?在實際開發(fā)中,我們很多地方都用到了UIViewController(例如MyViewController繼承自UIViewController),系統(tǒng)在bind的時候一定會逐一對地址進行修正,這就意味著我們的二進制文件中一定會有信息記錄到底哪些地方用到了哪些類,否則系統(tǒng)無法進行修正。

晦澀的 LC_DYLD_INFO_ONLY

壓縮字節(jié)流

bind信息存儲在LC_DYLD_INFO_ONLY中,通過MachOView我們可以直觀的看到相關的數(shù)據(jù)。

LC_DYLD_INFO_ONLY 的結構

LC_DYLD_INFO_ONLY(詳見下面的代碼注釋)記錄的是壓縮字節(jié)流的偏移和長度,這些壓縮字節(jié)流是在dyld加載鏡像時所需的數(shù)據(jù)。

/*
* The dyld_info_command contains the file offsets and sizes of 
* the new compressed form of the information dyld needs to 
* load the image. This information is used by dyld on Mac OS X
* 10.6 and later. All information pointed to by this command
* is encoded using byte streams, so no endian swapping is needed
* to interpret it. 
*/
public struct dyld_info_command </pre>

壓縮字節(jié)流是LC_DYLD_INFO_ONLY數(shù)據(jù)壓縮存儲的一種格式,按照特定的解法解析可以獲取到相應的數(shù)據(jù)。蘋果在這里用壓縮字節(jié)流存儲rebase & bind 信息是在保證解析效率的前提下盡量節(jié)省存儲空間

在iOS 15以前,iOS的bind可以分為3種類型,分別是bind、lazy_bind、weak_bind,iOS 15之后去除了lazy_bind。

這里的iOS 15 指的是 iOS Deployment Target 設置為iOS 15 ,而不是指在iOS 15系統(tǒng)運行。因為 iOS Deployment Target < 15 打出來的包需要在iOS 14等低系統(tǒng)上運行,因此打包出來的二進制不是真的具有iOS 15的新特性。iOS Deployment Target 設置為iOS 15 后,低端系統(tǒng)不再支持,所以iOS 的二進制文件才會有所變化。

iOS 對bind的壓縮字節(jié)流介紹如下:

/*
    * Dyld binds an image during the loading process, if the image
    * requires any pointers to be initialized to symbols in other images.  
    * The bind information is a stream of byte sized 
    * opcodes whose symbolic names start with BIND_OPCODE_.
    * Conceptually the bind information is a table of tuples:
    *   <seg-index, seg-offset, type, symbol-library-ordinal, symbol-name, addend>
    * The opcodes are a compressed way to encode the table by only
    * encoding when a column changes. In addition simple patterns
    * like for runs of pointers initialzed to the same value can be 
    * encoded in a few bytes.
    */
  public var bind_off: UInt32 /* file offset to binding info   */

簡單來說就是從bind 的壓縮字節(jié)流中,我們可以提取出來一個元組列表。其含義如下:

  <seg-index, seg-offset, type, symbol-library-ordinal, symbol-name, addend>
??
  <bind需要修改的地址在哪個段, 這個地址距離段的偏移, 類型(如Pointer), 這個地址所在的lib庫, 符號名, addend></pre>

我們通過MachOView查看下bind信息,經過MachOView解析好后的數(shù)據(jù),我們可以輕易讀懂相應內容。

解析好的bind信息展示

圖中表示的意思就是VM Address 0x100015D50 ~ 0x100015D58 這連續(xù)8字節(jié)的內容代表的是NSObject。因此在bind 時,系統(tǒng)可以清楚地知道0x100015D50 ~ 0x100015D58需要修改為哪個類的地址。

lazy_bindbind的數(shù)據(jù)格式一致,提取解析后也是元組列表。存儲在lazy_bind的符號并不是啟動立即綁定,而是在相關符號首次使用的時候的觸發(fā)綁定。一般來說NSLog函數(shù)是lazy_bind,那么在APP首次使用NSLog才會觸發(fā)相應的綁定。

那究竟如何將壓縮字節(jié)流解析為元組列表呢?

壓縮字節(jié)流的解析

bind 信息的讀取有一套特定的"語法"。在這套“語法”內,一個長度為8 bit的字節(jié)被拆分為2部分,高4位代表指令,低4位代表數(shù)據(jù)。

#define BIND_OPCODE_MASK0xF0
#define BIND_IMMEDIATE_MASK0x0F</pre>

因此一個字節(jié)可以標識16種指令和16以內的數(shù)據(jù)。我們以圖中數(shù)據(jù)流中第一個字節(jié)為例,offset = 0x181A0處的數(shù)據(jù)為0x12。

解析示例1
let opcode = 0x12 & BIND_OPCODE_MASK  
let immediate = 0x12 & BIND_IMMEDIATE_MASK</pre>

通過運算后就可以發(fā)現(xiàn) 指令為 opcode = BIND_OPCODE_SET_DYLIB_ORDINAL_IMM == 0x10 數(shù)據(jù)為 immediate == 0x02。BIND_OPCODE_SET_DYLIB_ORDINAL_IMM 的意思為后面的數(shù)據(jù)代表庫的索引值,即庫的索引值為0x02。通過這一個字節(jié),我們能獲取到元組中的一個數(shù)據(jù)。

解析示例2

與此類似,我們將緊接著解析下個字節(jié)0x40,0x40的指令為BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM,這條指令告訴我們后面的數(shù)據(jù)為一個symbol字符串。因此可元組中的字符串也能獲取到。依次類推,遍歷完成整個字節(jié)流后即可完整得到元組列表,相關代碼大家可以參考dyld || MachOView || WBBlades 。這里需要提一下,如果數(shù)據(jù)長度超過4 bit,壓縮字節(jié)流會通過特殊的指令告訴我們從下個字節(jié)開始是什么數(shù)據(jù),包括數(shù)據(jù)的類型及編碼格式。例如BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB 這條指令就會告訴我們當前這個字節(jié)的低4 bit代表段的索引,并且還告訴我們從這個字節(jié)以后的連續(xù)N字節(jié)代表指針距離段的偏移,并且還告訴我們編碼格式是ULEB格式。

ULEB128:Unsigned Little Endian Base 128

SLEB128:Signed Little Endian Base 128

ULEB128和SLEB128是為了解決存儲數(shù)據(jù)浪費而提出的變長編碼格式,適合存儲一些數(shù)據(jù)通常較小但是偶爾較大的數(shù)。在iOS中,常見的數(shù)據(jù)類型都是定長的,例如UInt8就是1個字節(jié),能表示的數(shù)據(jù)范圍為0 ~ 63。UInt16就是2個字節(jié)能表示的數(shù)據(jù)范圍為0 ~ 65535。那假設有這樣的數(shù)組[ 1 , 2 , 3 , 4 , 160 , 0 , 1 , 2 , 3 , 4 , 5 , 7],那數(shù)組該定義成什么類型呢?顯然UInt8(1Byte)不夠用,UInt16(2Byte)又有些浪費。因此如果數(shù)據(jù)能彈性伸縮就能解決存儲空間浪費的問題,ULEB128和SLEB128能解決此類問題。以ULEB128為例,ULEB128長度最少可以為1字節(jié),通常少于5字節(jié)。每個字節(jié)拆分為2部分,最高位為標記位,用于表示是否有后續(xù)字節(jié),如果最高位為1則說明后續(xù)有字節(jié)跟隨,為0則說明當前字節(jié)是數(shù)據(jù)的最后一個字節(jié)。每個字節(jié)的低7 bit用于表示數(shù)據(jù)。

舉個例子:

0xA001,占據(jù)2個字節(jié),其二進制表示為:'10100000 00000001'。

Step1: 去除標記位后 -> '0100000 0000001'

Step2: 小端調整 -> '0000001 0100000'

因此結果為 '10100000' ,即十進制的160

再看fishhook

簡單認識fishhook

通過解析bind、lazy_bind、weak_bind可以獲取到元組列表,每個元組會告訴我們符號和指針信息,指針信息包括指針位于哪個段以及在段的偏移。等等,元組列表是不是類似符號表?那是不是可以元組列表做點什么事情呢?看到這里可能有同學會跟我一樣想到了fishhook。Fishhook 是Facebook 開源的一個C函數(shù)hook 框架。它能夠在運行期間hook 一些外部C函數(shù)(在APP內我們自己寫的函數(shù)hook 不到,因為不涉及bind)。

介紹fishhook原理的文章非常多,因此有關fishhook的具體實現(xiàn)細節(jié)在這里不做討論,感興趣的同學可以搜索相關文章了解下原理。想直接并且簡單地了解fishhook大體思路的同學可以閱讀下面的總結。

在iOS中,我們使用用變量或類似NSLog()等外部函數(shù)并不是直接調用地址,而是在經過bind 或 lazy_bind后才能得到真正的地址。bind或lazy_bind后真正的函數(shù)地址記錄在 nl_symbol_ptrla_symbol_ptr中,通過符號表可以找到每個函數(shù)對應在nl_symbol_ptrla_symbol_ptr中的地址。fishhook 就是通過查找符號表,找到記錄函數(shù)指針的地址修改函數(shù)指針從而實現(xiàn)C函數(shù)的hook。

bind是在加載鏡像的時候就就已經綁定,而lazy_bind是在首次使用時才觸發(fā)綁定。

思考:lazy_bind是如何實現(xiàn)在首次調用函數(shù)時進行bind的呢?

打個比方來解釋:假設張三和李四是同學,老師手里有個名單,這個名單上記錄著要參加值日的同學。本來今天應該是李四值日,但是由于打印名單時教務處老師不知道李四的名字,因此打印了班長張三的名字。老師只認名單,因此老師找來張三打掃衛(wèi)生。但是張三只做了一件事情,就是把名單上的名字改成了李四,并且叫李四來打掃衛(wèi)生。這樣老師以后如果再吩咐打掃衛(wèi)生的事情時就直接找到了李四。這就是lazy_bind。故事中老師就是我們寫的代碼,代碼只認地址。名單就是la_symbol_ptr,上面記錄了值日同學名。張三就是stub機制,它只是起到了輔助作用。而李四則是真正的外部函數(shù),需要真正執(zhí)行的函數(shù)。

有關fishhook 近期的改動是比較有意思的,前端時間經常通過大家反饋fishhook 在iOS 14.5 上的崩潰,以及在iOS 15上的崩潰。主要問題在于fishhook 在寫入指針的時候寫入保護引起的。fishhook #87 Pr 做了解釋和修改。

C 函數(shù)的hook 可不止fishhook一種方案

除了fishhook外,筆者也有一種C函數(shù)的靜態(tài)hook方式,相比于fishhook,此方案不存在耗時的查找比對操作。下面我將介紹這種比較特殊的方案:基于動態(tài)庫的C函數(shù)hook 。

動態(tài)庫hook 流程圖

方案沒有任何代碼,但是比較難理解。

Step1: 首先在主工程中定義一個同名同參同返回的函數(shù),這樣在ld64鏈接時會認為func1func2 中用到的NSLog是我們自定義的函數(shù),這樣就不會跟系統(tǒng)庫的函數(shù)進行匹配,NSLog也就不會被標記為需要bind的函數(shù)。

Step2: 在我們自定義的NSLog內部,我們調用自定義動態(tài)庫的中間函數(shù)MyNSLog,這一步是為了能夠調用到真正的NSLog

Step3: 由于動態(tài)庫中我們沒有自定義NSLog去“欺騙”ld64,因此動態(tài)中的NSLog會去調用真正的系統(tǒng)函數(shù)。

到這里可能有同學會問,“難道動態(tài)庫的NSLog不存在重新調用到主程序的NSLog函數(shù)的風險嗎?那樣豈不是會死循環(huán)?”

不會的。因為動態(tài)庫是具備編譯和鏈接過程的產物。經過鏈接時,在二進制文件中就已經寫定了NSLog bind到系統(tǒng)庫中的NSLog了,因此在啟動階段dyld不會“違抗”二進制的命令執(zhí)行到主程序的NSLog。

這套方案在同城APP上有所應用,已經在線上運行將近1年。但是由于侵入性較強,僅對部分需要同步啟動用到的函數(shù)使用。fishhook 還是項目中最主要的使用方式。

第三種C函數(shù)hook 方案

上文中我們提到了壓縮字節(jié)流中提取的元組列表包含了地址和符號的映射。那么是不是可以借助元組列表實現(xiàn)C函數(shù)的hook呢?經過demo實踐發(fā)現(xiàn)是可行的。代碼已經上傳到WBBlades->ChubbyCat,GitHub 搜索WBBlades,demo在ChubbyCat目錄下。

typealias MYNSLogType = @convention(thin) (_ format: String, _ args: CVarArg...) -> Void
func MYNSLog(_ format: String, _ args: CVarArg...){
  print("test success")
}

class Test: NSObject {
  func test() {
      let replacement = unsafeBitCast(MYNSLog as MYNSLogType, to: UInt64.self)
      let ret = ChubbyCatHook.replaceC(name: "_NSLog", replacement:replacement)
      print("hook result = \(ret)")
  }
}

但是這個方案在iOS Deployment Target == 15上會失效,如果想進一步解決問題需要在線上從bundle下獲取磁盤文件進行輔助解析,得不償失,因此僅做學習和交流使用。

風云再起,iOS 15 的LC_DYLD_CHAINED_FIXUPS

在iOS15 上,APP的rebase & bind 的方式發(fā)生了變化。

Deployment Target設置

如果我們將iOS Deployment Target設置為15的話,通過MachOView查看打包后的Mach-O文件會發(fā)現(xiàn)新的二進制上出現(xiàn)了不支持的LC。

新的LoadCommand

這是由于LC_DYLD_INFO_ONLY被替換成了新增的LC_DYLD_EXPORTS_TRIELC_DYLD_CHAINED_FIXUPS。

圖中另外一個不支持的LC為 LC_BUILD_VERSION: /* build for platform min OS version */

文件的變化意味著iOS 15的rebasebind機制發(fā)生了變化?;仡檌OS 14及以前,dyld是通過解析壓縮字節(jié)流實現(xiàn)了rebasebind。解析壓縮字節(jié)會告訴dyld 整個二進制文件中有哪些地址需要修正,以及在bind時每個地址是為哪個外部符號預留。那iOS 15 dyld是如何進行過修正的呢?接下來我們探索下dyld。

dyld3 ? dyld4?

前段時間聽到有同學討論iOS 15 dyld3 更新為dyld4了。筆者無法確定蘋果是否偷偷地升級了dyld,但是從蛛絲馬跡中可以看出來dyld 確實是有變化,例如在instrument 中我們可以看到部分函數(shù)的命名空間變成了dyld4。還有就是一些API的調用上發(fā)生了一些變化,例如:

let header:UnsafePointer<mach_header> = _dyld_get_image_header(0)</pre>

在iOS 15系統(tǒng)之前通過索引獲取header時,如果index == 0,返回的是可執(zhí)行程序的header。但是在iOS 15中,index == 0獲取到的卻是系統(tǒng)庫。當然這些變化對我們的業(yè)務代碼可能還不足以產生影響,但是可以說明dyld 確定是有改動。那LC_DYLD_CHAINED_FIXUPSdyld的新特性嗎?我的答案是否定的。因為從dyld3dyld-852.2版本中可以看到LC_DYLD_CHAINED_FIXUPS早就預埋在dyld中了,只不過在iOS Deployment Target == 15時引起Mach-O文件變化后,才能進入相應的代碼分支。

回歸對bind的探索

iOS 15上rebase & bind發(fā)生的變化,iOS 15 如何讓你的應用啟動更快 (附英文原文) 一文做了介紹。感興趣的同學可以細讀此文,想直接看結論的同學可以看下面的文字,我對文章的內容做概括和總結。

在iOS 15中,原本用于rebase & bind 的壓縮字節(jié)流被替換,取而代之的是fixup-chains(鏈表結構)。在iOS 啟動時,dyld 先判斷是否存在fixup-chains,如果存在fixup-chains 則按照fixup-chains的方式進行解析,否則還是按照壓縮字節(jié)流的方式解析。解析的目的是為了將應用程序的地址進行修正。fixup-chains 機制是由三層結構進行存儲,分別是segment(段)-> pages(頁) -> fixup-chains(指針鏈表) 組成。LC_DYLD_CHAINED_FIXUPS所指向的數(shù)據(jù)會告訴我們有多少segments,每個segment的信息又會告訴我們這個segment有多少pages,以及每個page 的fixup-chains在哪里。 而 fixup-chains中的指針指向了當前page中每一個需要rebase 或者 bind的地址,這些地址中存儲的數(shù)據(jù)并非像iOS 15之前那樣都是0x00,而是有一定格式的具有一定意義的8字節(jié)數(shù)據(jù)。而這短短的8字節(jié)數(shù)據(jù)被按照不同的結構體拆分成多個bit,每個或連續(xù)幾個bit都具有其特殊的含義用于推斷rebase 或 bind 所需要的一切信息。iOS 15廢除了lazy_bind(weak_bind仍然保留),由于rebase和bind 被整合為一個鏈表,因此遍歷一次鏈表即可完成一個page所需的rebase和bind。

那fixup-chains為什么能加快啟動呢?

因為在iOS 15以前,rebase和bind的信息在壓縮字節(jié)流中是分別存儲的。這就意味著,在啟動時dyld在做rebase時會先遍歷一遍rebase壓縮字節(jié)流所記錄的地址進行地址修改,假設為N次page fault,由于經過rebase 的page 是被寫入數(shù)據(jù)的dirty page,因此不會被釋放,iOS 會通過壓縮的方式優(yōu)化最近沒有使用到的dirty page。然后在進行bind時,又遍歷bind壓縮字節(jié)流所記錄的那些地址進行修改,假設需要bind M個page。那么在N和M這兩個Pages集合中可能存在很多重疊,這就造成了二次遍歷,并且iOS可能對其中某些dirty page做了壓縮優(yōu)化。在這種情況下,bind時就需要對這些重疊的pages做解壓操作。而fixup-chains很巧妙地解決了這個問題,因為同一個page的rebase和bind整合成一個鏈表,同時進行這兩種操作,這樣就不會存在重復遍歷相同的page,也不會存在解壓的問題。

疑問

fixup-chains 會減少page falut次數(shù)嗎?:不會,依舊是MN

有人問這個算不算iOS 幫我們做了二進制重排?:完全是兩回事。雖然都提到了page fault,但是階段是不同的。

重回野指針探測

重新回到文章的開頭,在做野指針探測的時候我們通過解析壓縮字節(jié)流獲取到了項目中所有的被使用到的系統(tǒng)類。那在iOS 15沒有壓縮字節(jié)流的情況下,我們如何利用fixup-chains獲取到項目中的類呢?

查看頭文件可以發(fā)現(xiàn),我們通過LC_DYLD_CHAINED_FIXUPS所指向的結構體linkedit_data_command,可以找到當前文件的fixups_header。

從LC_DYLD_CHAINED_FIXUPS 找到 fixups_header

fixups_header是整個fixups信息的入口,具體信息如下:

// header of the LC_DYLD_CHAINED_FIXUPS payload
struct dyld_chained_fixups_header
{
  uint32_t   fixups_version;   // 0
  uint32_t   starts_offset;     // offset of dyld_chained_starts_in_image in chain_data
  uint32_t   imports_offset;   // offset of imports table in chain_data
  uint32_t   symbols_offset;   // offset of symbol strings in chain_data
  uint32_t   imports_count;     // number of imported symbol names
  uint32_t   imports_format;   // DYLD_CHAINED_IMPORT*
  uint32_t   symbols_format;   // 0 => uncompressed, 1 => zlib compressed
};

結構體中其他信息我們暫不做介紹,在這里我們只關注symbols_offset。symbols_offset記錄的是符號鏈表的偏移。symbols_offset 并不是我們常說的符號表,這里存儲的是bind所需的字符串,這里所記錄的符號并沒有復用在strtab中的數(shù)據(jù),推測可能是為了用空間換取時間,相對于bind啟動的消耗,這點存儲空間應該不算什么。symbols_offset的大概位置見下圖????:

symbols_offset的位置

symbols_offset位于原先存儲壓縮字節(jié)流的位置,由于MachOView并不支持fixups的展示,因此在圖中看不到相應的數(shù)據(jù)。

回到symbols_offset,通過遍歷字符串表我們就很容易拿到所有的外部符號。

//symbols_format: 0 => uncompressed
if fixup.pointee.symbols_format == 0 {
  print("import count = \(fixup.pointee.imports_count)")
  let symbolStarts = UInt(fixup.pointee.symbols_offset) + ptr
  var length : UInt = 0
  for _ in 0 ..< fixup.pointee.imports_count {
      let location = symbolStarts + length
      let symbol = UnsafeMutablePointer<CChar>(bitPattern: UInt(location))
      if let name = symbol{
          length += UInt(strlen(name)) + 1
          print(String.init(cString: name))
      }
  }
}else if fixup.pointee.symbols_format == 1{
  //TODO: zlib compressed
}

在上面的demo中,打印片段如下:

...
...
_OBJC_CLASS_$_UISceneConfiguration
_OBJC_CLASS_$_NSException
_OBJC_CLASS_$_UIViewController
_OBJC_METACLASS_$_UIViewController
_OBJC_METACLASS_$_NSObject
_OBJC_METACLASS_$_UIResponder
_OBJC_CLASS_$_UIResponder

請記住這里打印的信息,我們暫且稱之為 imports symbols,后續(xù)的內容會用上它

到這一步,我們的野指針探測所需的數(shù)據(jù)即使在最低版本iOS 15編譯的包上也可以應用。但是,細心的同學肯定會有疑問,iOS 的bind是需要知道在哪個地址需要綁定哪個庫的哪個符號,我們這里只是打印了所有的符號,那fixup-chains到底是如何準確知道符號和地址的關系從而實現(xiàn)bind的呢?比如我們要調用NSLog()函數(shù),在iOS 15之前,壓縮字節(jié)流會告訴我們哪個地址對應的8字節(jié)是NSLog()函數(shù)的指針。并且在bind之前,使用8字節(jié)的0x00來填入文件的某個位置,假設這個位置是0x140000字節(jié)處,那么在0x14000之后的8個字節(jié)都是0x00。在iOS 15時,0x140000字節(jié)處存儲的就不再是0x00了。而是類似下圖中的特定的8字節(jié)數(shù)據(jù)。那0x801000000000000A到底代表的是哪個函數(shù)呢?

需要bind的位置是特殊含義的數(shù)字

fixup-chains如何實現(xiàn)bind

我們先回顧下上文中提到的結構體dyld_chained_fixups_header,dyld_chained_fixups_header結構體中的starts_offset指向了當前鏡像內的需要做rebase& bind段信息的數(shù)據(jù),也就是dyld_chained_starts_in_image結構體。dyld_chained_starts_in_image結構體中,前4字節(jié)代表當前鏡像需要做rebase& bind的段的個數(shù),隨后是具體每個段的偏移。

dyld_chained_starts_in_image 如何找到每個段信息

在獲取到每個段的偏移信息后,我們就可以找到每個段的fixups信息結構體dyld_chained_starts_in_segment:

struct dyld_chained_starts_in_segment
{
  uint32_t   size;               // size of this (amount kernel needs to copy)
  uint16_t   page_size;         // 0x1000 or 0x4000
  uint16_t   pointer_format;     // DYLD_CHAINED_PTR_*
  uint64_t   segment_offset;     // offset in memory to start of segment
  uint32_t   max_valid_pointer; // for 32-bit OS, any value beyond this is not a pointer
  uint16_t   page_count;         // how many pages are in array
  uint16_t   page_start[1];     // each entry is offset in each page of first element in chain
};

查詢dyld_chained_starts_in_segment信息我們會發(fā)現(xiàn),我們能夠拿到當前段內的所有pages信息。其中page_start會告訴我們每個pagefixup-chains在這個page的偏移。

段、頁、鏈表 之間的位置關系

由于段是由一系列連續(xù)的pages組成,因此只要知道page的固定大小以及page的個數(shù),那么就能遍歷到整個段的所有fixup-chains的起始地址。

for pageIndex in 0 ..< segment.pointee.page_count {
  let offsetInPage : UInt16 = segment.pointee.page_start + 16 * pageIndex;
  if offsetInPage == DYLD_CHAINED_PTR_START_NONE {
      continue
  }
  //32-bit chains which may need multiple starts per page
  if (offsetInPage & UInt16( DYLD_CHAINED_PTR_START_MULTI)) != 0 {
      print("multiple starts per page")
  }else{
      // one chain start per page
      let chainStart = UInt64(headPtr) + segment.pointee.segment_offset + UInt64(offsetInPage) + UInt64(pageIndex * segment.pointee.page_size)
      let chainContentPtr = UnsafePointer<UInt>(bitPattern: UInt(chainStart))
      print("page:\(pageIndex) first pointer = \(String(format: "0x%llx", chainStart)) chainContent = \(String(format: "0x%llx", (chainContentPtr?.pointee ?? 0)))")
  }
}

上面代碼是從 dyld-852.2 中抽象提取的。 通過上面的代碼,我們能獲取到每個段的每個頁的fixup-chains鏈表。

實際上在這一步,我們獲取到的是鏈表的表頭,也就是第一個元素。那如何獲取到鏈表中的下一個元素呢?我們可以簡單理解為鏈表中每個元素都有幾個bit 來標識下個指針距離此處的偏移。那到底是幾個bit呢?在arm64中,如果stride = 4,則需要12 bit。如果stide = 8,則需要11 bit。

舉例說明:如果stride = 4 && next = 1,則說明下個指針距離此處為stride * next = 4個字節(jié)。如果stride = 8 && next = 10,則說明下個指針距離此處為stride * next = 80個字節(jié)。

總之,無論是next 用11 bit表示還是12 bit表示,總能覆蓋一個page 16KB的范圍。

在獲取到鏈表中的數(shù)據(jù)后,實際上我們知道了地址和數(shù)據(jù),但是對應的符號還是未知的。

地址 數(shù)據(jù) 未知symbol
0x10453c000 0x801000000000000A "_NSLog"
0x10453c008 0x801000000000000B "_printf"
.... .... ....

列表中數(shù)據(jù)為了方便大家理解寫的是bind之前的數(shù)據(jù)。例如0x10453c000這個指針的指向的內容在運行期間不可能獲取到0x801000000000000A,而是類似0x185fd0150這樣的函數(shù)地址。這個很好理解,我們的代碼在bind之后運行,除非我們去獲取磁盤中的Mach-O文件,否則不可能讀取到0x801000000000000A這樣的原始值,除非從磁盤中的文件中獲取。

因此我們想知道如何通過0x801000000000000A獲取到函數(shù)名_NSLog。

在上文的dyld_chained_starts_in_segment結構體中,存在一個成員pointer_format。pointer_format是描述當前這個段的數(shù)據(jù)解析格式的。一共有12種解析類型。

// values for dyld_chained_starts_in_segment.pointer_format
enum {
  DYLD_CHAINED_PTR_ARM64E                 = 1,   // stride 8, unauth target is vmaddr
  DYLD_CHAINED_PTR_64                     = 2,   // target is vmaddr
  DYLD_CHAINED_PTR_32                     = 3,
  DYLD_CHAINED_PTR_32_CACHE               = 4,
  DYLD_CHAINED_PTR_32_FIRMWARE           = 5,
  DYLD_CHAINED_PTR_64_OFFSET             = 6,   // target is vm offset
  DYLD_CHAINED_PTR_ARM64E_OFFSET         = 7,   // old name
  DYLD_CHAINED_PTR_ARM64E_KERNEL         = 7,   // stride 4, unauth target is vm offset
  DYLD_CHAINED_PTR_64_KERNEL_CACHE       = 8,
  DYLD_CHAINED_PTR_ARM64E_USERLAND       = 9,   // stride 8, unauth target is vm offset
  DYLD_CHAINED_PTR_ARM64E_FIRMWARE       = 10,   // stride 4, unauth target is vmaddr
  DYLD_CHAINED_PTR_X86_64_KERNEL_CACHE   = 11,   // stride 1, x86_64 kernel caches
  DYLD_CHAINED_PTR_ARM64E_USERLAND24     = 12,   // stride 8, unauth target is vm offset, 24-bit bind
};

為了搞清楚bind查找symbol的機制,我們選擇其中的一種類型DYLD_CHAINED_PTR_64來說明。查詢fixup-chains.h我們可以找到DYLD_CHAINED_PTR_64對應的8字節(jié)結構如下:

// DYLD_CHAINED_PTR_64
struct dyld_chained_ptr_64_bind
{
  uint64_t   ordinal   : 24,
              addend   : 8,   // 0 thru 255
              reserved : 19,   // all zeros
              next     : 12,   // 4-byte stride
              bind     : 1;   // == 1
};

dyld中,如果類型為DYLD_CHAINED_PTR_64,那么dyld會按照下面的方式進行獲取bind要綁定的值。

if ( fixupLoc->generic64.bind.bind ) {
  newValue = (void*)((long)bindTargets[fixupLoc->generic64.bind.ordinal] + fixupLoc->generic64.signExtendedAddend());
}

在這里我們不需要對這段代碼有什么了解,我們只需要知道兩件事情:

  • bindbindTargets數(shù)組有關,這也就意味著symbol與bindTargets有關。

  • bind 與 上面結構體dyld_chained_ptr_64_bind 中的ordinal有關。這也就意味著symbol與ordinal有關。

那接下來我們需要看下bindTargets是如何生成的,它的順序又是什么。在dyld3中有如下代碼:

for (uint32_t i=0; i < header->imports_count && !stop; ++i) {
  const char* symbolName = &symbolsPool[imports[i].name_offset];
  ...
  ...
  //一直回調,在回調中不斷向 bindTargets 存入數(shù)據(jù)。
  callback(libOrdinal, symbolName, 0, imports[i].weak_import, stop);
}

在上面的代碼中,我們知道callback不斷向 bindTargets 存入數(shù)據(jù)?;叵胂挛覀冊谏衔闹刑岬搅?code>imports信息并且獲取到了imports 所對應的所有symbols(也就是上文中讓大家記住的 imports symbols)。

這就意味著ordinal == 0則意味著bindTargets中的第一個元素的符號為imports symbols的第一個字符串。

因此dyld獲取到二進制文件中0x801000000000000A后,通過dyld_chained_ptr_64_bind解析就知道其ordinal0x0A = 10。其對應的符號為imports symbols0x0A個符號,在我的測試demo中為"_NSLog"。

總結

至此,我們從野指針探測引起的探索都已經介紹完成,在這里做個簡單的總結:

  • 野指針探測我們期望能拿到所有的使用到的 系統(tǒng)類,如UIView等,未使用到的系統(tǒng)類不想拿到。

  • 介紹了符號的類型的概念,以及如何通過命令+文本的方式達成上述目的。

  • 介紹了壓縮字節(jié)流的概念和解析方法。

  • 簡單介紹了fishhook的工作流程以及關鍵改動,并提出了通過壓縮字節(jié)流的符號和地址映射關系(即上文中的元組),不使用符號表實現(xiàn)C函數(shù)的替換。

  • 基于iOS 15 打包的文件不再具有壓縮字節(jié)流,而是采用fixup-chains來實現(xiàn)高效地rebase &bind。

  • 通過解析imports我們能獲取到所有的用到的系統(tǒng)符號,這已經能滿足我們野指針探測的需要。

  • 探索了bind的符號和地址是如何映射的,最終得到了bind中的ordinalimports symbols數(shù)組的索引下標的結論。

作者自述

大家好,我叫鄧竹立,目前就職于 58同城-用戶與價值增長中心-平臺技術部。非常感謝大家能耐著性子看完這萬字長文。寫這篇文章的原因是最近做了很多的技術調研,但是沒有將這些內容整理下來,因此想著通過一篇文章將自己探索和發(fā)現(xiàn)記錄下來,或許有人會用到。

由于個人能力和時間的限制,相關結論和觀點難免會有紕漏,如果您在閱讀過程中有任何問題都可以留言或者加我微信進行探討。

我是一個樂于分享也喜歡總結的人,因為分享和總結會促使我進行更深入的思考,也會反思自己的策略和方案是否能經得起推敲。有關動態(tài)庫懶加載技術、日志符號化、APP卡死、野指針探測、Mach-O探索等話題可以留言交流下~

參考:

1 、fishhook #87 : 關于fishhook 崩潰及修復方案的討論。

2、fishhook的實現(xiàn)原理淺析 : 有關fishhook原理解釋的很好的文章。

3、為什么 iOS 14.5 下 fishhook 會 crash : 關于fishhook崩潰的解釋。

5、給實習生講明白 Lazy/Non-lazy Binding : 對lazy_bind解釋的很清晰的文章。

6、iOS 15 如何讓你的應用啟動更快 : 有關iOS 15 fixup 少有的文章。

7、dyld :dyld源碼下載地址。

8、radare : 關于fixup-chains解析的三方代碼。

9、提米果的博客 : 符號類型介紹參考文章。

10、How Apple has supercharged app launching in iOS 15 and macOS Monterey : 有關iOS 15 fixups的另一篇文章,但是基本上在介紹Noah Martin 的文章內容。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容