Thunk程序的實現原理以及在iOS中的應用(二)

本文導讀:虛擬內存以及虛擬內存的remap機制,以及經過remap機制來實現經過靜態指令來構造thunk代碼塊。linux

👉Thunk程序的實現原理以及在iOS中的應用 入口處。git

thunk程序其實就是一段代碼塊,這段代碼塊能夠在運行時動態構造也能夠在編譯時構造。thunk程序除了在第一篇文章中介紹的用途外還能夠做爲某些真實函數調用的跳板(trampoline)代碼,以及解決一些函數參數不一致的調用對接問題。從設計模式的角度來說thunk程序能夠做爲一個適配器(Adapter)。本文將重點介紹如何經過編譯時的靜態代碼來實現thunk程序的方法,以便解決上一篇文章對於iOS系統下指令動態構造的約束限制的問題。github

虛擬內存實現的簡單介紹

在介紹靜態構造thunk程序以前,首先要熟悉一個知識點:虛擬內存。虛擬內存是現代操做系統對於內存管理的一個很重要的技術。經過虛擬內存的映射機制,使得每一個進程均可以擁有很是大並且徹底隔離和獨立的內存空間。操做系統對虛擬內存的分配和管理是以頁爲單位,當將一個可執行文件或者動態庫加載到內存中執行時,操做系統會將文件中的代碼段部分和數據段部分的內容經過內存映射文件的形式映射到對應的虛擬內存區域中。程序執行的代碼所在的代碼段部分老是被分配在一片具備可執行權限的虛擬內存區域中,不一樣的操做系統對可執行代碼所處的內存區域要求的不一樣,就好比iOS系統來講,可執行代碼所在的虛擬內存區域的權限只能是可執行的,不然就會產生系統崩潰,這也就是說咱們不能夠在具備可讀寫權限的內存區域中(好比堆內存或者棧內存空間)動態的構造出指令來供CPU執行。也就是說在iOS系統中不支持將某段內存的保護機制先設置爲讀寫以便填充好數據後再設置爲可執行的保護機制來實現動態的指令構造(也就是所謂的JIT技術)。不過好在操做系統提供了虛擬內存的remap機制來解決這個問題。所謂虛擬內存的remap機制就是能夠將新分配的虛擬內存頁從新映射到已經分配好的虛擬內存頁中,新分配的虛擬內存頁能夠和已經存在的虛擬內存頁中的內容保持一致,而且能夠繼承原始虛擬內存頁面的保護權限。虛擬內存的remap機制使得進程之間或者進程內中的虛擬內存共享相同的物理內存。設計模式

虛擬內存到物理內存之間的映射

從上面的圖中能夠得出一些結論:安全

  1. 不管是物理內存仍是虛擬內存的管理都是以頁爲單位來進行管理的,而且通常狀況下兩者的尺寸保持一致。
  2. 操做系統爲每一個進程創建一張進程頁表,頁表記錄着虛擬內存頁到物理內存頁的映射關係以及相關的權限。而且頁表是保存在物理內存頁中的。所以所謂的虛擬內存分配其本質就是在頁表中創建一個從虛擬內存頁到物理內存頁的映射關係而已。而所謂的remap就是將不一樣的虛擬頁號映射到同一個物理頁號而已。就如例子中進程1的第1頁和第4頁都是映射在同一個6號物理頁中。
  3. 不一樣進程之間的不一樣虛擬頁號能夠映射到相同的物理頁號。這樣的一個應用是解決動態庫的共享加載問題,好比UIKit這個框架庫在第一個進程運行時被加載到內存中,那麼當第二個進程運行時而且須要UIKit庫時就再也不須要從新從文件加載內存中而是共享已經加載到物理內存的UIKit動態庫。上面的例子中進程1的第5頁和進程2的第7頁共享相同的物理內存第9頁。
  4. 操做系統還會維持一個全局物理頁空閒信息表,用來記錄當前未被分配的物理內存。這樣一旦有進程須要分配虛擬內存空間時就從這個表中查找空閒的區域進行快速分配。

iOS的內核系統中有一層Mach子系統,Mach子系統是內核中的內核,它是一種微內核。Mach子系統中將進程(task)、線程、內存的管理都稱之爲一個對象,而且爲每一個對象都會分配一個被稱之爲port的端口號,全部對象之間的通訊和功能調用都是經過port爲標識的mach message來進行通訊的。bash

虛擬內存的remap機制

下面的代碼將展現虛擬內存分配銷燬以及虛擬內存的remap機制。例子裏面演示了經過remap機制來實現同一個函數實現的兩個不一樣的入口地址的調用實現:閉包

#import <mach/mach.h>

//由於新分配的虛擬內存是以頁爲單位的,因此要被映射的內存也要頁對齊,因此這裏的函數起始地址是以頁爲單位對齊的。
int __attribute__ ((aligned (PAGE_MAX_SIZE))) testfn(int a, int b)
{
    int c = a + b;
    return c;
}

int main(int argc, char *argv[])
{
    //經過vm_alloc以頁爲單位分配出一塊虛擬內存。
    vm_size_t page_size = 0;
    host_page_size(mach_host_self(), &page_size);  //獲取一頁虛擬內存的尺寸
    vm_address_t addr = 0;
    //在當前進程內的空閒區域中分配出一頁虛擬內存出來,addr指向虛擬內存的開始位置。
    kern_return_t ret = vm_allocate(mach_task_self(), &addr, page_size, VM_FLAGS_ANYWHERE);
    if (ret == KERN_SUCCESS)
    {
        //addr被分配出來後,咱們能夠對這塊內存進行讀寫操做
        memcpy((void*)addr, "Hello World!\n", 14);
        printf((const char*)addr);
        //執行上述代碼後,這時候內存addr的內容除了最開始有「Hello World!\n「其餘區域是一篇空白,並且並非可執行的代碼區域。
        
        //虛擬內存的remap重映射。執行完vm_remap函數後addr的內存將被從新映射到testfn函數所在的內存頁中,這時候addr所指的內容將不在是Hello world!了,而是和函數testfn的代碼保持一致。
        vm_prot_t cur,max;
        ret = vm_remap(mach_task_self(), &addr, page_size, 0, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, mach_task_self(), (vm_address_t)testfn, false, &cur, &max, VM_INHERIT_SHARE);
        if (ret == KERN_SUCCESS)
        {
           int c1 = testfn(10, 20);    //執行testfn函數
           int c2 = ((int (*)(int,int))addr)(10,20); //addr從新映射後將和testfn函數具備相同內容,因此這裏能夠將addr當作是testfn函數同樣被調用。
           NSAssert(c1 == c2, @"oops!");
        }

       vm_deallocate(mach_task_self(), addr, page_size);
    }

   return 0;
}
複製代碼

首先咱們用vm_allocate函數以頁的尺寸大小爲單位在空閒區域分配出一頁虛擬內存出來並由addr指向內存的首地址。當分配成功後咱們就能夠像操做普通內存同樣任意對這塊內存進行讀寫處理。這裏對addr分別進行了memcpy的寫操做,以及printf函數對addr進行讀操做。這時候addr所指的內存具備讀寫屬性。addr內存中存儲的信息以下: 框架

addr地址的內存佈局

接下來咱們又經過vm_remp函數來對addr內存地址進行從新映射,vm_remap函數中分別有兩個port參數分別用來指定目標進程和原進程,也就是說vm_remap函數能夠將任何兩個進程中的內存地址進行相互映射。這種內存映射的支持其實也能夠用來實現進程之間的通訊處理,固然在iOS系統中是沒法實現跨進程的內存映射的,所以目標進程和原進程必須具備相同的port。除了指定源進程和目標進程端口外,還須要指定目標地址和源地址,也就是vm_remap函數使得目標地址映射到源地址上,使得目標地址所指的內存和源地址保持一致。而上面的目標地址是addr,而源地址則是函數testfn的起始地址。通過映射操做後的結果是addr所指的內存和testfn所指的內容將保持一致,並且addr還會繼承源地址testfn的保護權限。由於testfn是編譯時的代碼,最終會存放在代碼段中並只具備可執行權限, 這樣最終的結果是addr也變成只具備可執行權限的內存區域了,並且它所指向的內容就是和函數testfn所指向的內容都同樣了,都是一段可執行的代碼。然後續的兩個函數調用的結果保持一致,也證實告終果是正確的。咱們能夠看出addr和testfn所指向的內容已經徹底一致了: 函數

addr地址被remap後的內存佈局

經過vm_remap函數咱們可以實現兩個不一樣的虛擬內存地址所指向的物理地址保持一致。oop

一個頗有意思的說法是,在面向對象系統中一個對象的惟一標識是對象所處的內存地址,包括一些系統中的基類的equal函數的實現每每是比較對象的地址是否相等。那若是在有vm_remap的處理下,這個結論將被打破,所以經過vm_remap咱們就能實現一個對象能夠經過多個不一樣的地址來進行訪問,這裏咱們也能夠思考一下是否能夠用這種技術來解決一些目前的一些問題呢?

vm_allocate能夠用來實現虛擬內存的分配,malloc也能夠用來實現堆內存的分配,這二者之間有什麼關係呢?前者實際上是更加底層的內存管理API,並且分配的內存的尺寸都是以頁的倍數做爲邊界的;然後者中的堆內存是高級內存管理API,一個進程的堆內存區域在實現中實際上是先經過vm_allocate分配出來一大片內存區域(包括棧內存也如此)。而後再在這塊大的內存區域上進行分割管理以及空閒複用等等高級操做來實現一些零碎和範圍內存分配操做。可是無論如何最終咱們均可以藉助這些函數來對分配出來的內存進行讀寫處理。

上面的addr對testfn的映射後addr 可以和testfn具備相同的能力,可是這種能力實際上是須要對testfn的函數體全部約束的,這個約束就是testfn中不能出現一些常量以及全局變量以及不能再出現函數調用,緣由是這些操做在編譯爲機器指令後訪問這些數據都是經過相對偏移來實現的,所以若是addr映射成功後由於函數實現的基地址有變化,若是經過addr進行訪問時,那麼指令中的相對偏移值將是一個錯誤的結果,從而形成函數調用時的崩潰發生。

靜態構造thunk程序

上一篇文章中實現了經過在內存中動態的構造機器指令來實現一段thunk代碼,可是這種機制在iOS系統中是沒法在發佈版證書打包的程序中運行的。仔細考察手動構造thunk代碼指令:

mov x2, x1
    mov x1, x0
    ldr x0, #0x0c
    ldr x3, #0x10
    br x3
  arg0:
    .quad 0
  realfn:
    .quad 0
複製代碼

就能夠看出,指令塊的重點是在第3條和第4條指令。這兩條指令經過讀取距離當前指令偏移0x0c和0x10處的數據來賦值給特定的寄存器,而咱們又能夠在內存構造時動態的調整和設置這部份內存的值,從而實現運行時的thunk的能力。如今將上述的代碼改動一下:

mov x2, x1
     mov x1, x0
     ldr x0, PAGE_MAX_SIZE - 8
     ldr x3, PAGE_MAX_SIZE - 4
     br x3
複製代碼

能夠看出第3條和第4條指令的偏移變爲了PAGE_MAX_SIZE也就是變爲一個虛擬內存頁尺寸的值,指令取數據的偏移位置被放大了。可問題是若是隻動態構造了很小一部份內存來存儲指令,並無多分配一頁內存來存儲數據,那這樣有什麼意義呢?

想象一下若是上面的那部分指令並非被動態構造,而是靜態編譯時就存在的代碼呢?這樣這部分代碼就不會由於簽名問題而沒法在iOS系統上運行。進一步來講,咱們能夠在運行時分配2頁虛擬內存,當分配完成後,將第1頁虛擬內存地址remap到上述那部分代碼所在的內存地址,而將第2頁分配的虛擬內存用來存放指令中所指定偏移的數據。根據上面對remap機制的描述能夠得出當進行remap後所分配的第1頁虛擬內存具有了可執行代碼的能力,而又由於代碼中第三、4條指令所取的數據是對應的第2頁虛擬內存的數據,這樣就能夠實如今不動態構造指令的狀況下來解決生成thunk程序的問題了。整個實現的原理以下:

靜態指令來實現thunk程序的流程

從上面的流程圖中能夠很清楚的瞭解到經過對虛擬內存進行remap就能夠不用動態構造指令來完成構建一個thunk程序塊的能力,下面咱們就結合第一篇文章中的快速排序,以及本文的remap機制來實現靜態構造thunk塊的能力

  1. 首先在你的工程裏面添加一個後綴爲.s的彙編代碼文件(new file -> assembly file)。本文件中的代碼只實現對arm64位系統的支持
//
//  thunktemplate.s
//  thunktest
//
//  Created by youngsoft on 2019/1/30.
//  Copyright © 2019年 youngsoft. All rights reserved.
//

#if __arm64__

#include <mach/vm_param.h>

/*
  指令在代碼段中,聲明外部符號_thunktemplate,而且指令地址按頁的大小對齊!
 */
.text
.private_extern _thunktemplate
.align PAGE_MAX_SHIFT
_thunktemplate:
mov x2, x1
mov x1, x0
ldr x0, PAGE_MAX_SIZE - 8
ldr x3, PAGE_MAX_SIZE - 4
br x3

#endif

複製代碼
  1. 而後咱們在另一個文件中實現排序的代碼:
extern void *thunktemplate;   //聲明使用thunk模板符號,注意不要帶下劃線

typedef struct
{
    int age;
    char *name;
}student_t;

//按年齡升序排列的函數
int  ageidxcomparfn(student_t students[], const int *idx1ptr, const int *idx2ptr)
{
    return students[*idx1ptr].age - students[*idx2ptr].age;
}

int main(int argc, const char *argv[])
{
    vm_address_t thunkaddr = 0;
    vm_size_t page_size = 0;
    host_page_size(mach_host_self(), &page_size);
    //分配2頁虛擬內存,
    kern_return_t ret = vm_allocate(mach_task_self(), &thunkaddr, page_size * 2, VM_FLAGS_ANYWHERE);
    if (ret == KERN_SUCCESS)
    {
        //第一頁用來重映射到thunktemplate地址處。
        vm_prot_t cur,max;
        ret = vm_remap(mach_task_self(), &thunkaddr, page_size, 0, VM_FLAGS_FIXED | VM_FLAGS_OVERWRITE, mach_task_self(), (vm_address_t)&thunktemplate, false, &cur, &max, VM_INHERIT_SHARE);
        if (ret == KERN_SUCCESS)
        {
            student_t students[5] = {{20,"Tom"},{15,"Jack"},{30,"Bob"},{10,"Lily"},{30,"Joe"}};
            int idxs[5] = {0,1,2,3,4};
            
            //第二頁的對應位置填充數據。
            void **p = (void**)(thunkaddr + page_size);
            p[0] = students;
            p[1] = ageidxcomparfn;
            
            //將thunkaddr做爲回調函數的地址。
            qsort(idxs, 5, sizeof(int), (int (*)(const void*, const void*))thunkaddr);
            for (int i = 0; i < 5; i++)
            {
                printf("student:[age:%d, name:%s]\n", students[idxs[i]].age, students[idxs[i]].name);
            }
        }
        
        vm_deallocate(mach_task_self(), thunkaddr, page_size * 2);
    }
    
   return 0;
}

複製代碼

能夠看出經過remap機制能夠創造性的解決了動態構造內存指令來實現thunk程序的缺陷問題,整個過程不須要咱們構造指令,而是借用現有已經存在的指令來構造thunk程序,並且這樣的代碼不存在簽名的問題,也能夠在iOS的任何簽名下被安全運行。固然這個技巧也是可使用在linux/unix系統之上的。

後記

本文中所介紹的技術和技巧參考自開源庫libffi中對閉包的支持以及iOS的runtime中經過一個block對象來獲得IMP函數指針的實現方法。


歡迎你們訪問歐陽大哥2013的github地址

相關文章
相關標籤/搜索