Linux系統調用Hook:如何給內核接口 “裝監控”?
在 Linux 系統的運行脈絡中,系統調用是用戶程序與內核交互的核心通道。但你是否想過,如何實時追蹤這些交互?如何在不破壞原有邏輯的前提下,給內核接口裝上 “監控”?這正是 Linux 系統調用 Hook 技術的魅力所在。它像一把精巧的 “鉤子”,能悄無聲息地附著在系統調用的關鍵節點上,既不阻斷正常的調用流程,又能精準捕獲每一次交互細節 —— 從參數傳遞到返回值生成,從調用頻率到異常行為。
無論是開發調試時追蹤程序行為,還是安全防護中監測惡意調用,甚至是性能優化時分析資源消耗,這種 “監控” 能力都不可或缺。然而,給內核接口裝 “監控” 絕非易事。內核空間的嚴格權限控制、不同版本的兼容性差異、以及誤操作可能引發的系統崩潰風險,都讓這項技術充滿挑戰。接下來,我們就揭開 Linux 系統調用 Hook 的神秘面紗,看看它如何實現對內核接口的 “隱形監控”。
一、Linux系統調用簡介
系統調用(syscall)是一個通用的概念,它既包括應用層系統函數庫的調用,也包括ring0層系統提供的syscall_table提供的系統api。
1.1系統調用的概念
系統調用是操作系統內核提供給用戶空間應用程序使用的接口。當應用程序需要訪問硬件資源(如磁盤、網絡)、創建進程、分配內存等操作時,就會通過系統調用陷入內核態,由內核來完成這些任務。例如,常見的文件讀寫函數read和write,在底層實際上就是通過系統調用實現的。
1.2系統調用的實現機制
在 Linux 中,系統調用通過軟件中斷實現。以 x86 架構為例,應用程序執行int 0x80指令(在較新的內核中,也使用sysenter指令),觸發一個軟件中斷,CPU 會切換到內核態,然后根據系統調用號在系統調用表中找到對應的內核函數進行執行。系統調用表是一個存儲了所有系統調用函數指針的數組,每個系統調用都有唯一的編號,通過這個編號可以快速定位到相應的處理函數。
我們必須要明白,Hook技術是一個相對較寬的話題,因為操作系統從ring3到ring0是分層次的結構,在每一個層次上都可以進行相應的Hook,它們使用的技術方法以及取得的效果也是不盡相同的。本文的主題是"系統調用的Hook學習","系統調用的Hook"是我們的目的,而要實現這個目的可以有很多方法,本文試圖盡量覆蓋從ring3到ring0中所涉及到的Hook技術,來實現系統調用的監控功能。
二、Hook技術詳解
2.1Ring3中Hook技術
⑴LD_PRELOAD動態連接.so函數劫持
LD_PRELOAD hook技術屬于so依賴劫持技術的一種實現,所以要討論這種技術的技術原理,我們先來看一下linux操作系統加載so的底層原理。
括Linux系統在內的很多開源系統都是基于Glibc的,動態鏈接的ELF可執行文件在啟動時同時會啟動動態鏈接器(/lib/ld-linux.so.X),程序所依賴的共享對象全部由動態鏈接器負責裝載和初始化,所以這里所謂的共享庫的查找過程,本質上就是動態鏈接器(/lib/ld-linux.so.X)對共享庫路徑的搜索過程,搜索過程如下:
/etc/ld.so.cache:Linux為了加速LD_PRELOAD的搜索過程,在系統中建立了一個ldconfig程序,這個程序負責
- 將共享庫下的各個共享庫維護一個SO-NAME(一一對應的符號鏈接),這樣每個共享庫的SO-NAME就能夠指向正確的共享庫文件
- 將全部SO-NAME收集起來,集中放到/etc/ld.so.cache文件里面,并建立一個SO-NAME的緩存
- 當動態鏈接器要查找共享庫時,它可以直接從/etc/ld.so.cache里面查找。所以,如果我們在系統指定的共享庫目錄下添加、刪除或更新任何一個共享庫,或者我們更改了/etc/ld.so.conf、/etc/ld.preload的配置,都應該運行一次ldconfig這個程序,以便更新SO-NAME和/etc/ld.so.cache。很多軟件包的安裝程序在結束共享庫安裝以后都會調用ldconfig
根據/etc/ld.so.preload中的配置進行搜索(LD_PRELOAD):這個配置文件中保存了需要搜索的共享庫路徑,Linux動態共享庫加載器根據順序進行逐行廣度搜索
根據環境變量LD_LIBRARY_PATH指定的動態庫搜索路徑:
根據ELF文件中的配置信息:任何一個動態鏈接的模塊所依賴的模塊路徑保存在".dynamic"段中,由DT_NEED類型的項表示,動態鏈接器會按照這個路徑去查找DT_RPATH所指定的路徑,編譯目標代碼時,可以對gcc加入鏈接參數"-Wl,-rpath"指定動態庫搜索路徑。
- DT_NEED段中保存的是絕對路徑,則動態鏈接器直接按照這個路徑進行直接加載
- DT_NEED段中保存的是相對路徑,動態鏈接器會在按照一個約定的順序進行庫文件查找下列路徑:/lib、/usr/lib、/etc/ld.so.conf中配置指定的搜索路徑
可以看到,LD_PRELOAD是Linux系統中啟動新進程首先要加載so的搜索路徑,所以它可以影響程序的運行時的鏈接(Runtime linker),它允許你定義在程序運行前"優先加載"的動態鏈接庫。
我們只要在通過LD_PRELOAD加載的.so中編寫我們需要hook的同名函數,根據Linux對外部動態共享庫的符號引入全局符號表的處理,后引入的符號會被省略,即系統原始的.so(/lib64/libc.so.6)中的符號會被省略。
通過strace program也可以看到,Linux是優先加載LD_PRELOAD指明的.so,然后再加載系統默認的.so的:
圖片
⑵通過自寫.so文件劫持LD_PRELOAD
①demo例子
正常程序main.c:
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
if( strcmp(argv[1], "test") )
{
printf("Incorrect password\n");
}
else
{
printf("Correct password\n");
}
return 0;
}
用于劫持函數的.so代碼hook.c
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
/*
hook的目標是strcmp,所以typedef了一個STRCMP函數指針
hook的目的是要控制函數行為,從原庫libc.so.6中拿到strcmp指針,保存成old_strcmp以備調用
*/
typedef int(*STRCMP)(const char*, const char*);
int strcmp(const char *s1, const char *s2)
{
static void *handle = NULL;
static STRCMP old_strcmp = NULL;
if( !handle )
{
handle = dlopen("libc.so.6", RTLD_LAZY);
old_strcmp = (STRCMP)dlsym(handle, "strcmp");
}
printf("oops!!! hack function invoked. s1=<%s> s2=<%s>\n", s1, s2);
return old_strcmp(s1, s2);
}
編譯:
gcc -o test main.c
gcc -fPIC -shared -o hook.so hook.c -ldl
運行:
LD_PRELOAD=./hook.so ./test 123
②hook function注意事項
在編寫用于function hook的.so文件的時候,要考慮以下幾個因素
1. Hook函數的覆蓋完備性
對于Linux下的指令執行來說,有7個Glibc API都可是實現指令執行功能,對這些API對要進行Hook
/*
#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, .../* (char *)0, char *const envp[] */ );
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], char *const envp[]);
http://www.2cto.com/os/201410/342362.html
*/
2. 當前系統中存在function hook的重名覆蓋問題
1) /etc/ld.so.preload中填寫了多條.so加載條目
2) 其他程序通過"export LD_PRELOAD=.."臨時指定了待加載so的路徑
在很多情況下,出于系統管理或者集群系統日志收集的目的,運維人員會向系統中注入.so文件,對特定function函數進行hook,這個時候,當我們注入的.so文件中的hook function和原有的hook function存在同名的情況,Linux會自動忽略之后載入了hook function,這種情況我們稱之為"共享對象全局符號介入"
3. 注入.so對特定function函數進行hook要保持原始業務的兼容性
典型的hook的做法應該是
hook_function()
{
save ori_function_address;
/*
do something in here
span some time delay
*/
call ori_function;
}
hook函數在執行完自己的邏輯后,應該要及時調用被hook前的"原始函數",保持對原有業務邏輯的透明
4. 盡量減小hook函數對原有調用邏輯的延時
hook_function()
{
save ori_function_address;
/*
do something in here
span some time delay
*/
call ori_function;
}
hook這個操作是一定會對原有的代碼調用執行邏輯產生延時的,我們需要盡量減少從函數入口到"call ori_function"這塊的代碼邏輯,讓代碼邏輯盡可能早的去"call ori_function"
在一些極端特殊的場景下,存在對單次API調用延時極其嚴格的情況,如果延時過長可能會導致原始業務邏輯代碼執行失敗
如果需要不僅僅是替換掉原有庫函數,而且還希望最終將函數邏輯傳遞到原有系統函數,實現透明hook(完成業務邏輯的同時不影響正常的系統行為)、維持調用鏈,那么需要用到RTLD_NEXT
當調用dlsym的時候傳入RTLD_NEXT參數,gcc的共享庫加載器會按照"裝載順序(load order)(即先來后到的順序)"獲取"下一個共享庫"中的符號地址
/*
Specifies the next object after this one that defines name. This one refers to the object containing the invocation of dlsym(). The next object is the one found upon the application of a load order symbol resolution algorithm (see dlopen()). The next object is either one of global scope (because it was introduced as part of the original process image or because it was added with a dlopen() operation including the RTLD_GLOBAL flag), or is an object that was included in the same dlopen() operation that loaded this one.
The RTLD_NEXT flag is useful to navigate an intentionally created hierarchy of multiply-defined symbols created through interposition. For example, if a program wished to create an implementation of malloc() that embedded some statistics gathering about memory allocations, such an implementation could use the real malloc() definition to perform the memory allocation-and itself only embed the necessary logic to implement the statistics gathering function.
http://pubs.opengroup.org/onlinepubs/009695399/functions/dlsym.html
http://www.newsmth.net/nForum/#!article/KernelTech/413
*/
code example
// used for getting the orginal exported function address
#if defined(RTLD_NEXT)
# define REAL_LIBC RTLD_NEXT
#else
# define REAL_LIBC ((void *) -1L)
#endif
//REAL_LIBC代表當前調用鏈中緊接著下一個共享庫,從調用方鏈接映射列表中的下一個關聯目標文件獲取符號
#define FN(ptr,type,name,args) ptr = (type (*)args)dlsym (REAL_LIBC, name)
...
FN(func,int,"execve",(const char *, char **const, char **const));
我們知道,如果當前進程空間中已經存在某個同名的符號,則后載入的so的同名函數符號會被忽略,但是不影響so的載入,先后載入的so會形成一個鏈式的依賴關系,通過RTLD_NEXT可以遍歷
③SO功能代碼編寫
這個小節我們來完成一個基本的進程、網絡、模塊加載監控的小demo。
1. 指令執行
1) execve
2) execv
2. 網絡連接
1) connect
3. LKM模塊加載
1) init_module
hook.c
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>
#include <stdlib.h>
#include <sys/types.h>
#include <string.h>
#include <unistd.h>
#include <limits.h>
#include <netinet/in.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#if defined(RTLD_NEXT)
# define REAL_LIBC RTLD_NEXT
#else
# define REAL_LIBC ((void *) -1L)
#endif
#define FN(ptr, type, name, args) ptr = (type (*)args)dlsym (REAL_LIBC, name)
int execve(const char *filename, char *const argv[], char *const envp[])
{
static int (*func)(const char *, char **, char **);
FN(func,int,"execve",(const char *, char **const, char **const));
//print the log
printf("filename: %s, argv[0]: %s, envp:%s\n", filename, argv[0], envp);
return (*func) (filename, (char**) argv, (char **) envp);
}
int execv(const char *filename, char *const argv[])
{
static int (*func)(const char *, char **);
FN(func,int,"execv", (const char *, char **const));
//print the log
printf("filename: %s, argv[0]: %s\n", filename, argv[0]);
return (*func) (filename, (char **) argv);
}
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
{
static int (*func)(int, const struct sockaddr *, socklen_t);
FN(func,int,"connect", (int, const struct sockaddr *, socklen_t));
/*
print the log
獲取、打印參數信息的時候需要注意
1. 加鎖
2. 拷貝到本地棧區變量中
3. 然后再打印
調試的時候發現直接獲取打印會導致core dump
*/
printf("socket connect hooked!!\n");
//return (*func) (sockfd, (const struct sockaddr *) addr, (socklen_t)addrlen);
return (*func) (sockfd, addr, addrlen);
}
int init_module(void *module_image, unsigned long len, const char *param_values)
{
static int (*func)(void *, unsigned long, const char *);
FN(func,int,"init_module",(void *, unsigned long, const char *));
/*
print the log
lkm的加載不需要取參數,只需要捕獲事件本身即可
*/
printf("lkm load hooked!!\n");
return (*func) ((void *)module_image, (unsigned long)len, (const char *)param_values);
}
編譯,并裝載。
//編譯出一個so文件
gcc -fPIC -shared -o hook.so hook.c -ldl
添加LD_PRELOAD有很多種方式。
1. 臨時一次性添加(當條指令有效)
LD_PRELOAD=./hook.so nc www.baidu.com 80
/*
LD_PRELOAD后面接的是具體的庫文件全路徑,可以連接多個路徑
程序加載時,LD_PRELOAD加載路徑優先級高于/etc/ld.so.preload
*/
2. 添加到環境變量LD_PRELOAD中(當前會話SESSION有效)
export LD_PRELOAD=/zhenghan/snoopylog/hook.so
//"/zhenghan/snoopylog/"是編譯.so文件的目錄
unset LD_PRELOAD
3. 添加到環境變量LD_LIBRARY_PATH中
假如現在需要在已有的環境變量上添加新的路徑名,則采用如下方式
LD_LIBRARY_PATH=/zhenghan/snoopylog/hook.so:$LD_LIBRARY_PATH.(newdirs是新的路徑串)
/*
LD_LIBRARY_PATH指定查找路徑,這個路徑優先級別高于系統預設的路徑
*/
4. 添加到系統配置文件中
vim /etc/ld.so.preload
add /zhenghan/snoopylog/hook.so
5. 添加到配置文件目錄中
cat /etc/ld.so.conf
//include ld.so.conf.d/*.conf
效果測試:
1. 指令執行
在代碼中手動調用: execve(argv[1], newargv, newenviron);
2. 網絡連接
執行: nc www.baidu.com 80
3. LKM模塊加載
編寫測試LKM模塊,執行: insmod hello.ko
在真實的環境中,socket的網絡連接存在大量的連接失敗,非阻塞等待等等情況,這些都會觸發connect的hook調用,對于connect的hook來說,我們需要對以下的事情進行過濾。
1. 區分IPv4、IPv6
根據connect參數中的(struct sockaddr *addr)->sa_family進行判斷
2. 區分執行成功、執行失敗
如果本次connect調用執行失敗,則不應該繼續進行參數獲取
int ret_code = (*func) (sockfd, addr, addrlen);
int tmp_errno = errno;
if (ret_code == -1 && tmp_errno != EINPROGRESS)
{
return ret_code;
}
3. 區分TCP、UDP連接
對于TCP和UDP來說,它們都可以發起connect請求,我們需要從中過濾出TCP Connect請求
#include <sys/types.h>
#include <sys/socket.h>
int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
/*
#include <sys/types.h>
#include <sys/socket.h>
main()
{
int s;
int optval;
int optlen = sizeof(int);
if((s = socket(AF_INET, SOCK_STREAM, 0)) < 0)
perror("socket");
getsockopt(s, SOL_SOCKET, SO_TYPE, &optval, &optlen);
printf("optval = %d\n", optval);
close(s);
}
*/
執行:
optval = 1 //SOCK_STREAM 的定義正是此值
④劫持效果測試
指令執行監控
execve.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
char *newargv[] = { NULL, "hello", "world", NULL };
char *newenviron[] = { NULL };
if (argc != 2)
{
fprintf(stderr, "Usage: %s <file-to-exec>\n", argv[0]);
exit(EXIT_FAILURE);
}
newargv[0] = argv[1];
execve(argv[1], newargv, newenviron);
perror("execve"); /* execve() only returns on error */
exit(EXIT_FAILURE);
}
//gcc -o execve execve.c
myecho.c
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
int j;
for (j = 0; j < argc; j++)
printf("argv[%d]: %s\n", j, argv[j]);
exit(EXIT_SUCCESS);
}
//gcc -o myecho myecho.c
圖片
可以看到,LD_PRELOAD在所有程序代碼庫加載前優先加載,對glibc中的導出函數進行了hook
網絡連接監控
圖片
模塊加載監控:hello.c
#include <linux/module.h> // included for all kernel modules
#include <linux/kernel.h> // included for KERN_INFO
#include <linux/init.h> // included for __init and __exit macros
#include <linux/cred.h>
#include <linux/sched.h>
static int __init hello_init(void)
{
struct cred *currentCred;
currentCred = current->cred;
printk(KERN_INFO "uid = %d\n", currentCred->uid);
printk(KERN_INFO "gid = %d\n", currentCred->gid);
printk(KERN_INFO "suid = %d\n", currentCred->suid);
printk(KERN_INFO "sgid = %d\n", currentCred->sgid);
printk(KERN_INFO "euid = %d\n", currentCred->euid);
printk(KERN_INFO "egid = %d\n", currentCred->egid);
printk(KERN_INFO "Hello world!\n");
return 0; // Non-zero return means that the module couldn't be loaded.
}
static void __exit hello_cleanup(void)
{
printk(KERN_INFO "Cleaning up module.\n");
}
module_init(hello_init);
module_exit(hello_cleanup);
Makefile
obj-m := hello.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
加載模塊:insmod hello.ko
圖片
使用snoopy進行execve/execv、connect、init_module hook
snoopy會監控服務器上的命令執行,并記錄到syslog。
本質上,snoopy是利用ld_preload技術實現so依賴劫持的,只是它的工程化完善度更高,日志采集和日志整理傳輸這方面已經幫助我們完成了。
#cat /etc/ld.so.preload
/usr/local/snoopy/lib/snoopy.so
基于PD_PRELOAD、LD_LIBRARY_PATH環境變量劫持繞過Hook模塊
我們知道,snoopy監控服務器上的指令執行,是通過修改系統的共享庫預加載配置文件(/etc/ld.so.preload)實現,但是這種方式存在一個被黑客繞過的可能
圖片
LD_PRELOAD的加載順序優先于/etc/ld.so.preload的配置項,黑客可以利用這點來強制覆蓋共享庫的加載順序
1. 強制指定LD_PRELOAD的環境變量
export LD_PRELOAD=/lib64/libc.so.6
bash
/*
新啟動的bash終端默認會使用LD_PRELOAD的共享庫路徑
*/
2. LD_PRELOAD="/lib64/libc.so.6" bash
/*
重新開啟一個加載了默認libc.so.6共享庫的bash session
因為對于libc.so.6來說,它沒有使用dlsym去動態獲取API Function調用鏈條的RTL_NEXT函數,即調用鏈是斷開的
*/
在這個新的Bash下執行的指令,因為都不會調用到snoopy的hook函數,所以也不會被記錄下來。
基于ptrace()調試技術進行API Hook
在Linux下,除了使用LD_PRELOAD這種被動Glibc API注入方式,還可以使用基于調試器(Debuger)思想的ptrace()主動注入方式,總體思路如下:
- 使用Linux Module、或者LSM掛載點對進程的啟動動作進行實時的監控,并通過Ring0-Ring3通信,通知到Ring3程序有新進程啟動的動作
- 用ptrace函數attach上目標進程
- 讓目標進程的執行流程跳轉到mmap函數來分配一小段內存空間
- 把一段機器碼拷貝到目標進程中剛分配的內存中去
- 最后讓目標進程的執行流程跳轉到注入的代碼執行
通過靜態編碼繞過LD_PRELOAD機制監控
通過靜態鏈接方式編譯so模塊:
gcc -o test test.c -static
在靜態鏈接的模式下,程序不會去搜索系統中的so文件(不同是系統默認的、還是第三方加入的),所以也就不會調用到Hook SO模塊。
通過內聯匯編的方式繞過LD_PRELOAD機制監控
使用內嵌匯編的形式直接通過syscall指令使用系統調用功能,同樣也不會調用到Glibc提供的API。
asm("movq $2, %%rax\n\t syscal:"=a"(ret));
2.2Ring0中Hook技術
傳統的kernel inline hook技術就是修改內核函數的opcode,通過寫入jmp或push ret等指令跳轉到新的內核函數中,從何達到劫持的目的。
- 我們知道實現一個系統調用的函數中一定會遞歸的嵌套有很多的子函數,即它必定要調用它的下層函數。
- 從匯編的角度來說,對一個子函數的調用是采用"段內相對短跳轉 jmp offset"來實現的,即CPU根據offset來進行一個偏移量的跳轉。如果我們把下層函數在上層函數中的offset替換成我們"Hook函數"的offset,這樣上層函數調用下層函數時,就會跳到我們的"Hook函數"中。
- 我們就可以在"Hook函數"中做過濾和劫持內容的工作
以sys_read作為例子:
\linux-2.6.32.63\fs\read_write.c
asmlinkage ssize_t sys_read(unsigned int fd, char __user * buf, size_t count)
{
struct file *file;
ssize_t ret = -EBADF;
int fput_needed;
file = fget_light(fd, &fput_needed);
if (file)
{
loff_t pos = file_pos_read(file);
ret = vfs_read(file, buf, count, &pos);
file_pos_write(file, pos);
fput_light(file, fput_needed);
}
return ret;
}
EXPORT_SYMBOL_GPL(sys_read);
在sys_read()中,調用了子函數vfs_read()來完成讀取數據的操作,在sys_read()中調用子函數vfs_read()的匯編命令是:
call 0xc106d75c <vfs_read>
等同于:
jmp offset(相對于sys_read()的基址偏移)
所以,我們的思路很明確,找到call 0xc106d75c <vfs_read>這條匯編,把其中的offset改成我們的Hook函數對應的offset,就可以實現劫持目的了
1. 搜索sys_read的opcode 2. 如果發現是call指令,根據call后面的offset計算要跳轉的地址是不是我們要hook的函數地址 1) 如果"不是"就重新計算Hook函數的offset,用Hook函數的offset替換原來的offset 2) 如果"已經是"Hook函數的offset,則說明函數已經處于被劫持狀態了,我們的Hook引擎應該直接忽略跳過,避免重復劫持
poc:
/*
參數:
1. handler是上層函數的地址,這里就是sys_read的地址
2. old_func是要替換的函數地址,這里就是vfs_read
3. new_func是新函數的地址,這里就是new_vfs_read的地址
*/
unsigned int patch_kernel_func(unsigned int handler, unsigned int old_func,
unsigned int new_func)
{
unsigned char *p = (unsigned char *)handler;
unsigned char buf[4] = "\x00\x00\x00\x00";
unsigned int offset = 0;
unsigned int orig = 0;
int i = 0;
DbgPrint("\n*** hook engine: start patch func at: 0x%08x\n", old_func);
while (1) {
if (i > 512)
return 0;
if (p[0] == 0xe8) {
DbgPrint("*** hook engine: found opcode 0x%02x\n", p[0]);
DbgPrint("*** hook engine: call addr: 0x%08x\n",
(unsigned int)p);
buf[0] = p[1];
buf[1] = p[2];
buf[2] = p[3];
buf[3] = p[4];
DbgPrint("*** hook engine: 0x%02x 0x%02x 0x%02x 0x%02x\n",
p[1], p[2], p[3], p[4]);
offset = *(unsigned int *)buf;
DbgPrint("*** hook engine: offset: 0x%08x\n", offset);
orig = offset + (unsigned int)p + 5;
DbgPrint("*** hook engine: original func: 0x%08x\n", orig);
if (orig == old_func) {
DbgPrint("*** hook engine: found old func at"
" 0x%08x\n",
old_func);
DbgPrint("%d\n", i);
break;
}
}
p++;
i++;
}
offset = new_func - (unsigned int)p - 5;
DbgPrint("*** hook engine: new func offset: 0x%08x\n", offset);
p[1] = (offset & 0x000000ff);
p[2] = (offset & 0x0000ff00) >> 8;
p[3] = (offset & 0x00ff0000) >> 16;
p[4] = (offset & 0xff000000) >> 24;
DbgPrint("*** hook engine: pachted new func offset.\n");
return orig;
}
對于這類劫持攻擊,目前常見的做法是fireeye的"函數返回地址污點檢測",通過對原有指令返回位置的匯編代碼作污點標記,通過查找jmp,push ret等指令來進行防御。
⑴利用0x80中斷劫持system_call->sys_call_table進行系統調用Hook
我們知道,要對系統調用(sys_call_table)進行替換,卻必須要獲取該地址后才可以進行替換。但是Linux 2.6版的內核出于安全的考慮沒有將系統調用列表基地址的符號sys_call_table導出,但是我們可以采取一些hacking的方式進行獲取。
因為系統調用都是通過0x80中斷來進行的,故可以通過查找0x80中斷的處理程序來獲得sys_call_table的地址。其基本步驟是
1. 獲取中斷描述符表(IDT)的地址(使用C ASM匯編) 2. 從中查找0x80中斷(系統調用中斷)的服務例程(8*0x80偏移) 3. 搜索該例程的內存空間, 4. 從其中獲取sys_call_table(保存所有系統調用例程的入口地址)的地址
編程示例
find_sys_call_table.c
#include <linux/module.h>
#include <linux/kernel.h>
// 中斷描述符表寄存器結構
struct
{
unsigned short limit;
unsigned int base;
} __attribute__((packed)) idtr;
// 中斷描述符表結構
struct
{
unsigned short off1;
unsigned short sel;
unsigned char none, flags;
unsigned short off2;
} __attribute__((packed)) idt;
// 查找sys_call_table的地址
void disp_sys_call_table(void)
{
unsigned int sys_call_off;
unsigned int sys_call_table;
char* p;
int i;
// 獲取中斷描述符表寄存器的地址
asm("sidt %0":"=m"(idtr));
printk("addr of idtr: %x\n", &idtr);
// 獲取0x80中斷處理程序的地址
memcpy(&idt, idtr.base+8*0x80, sizeof(idt));
sys_call_off=((idt.off2<<16)|idt.off1);
printk("addr of idt 0x80: %x\n", sys_call_off);
// 從0x80中斷服務例程中搜索sys_call_table的地址
p=sys_call_off;
for (i=0; i<100; i++)
{
if (p=='\xff' && p[i+1]=='\x14' && p[i+2]=='\x85')
{
sys_call_table=*(unsigned int*)(p+i+3);
printk("addr of sys_call_table: %x\n", sys_call_table);
return ;
}
}
}
// 模塊載入時被調用
static int __init init_get_sys_call_table(void)
{
disp_sys_call_table();
return 0;
}
module_init(init_get_sys_call_table);
// 模塊卸載時被調用
static void __exit exit_get_sys_call_table(void)
{
}
module_exit(exit_get_sys_call_table);
// 模塊信息
MODULE_LICENSE("GPL2.0");
MODULE_AUTHOR("LittleHann");
Makefile
obj-m := find_sys_call_table.o
編譯
make -C /usr/src/kernels/2.6.32-358.el6.i686 M=$(pwd) modules
測試效果
dmesg| tail
圖片
獲取到了sys_call_table的基地址之后,我們就可以修改指定offset對應的系統調用了,從而達到劫持系統調用的目的。
⑵獲取sys_call_table的常用方法
①通過dump獲取絕對地址
模擬出一個call *sys_call_table(,%eax,4),然后看其機器碼,然后在system_call的附近基于這個特征進行尋找
#include <stdio.h>
void fun1()
{
printf("fun1/n");
}
void fun2()
{
printf("fun2/n");
}
unsigned int sys_call_table[2] = {fun1, fun2};
int main(int argc, char **argv)
{
asm("call *sys_call_table(%eax,4");
}
編譯
gcc test.c -o test
objdump進行dump
objdump -D ./test | grep sys_call_table
②通過/boot/System.map-2.6.32-358.el6.i686文件查找
cd /boot
grep sys_call_table System.map-2.6.32-358.el6.i686
③通過讀取/dev/kmem虛擬內存全鏡像設備文件獲得sys_call_table地址
Linux下/dev/mem和/dev/kmem的區別:
/dev/mem: 物理內存的全鏡像。可以用來訪問物理內存。比如: 1) X用來訪問顯卡的物理內存, 2) 嵌入式中訪問GPIO。用法一般就是open,然后mmap,接著可以使用map之后的地址來訪問物理內存。這其實就是實現用戶空間驅動的一種方法。2. /dev/kmem: kernel看到的虛擬內存的全鏡像。可以用來: 1) 訪問kernel的內容,查看kernel的變量, 2) 用作rootkit之類的
code
#include <stdio.h>
#include <sys/types.h>
#include <fcntl.h>
#include <stdlib.h>
int kfd;
struct
{
unsigned short limit;
unsigned int base;
} __attribute__ ((packed)) idtr;
struct
{
unsigned short off1;
unsigned short sel;
unsigned char none, flags;
unsigned short off2;
} __attribute__ ((packed)) idt;
int readkmem (unsigned char *mem, unsigned off, int bytes)
{
if (lseek64 (kfd, (unsigned long long) off, SEEK_SET) != off)
{
return -1;
}
if (read (kfd, mem, bytes) != bytes)
{
return -1;
}
}
int main (void)
{
unsigned long sct_off;
unsigned long sct;
unsigned char *p, code[255];
int i;
/* request IDT and fill struct */
asm ("sidt %0":"=m" (idtr));
if ((kfd = open ("/dev/kmem", O_RDONLY)) == -1)
{
perror("open");
exit(-1);
}
if (readkmem ((unsigned char *)&idt, idtr.base + 8 * 0x80, sizeof (idt)) == -1)
{
printf("Failed to read from /dev/kmem\n");
exit(-1);
}
sct_off = (idt.off2 << 16) | idt.off1;
if (readkmem (code, sct_off, 0x100) == -1)
{
printf("Failed to read from /dev/kmem\n");
exit(-1);
}
/* find the code sequence that calls SCT */
sct = 0;
for (i = 0; i < 255; i++)
{
if (code[i] == 0xff && code[i+1] == 0x14 && code[i+2] == 0x85)
{
sct = code[i+3] + (code[i+4] << 8) + (code[i+5] << 16) + (code[i+6] << 24);
}
}
if (sct)
{
printf ("sys_call_table: 0x%x\n", sct);
}
close (kfd);
}
④通過函數特征碼循環搜索獲取sys_call_table地址 (64 bit)
unsigned long **find_sys_call_table()
{
unsigned long ptr;
unsigned long *p;
for (ptr = (unsigned long)sys_close; ptr < (unsigned long)&loops_per_jiffy; ptr += sizeof(void *))
{
p = (unsigned long *)ptr;
if (p[__NR_close] == (unsigned long)sys_close)
{
printk(KERN_DEBUG "Found the sys_call_table!!!\n");
return (unsigned long **)p;
}
}
return NULL;
}
要特別注意的是代碼中進行函數地址搜索的代碼:if (p[__NR_close] == (unsigned long)sys_close)
在64bit Linux下,函數的地址是8字節的,所以要使用unsigned long
我們可以在linux下執行以下兩條指令
grep sys_close System.map-2.6.32-358.el6.i686
grep loops_per_jiffy System.map-2.6.32-358.el6.i686
圖片
可以看到,系統調用表sys_call_table中的函數地址都落在這個地址區間中,因此我們可以使用loop搜索的方法去獲取sys_call_table的基地址
⑶通過kprobe方式動態獲取kallsyms_lookup_name,然后利用kallsyms_lookup_name獲取sys_call_table的地址
通過kprobe的函數hook掛鉤機制,可以獲取內核中任意函數的入口地址,我們可以先獲取"kallsyms_lookup_name"函數的入口地址
//get symbol name by "kprobe.addr"
//when register a kprobe on succefully return,the structure of kprobe save the symbol address at "kprobe.addr"
//just return this value
static void* aquire_symbol_by_kprobe(char* symbol_name)
{
void *symbol_addr=NULL;
struct kprobe kp;
do
{
memset(&kp,0,sizeof(kp));
kp.symbol_name=symbol_name;
kp.pre_handler=kprobe_pre;
if(register_kprobe(&kp)!=0)
{
break;
}
//this is the address of "symbol_name"
symbol_addr=(void*)kp.addr;
//now kprobe is not used any more,so unregister it
unregister_kprobe(&kp);
}while(false);
return symbol_addr;
}
//調用之
tmp_lookup_func = aquire_symbol_by_kprobe("kallsyms_lookup_name");
kallsyms_lookup_name()可以用于獲取內核導出符號表中的符號地址,而sys_call_table的地址也存在于內核導出符號表中,我們可以使用kallsyms_lookup_name()獲取到sys_call_table的基地址
(void**)kallsyms_lookup_name("sys_call_table");
⑷利用Linux內核機制kprobe機制(kprobes, jprobe和kretprobe)進行系統調用Hook
kprobe是一個動態地收集調試和性能信息的工具,它從Dprobe項目派生而來,它幾乎可以跟蹤任何函數或被執行的指令以及一些異步事件。它的基本工作機制是:
- 1. 用戶指定一個探測點,并把一個用戶定義的處理函數關聯到該探測點
- 2. 在注冊探測點的時候,對被探測函數的指令碼進行替換,替換為int 3的指令碼
- 3. 在執行int 3的異常執行中,通過通知鏈的方式調用kprobe的異常處理函數
- 4. 在kprobe的異常出來函數中,判斷是否存在pre_handler鉤子,存在則執行
- 5. 執行完后,準備進入單步調試,通過設置EFLAGS中的TF標志位,并且把異常返回的地址修改為保存的原指令
- 6. 代碼返回,執行原有指令,執行結束后觸發單步異常 7. 在單步異常的處理中,清除單步標志,執行post_handler流程,并最終返回
從原理上來說,kprobe的這種機制屬于系統提供的"回調訂閱",和netfilter是類似的,linux內核通過在某些代碼執行流程中給出回調函數接口供程序員訂閱,內核開發人員可以在這些回調點上注冊(訂閱)自定義的處理函數,同時還可以獲取到相應的狀態信息,方便進行過濾、分析
kprobe實現了三種類型的探測點:
- 1. kprobes kprobes是可以被插入到內核的任何指令位置的探測點,kprobe允許在同一地址注冊多個kprobes,但是不能同時在該地址上有多個jprobes
- 2. jprobe jprobe則只能被插入到一個內核函數的入口
- 3. kretprobe(也叫返回探測點) 而kretprobe則是在指定的內核函數返回時才被執行
在本文中,我們可以使用kprobe的程序實現作一個內核模塊,模塊的初始化函數來負責安裝探測點,退出函數卸載那些被安裝的探測點。kprobe提供了接口函數(APIs)來安裝或卸載探測點。目前kprobe支持如下架構:i386、x86_64、ppc64、ia64(不支持對slot1指令的探測)、sparc64 (返回探測還沒有實現)
三、Linux系統調用中常見的Hook技術方法
3.1基于函數指針的 Hook
在 Linux 內核中,系統調用函數的入口地址存儲在系統調用表中,這個表實際上是一個函數指針數組。基于函數指針的 Hook 方法就是直接修改系統調用表中對應函數的指針,使其指向我們自定義的 Hook 函數。
實現步驟:首先需要找到系統調用表的地址,這在不同的內核版本和架構上可能有所不同。然后,通過修改內存中的函數指針,將其指向自定義的 Hook 函數。在 Hook 函數中,可以先執行自己的邏輯,然后再調用原來的系統調用函數(如果需要的話)。
示例代碼(簡化示意):
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/syscalls.h>
// 保存原來的系統調用函數指針
asmlinkage long (*original_sys_open)(const char __user *, int, umode_t);
// 自定義的Hook函數
asmlinkage long my_sys_open(const char __user *filename, int flags, umode_t mode) {
printk(KERN_INFO "MyHook: Opening file: %s\n", filename);
// 調用原來的系統調用函數
return original_sys_open(filename, flags, mode);
}
static int __init my_init(void) {
// 獲取系統調用表地址
unsigned long *sys_call_table = (unsigned long *)sys_call_table;
// 保存原來的系統調用函數指針
original_sys_open = (void *)sys_call_table[__NR_open];
// 修改系統調用表中的函數指針
sys_call_table[__NR_open] = (unsigned long)my_sys_open;
return 0;
}
static void __exit my_exit(void) {
// 恢復原來的系統調用函數指針
unsigned long *sys_call_table = (unsigned long *)sys_call_table;
sys_call_table[__NR_open] = (unsigned long)original_sys_open;
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
優點與局限性:這種方法簡單直接,效果顯著。但是,由于直接修改系統調用表,可能會導致內核的穩定性問題,并且在不同的內核版本之間移植性較差,因為系統調用表的結構和地址可能會發生變化。
3.2基于 GOT(Global Offset Table)的 Hook
在用戶空間的動態鏈接庫中,GOT 是一個重要的數據結構,用于存儲外部函數的地址。基于 GOT 的 Hook 方法主要應用于用戶空間的程序,通過修改 GOT 表中函數的地址,實現對系統調用的 Hook。
實現步驟:首先需要找到目標函數在 GOT 表中的項,然后修改該項的內容,使其指向自定義的 Hook 函數。在 Hook 函數中,可以進行自己的邏輯處理,最后再通過調用原來的函數地址(保存在一個臨時變量中)來執行原函數的功能。
示例代碼(以 C 語言和動態鏈接庫為例):
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
// 自定義的Hook函數
int my_open(const char *pathname, int flags, mode_t mode) {
printf("MyHook: Opening file: %s\n", pathname);
// 獲取原來的open函數指針
int (*original_open)(const char *, int, mode_t) = dlsym(RTLD_NEXT, "open");
// 調用原來的open函數
return original_open(pathname, flags, mode);
}
// 通過環境變量LD_PRELOAD加載這個庫時,會優先使用這個函數
__attribute__((constructor)) void my_init(void) {
void *handle = dlopen(NULL, RTLD_NOW);
if (!handle) {
fprintf(stderr, "Error opening library: %s\n", dlerror());
exit(EXIT_FAILURE);
}
// 使用dlsym獲取原來的open函數指針(這里只是示例,實際可能需要更復雜的處理)
int (*original_open)(const char *, int, mode_t) = dlsym(handle, "open");
if (!original_open) {
fprintf(stderr, "Error getting original open function: %s\n", dlerror());
dlclose(handle);
exit(EXIT_FAILURE);
}
// 這里可以通過一些技巧修改GOT表中open函數的地址,使其指向my_open函數
// 具體實現因不同系統和編譯器而異,此處簡化示意
}
優點與局限性:這種方法主要在用戶空間操作,對內核的影響較小,相對安全穩定。而且,它可以針對特定的用戶程序進行 Hook,具有較好的靈活性。但是,它只能 Hook 用戶空間調用的系統調用函數,對于內核內部直接調用的系統調用則無法生效。
3.3基于內核模塊的 Kprobes Hook
Kprobes 是 Linux 內核提供的一種動態探測機制,它允許開發者在不修改內核源代碼的情況下,對內核函數的執行進行探測和干預。基于 Kprobes 的 Hook 技術就是利用這一機制來實現對系統調用的 Hook。
實現步驟:首先需要注冊一個 Kprobe 結構體,指定要 Hook 的內核函數以及在函數執行前、執行后和發生異常時的回調函數。然后,通過內核提供的函數將 Kprobe 注冊到內核中。在回調函數中,可以編寫自己的 Hook 邏輯。
示例代碼(簡化示意):
#include <linux/module.h>
#include <linux/kprobes.h>
// 定義一個Kprobe結構體
static struct kprobe my_kprobe = {
.symbol_name = "__x64_sys_open", // 要Hook的系統調用函數名
};
// 函數執行前的回調函數
static int pre_handler(struct kprobe *p, struct pt_regs *regs) {
printk(KERN_INFO "MyHook: Before open system call\n");
return 0;
}
// 函數執行后的回調函數
static void post_handler(struct kprobe *p, struct pt_regs *regs, unsigned long flags) {
printk(KERN_INFO "MyHook: After open system call\n");
}
// 發生異常時的回調函數
static void fault_handler(struct kprobe *p, struct pt_regs *regs, int trapnr) {
printk(KERN_INFO "MyHook: Fault in open system call\n");
}
static int __init my_init(void) {
// 設置回調函數
my_kprobe.pre_handler = pre_handler;
my_kprobe.post_handler = post_handler;
my_kprobe.fault_handler = fault_handler;
// 注冊Kprobe
if (register_kprobe(&my_kprobe) < 0) {
printk(KERN_INFO "Failed to register kprobe\n");
return -1;
}
return 0;
}
static void __exit my_exit(void) {
// 注銷Kprobe
unregister_kprobe(&my_kprobe);
printk(KERN_INFO "Kprobe unregistered\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
優點與局限性:Kprobes Hook 技術功能強大,能夠深入內核內部進行 Hook 操作,并且對內核的影響相對較小,因為它是一種動態探測機制,不需要修改內核的靜態代碼。然而,它的實現相對復雜,需要對內核機制有較深入的理解,并且在使用不當的情況下,可能會對內核性能產生一定的影響。
四、Hook技術挑戰與應對
4.1內核穩定性問題
無論是直接修改系統調用表還是使用 Kprobes 等技術,都可能對內核的穩定性造成潛在威脅。一旦 Hook 代碼出現錯誤,可能導致內核崩潰或者出現不可預測的行為。
應對方法:在編寫 Hook 代碼時,要進行嚴格的測試和調試,確保代碼的正確性和穩定性。同時,可以采用一些安全機制,如對修改的內存區域進行備份,以便在出現問題時能夠快速恢復。
4.2兼容性問題
不同的 Linux 內核版本和架構在系統調用表結構、函數命名等方面可能存在差異,這給 Hook 技術的跨版本和跨架構應用帶來了困難。
應對方法:在編寫 Hook 代碼時,要充分考慮內核版本和架構的兼容性。可以通過宏定義等方式,根據不同的內核版本和架構進行不同的代碼處理。同時,關注內核的更新和變化,及時調整 Hook 代碼。
4.3安全風險
Hook 技術如果被惡意利用,可能會對系統安全造成嚴重威脅。例如,惡意軟件可以通過 Hook 關鍵系統調用,隱藏自己的行為或者獲取敏感信息。
應對方法:加強系統的安全防護,使用安全檢測工具及時發現和阻止惡意的 Hook 行為。同時,對于合法的 Hook 應用,要進行嚴格的權限管理和審計,確保 Hook 技術的使用是安全可靠的。