Linux Hook 筆記
來自: http://www.cnblogs.com/pannengzhi/p/5203467.html
相信很多人對"Hook"都不會陌生,其中文翻譯為"鉤子".在編程中,
鉤子表示一個可以允許編程者插入自定義程序的地方,通常是打包好的程序中提供的接口.
比如,我們想要提供一段代碼來分析程序中某段邏輯路徑被執行的頻率,或者想要在其中
插入更多功能時就會用到鉤子. 鉤子都是以固定的目的提供給用戶的,并且一般都有文檔說明.
通過Hook,我們可以暫停系統調用,或者通過改變系統調用的參數來改變正常的輸出結果,
甚至可以中止一個當前運行中的進程并且將控制權轉移到自己手上.
基本概念
操作系統通過一系列稱為系統調用的方法來提供各種服務.他們提供了標準的API來訪問下面的
硬件設備和底層服務,比如文件系統. 以32位系統為例,當進程運行系統調用前,會先把系統調用號放到寄存器
%eax 中,并且將該系統調用的參數依次放入寄存器 %ebx, %ecx, %edx 以及 %esi 和 %edi 中.
以write系統調用為例:
write(2, "Hello", 5);
在32位系統中會轉換成:
movl $1, %eax movl $2, %ebx movl $hello,%ecx movl $5, %edx int $0x80
其中 1 為write的系統調用號, 所有的系統調用號碼定義在 unistd.h 文件中. $hello表示字符串
"Hello"的地址; 32位Linux系統通過0x80中斷來進行系統調用.
如果是64位系統則有所不同, 用戶層應用層用整數寄存器 %rdi, %rsi, %rdx, %rcx, %r8 以及 %r9 來傳參,
而 內核接口 用 %rdi, %rsi, %rdx, %r10, %r8 以及 %r10 來傳參. 并且用 syscall 指令而不是80中斷
來進行系統調用. 相同之處是都用寄存器 %rax 來保存調用號和返回值.
更多關于32位和64位匯編指令的區別可以參考 stack overflow的總結 ,
因為我當前環境是64位Linux,所以下文的操作都以64位系統為例.
進程追蹤
上面說到鉤子一般由程序提供,那么操作系統內核作為一個程序,是否有提供相應的鉤子呢?
答案是肯定的, ptrace (Process Trace)系統調用就提供了這樣的功能. ptrace提供了許多
方法來觀察和控制其他進程的執行, 并且可以檢查和修改其內核鏡像和寄存器. 通常用來
作為調試器(如gdb)或用來跟蹤各種其他系統調用.
那么,ptrace在程序運行的哪個階段起作用呢? 答案是在執行系統調用之前. 內核會先檢查是否
進程正在被追蹤, 如果是的話, 內核會停止進程并將控制權轉移給追蹤進程, 因此其可以查看和
修改被追蹤進程的寄存器. 舉例說明:
#include <stdio.h> #include <unistd.h> #include <sys/ptrace.h> #include <sys/types.h> #include <sys/wait.h> #include <sys/reg.h> /* For constants ORIG_RAX etc */ int main() { pid_t child; long orig_rax; child = fork(); if(child == 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl("/bin/ls", "ls", NULL); } else { wait(NULL); orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL); printf("The child made a " "system call %ld\n", orig_rax); ptrace(PTRACE_CONT, child, NULL, NULL); } return 0; }
程序編譯運行后輸出:
The child made a system call 59
以及 ls 的結果. 系統調用號59是 __NR_execve , 由子進程調用的 execl 產生.
在上面的例子中我們可以看見, 父進程fork了一個子進程,并且在子進程中進行系統調用.
在執行調用前,子進程運行了ptrace,并設置第一個參數為 PTRACE_TRACEME , 這告訴內核
當前進程正在被追蹤. 因此當子進程運行到execl時, 會把控制權轉回父進程. 父進程用wait
函數(系統調用)來等待內核通知. 然后就可以查看系統調用的參數以及做其他事情.
當系統調用出現的時候, 內核會保存原始的rax寄存器值(其中包含系統調用號), 我們可以
從子進程的 USER 段讀取這個值, 這里是使用ptrace并且設置第一個參數為 PTRACE_PEEKUSER .
當我們檢查完了系統調用之后, 可以調用ptrace并設置參數 PTRACE_CONT 讓子進程繼續運行.
值得一提的是, 這里的child為子進程的進程ID, 由fork函數返回.
寄存器讀寫
ptrace函數通過四個參數來調用, 其原型為:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
其中第一個參數決定了ptrace的行為以及其他參數的含義, request的值可以是下列值中的一個:
PTRACE_TRACEME, PTRACE_PEEKTEXT, PTRACE_PEEKDATA, PTRACE_PEEKUSER, PTRACE_POKETEXT, PTRACE_POKEDATA, PTRACE_POKEUSER, PTRACE_GETREGS, PTRACE_GETFPREGS, PTRACE_SETREGS, PTRACE_SETFPREGS, PTRACE_CONT, PTRACE_SYSCALL, PTRACE_SINGLESTEP, PTRACE_DETACH.
在系統調用追蹤中, 常見的流程如下圖所示:
讀取系統調用參數
系統調用的參數按順序存放在rbx,rcx...之中,因此以write系統調用為例看如何讀取寄存器的值:
#include <sys/ptrace.h> #include <sys/wait.h> #include <sys/reg.h> /* For constants ORIG_EAX etc */ #include <sys/user.h> #include <sys/syscall.h> /* SYS_write */ int main() { pid_t child; long orig_rax; int status; int iscalling = 0; struct user_regs_struct regs; child = fork(); if(child == 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl("/bin/ls", "ls", "-l", "-h", NULL); } else { while(1) { wait(&status); if(WIFEXITED(status)) break; orig_rax = ptrace(PTRACE_PEEKUSER, child, 8 * ORIG_RAX, NULL); if(orig_rax == SYS_write) { ptrace(PTRACE_GETREGS, child, NULL, ®s); if(!iscalling) { iscalling = 1; printf("SYS_write call with %lld, %lld, %lld\n", regs.rdi, regs.rsi, regs.rdx); } else { printf("SYS_write call return %lld\n", regs.rax); iscalling = 0; } } ptrace(PTRACE_SYSCALL, child, NULL, NULL); } } return 0; }
編譯運新有如下輸出:
SYS_write call with 1, 140693012086784, 10 total 32K SYS_write call return 10 SYS_write call with 1, 140693012086784, 45 -rwxr-xr-x 1 lxy lxy 13K Feb 21 12:19 a.out SYS_write call return 45 SYS_write call with 1, 140693012086784, 46 -rw-r--r-- 1 lxy lxy 1.5K Feb 20 20:52 test.c SYS_write call return 46 SYS_write call with 1, 140693012086784, 53 -rw-r--r-- 1 lxy lxy 5.0K Feb 21 12:19 trace_write.c SYS_write call return 53
可以看到我們的 ls -l -h 命令中, 發生了四次write系統調用.這里讀取寄存器的時候可以用之前
的 PTRACE_PEEKUSER 參數,也可以直接用 PTRACE_PEEKUSER 參數將寄存器的值讀取到結構體 user_regs_struct ,
該結構體定義在 sys/user.h 中.
程序中WIFEXITED函數(宏)用來檢查子進程是被ptrace暫停的還是準備退出, 可以通過 wait(2) 的man page
查看詳細的內容. 其中還有個值得一提的參數是 PTRACE_SYSCALL ,其作用是使內核在子進程進入和
退出系統調用時都將其暫停, 等價于調用 PTRACE_CONT 并且在下一個 entry/exit 系統調用前暫停.
修改系統調用參數
假設我們現在要修改write系統調用的參數從而修改打印的內容,根據文檔可知,其第二個參數為write字符串的地址,第三個參數為字符串的字節數,因此我們可以用:
val = ptrace(PTRACE_PEEKDATA, child, addr, NULL);
來得到字符串的內容. 值得一提的是, 由于ptrace的返回值是long型的,因此一次最多只能讀取sizeof(long)個字節 的數據,可以多次讀取 addr + i*sizeof(long) 然后合并得到最終的字符串內容. 在64bit系統下一次可以讀取64/8=8字節的數據.
修改字符串后,可以用:
ptrace(PTRACE_POKEDATA, child, addr, data);
來更新系統調用參數. 同樣一次只能更新8字節,因此需要分多次將結果放到long型的data里,再按順序更新到 addr + i*sizeof(long) 中.
一個讀取參數字符串值的例子如下:
#define long_size sizeof(long); void getdata(pid_t child, long addr, char *str, int len) { char *laddr; int i, j; union u { long val; char chars[long_size]; }data; i = 0; j = len / long_size; laddr = str; while(i < j) { data.val = ptrace(PTRACE_PEEKDATA, child, addr + i * 8, NULL); if(data.val == -1) if(errno) { printf("READ error: %s\n", strerror(errno)); } memcpy(laddr, data.chars, long_size); ++i; laddr += long_size; } j = len % long_size; if(j != 0) { data.val = ptrace(PTRACE_PEEKDATA, child, addr + i * 8, NULL); memcpy(laddr, data.chars, j); } str[len] = '\0'; }
值得一提的是union類型可以用來很方便地往64bit寄存器(long型)讀寫和轉換其他類型(如char)格式的數據.
追蹤其他程序的進程
上面舉的例子都是追蹤并修改聲明了 PTRACE_TRACEME 的子進程的,那么我們能否追蹤其他獨立的正在運行的進程呢?
使用 PTRACE_ATTACH 參數就可以追蹤正在運行的程序:
ptrace(PTRACE_ATTACH, pid, NULL, NULL)
其中pid位想要追蹤的進程的進程id. 當前進程會給被追蹤進程發送 SIGSTOP 信號,但不要求立即停止,
一般會等待子進程完成當前調用. ATTACH之后就和操作fork出來的TRACEME子進程一樣操作就好了.
如果要結束追蹤,則再調用 PTRACE_DETACH 即可.
動態注入指令
用過gdb等調試器的人都知道,debugger工具可以給程序打斷點和單步運行等. 這些功能其實也能用ptrace實現,
其原理就是ATTACH并追蹤正在運行的進程, 讀取其指令寄存器IR(32bit系統為%eip, 64位系統為%rip)的內容,
備份后替換成目標指令,再使其返回運行;此時被追蹤進程就會執行我們替換的指令. 運行完注入的指令之后,
我們再恢復原進程的IR,從而達到改變原程序運行邏輯的目的. talk is cheap, 先寫個循環打印的程序:
//victim.c int main() { while(1) { printf("Hello, ptrace! [pid:%d]\n", getpid()); sleep(2); } return 0; }
程序運行后會每隔2秒會打印到終端.然后再另外編寫一個程序:
//attach.c int main(int argc, char *argv[]) { if(argc!=2) { printf("Usage: %s pid\n", argv[0]); return 1; } pid_t victim = atoi(argv[1]); struct user_regs_struct regs; /* int 0x80, int3 */ unsigned char code[] = {0xcd,0x80,0xcc,0x00,0,0,0,0}; char backup[8]; ptrace(PTRACE_ATTACH, victim, NULL, NULL); long inst; wait(NULL); ptrace(PTRACE_GETREGS, victim, NULL, ®s); inst = ptrace(PTRACE_PEEKTEXT, victim, regs.rip, NULL); printf("Victim: EIP:0x%llx INST: 0x%lx\n", regs.rip, inst); /* Copy instructions into a backup variable */ getdata(victim, regs.rip, backup, 7); /* Put the breakpoint */ putdata(victim, regs.rip, code, 7); /* Let the process continue and execute the int 3 instruction */ ptrace(PTRACE_CONT, victim, NULL, NULL); wait(NULL); printf("Press Enter to continue ptraced process.\n"); getchar(); putdata(victim, regs.rip, backup, 7); ptrace(PTRACE_SETREGS, victim, NULL, ®s); ptrace(PTRACE_CONT, victim, NULL, NULL); ptrace(PTRACE_DETACH, victim, NULL, NULL); return 0; }
運行后會將一直循環輸出的進程暫停, 再按回車使得進程恢復循環輸出. 其中putdata和getdata在上文中已經介紹過了.
我們用之前替換寄存器內容的方法,將%rip的內容修改為 int 3 的機器碼, 使得對應進程暫停執行;
恢復寄存器狀態時使用的是 PTRACE_SETREGS 參數. 值得一提的是對于不同的處理器架構, 其使用的寄存器名稱
也不盡相同, 在不同的機器上允許時代碼也要作相應的修改.
這里注入的代碼長度只有8個字節, 而且是用shellcode的格式注入, 但實際中我們可以在目標進程中動態加載庫文件(.so),
包括標準庫文件(如libc.so)和我們自己編譯的庫文件, 從而可以通過傳遞函數地址和參數來進行復雜的注入,限于篇幅暫不細說.
不過需要注意的是動態鏈接庫掛載的地址是動態確定的, 可以在 /proc/$pid/maps 文件中查看, 其中$pid為進程id.
參考資料
博客地址:
歡迎交流,文章轉載請注明出處.