執行時Hook所有Block方法呼叫的技術實現

歐陽大哥2013發表於2019-03-31

本技術實現在YSBlockHook中。

1.方法呼叫的幾種Hook機制

iOS系統中一共有:C函式、Block、OC類方法三種形式的方法呼叫。Hook一個方法呼叫的目的一般是為了監控攔截或者統計一些系統的行為。Hook的機制有很多種,通常良好的Hook方法都是以AOP的形式來實現的。

當我們想Hook一個OC類的某些具體的方法時可以通過Method Swizzling技術來實現、當我們想Hook動態庫中匯出的某個C函式時可以通過修改匯入函式地址表中的資訊來實現(可以使用開源庫fishhook來完成)、當我們想Hook所有OC類的方法時則可以通過替換objc_msgSend系列函式來實現。。。

那麼對於Block方法呢而言呢?

2.Block的內部實現原理和實現機制簡介

這裡假定你對Block內部實現原理和執行機制有所瞭解,如果不瞭解則請參考文章《深入解構iOS的block閉包實現原理》或者自行通過搜尋引擎搜尋。

源程式中定義的每個Block在編譯時都會轉化為一個和OC類物件佈局相似的物件,每個Block也存在著isa這個資料成員,根據isa指向的不同,Block分為__NSStackBlock、__NSMallocBlock、__NSGlobalBlock 三種型別。也就是說從某種程度上Block物件也是一種OC物件。下面的類圖描述了Block類的層次結構。

Block類層次結構圖

Block類以及其派生類在CoreFoundation.framework中被定義和實現,並且沒有對外公開。

每個Block物件在記憶體中的佈局,也就是Block物件的儲存結構被定義如下(程式碼出自蘋果開源出來的庫實現libclosure中的檔案Block_private.h):

//需要注意的是下面兩個只是模板,具體的每個Block定義時總是按這個模板來定義的。

//Block描述,每個Block一個描述並定義在全域性資料段
struct Block_descriptor_1 {
    uintptr_t reserved;   //記住這個變數和結構體,它很重要!!
    uintptr_t size;
};

//Block物件的記憶體佈局
struct Block_layout {
    void *isa;
    volatile int32_t flags; // contains ref count
    int32_t reserved;
    uintptr_t invoke;   //Block物件的實現函式
    struct Block_descriptor_1 *descriptor;
    // imported variables,這裡是每個block物件的特定資料成員區域
};
複製程式碼

這裡要關注一下struct Block_descriptor_1中的reserved這個資料成員,雖然系統沒有用到它,但是下面就會用到它而且很重要!

在瞭解了Block物件的型別以及Block物件的記憶體佈局後,再來考察一下一個Block從定義到呼叫是如何實現的。就以下面的原始碼為例:

int main(int argc, char *argv[])
{
   //定義
    int a = 10;
    void (^testblock)(void) = ^(){
        NSLog(@"Hello world!%d", a);
    };
    
    //執行
    testblock();

    return 0;
}

複製程式碼

在將OC程式碼翻譯為C語言程式碼後每個Block的定義和呼叫將變成如下的虛擬碼:

//testblock的描述資訊
struct Block_descriptor_1_fortestblock {
    uintptr_t reserved; 
    uintptr_t size;
};

//testblock的佈局儲存結構體
struct Block_layout_fortestblock {
    void *isa;
    volatile int32_t flags; // contains ref count
    int32_t reserved;
    uintptr_t invoke;   //Block物件的實現函式
    struct Block_descriptor_1_fortestblock *descriptor;
    int m_a;  //外部的傳遞進來的資料。
};

//testblock函式的實現。
void main_invoke_fortestblock(struct Block_layout_fortestblock *cself)
{
      NSLog(@"Hello world!%d", cself->m_a);
}

//testblock物件描述的例項,儲存在全域性記憶體區
struct Block_descriptor_1_fortestblock  _testblockdesc = {0, sizeof(struct Block_layout_fortestblock)};

int main(int argc, char *argv[])
{
   //定義部分
    int a = 10;
    struct Block_layout_fortestblock testblock = {
            .isa = __NSConcreteStackBlock,
            .flags =0,
            .reserved = 0,
            .invoke = main_invoke_fortestblock,
            .descriptor = & _testblockdesc,
            .m_a = a
    };

   //呼叫部分
   testblock.invoke();
   
    return 0;
}


複製程式碼

可以看出Block物件的生成和呼叫都是在編譯期間就已經固定在程式碼中了,它不像其他OC物件呼叫方法時需要通過runtime來執行間接呼叫。並且線上程式中所有關於Block的符號資訊都會被strip掉。所以上述的所介紹的幾種Hook方法都無法Hook住一個Block物件的函式呼叫。

如果想要Hook住系統的所有Block呼叫,需要解決如下幾個問題:

a. 如何在執行時將所有的Block的invoke函式替換為一個統一的Hook函式。

b. 這個統一的Hook函式如何呼叫原始Block的invoke函式。

c. 如何構建這個統一的Hook函式。

3.實現Block物件Hook的方法和原理

一個OC類物件的例項通過引用計數來管理物件的生命週期。在MRC時代當物件進行賦值和拷貝時需要通過呼叫retain方法來實現引用計數的增加,而在ARC時代物件進行賦值和拷貝時就不再需要顯示呼叫retain方法了,而是系統內部在編譯時會自動插入相應的程式碼來實現引用計數的新增和減少。不管如何只要是對OC物件執行賦值拷貝操作,最終內部都會呼叫retain方法。

Block物件也是一種OC物件!!

每當一個Block物件在需要進行賦值或者拷貝操作時,也會激發對retain方法的呼叫。因為Block物件賦值操作一般是發生在Block方法執行之前,因此我們可以通過Method Swizzling的機制來Hook 類的retain方法,然後在重寫的retain方法內部將Block物件的invoke資料成員替換為一個統一的Hook函式!

通過考察__NSStackBlock、__NSMallocBlock、__NSGlobalBlock 三個類的實現發現這三個類都過載了NSObject的retain方法,這樣在執行Method Swizzling時就不需要對NSObject的retain方法執行替換,而只要對上述三個類的retain執行替換即可。

你可以說出為什麼這三個派生類都會對retain方法進行過載嗎?答案可以從這三種Block的型別定義以及所表示的意義中去尋找。

Block技術不僅可以用在OC語言中,LLVM對C語言進行的擴充套件也能使用Block,比如gcd庫中大量的使用了Block。在C語言中如果對一個Block進行賦值或者拷貝系統需要通過C庫函式:

//函式宣告在Block.h標頭檔案彙總
// Create a heap based copy of a Block or simply add a reference to an existing one.
// This must be paired with Block_release to recover memory, even when running
// under Objective-C Garbage Collection.
BLOCK_EXPORT void *_Block_copy(const void *aBlock)
    __OSX_AVAILABLE_STARTING(__MAC_10_6, __IPHONE_3_2);
複製程式碼

來實現,這個函式定義在libsystem_blocks.dylib庫中,並且庫實現已經開源:libclosure。因此可以藉助fishhook庫來對__Block_copy這個函式進行替換處理,然後在替換的函式函式中將一個Block的原始的invoke函式替換為統一的Hook函式。

另外一個C語言函式objc_retainBlock,也是實現了對Block進行賦值時的引用計數增加,這個函式內部就是簡單的呼叫__Block_copy方法。因此我們也可以新增對objc_retainBlock的替換處理。

解決了第一個問題後,接下來再解決第二個問題。還記得上面提到過的struct Block_descriptor_1中的reserved這個資料成員嗎? 當我們通過上述的方法對所有Block物件的invoke成員替換為一個統一的Hook函式前,可以將Block物件的原始invoke函式儲存到這個保留欄位中去。然後就可以在統一的Hook函式內部讀取這個保留欄位中的儲存的原始invoke函式來執行真實的方法呼叫了。

因為一個Block物件函式的第一個引數其實是一個隱藏的引數,這個隱藏的引數就是Block物件本身,因此很容易就可以從隱藏的引數中來獲取到對應的保留欄位。

下面的程式碼將展示通過方法交換來實現Hook處理的虛擬碼

struct Block_descriptor {
    void *reserved;
    uintptr_t size;
};

struct Block_layout {
    void *isa;
    int32_t flags; // contains ref count
    int32_t reserved;
    void  *invoke;
    struct Block_descriptor *descriptor;
};

//統一的Hook函式,這裡以虛擬碼的形式提供
void blockhook(void *obj, ...)
{
   struct Block_layout *layout = (struct Block_layout*) obj;
   //呼叫原始的invoke函式
   layout->descriptor->reserved(...);
}
//模擬器下如果返回型別是結構體並且大於16位元組那麼第一個引數是返回值儲存的記憶體地址,block物件變為第二個引數
void blockhook_stret(void *pret, void *obj, ...)
{
   struct Block_layout *layout = (struct Block_layout*) obj;
   //呼叫原始的invoke函式
   layout->descriptor->reserved(...);
}

//執行Block物件的方法替換處理
void replaceBlockInvokeFunction(const void *blockObj)
{
   struct Block_layout *layout = (struct Block_layout*)blockObj;
   if (layout != NULL && layout->descriptor != NULL){
         int32_t BLOCK_USE_STRET = (1 << 29);  //如果模擬器下返回的型別是一個大於16位元組的結構體,那麼block的第一個引數為返回的指標,而不是block物件。
         void *hookfunc = ((layout->flags & BLOCK_USE_STRET) == BLOCK_USE_STRET) ? blockhook_stret : blockhook;
         if (layout->invoke != hookfunc){
                layout->descriptor->reserved = layout->invoke;
                layout->invoke = hookfunc;
            }
    }
}

void *(*__NSStackBlock_retain_old)(void *obj, SEL cmd) = NULL;
void *__NSStackBlock_retain_new(void *obj, SEL cmd)
{
    replaceBlockInvokeFunction(obj);
    return __NSStackBlock_retain_old(obj, cmd);
}

void *(*__NSMallocBlock_retain_old)(void *obj, SEL cmd) = NULL;
void *__NSMallocBlock_retain_new(void *obj, SEL cmd)
{
    replaceBlockInvokeFunction(obj);
    return __NSMallocBlock_retain_old(obj, cmd);
}

void *(*__NSGlobalBlock_retain_old)(void *obj, SEL cmd) = NULL;
void *__NSGlobalBlock_retain_new(void *obj, SEL cmd)
{
    replaceBlockInvokeFunction(obj);
    return __NSGlobalBlock_retain_old(obj, cmd);
}

int main(int argc, char *argv[])
{
      //因為類名和方法名都不能直接使用,所以這裡都以字串的形式來轉換獲取。
    __NSStackBlock_retain_old = (void *(*)(void*,SEL))class_replaceMethod(NSClassFromString(@"__NSStackBlock"), sel_registerName("retain"), (IMP)__NSStackBlock_retain_new, nil);
    __NSMallocBlock_retain_old = (void *(*)(void*,SEL))class_replaceMethod(NSClassFromString(@"__NSMallocBlock"), sel_registerName("retain"), (IMP)__NSMallocBlock_retain_new, nil);
    __NSGlobalBlock_retain_old = (void *(*)(void*,SEL))class_replaceMethod(NSClassFromString(@"__NSGlobalBlock"), sel_registerName("retain"), (IMP)__NSGlobalBlock_retain_new, nil);

    return 0;
 }

複製程式碼

解決了第二個問題後,就需要解決第三個問題。上面的統一Hook函式blockhook和block_stret只是虛擬碼實現,因為任何一個Block中的函式的引數型別和個數是不一樣的,而且統一Hook函式也需要在適當的時候呼叫原始的預設Block函式實現,並且不能破壞引數資訊。為了解決這些問題就使得這個統一的Hook函式不能用高階語言來實現,而只能用匯編語言來實現。下面就是在arm64位體系下的實現程式碼:

.text
.align 5
.private_extern _blockhook   
_blockhook:
   //為了不破壞原有引數,這裡將所有引數壓入棧中
  stp q6, q7, [sp, #-0x20]!
  stp q4, q5, [sp, #-0x20]!
  stp q2, q3, [sp, #-0x20]!
  stp q0, q1, [sp, #-0x20]!
  stp x6, x7, [sp, #-0x10]!
  stp x4, x5, [sp, #-0x10]!
  stp x2, x3, [sp, #-0x10]!
  stp x0, x1, [sp, #-0x10]!
  stp x8, x30, [sp, #-0x10]!
  
  //這裡可以新增任意邏輯來進行hook處理。

  //這裡將所有引數還原
  ldp x8, x30, [sp], #0x10
  ldp x0, x1, [sp], #0x10
  ldp x2, x3, [sp], #0x10
  ldp x4, x5, [sp], #0x10
  ldp x6, x7, [sp], #0x10
  ldp q0, q1, [sp], #0x20
  ldp q2, q3, [sp], #0x20
  ldp q4, q5, [sp], #0x20
  ldp q6, q7, [sp], #0x20

  ldr x16, [x0, #0x18]   //將block物件的descriptor資料成員取出
  ldr x16, [x16]         //獲取descriptor中的reserved成員
  br x16                 //執行reserved中儲存的原始函式指標。
LExit_blockhook:

複製程式碼

對於x86_64/arm32位系統來說,如果block函式的返回是一個結構體並且長度超過16位元組(arm32是8位元組)。那麼block物件裡面的flags屬性就會設定為BLOCK_USE_STRET。而x86_64/arm32位系統對於這種返回型別的函式就會將返回值存放到第一個引數所指向的記憶體中,同時會把原本的block物件變化為第二個引數,因此需要對這種情況進行特殊處理。

關於在執行時Hook所有Block方法呼叫的技術實現原理就介紹到這裡了。當然一個完整的系統可能需要其他一些能力:

具體完整的程式碼可以訪問我的github中的專案:YSBlockHook。這個專案以AOP的形式實現了真機arm64位模式下對可執行程式中所有定義的Block進行Hook的方法,Hook所做的事情就是在所有Block呼叫前,列印出這個Block的符號資訊。


歡迎大家訪問歐陽大哥2013的github地址簡書地址

相關文章