Linux Hook 筆記

有價值炮灰發表於2016-02-21

相信很多人對"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.

在系統呼叫追蹤中, 常見的流程如下圖所示:

ptrace

讀取系統呼叫引數

系統呼叫的引數按順序存放在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, &regs);
                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, &regs);
    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, &regs);

    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.

參考資料

部落格地址:

歡迎交流,文章轉載請註明出處.

相關文章