hook的幾種方式及原理學習

G1733發表於2024-06-01

原文

概述

對於大型的工程專案,依賴許多人的配合,包含大量不同的程式碼庫與服務,有的我們能夠訪問程式的原始碼,有的可以訪問程式的可重定位檔案,有的可以訪問到可執行檔案及其環境,假如我們想在在不同的層面改變或者新增一些邏輯,作業系統、編譯器以及程式語言、程式碼庫等都提供了 一些機制使得 開發者可以 方便的 增加或替換程式碼邏輯,對於邏輯除錯、測試、效能分析、版本相容等都有比較好的效果。

編譯器支援

Function Attribute

GNU C 使用attribute 可以設定函式屬性(Function Attribute )、變數屬性(Variable Attribute )和型別屬性(Type Attribute )。 attribute前後都有兩個下劃線,並且後面會緊跟一對原括弧,括弧裡面是相應的attribute引數。

attribute語法格式為:attribute ( ( attribute-list ) )

比如常用的constructor屬性,則會使函式在main()函式執行之前被自動的執行。

#include <stdio.h>
#include <stdlib.h>
static int * g_count = NULL;
__attribute__((constructor)) void load_file()
{
    printf("Constructor is called.\n");
    g_count = (int *)malloc(sizeof(int));
    if (g_count == NULL)
    {
    fprintf(stderr, "Failed to malloc memory.\n");
    }
}
__attribute__((destructor)) void unload_file()
{
    printf("destructor is called.\n");
    if (g_count)
    free(g_count);
}
int main()
{
    return 0;
}

參考: https://gcc.gnu.org/onlinedocs/gcc-4.7.0/gcc/Function-Attributes.html

庫打樁機制

linux 連結器支援一個很強大的技術,稱為庫打樁機制,它允許你截獲對共享庫函式的呼叫,取而代之執行自己的程式碼。使用打樁機制,你可以追蹤對某個特殊庫函式的呼叫次數、驗證和追蹤它的輸入和輸出,甚至可以把它替換成一個完全不同的實現。

編譯時
/main.c/
#include <stdio.h>
#include <malloc.h>

int main()
{
    int*p = malloc(32);

    free(p);

    return 0;

}
/*  malloc.h  */
#define malloc(size) my_malloc(size)
#define free(ptr)    my_free(ptr)


void * my_malloc(size_t size);
void * my_free(void* ptr);
/*  my_malloc.c  */
#ifdef COMPILE_TIME

#include <stdio.h>
#include <malloc.h>

void *my_malloc(size_t size)
{
    printf("enter my_malloc \n");
    void * ptr = malloc(size);
    printf("malloc(%d) = %p \n", (int ) size , ptr);

    return ptr;
}


void my_free( void* ptr )
{
    printf("enter my_free \n");
    free(ptr);
    printf("free (%p) \n" , ptr);
}

#endif
gcc -DCOMPILE_TIME -c my_malloc.c 
gcc -I. -o a.out main.c my_malloc.o

由於有 I. 引數,所以會進行打樁,它告訴C前處理器,在搜尋通常的系統目錄之前,現在當前的目錄查詢malloc.h

連結時

linux的靜態連結器支援使用 –wrap f標誌進行連線時打樁,這個標誌告訴連結器,把對符號 f 的引用 解析成 wrap_f 還要把對符號 real_f的 引用解析成 f 。

/*  link.c  */
#ifdef LINK_TIME

#include <stdio.h>

void * __real_malloc(size_t size);
void * __real_free(void * ptr);

void * __wrap_malloc(size_t size)
{
    printf("enter __wrap_malloc\n");
    void * ptr = __real_malloc(size);
    printf("malloc(%d) = %p \n", (int ) size , ptr);
    return ptr;
}


void __wrap_free(void *ptr)
{
    printf("enter __wrap_free\n");
    __real_free(ptr);
    printf("free (%p) \n" , ptr);
}

#endif
gcc -DLINK_TIME -c link.c
gcc -c main.c 
gcc -Wl,--wrap,malloc -Wl,--wrap,free -o a.out main.o  link.o
執行時

編譯時打樁需要能夠訪問程式的原始碼,連結時打樁需要能夠訪問程式的可重定位檔案。不過,有一種機制能夠在執行時打樁,只需能夠訪問可執行目標檔案。 執行時打樁基於動態連結器的 LD_PRELOAD 環境變數。

如果 LD_PRELOAD 環境變數 被設定成為 共享庫路徑名的列表,當執行和載入程式的時候,當需要解析未定義的引用時,動態連結器會先搜尋 LD_PRELOAD 庫,然後才搜尋其他的庫。

/*random.c   */

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main(){
  srand(time(NULL));
  int i = 10;
  while(i--) printf("%d\n",rand()%100);
  return 0;
}

/*unrandom.c */
int rand(){
    return 42; //the most random number in the universe
}
gcc -shared -fPIC unrandom.c -o unrandom.so
export LD_PRELOAD=your_path

動態庫載入特性 - got 替換

ELF檔案格式

ELF格式通常有linking view和execution view,即編譯時和執行時,一般連結時統稱 section , 執行時稱segment,segment是執行時把許可權相同的section合併了載入到記憶體,從檢視上看,兩個檢視資料是一樣的,只不過有兩種形態。

             +-----------------+
        +----| ELF File Header |----+
        |    +-----------------+    |
        v                           v
+-----------------+      +-----------------+
| Program Headers |      | Section Headers |
+-----------------+      +-----------------+
     ||                               ||
     ||                               ||
     ||                               ||
     ||   +------------------------+  ||
     +--> | Contents (Byte Stream) |<--+
          +------------------------+
          
       +-------------------------------+
       | ELF File Header               |
       +-------------------------------+
       | Program Header for segment #1 |
       +-------------------------------+
       | Program Header for segment #2 |
       +-------------------------------+
       | ...                           |
       +-------------------------------+
       | Contents (Byte Stream)        |
       | ...                           |
       +-------------------------------+
       | Section Header for section #1 |
       +-------------------------------+
       | Section Header for section #2 |
       +-------------------------------+
       | ...                           |
       +-------------------------------+
       | ".shstrtab" section           |
       +-------------------------------+
       | ".symtab"   section           |
       +-------------------------------+
       | ".strtab"   section           |
       +-------------------------------+

ELF檔案是連線編譯連結與執行的資料存在,其中裡面的 .text (程式碼段)、 .rodata(只讀資料段) 、 .data (資料段)、 .symtab(符號表) 我們都耳熟能詳,我們這裡關心的是 下面幾個段:

# 測試的 test 原始碼在下面
[root@VM_8_16_centos ~/hook]# readelf -S test | grep -E "plt|got|dyn" --color
  [ 5] .dynsym           DYNSYM           00000000004002b8  000002b8            // 動態符號表
  [ 6] .dynstr           STRTAB           0000000000400498  00000498            // 動態字串
  [ 9] .rela.dyn         RELA             0000000000400598  00000598            // 資料有關的重定位表
  [10] .rela.plt         RELA             00000000004005b0  000005b0            // 函式有關的重定位表
  [12] .plt              PROGBITS         00000000004007a0  000007a0            // plt段
  [21] .dynamic          DYNAMIC          0000000000601e28  00001e28            // 動態資訊表
  [22] .got              PROGBITS         0000000000601ff8  00001ff8            // 資料的全域性偏移表
  [23] .got.plt          PROGBITS         0000000000602000  00002000            // 函式的全域性偏移表

先直觀的看一下有這幾個表,他們基本都與重定位有關,需要看下重定位的概念。

image
image
重定位與動態連結

當多個 .o 檔案連結或 執行時需要動態庫的時候,都有重定位的概念,在連結的時候,多個.o之間 相互依賴的變數和函式 要找到實際的地址, 同樣執行時依賴動態庫中的函式,一般是記錄在全域性偏移表中,執行之前或執行時 找到實際地址,記錄到偏移表,執行的時候透過 全域性偏移表找到實際地址,從而執行。

重定位表:

重定位表是”.rel.dyn”和”.rel.plt”,它們分別相當於靜態連結中的”.rel.data”和”.rel.text”。”.rel.dyn”實際上是對資料引用的修正,它所修正的位置相當於”.got “以及資料段;而”.rel.plt”則是對函式引用的修正,所修正的位置位於”.got.plt”。使用”readelf -r”命令,檢視重定位表

[root@VM_8_16_centos ~/hook]# readelf -r test

Relocation section '.rela.dyn' at offset 0x598 contains 1 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000601ff8  000c00000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0

Relocation section '.rela.plt' at offset 0x5b0 contains 19 entries:
  Offset          Info           Type           Sym. Value    Sym. Name + Addend
000000602018  000100000007 R_X86_64_JUMP_SLO 0000000000000000 puts + 0
000000602020  000200000007 R_X86_64_JUMP_SLO 0000000000000000 readlink + 0
000000602028  000300000007 R_X86_64_JUMP_SLO 0000000000000000 getpid + 0
000000602030  000400000007 R_X86_64_JUMP_SLO 0000000000000000 fclose + 0
000000602038  000500000007 R_X86_64_JUMP_SLO 0000000000000000 printf + 0
000000602040  000600000007 R_X86_64_JUMP_SLO 0000000000000000 snprintf + 0
000000602048  000700000007 R_X86_64_JUMP_SLO 0000000000000000 __assert_fail + 0
000000602050  000800000007 R_X86_64_JUMP_SLO 0000000000000000 __libc_start_main + 0
000000602058  000900000007 R_X86_64_JUMP_SLO 0000000000000000 memcmp + 0
000000602060  000a00000007 R_X86_64_JUMP_SLO 0000000000000000 fgets + 0
000000602068  000b00000007 R_X86_64_JUMP_SLO 0000000000000000 strcmp + 0
000000602070  000c00000007 R_X86_64_JUMP_SLO 0000000000000000 __gmon_start__ + 0
000000602078  000d00000007 R_X86_64_JUMP_SLO 0000000000000000 mprotect + 0
000000602080  000e00000007 R_X86_64_JUMP_SLO 0000000000000000 fopen + 0
000000602088  000f00000007 R_X86_64_JUMP_SLO 0000000000000000 strtok + 0
000000602090  001000000007 R_X86_64_JUMP_SLO 0000000000000000 strtoul + 0
000000602098  001100000007 R_X86_64_JUMP_SLO 0000000000000000 getpagesize + 0
0000006020a0  001200000007 R_X86_64_JUMP_SLO 0000000000000000 strstr + 0
0000006020a8  001300000007 R_X86_64_JUMP_SLO 0000000000000000 rand + 0
GOT 及 PLT 表

在Linux下,GOT被拆分成”.got”和”.got.plt”2個表。其中”.got”用來儲存全域性變數引用的地址,”.got.plt”用來儲存函式引用的地址 GOT表項還保留了3個公共表項,也即got的前3項,分別儲存:

got[0]: 本ELF動態段(.dynamic段)的裝載地址 got[1]:本ELF的link_map資料結構描述符地址 got[2]:_dl_runtime_resolve函式的地址

作業系統設計了一段比較精巧的指令來實現延遲重定位,歷史的版本應該是,程序執行的時候,如果依賴動態庫,那麼執行之前,需要把 程式依賴的動態庫裡面的每個變數和函式都 初始化 GOT表,這樣的後果就是 如果依賴比較多,載入緩慢; 後來透過 PLT 設計了 延遲載入的功能, 主要思想是 第一次執行的時候,透過一段跳轉指令, 轉去動態連結器中的_dl_runtime_resolve 函式查詢,查詢後寫入GOT, 第二次的時候便可以直接訪問 GOT,直接地址訪問。 下面有使用 gdb 動態除錯的過程, 實際過程中,可能直接 disas _dl_runtime_resolve 發現沒有效果或者找不到函式, 按照記憶體查 可知 最新的函式名字上有所改變。

檢視plt的內容,實際就是程式碼:

objdump -d test
image
image

gdb除錯 plt懶載入過程:

image
image
image
image
重定位型別及偏移表

我們如何計算GOT表應該偏移多少呢,又有哪些偏移的型別呢? 參考: http://www.ucw.cz/~hubicka/papers/abi/node19.html

image
image

比如rand , 我們直接用 rel表的地址

GOT表項替換

全域性符號表(GOT表)hook實際是透過解析SO檔案,將待hook函式在got表的地址替換為自己函式的入口地址,這樣目標程序每次呼叫待hook函式時,實際上是執行了我們自己的函式。

匯入表的hook有兩種方法:

  • 方法一:

  透過解析elf格式,分析Section header table找出靜態的.got表的位置,並在記憶體中找到相應的.got表位置,這個時候記憶體中.got表儲存著匯入函式的地址,讀取目標函式地址,與.got表每一項函式入口地址進行匹配,找到的話就直接替換新的函式地址,這樣就完成了一次匯入表的Hook操作了。    

  • 方法二

  透過分析program header table查詢got表。匯入表對應在動態連結段.got.plt(DT_PLTGOT)指向處,但是每項的資訊是和GOT表中的表項對應的,因此,在解析動態連結段時,需要解析DT_JMPREL、DT_SYMTAB,前者指向了每一個匯入表表項的偏移地址和相關資訊,包括在GOT表中偏移,後者為GOT表。   

方法二的測試:

詳細程式碼: https://github.com/changan29/playcpp/tree/master/hook/got_hook

核心除錯介面

ptrace 系統呼叫

有很多大家所常用的工具都基於ptrace來實現,如strace和gdb。

ptrace系統調從名字上看是用於程序跟蹤的,它提供了父程序可以觀察和控制其子程序執行的能力,並允許父程序檢查和替換子程序的核心映象(包括暫存器)的值。其基本原理是: 當使用了ptrace跟蹤後,所有傳送給被跟蹤的子程序的訊號(除了SIGKILL),都會被轉發給父程序,而子程序則會被阻塞,這時子程序的狀態就會被系統標註為TASK_TRACED。而父程序收到訊號後,就可以對停止下來的子程序進行檢查和修改,然後讓子程序繼續執行。

使用ptrace 可以動態除錯程序,可以做到自定義gdb的某些功能,參考: https://www.cnblogs.com/tangr206/articles/3094358.html

跳轉程式碼修改

inline hook

詳細程式碼: https://github.com/changan29/playcpp/tree/master/hook/inline-hook

inline-hook大致的原理:一般是在具體的程式碼函式頭加一段跳轉指令,跳轉的地址在runtime的時候指定,然後執行該方法的時候,就跳到指定的函式,執行hook。

image
image

更直觀的理解是 比如找個函式,從直觀的位元組上理解:

image
image

一般,我們使用jmp跳轉來實現inline hook, 獲取程式碼地址- 修改 函式內容 - 實現自定義跳轉

void hooker::HookerX64::doHook(void *func,void *newAddr,void **origFunc) const {
  
      char *f = (char *)func;
      if (origFunc) {
          // find the return instruction retq: c3
          int index = 0;
          while (true) {
              if (static_cast<uint8_t>(f[index++]) == 0xc3 || index >= 1024) {
                  break;
              }
          }
  
          void *old = malloc(index);
         printf("HookerX64::doHookbackup : %p \n", old);
         if (old != nullptr)
         {
              memcpy(old, func, index);
              changeCodeAttrs(old, CODE_READ_ONLY);
              *origFunc = old;
          }
      }
  
      /*
      x64位下使用的跳轉是
  
      jmp 或者 call模式。
      jmp共使用14個位元組,0xFF2500000000為6個位元組,目標地址為00000000`00000000為8位元組。
  
      call模式
      0xff1500000002為6個位元組,目標地址為00000000`00000000為8位元組。
      */
  
  
      *(uint16_t *)&f[0] = 0x25ff;
      *(int *)&f[2] = 0x00000000;
      *(long *)&f[6] = (long)newAddr;
 
      printf("HookerX64::doHook newAddr: %p\n " ,  newAddr);
  }
inline-hook的注意點

具體程式碼見附件。 執行完inline hook , 儲存了原函式,只不過,此時的原函式內容 被複製到了其他的地方,再次呼叫原函式的時候,有的時候會core

image
image

為什麼在開頭呼叫一個函式就會core呢? gdb 列印複製後的函式與原函式 對比,發現 調整後的指令如果有 %rip relative的地址(offset),那麼這個地址是需要對應調整的。

image
image

其他

  • 基於虛擬機器提供能力、Java語言特性、訊息hook(特別作業系統支援)等機制,Windows及Android上常用的hook機制。
  • Android的 xposed 、 jni hook等

這一部分最近沒有使用,後面有用到再實踐。

引用

  • http://www.ucw.cz/~hubicka/papers/abi/node19.html
  • http://www.wireghost.cn/2015/04/01/ELF%E6%96%87%E4%BB%B6%E7%BB%93%E6%9E%84%E8%AF%A6%E8%A7%A3/
  • https://www.cnblogs.com/goodhacker/p/9306997.html
  • https://stevens.netmeister.org/631/elf.html
  • https://github.com/liuyx/inline-hook
  • https://github.com/zhougy0717/inject_got
  • 《程式設計師的自我修養》
  • 《CSAPP 第3、7章》

相關文章