導讀
你想要在沒有源代碼的狀況下調試一個C/C++程序嗎?
你想要在不從新安裝php的狀況下調試php嗎?
你想要打印或者修改網絡通訊的數據包嗎?
你想要在異步或者多進程、多線程下跟蹤調試嗎?
你想要隨意修改程序的邏輯、數據,或異常測試,或破解?
…………
本文將經過testdbg的原理及實現來介紹如何作到這些,具體的使用說明能夠參考testdbg工具。
概要
testdbg是經過動態修改程序的內存映像來達到hook的目的,不會對程序的二進制文件有任何影響,因此程序裸跑的時候和原來行爲徹底一致。
採用的是一種彙編指令級別的hook手段,也能夠說是一種hack的方式,因此不只能夠實現API hook,還能夠操控、改變程序的任何邏輯(一切都在掌握之中)。
不須要root權限,不須要修改源代碼,不須要從新編譯源程序。
原理
testdbg主要包含3個模塊:
• 主程序testdbg,負責加載hook主模塊到目標程序內存空間,而後啓動目標程序
• 主模塊hookmon.so,負責加載用戶編寫的樁模塊,實現原代碼到樁的內存hook
• 樁模塊hook.so,用戶編寫的樁
內存分佈
爲了更好地說明它們和目標程序的關係,能夠先看下程序單獨運行以及用testdbg啓動運行時的內存鏡像:
php
(a)中是程序單獨啓動時的內存狀況,首先加載的是程序的.text和.data段,其次加載了依賴的系統共享庫。
(b)中用testdbg啓動程序,首先加載的也是程序的代碼段,接着優先加載了主模塊hookmon.so,在其初始化的過程當中加載了樁模塊hook.so,同時修改內存創建hook,這樣程序在運行的時候便會根據主模塊修改的邏輯執行,完成以後纔開始加載依賴的系統庫。
創建hook
主模塊hookmon.so由testdbg加載到目標程序內存後,會先在堆裏動態申請一段內存來保存被修改的原函數入口(簡稱爲跳板),而後修改原函數入口令其跳轉到指定的樁代碼中執行,樁代碼在處理完邏輯後再經過跳板來執行原函數,關係以下圖所示:
html
實現步驟
testdbg實現主要分爲如下幾個步驟:
1. 將樁代碼動態加載到程序的內存空間
2. 在程序啓動前獲取控制權以便修改內存
3. 分析ELF文件獲取所需的符號信息
4. 反彙編備份原指令並修改爲遠跳轉到樁代碼
5. 樁代碼經過跳板執行原指令
樁代碼動態加載到程序的內存空間
要將樁代碼插入到源程序,能夠考慮經過修改源代碼、編譯期插樁或者修改生成的可執行文件等實現,但這幾種方法都存在一些缺點:
• 直接修改源代碼插樁須要考慮版本升級後完整的將樁代碼移植
• 編譯期插樁須要從新編譯源程序
• 修改可執行文件難度大,並且破壞了原文件
將樁動態插入到程序的內存空間能夠實現樁的熱加載,使用起來更靈活~
ptrace嘗試
一開始考慮的是一種比較hack的方法,讓源程序以子進程的方式啓動,並設置PTRACE_TRACEME由父進程控制,父進程獲取控制權時在源程序入口處下軟中斷以獲取下一次的控制權,而後分析ELF獲取動態連接器的內部結構link_map,該結構是一個鏈表,保存着已加載的動態連接庫的信息,遍歷link_map查找_dl_open內部函數(或dlopen函數,但前提是源程序有連接libdl共享庫,不然沒法找到),接着修改RIP執行_dl_open來加載樁程序到源程序的內存空間,但這種方法在新版本glibc已經不可用了_dl_open會驗證調用者的返回地址是否在有效的內存地址以防止hack,以下檢查調用者是否在libc或者libdl的內存空間:
int
attribute_hidden
_dl_check_caller (const void *caller, enum allowmask mask)
{
static const char expected1[] = LIBC_SO;
static const char expected2[] = LIBDL_SO;
//……
for (Lmid_t ns = 0; ns < DL_NNS; ++ns)
for (struct link_map *l = GL(dl_ns)[ns]._ns_loaded; l != NULL;
l = l->l_next)
if (caller >= (const void *) l->l_map_start
&& caller < (const void *) l->l_text_end)
{
/* The address falls into this DSO's address range. Check the
name. */
if ((mask & allow_libc) && strcmp (expected1, l->l_name) == 0)
return 0;
if ((mask & allow_libdl) && strcmp (expected2, l->l_name) == 0)
return 0;
//……
}
return 1;
}
LD_REPLOAD
使用linux提供的LD_PRELOAD環境變量,它能夠在程序全部共享庫加載以前優先加載LD_PRELOAD設置的共享庫到程序的內存空間,先看個例子,一個簡單hello程序:
#include<stdio.h>
#include<unistd.h>node
int main()
{
printf("hello, world!\n");
pause();
return 0;
}
編譯後ldd看一下都依賴了哪些共享庫:
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ ldd hello
libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x0000003b8c100000)
libm.so.6 => /lib64/tls/libm.so.6 (0x0000003b8a500000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x0000003b8c900000)
libc.so.6 => /lib64/tls/libc.so.6 (0x0000003b8a200000)
/lib64/ld-linux-x86-64.so.2 (0x0000003b8a000000)
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ ./hello
hello, world!
設置LD_PRELOAD變量,ldd發現多了./preload.so,再運行./hello調用printf時優先查找到了preload.so的printf函數,打印了"hack!!"
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ cat preload.c
int printf ( const char * format, ... )
{
puts("hack !!\n");
return 0;
}
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ export LD_PRELOAD="./preload.so"
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ ldd hello
./preload.so (0x0000002a95557000)
libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x0000003b8c100000)
libm.so.6 => /lib64/tls/libm.so.6 (0x0000003b8a500000)
libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x0000003b8c900000)
libc.so.6 => /lib64/tls/libc.so.6 (0x0000003b8a200000)
/lib64/ld-linux-x86-64.so.2 (0x0000003b8a000000)
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ ./hello
hack !!
能夠經過strace看下系統調用:
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ strace ./hello
execve("./hello", ["./hello"], [/* 38 vars */]) = 0
uname({sys="Linux", node="db-testing-t61.db01.baidu.com", ...}) = 0
brk(0) = 0x501000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x2a95556000
open("./preload.so", O_RDONLY) = 3
……
open("/usr/lib64/libstdc++.so.6", O_RDONLY) = 3
……
open("/lib64/tls/libm.so.6", O_RDONLY) = 3
……
LD_REPLOAD指定的./preload.so最早被加載,看下此時hello的內存map:
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ pgrep hello
24934
[xuanbiao@db-testing-t61.db01.baidu.com preload]$ cat /proc/24934/maps
00400000-00401000 r-xp 00000000 08:03 42893328 /home/xuanbiao/studio/code/elf/preload/hello
00500000-00501000 rw-p 00000000 08:03 42893328 /home/xuanbiao/studio/code/elf/preload/hello
2a95556000-2a95557000 rw-p 2a95556000 00:00 0
2a95557000-2a95558000 r-xp 00000000 08:03 42882221 /home/xuanbiao/studio/code/elf/preload/preload.so
2a95558000-2a95657000 ---p 00001000 08:03 42882221 /home/xuanbiao/studio/code/elf/preload/preload.so
2a95657000-2a95658000 rw-p 00000000 08:03 42882221 /home/xuanbiao/studio/code/elf/preload/preload.so
能夠看到preload.so已經被加載到hello程序內存的0x2a95557000 - 0x2a95658000之間~
使用LD_REPLOAD有2個好處,一個好處是能夠很輕易將樁程序加載到源程序的內存空間,另外一個好處是當程序調用如malloc、printf、open、read、write等系統函數時會優先查找LD_PRELOAD設置的共享庫,因此你的共享庫若是存在和這些系統函數聲明一致的函數則會優先被調用進而達到API Hook的目的,對於模擬磁盤滿、內存分配失敗、網絡讀寫等異常經過這種方法就能夠實現,但對於非共享庫的函數(可執行文件及靜態庫的函數)則須要繼續往下看。
在程序啓動前獲取控制權
樁程序加載到源程序內存空間後須要在源程序啓動前獲取控制權,以便觸發一些初始化操做,不然源程序仍是會按原來的邏輯執行,除了系統函數的hook有效外沒有其它改變,但這不是咱們的目的。
ptrace再次嘗試
原覺得linux的so共享庫並無相似於windows的dll動態庫同樣存在DllMain函數來執行初始化,仍是考慮了上面提到ptrace方法,只要啓動主程序與樁程序約定哪一個函數做爲初始化函數就能夠了,但這種實現過於麻煩,並且容易引起兼容性問題,這裏不作過多的介紹。
共享庫的構建與析構函數
共享庫能夠經過__attribute__((constructor))和__attribute__((destructor))分別定義構建和析構函數,構建函數會在dlopen返回前執行,若是是在加載期加載則會在main函數調用前執行,析構函數在dlclose返回前執行,若是是在加載期加載的則會在exit()或者main函數完成後執行,函數原型以下:
void __attribute__ ((constructor)) my_init(void);
void __attribute__ ((destructor)) my_fini(void);
須要注意的是在gcc編譯共享庫時不能加入'-nostartfiles'和'-nostdlib'選項,不然將不會被執行。
分析ELF文件獲取所需的符號信息
testdbg裏實現了非共享庫函數的hook,在hook以前須要獲取指定函數的入口虛擬地址(即內存地址),能夠經過分析ELF文件(即程序的二進制文件,之前老的的格式這裏不作討論)的符號節.symtab來獲取,該節保存了全部有用的符號信息(包括引用的外部共享庫的符號),這裏只須要過濾獲取符合要求的函數符號,而後經過符號信息獲取指定函數的入口虛擬地址便可。
下面是demo1的代碼及用readelf查看到的符號信息:
#include <stdio.h>linux
int func(int val, char *str)
{
printf("val:%d, str:%s\n", val, str);
return 0;
}
int main()
{
func(10, "hello, world!");
func(20, "just a test!");
return 0;
}
用readelf -s demo1查看符號信息:
[xuanbiao@db-testing-t61.db01.baidu.com demo1]$ readelf -s demo1c++
Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
2: 0000000000000000 160 FUNC GLOBAL DEFAULT UND printf@GLIBC_2.2.5 (2)windows
Symbol table '.symtab' contains 72 entries:
Num: Value Size Type Bind Vis Ndx Name
50: 0000000000400558 44 FUNC GLOBAL DEFAULT 12 _Z4funciPcsass
53: 0000000000000000 160 FUNC GLOBAL DEFAULT UND printf@@GLIBC_2.2.5網絡
57: 0000000000400584 41 FUNC GLOBAL DEFAULT 12 main
能夠看到打印了2個symbol table,.dynsym動態符號節和.symtab符號節,.dynsym只包含引用的外部符號信息,.symtab則包含了全部的符號信息,在.symtab節中能夠看到func、printf和main函數的符號信息,因爲demo1是用g++編譯的,func的符號名變成了_Z4funciPc,這是因爲C++容許重載,因此採用了name mangling來爲每一個函數生成不一樣的符號名。
用c++filt能夠查看該符號名的原型:
[xuanbiao@db-testing-t61.db01.baidu.com demo1]$ c++filt _Z4funciPc
func(int, char*)
反彙編備份原指令並修改爲遠跳轉到樁代碼
非共享庫函數hook一個關鍵的地方就是修改原函數的入口來跳轉到hook函數來執行,但這樣就破壞了原函數,若是要在hook函數裏調用原函數則須要在修改以前將原函數備份,因爲原函數只是在入口處修改爲一個跳轉指令,因此能夠經過動態建立一個"跳板"函數,保存被修改以前的指令,而後再跳轉回剩下的指令執行,這就使得在hook函數裏調用跳板函數和調用原函數同樣了。
函數hook關係圖
原函數、hook函數及跳板函數的關係以下:
多線程
彙編指令備份拷貝
原函數入口的彙編指令備份拷貝須要注意2個地方,一個是指令需對齊,不能中間截斷,另外一個是拷貝到新內存後須要將原來的相對地址從新計算。
指令對齊須要先進行反彙編,可使用libopcodes裏的API實現,具體的細節能夠直接看testdbg裏的代碼實現。
包含相對地址的指令拷貝到新內存,若是該相對地址的實際跳轉地址不在一塊兒拷貝過去的內存中則會出錯,因此須要將這些相對地址都從新計算。根據絕對地址的計算公式:
絕對地址=當前指令所在地址+當前指令長度+相對地址
很容易計算出實際的絕對地址,再根據新內存的地址和新的指令長度便可計算出新的相對地址,須要注意的是小於4字節的相對地址從新計算後可能變成了4字節的相對地址,這就須要將原指令的操做碼都替換成對應的支持4字節的操做碼,保持語義的一致性。
修改內存實現遠跳轉
無條件JMP跳轉指令的直接尋址跳轉能力只有32位,而64位操做系統卻有64位的尋址能力,並且在以前LD_PRELOAD一節中看到的程序內存map,共享庫都被加載到了高位地址,超出了32位地址範圍,因此JMP跳轉只能經過間接尋址跳轉,好比jmp %rax、jmp %rbx、jmp imm(%rip)等,考慮到不破壞原寄存器的值,這裏用的是jmp 0(%rip),而後將要跳轉的64位目標地址寫入到緊挨該指令的內存地址中。
demo程序的func原函數反彙編:
(gdb) disass func
Dump of assembler code for function _Z4funciPc:
0x0000000000400558 <_Z4funciPc+0>: push %rbp
0x0000000000400559 <_Z4funciPc+1>: mov %rsp,%rbp
0x000000000040055c <_Z4funciPc+4>: sub $0x10,%rsp
0x0000000000400560 <_Z4funciPc+8>: mov %edi,0xfffffffffffffffc(%rbp)
0x0000000000400563 <_Z4funciPc+11>: mov %rsi,0xfffffffffffffff0(%rbp)
0x0000000000400567 <_Z4funciPc+15>: mov 0xfffffffffffffff0(%rbp),%rdx
0x000000000040056b <_Z4funciPc+19>: mov 0xfffffffffffffffc(%rbp),%esi
0x000000000040056e <_Z4funciPc+22>: mov $0x40069c,%edi
0x0000000000400573 <_Z4funciPc+27>: mov $0x0,%eax
0x0000000000400578 <_Z4funciPc+32>: callq 0x400480
0x000000000040057d <_Z4funciPc+37>: mov $0x0,%eax
0x0000000000400582 <_Z4funciPc+42>: leaveq
0x0000000000400583 <_Z4funciPc+43>: retq
End of assembler dump.
被修改後的func函數反彙編:
(gdb) disass func
Dump of assembler code for function _Z4funciPc:
0x00000000004005a8 <_Z4funciPc+0>: jmpq *0(%rip) # 0x4005ae <_Z4funciPc+6>
0x00000000004005ae <_Z4funciPc+6>: movl %?,(%rax)
0x00000000004005b0 <_Z4funciPc+8>: (bad)
0x00000000004005b1 <_Z4funciPc+9>: xchg %eax,%ebp
0x00000000004005b2 <_Z4funciPc+10>: sub (%rax),%al
0x00000000004005b4 <_Z4funciPc+12>: add %al,(%rax)
0x00000000004005b6 <_Z4funciPc+14>: lock mov 0xfffffffffffffff0(%rbp),%rdx
0x00000000004005bb <_Z4funciPc+19>: mov 0xfffffffffffffffc(%rbp),%esi
0x00000000004005be <_Z4funciPc+22>: mov $0x4006fc,%edi
0x00000000004005c3 <_Z4funciPc+27>: mov $0x0,%eax
0x00000000004005c8 <_Z4funciPc+32>: callq 0x4004b8
0x00000000004005cd <_Z4funciPc+37>: mov $0x0,%eax
0x00000000004005d2 <_Z4funciPc+42>: leaveq
0x00000000004005d3 <_Z4funciPc+43>: retq
End of assembler dump.
(gdb) x /xg 0x00000000004005ae
0x4005ae <_Z4funciPc+6>: 0x0000002a9582388c
func函數入口已經被修改成了jmpq *0(%rip),因爲緊挨着該指令的內存爲目標跳轉地址,是數據不是指令,因此後面的指令反彙編後大部分是錯的,0x00000000004005ae保存的0x0000002a9582388c即爲hook函數的虛擬地址,再看下跳板函數的反彙編:
(gdb) p /x old_func
$2 = 0x502720
(gdb) disass 0x502720 0x502738
Dump of assembler code from 0x502720 to 0x502738:
0x0000000000502720: push %rbp
0x0000000000502721: mov %rsp,%rbp
0x0000000000502724: sub $0x10,%rsp
0x0000000000502728: mov %edi,0xfffffffffffffffc(%rbp)
0x000000000050272b: mov %rsi,0xfffffffffffffff0(%rbp)
0x000000000050272f: jmpq *0(%rip) # 0x502735
0x0000000000502735: mov $0x5,%bh
0x0000000000502737: add %al,(%rax)
End of assembler dump.
(gdb) x /xg 0x0000000000502735
0x502735: 0x00000000004005b7
func原函數入口的指令備份到了這裏,而後緊接着又是一條jmp遠跳轉指令到0x00000000004005b7執行func剩下的部分。
樁代碼經過跳板執行原指令
跳板的原理前面也提到了,等價於備份的原指令+跳轉到剩餘指令,其做用和修改前的原指令是同樣的,只是繞了個彎,因此hook函數裏要調用原函數則能夠直接調用跳板就能夠了。
後記
上面介紹的是隻是針對非動態庫函數的hook,根據這個原理能夠衍生出更多的應用,好比:
• 在任意地址注入一段代碼。如testdbg函數調用跟蹤的實現,只是比函數hook多了一步備份和恢復寄存器。
• 反彙編修改原程序的邏輯。好比je改爲jne等,cracker經常使用的破解手段,呵呵。
• ……
反饋建議異步
(做者:xuanbiao)