Swift Hook 新思路 -- 虛函數(shù)表
摘要:業(yè)界對Swift 的 Hook 大多都需要依靠 OC 的消息轉(zhuǎn)發(fā)特性來實現(xiàn),本文從修改 Swift 的虛函數(shù)表的角度,介紹了一種新的 Hook 思路。并以此為主線,重點介紹 Swift 的詳細結(jié)構(gòu)以及應(yīng)用。
1. 前言
由于歷史包袱的原因,目前主流的大型APP基本都是以 Objective-C 為主要開發(fā)語言。
但是敏銳的同學(xué)應(yīng)該能發(fā)現(xiàn),從 Swift 的 ABI 穩(wěn)定以后,各個大廠開始陸續(xù)加大對 Swift 的投入。
雖然在短期內(nèi) Swift 還難以取代 Objective-C,但是其與 Objective-C 并駕齊驅(qū)的趨勢是越來越明顯,從招聘的角度就即可管中窺豹。
在過去一年的招聘過程中我們總結(jié)發(fā)現(xiàn),有相當數(shù)量的候選人只掌握 Swift 開發(fā),對Objective-C 開發(fā)并不熟悉,而且這部分候選人大多數(shù)比較年輕。
另外,以 RealityKit 等新框架為例,其只支持 Swift 不支持 Objective-C。上述種種現(xiàn)象意味著隨著時間的推移,如果項目不能很好的支持 Swift 開發(fā),那么招聘成本以及應(yīng)用創(chuàng)新等一系列問題將會凸顯出來。
因此,58 同城在 2020 年 Q4 的時候在集團內(nèi)發(fā)起了跨部門協(xié)同項目,從各個層面打造 Objective-C 與 Swift 的混編生態(tài)環(huán)境——項目代號 ”混天“。
一旦混編生態(tài)構(gòu)建完善,那么很多問題將迎刃而解。
2. 原理簡述
本文的技術(shù)方案僅針對通過虛函數(shù)表調(diào)用的函數(shù)進行 Hook,不涉及直接地址調(diào)用和objc_msgSend 的調(diào)用的情況。
另外需要注意的是,Swift Compiler 設(shè)置為 Optimize for speed(Release默認)則TypeContext 的 VTable 的函數(shù)地址會清空。
設(shè)置為 Optimize for size 則 Swfit 可能會轉(zhuǎn)變?yōu)橹苯拥刂氛{(diào)用。
以上兩種配置都會造成方案失效。因此本文重點在介紹技術(shù)細節(jié)而非方案推廣。
如果 Swift 通過虛函數(shù)表跳表的方式來實現(xiàn)方法調(diào)用,那么可以借助修改虛函數(shù)表來實現(xiàn)方法替換。即將特定虛函數(shù)表的函數(shù)地址修改為要替換的函數(shù)地址。但是由于虛函數(shù)表不包含地址與符號的映射,我們不能像 Objective-C 那樣根據(jù)函數(shù)的名字獲取到對應(yīng)的函數(shù)地址,因此修改 Swift 的虛函數(shù)是依靠函數(shù)索引來實現(xiàn)的。
簡單理解就是將虛函數(shù)表理解為數(shù)組,假設(shè)有一個 FuncTable[],我們修改函數(shù)地址只能通過索引值來實現(xiàn),就像 FuncTable[index] = replaceIMP 。但是這也涉及到一個問題,在版本迭代過程中我們不能保證代碼是一層不變的,因此這個版本的第 index 個函數(shù)可能是函數(shù) A,下個版本可能第 index 個函數(shù)就變成了函數(shù) B。顯然這對函數(shù)的替換會產(chǎn)生重大影響。
為此,我們通過 Swift 的 OverrideTable 來解決索引變更的問題。在 Swift 的OverrideTable 中,每個節(jié)點都記錄了當前這個函數(shù)重寫了哪個類的哪個函數(shù),以及重寫后函數(shù)的函數(shù)指針。
因此只要我們能獲取到 OverrideTable 也就意味著能獲取被重寫的函數(shù)指針 IMP0 以及重寫后的函數(shù)指針 IMP1。只要在 FuncTable[] 中找到 IMP0 并替換成 IMP1 即可完成方法替換。
接下來將詳細介紹Swift的函數(shù)調(diào)用、TypeContext、Metadata、VTable、OverrideTable 等細節(jié),以及他們彼此之間有何種關(guān)聯(lián)。為了方便閱讀和理解,本文所有代碼及運行結(jié)果,都是基于 arm64 架構(gòu)
3. Swift 的函數(shù)調(diào)用
首先我們需要了解 Swift 的函數(shù)如何調(diào)用的。與 Objective-C 不同,Swift 的函數(shù)調(diào)用存在三種方式,分別是:基于 Objective-C 的消息機制、基于虛函數(shù)表的訪問、以及直接地址調(diào)用。
▐ 3.1 Objective-C 的消息機制
首先我們需要了解在什么情況下 Swift 的函數(shù)調(diào)用是借助 Objective-C 的消息機制。如果方法通過 @objc dynamic 修飾,那么在編譯后將通過 objc_msgSend 的來調(diào)用函數(shù)。
假設(shè)有如下代碼
- class MyTestClass :NSObject {
- @objc dynamic func helloWorld() {
- print("call helloWorld() in MyTestClass")
- }
- }
- let myTest = MyTestClass.init()
- myTest.helloWorld()
編譯后其對應(yīng)的匯編為
- 0x1042b8824 <+120>: bl 0x1042b9578 ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
- 0x1042b8828 <+124>: mov x20, x0
- 0x1042b882c <+128>: bl 0x1042b8998 ; SwiftDemo.MyTestClass.__allocating_init() -> SwiftDemo.MyTestClass at ViewController.swift:22
- 0x1042b8830 <+132>: stur x0, [x29, #-0x30]
- 0x1042b8834 <+136>: adrp x8, 13
- 0x1042b8838 <+140>: ldr x9, [x8, #0x320]
- 0x1042b883c <+144>: stur x0, [x29, #-0x58]
- 0x1042b8840 <+148>: mov x1, x9
- 0x1042b8844 <+152>: str x8, [sp, #0x60]
- 0x1042b8848 <+156>: bl 0x1042bce88 ; symbol stub for: objc_msgSend
- 0x1042b884c <+160>: mov w11, #0x1
- 0x1042b8850 <+164>: mov x0, x11
- 0x1042b8854 <+168>: ldur x1, [x29, #-0x48]
- 0x1042b8858 <+172>: bl 0x1042bcd5c ; symbol stub for:
從上面的匯編代碼中我們很容易看出調(diào)用了地址為0x1042bce88的objc_msgSend 函數(shù)。
▐ 3.2 虛函數(shù)表的訪問
虛函數(shù)表的訪問也是動態(tài)調(diào)用的一種形式,只不過是通過訪問虛函數(shù)表的方式進行調(diào)用。
假設(shè)還是上述代碼,我們將 @objc dynamic 去掉之后,并且不再繼承自 NSObject。
- class MyTestClass {
- func helloWorld() {
- print("call helloWorld() in MyTestClass")
- }
- }
- let myTest = MyTestClass.init()
- myTest.helloWorld()
匯編代碼變成了下面這樣👇
- 0x1026207ec <+120>: bl 0x102621548 ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
- 0x1026207f0 <+124>: mov x20, x0
- 0x1026207f4 <+128>: bl 0x102620984 ; SwiftDemo.MyTestClass.__allocating_init() -> SwiftDemo.MyTestClass at ViewController.swift:22
- 0x1026207f8 <+132>: stur x0, [x29, #-0x30]
- 0x1026207fc <+136>: ldr x8, [x0]
- 0x102620800 <+140>: adrp x9, 8
- 0x102620804 <+144>: ldr x9, [x9, #0x40]
- 0x102620808 <+148>: ldr x10, [x9]
- 0x10262080c <+152>: and x8, x8, x10
- 0x102620810 <+156>: ldr x8, [x8, #0x50]
- 0x102620814 <+160>: mov x20, x0
- 0x102620818 <+164>: stur x0, [x29, #-0x58]
- 0x10262081c <+168>: str x9, [sp, #0x60]
- 0x102620820 <+172>: blr x8
- 0x102620824 <+176>: mov w11, #0x1
- 0x102620828 <+180>: mov x0, x11
從上面匯編代碼可以看出,經(jīng)過編譯后最終是通過 blr 指令調(diào)用了 x8 寄存器中存儲的函數(shù)。至于 x8 寄存器中的數(shù)據(jù)從哪里來的,留到后面的章節(jié)闡述。
▐ 3.3 直接地址調(diào)用
假設(shè)還是上述代碼,我們再將 Build Setting 中Swift Compiler - Code Generaation -> Optimization Level 修改為 Optimize for Size[-Osize],匯編代碼變成了下面這樣👇
- 0x1048c2114 <+40>: bl 0x1048c24b8 ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
- 0x1048c2118 <+44>: add x1, sp, #0x10 ; =0x10
- 0x1048c211c <+48>: bl 0x1048c5174 ; symbol stub for: swift_initStackObject
- 0x1048c2120 <+52>: bl 0x1048c2388 ; SwiftDemo.MyTestClass.helloWorld() -> () at ViewController.swift:23
- 0x1048c2124 <+56>: adr x0, #0xc70c ; demangling cache variable for type metadata for Swift._ContiguousArrayStorage<Any>
這是大家就會發(fā)現(xiàn)bl 指令后跟著的是一個常量地址,并且是 SwiftDemo.MyTestClass.helloWorld() 的函數(shù)地址。
4. 思考
既然基于虛函數(shù)表的派發(fā)形式也是一種動態(tài)調(diào)用,那么是不是以為著只要我們修改了虛函數(shù)表中的函數(shù)地址,就實現(xiàn)了函數(shù)的替換?
5. 基于 TypeContext 的方法交換
在往期文章《從 Mach-O 角度談?wù)?Swift 和 OC 的存儲差異》我們可以了解到在Mach-O 文件中,可以通過 __swift5_types 查找到每個 Class 的ClassContextDescriptor,并且可以通過 ClassContextDescriptor 找到當前類對應(yīng)的虛函數(shù)表,并動態(tài)調(diào)用表中的函數(shù)。
注意:(在 Swift 中,Class/Struct/Enum 統(tǒng)稱為 Type,為了方便起見,我們在文中提到的TypeContext 和 ClassContextDescriptor 都指的是 ClassContextDescriptor)。
首先我們來回顧下 Swift 的類的結(jié)構(gòu)描述,結(jié)構(gòu)體 ClassContextDescriptor 是 Swift 類在Section64(__TEXT,__const) 中的存儲結(jié)構(gòu)。
- struct ClassContextDescriptor{
- uint32_t Flag;
- uint32_t Parent;
- int32_t Name;
- int32_t AccessFunction;
- int32_t FieldDescriptor;
- int32_t SuperclassType;
- uint32_t MetadataNegativeSizeInWords;
- uint32_t MetadataPositiveSizeInWords;
- uint32_t NumImmediateMembers;
- uint32_t NumFields;
- uint32_t FieldOffsetVectorOffset;
- <泛型簽名> //字節(jié)數(shù)與泛型的參數(shù)和約束數(shù)量有關(guān)
- <MaybeAddResilientSuperclass>//有則添加4字節(jié)
- <MaybeAddMetadataInitialization>//有則添加4*3字節(jié)
- VTableList[]//先用4字節(jié)存儲offset/pointerSize,再用4字節(jié)描述數(shù)量,隨后N個4+4字節(jié)描述函數(shù)類型及函數(shù)地址。
- OverrideTableList[]//先用4字節(jié)描述數(shù)量,隨后N個4+4+4字節(jié)描述當前被重寫的類、被重寫的函數(shù)描述、當前重寫函數(shù)地址。
- }
從上述結(jié)構(gòu)可以看出,ClassContextDescriptor 的長度是不固定的,不同的類 ClassContextDescriptor 的長度可能不同。那么如何才能知道當前這個類是不是泛型?以及是否有 ResilientSuperclass、MetadataInitialization 特征?其實在前一篇文章《從Mach-O 角度談?wù)?Swift 和 OC 的存儲差異》中已經(jīng)做了說明,我們可以通過 Flag 的標記位來獲取相關(guān)信息。
例如,如果 Flag 的 generic 標記位為 1,則說明是泛型。
- | TypeFlag(16bit) | version(8bit) | generic(1bit) | unique(1bit) | unknow (1bi) | Kind(5bit) |
- //判斷泛型
- (Flag & 0x80) == 0x80
那么泛型簽名到底能占多少字節(jié)呢?Swift 的 GenMeta.cpp 文件中對泛型的存儲做了解釋,整理總結(jié)如下:
- 假設(shè)有泛型有paramsCount個參數(shù),有requeireCount個約束
- /**
- 16B = 4B + 4B + 2B + 2B + 2B + 2B
- addMetadataInstantiationCache -> 4B
- addMetadataInstantiationPattern -> 4B
- GenericParamCount -> 2B
- GenericRequirementCount -> 2B
- GenericKeyArgumentCount -> 2B
- GenericExtraArgumentCount -> 2B
- */
- short pandding = (unsigned)-paramsCount & 3;
- 泛型簽名字節(jié)數(shù) = (16 + paramsCount + pandding + 3 * 4 * (requeireCount) + 4);
因此只要明確了 Flag 各個標記位的含義以及泛型的存儲長度規(guī)律,那么就能計算出虛函數(shù)表 VTable 的位置以及各個函數(shù)的字節(jié)位置。
了解了泛型的布局以及 VTable 的位置,是不是就意味著能實現(xiàn)函數(shù)指針的修改了呢?答案當然是否定的,因為 VTable 存儲在 __TEXT 段,__TEXT 是只讀段,我們沒辦法直接進行修改。不過最終我們通過 remap 的方式修改代碼段,將 VTable 中的函數(shù)地址進行了修改,然而發(fā)現(xiàn)在運行時函數(shù)并沒有被替換為我們修改的函數(shù)。那到底是怎么一回事呢?
6. 基于 Metadata 的方法交換
上述實驗的失敗當然是我們的不嚴謹導(dǎo)致的。在項目一開始我們先研究的是類型存儲描述 TypeContext,主要是類的存儲描述 ClassContextDescriptor。在找到 VTable 后我們想當然的認為運行時 Swift 是通過訪問 ClassContextDescriptor 中的 VTable 進行函數(shù)調(diào)用的。但是事實并不是這樣。
7. VTable 函數(shù)調(diào)用
接下來我們將回答下 Swift的函數(shù)調(diào)用 章節(jié)中提的問題,x8 寄存器的函數(shù)地址是從哪里來的。還是前文中的 Demo,我們在 helloWorld() 函數(shù)調(diào)用前打斷點
- let myTest = MyTestClass.init()
- -> myTest.helloWorld()
斷點停留在 0x100230ab0 處👇
- 0x100230aac <+132>: stur x0, [x29, #-0x30]
- 0x100230ab0 <+136>: ldr x8, [x0]
- 0x100230ab4 <+140>: ldr x8, [x8, #0x50]
- 0x100230ab8 <+144>: mov x20, x0
- 0x100230abc <+148>: str x0, [sp, #0x58]
- 0x100230ac0 <+152>: blr x8
此時 x0 寄存器中存儲的是 myTest 的地址 x0 = 0x0000000280d08ef0,ldr x8, [x0] 則是將 0x280d08ef0 處存儲的數(shù)據(jù)放入 x8(注意,這里是只將 *myTest 存入 x8,而不是將 0x280d08ef0 存入 x8)。單步執(zhí)行后,通過 re read 查看各個寄存器的數(shù)據(jù)后會發(fā)現(xiàn) x8 存儲的是 type metadata 的地址,而不是 TypeContext 的地址。
- x0 = 0x0000000280d08ef0
- x1 = 0x0000000280d00234
- x2 = 0x0000000000000000
- x3 = 0x00000000000008fd
- x4 = 0x0000000000000010
- x5 = 0x000000016fbd188f
- x6 = 0x00000002801645d0
- x7 = 0x0000000000000000
- x8 = 0x000000010023e708 type metadata for SwiftDemo.MyTestClass
- x9 = 0x0000000000000003
- x10= 0x0000000280d08ef0
- x11= 0x0000000079c00000
經(jīng)過上步單步執(zhí)行后,當前程序要做的是 ldr x8, [x8, #0x50],即將 type metadata + 0x50 處的數(shù)據(jù)存儲到 x8。這一步就是跳表,也就是說經(jīng)過這一步后,x8 寄存器中存儲的就是 helloWorld() 的地址。
- 0x100230aac <+132>: stur x0, [x29, #-0x30]
- 0x100230ab0 <+136>: ldr x8, [x0]
- -> 0x100230ab4 <+140>: ldr x8, [x8, #0x50]
- 0x100230ab8 <+144>: mov x20, x0
- 0x100230abc <+148>: str x0, [sp, #0x58]
- 0x100230ac0 <+152>: blr x8
那是否真的是這樣呢?ldr x8, [x8, #0x50] 執(zhí)行后,我們再次查看 x8,看看寄存器中是否為函數(shù)地址👇
- x0 = 0x0000000280d08ef0
- x1 = 0x0000000280d00234
- x2 = 0x0000000000000000
- x3 = 0x00000000000008fd
- x4 = 0x0000000000000010
- x5 = 0x000000016fbd188f
- x6 = 0x00000002801645d0
- x7 = 0x0000000000000000
- x8 = 0x0000000100231090 SwiftDemo`SwiftDemo.MyTestClass.helloWorld() -> () at ViewController.swift:23
- x9 = 0x0000000000000003
結(jié)果表明 x8 存儲的確實是 helloWorld() 的函數(shù)地址。上述實驗表明經(jīng)過跳轉(zhuǎn)0x50 位置后,程序找到了 helloWorld() 函數(shù)地址。類的 Metadata 位于__DATA 段,是可讀寫的。其結(jié)構(gòu)如下:
- struct SwiftClass {
- NSInteger kind;
- id superclass;
- NSInteger reserveword1;
- NSInteger reserveword2;
- NSUInteger rodataPointer;
- UInt32 classFlags;
- UInt32 instanceAddressPoint;
- UInt32 instanceSize;
- UInt16 instanceAlignmentMask;
- UInt16 runtimeReservedField;
- UInt32 classObjectSize;
- UInt32 classObjectAddressPoint;
- NSInteger nominalTypeDescriptor;
- NSInteger ivarDestroyer;
- //func[0]
- //func[1]
- //func[2]
- //func[3]
- //func[4]
- //func[5]
- //func[6]
- ....
- };
上面的代碼在經(jīng)過0x50 字節(jié)的偏移后正好位于 func[0] 的位置。因此要想動態(tài)修改函數(shù)需要修改Metadata中的數(shù)據(jù)。
經(jīng)過試驗后發(fā)現(xiàn)修改后函數(shù)確實是在運行后發(fā)生了改變。但是這并沒有結(jié)束,因 為虛函數(shù)表與消息發(fā)送有所不同,虛函數(shù)表中并沒有任何函數(shù)名和函數(shù)地址的映射,我們只能通過偏移來修改函數(shù)地址。
比如,我想修改第1個函數(shù),那么我要找到 Meatadata,并修改 0x50 處的 8 字節(jié)數(shù)據(jù)。同理,想要修改第 2 個函數(shù),那么我要修改 0x58 處的 8 字節(jié)數(shù)據(jù)。這就帶來一個問題,一旦函數(shù)數(shù)量或者順序發(fā)生了變更,那么都需要重新進行修正偏移索引。
舉例說明下,假設(shè)當前 1.0 版本的代碼為
- class MyTestClass {
- func helloWorld() {
- print("call helloWorld() in MyTestClass")
- }
- }
此時我們對 0x50 處的函數(shù)指針進行了修改。當 2.0 版本變更為如下代碼時,此時我們的偏移應(yīng)該修改為 0x58,否則我們的函數(shù)替換就發(fā)生了錯誤。
- class MyTestClass {
- func sayhi() {
- print("call sayhi() in MyTestClass")
- }
- func helloWorld() {
- print("call helloWorld() in MyTestClass")
- }
- }
為了解決虛函數(shù)變更的問題,我們需要了解下 TypeContext 與 Metadata 的關(guān)系。
8. TypeContext 與 Metadata 的關(guān)系
Metadata 結(jié)構(gòu)中的 nominalTypeDescriptor 指向了 TypeContext,也就是說當我們獲取到 Metadata 地址后,偏移 0x40 字節(jié)就能獲取到當前這個類對應(yīng)的 TypeContext地址。那么如何通過 TypeContext 找到 Metadata 呢?
我們還是看剛才的那個 Demo,此時我們將斷點打到 init() 函數(shù)上,我們想了解下 MyTestClass 的 Metadata 到底是哪里來的。
- -> let myTest = MyTestClass.init()
- myTest.helloWorld()
此時展開為匯編我們會發(fā)現(xiàn),程序準備調(diào)用一個函數(shù)。
- -> 0x1040f0aa0 <+120>: bl 0x1040f16a8 ; type metadata accessor for SwiftDemo.MyTestClass at <compiler-generated>
- 0x1040f0aa4 <+124>: mov x20, x0
- 0x1040f0aa8 <+128>: bl 0x1040f0c18 ; SwiftDemo.MyTestClass.__al
在執(zhí)行 bl 0x1040f16a8 指令之前,x0 寄存器為 0。
- x0 = 0x0000000000000000
此時通過 si 單步調(diào)試就會發(fā)現(xiàn)跳轉(zhuǎn)到了函數(shù) 0x1040f16a8 處,其函數(shù)指令較少,如下所示👇
- SwiftDemo`type metadata accessor for MyTestClass:
- -> 0x1040f16a8 <+0>: stp x29, x30, [sp, #-0x10]!
- 0x1040f16ac <+4>: adrp x8, 13
- 0x1040f16b0 <+8>: add x8, x8, #0x6f8 ; =0x6f8
- 0x1040f16b4 <+12>: add x8, x8, #0x10 ; =0x10
- 0x1040f16b8 <+16>: mov x0, x8
- 0x1040f16bc <+20>: bl 0x1040f4e68 ; symbol stub for: objc_opt_self
- 0x1040f16c0 <+24>: mov x8, #0x0
- 0x1040f16c4 <+28>: mov x1, x8
- 0x1040f16c8 <+32>: ldp x29, x30, [sp], #0x10
- 0x1040f16cc <+36>: ret
在執(zhí)行 0x1040f16a8 函數(shù)執(zhí)行完后,x0 寄存器就存儲了 MyTestClass 的 Metadata 地址。
- x0 = 0x00000001047e6708 type metadata for SwiftDemo.MyTestClass
那么這個被標記為 type metadata accessor for SwiftDemo.MyTestClass at
在上文介紹的 struct ClassContextDescriptor 貌似有個成員是 AccessFunction,那這個 ClassContextDescriptor 中的 AccessFunction 是不是 Metadata 的訪問函數(shù)呢?這個其實很容易驗證。
我們再次運行 Demo,此時metadata accessor 為 0x1047d96a8,繼續(xù)執(zhí)行后Metadata地址為 0x1047e6708。
- x0 = 0x00000001047e6708 type metadata for SwiftDemo.MyTestClass
查看 0x1047e6708,繼續(xù)偏移 0x40 字節(jié)后可以得到 Metadata 結(jié)構(gòu)中的 nominalTypeDescriptor 地址 0x1047e6708 + 0x40 = 0x1047e6748。
查看 0x1047e6748 存儲的數(shù)據(jù)為 0x1047df4a0。
- (lldb) x 0x1047e6748
- 0x1047e6748: a0 f4 7d 04 01 00 00 00 00 00 00 00 00 00 00 00 ..}.............
- 0x1047e6758: 90 90 7d 04 01 00 00 00 18 8c 7d 04 01 00 00 00 ..}.......}.....
ClassContextDescriptor 中的 AccessFunction 在第 12 字節(jié)處,因此對 0x1047df4a0 + 12 可知 AccessFunction 的位置為 0x1047df4ac。繼續(xù)查看 0x1047df4ac 存儲的數(shù)據(jù)為
- (lldb) x 0x1047df4ac
- 0x1047df4ac: fc a1 ff ff 70 04 00 00 00 00 00 00 02 00 00 00 ....p...........
- 0x1047df4bc: 0c 00 00 00 02 00 00 00 00 00 00 00 0a 00 00 00 ................
由于在 ClassContextDescriptor 中,AccessFunction 為相對地址,因此我們做一次地址計算 0x1047df4ac + 0xffffa1fc - 0x10000000 = 0x1047d96a8,與 metadata accessor 0x1047d96a8 相同,這就說明 TypeContext 是通過 AccessFunction 來獲取對應(yīng)的Metadata的地址的。
當然,實際上也會有例外,有時編譯器會直接使用緩存的 cache Metadata 的地址,而不再通過 AccessFunction 來獲取類的 Metadata。
9. 基于 TypeContext 和 Metadata 的方法交換
在了解了 TypeContext 和 Metadata 的關(guān)系后,我們就能做一些設(shè)想了。在 Metadata中雖然存儲了函數(shù)的地址,但是我們并不知道函數(shù)的類型。這里的函數(shù)類型指的是函數(shù)是普通函數(shù)、初始化函數(shù)、getter、setter 等。
在 TypeContext 的 VTable 中,method 存儲一共是 8 字節(jié),第一個4字節(jié)存儲的函數(shù)的 Flag,第二個4字節(jié)存儲的函數(shù)的相對地址。
- struct SwiftMethod {
- uint32_t Flag;
- uint32_t Offset;
- };
通過 Flag 我們很容易知道是否是動態(tài),是否是實例方法,以及函數(shù)類型 Kind。
- | ExtraDiscriminator(16bit) |... | Dynamic(1bit) | instanceMethod(1bit) | Kind(4bit) |
Kind 枚舉如下👇
- typedef NS_ENUM(NSInteger, SwiftMethodKind) {
- SwiftMethodKindMethod = 0, // method
- SwiftMethodKindInit = 1, //init
- SwiftMethodKindGetter = 2, // get
- SwiftMethodKindSetter = 3, // set
- SwiftMethodKindModify = 4, // modify
- SwiftMethodKindRead = 5, // read
- };
從 Swift 的源碼中可以很明顯的看到,類重寫的函數(shù)是單獨存儲的,也就是有單獨的OverrideTable。
并且 OverrideTable 是存儲在 VTable 之后。與 VTable 中的 method 結(jié)構(gòu)不同,OverrideTable 中的函數(shù)需要 3 個 4 字節(jié)描述:
- struct SwiftOverrideMethod {
- uint32_t OverrideClass;//記錄是重寫哪個類的函數(shù),指向TypeContext
- uint32_t OverrideMethod;//記錄重寫哪個函數(shù),指向SwiftMethod
- uint32_t Method;//函數(shù)相對地址
- };
也就是說 SwiftOverrideMethod 中能夠包含兩個函數(shù)的綁定關(guān)系,這種關(guān)系與函數(shù)的編譯順序和數(shù)量無關(guān)。
如果 Method 記錄用于 Hook 的函數(shù)地址,OverrideMethod 作為被Hook的函數(shù),那是不是就意味著無論如何改變虛函數(shù)表的順序及數(shù)量,只要 Swift 還是通過跳表的方式進行函數(shù)調(diào)用,那么我們就無需關(guān)注函數(shù)變化了。
為了驗證可行性,我們寫 Demo 測試一下:
- class MyTestClass {
- func helloWorld() {
- print("call helloWorld() in MyTestClass")
- }
- }//作為被Hook類及函數(shù)
- <--------------------------------------------------->
- class HookTestClass: MyTestClass {
- override func helloWorld() {
- print("\n********** call helloWorld() in HookTestClass **********")
- super.helloWorld()
- print("********** call helloWorld() in HookTestClass end **********\n")
- }
- }//通過繼承和重寫的方式進行Hook
- <--------------------------------------------------->
- let myTest = MyTestClass.init()
- myTest.helloWorld()
- //do hook
- print("\n------ replace MyTestClass.helloWorld() with HookTestClass.helloWorld() -------\n")
- WBOCTest.replace(HookTestClass.self);
- //hook 生效
- myTest.helloWorld()
運行后,可以看出 helloWorld() 已經(jīng)被替換成功👇
- 2021-03-09 17:25:36.321318+0800 SwiftDemo[59714:5168073] _mh_execute_header = 4368482304
- call helloWorld() in MyTestClass
- ------ replace MyTestClass.helloWorld() with HookTestClass.helloWorld() -------
- ********** call helloWorld() in HookTestClass **********
- call helloWorld() in MyTestClass
- ********** call helloWorld() in HookTestClass end **********
10. 總結(jié)
本文通過介紹 Swift 的虛函數(shù)表 Hook 思路,介紹了 Swift Mach-O 的存儲結(jié)構(gòu)以及運行時的一些調(diào)試技巧。Swift 的 Hook 方案一直是從 Objective-C 轉(zhuǎn)向 Swift 開發(fā)的同學(xué)比較感興趣的事情。我們想通過本文向大家介紹關(guān)于 Swift 更深層的一些內(nèi)容,至于方案本身也許并不是最重要的,重要的是我們希望是否能夠從中 Swift 的二進制中找到更多的應(yīng)用場景。比如,Swift 的調(diào)用并不會存儲到 classref 中,那如何通過靜態(tài)掃描知道哪些 Swift 的類或 Struct 被調(diào)用了?其實解決方案也是隱含在本文中。