这几天做操作系统课程的作业,其中一个要求是设计内核模块,在查阅资料的过程中发现内核模块可以在不重新编译内核的情况下增加内核的功能,当然也可以对内核进行修改,实现对系统调用的劫持。这篇文章就是对linux下劫持系统调用的介绍和资料的整理。
一、介绍
先介绍一下内核模块。内核模块是在操作系统需要的时候动态载入的目标文件对象,链接成内核的一部分,从而可以在不重新编译整个内核的情况加扩展内核的功能。例如一些内核模块是设备驱动程序模块,它们用来让操作系统正确的识别和使用硬件设备,如果没有内核模块,每次增加新驱动程序就得重新编译内核和重启了。
复习一下系统调用的流程。Linux中实现系统调用时利用了i386体系结构中的软件中断,系统调用使用的是0x80号中断。当系统调用发生时,CPU被切换到内核态执行中断向量表IDT对于的第0x80号中断处理函数,即跳转到了我们熟悉的system_call()的入口,system_call()函数检查系统调用号(在寄存器中),然后在sys_call_table中找到与系统调用号相对应的内核函数的入口地址,接着调用这个内核函数,在返回后做一些系统检查,最后返回到进程。
下面看看system_call的主要源码,在linux\arch\i386\kernel\entry.S中。
ENTRY(system_call) ... SAVE_ALL #保存现场 ... cmpl $(nr_syscalls), %eax #检查系统调用号是否合法 jae syscall_badsys syscall_call: call *sys_call_table(,%eax,4) #第一个参数为空代表从sys_call_table数组的基址开始 #偏移量为%eax*4,4是一个函数指针的长度,%eax是系统调用号 movl %eax,EAX(%esp) #保存返回值 ...
二、系统调用劫持原理
在2.4的内核之前,sys_call_table可以直接导出sys_call_table符号,即使用extern修饰符跨文件域的引用,但在2.4之后,出于对安全的考虑,内核不再能导出sys_call_table符号。后来有人利用IDT表及/dev/kmem确定sys_call_table的地址,这样做的优点是在用户空间就可以实现,不过需要更多的技巧。也有更加暴力的通过idtr寄存器获得IDT表之后,直接用自己构造的中断描述符替代原来的0x80号中断的描述符,使得此处的中断处理函数地址指向我们自定义的处理函数,最终实现系统调用发生时执行我们想要的操作。还有些是劫持特点的系统调用,例如通过修改vfs文件系统。
下面介绍一下几种系统调用劫持方法:
1、通过修改vfs文件系统,进行特定系统调用劫持。
主要代码如下所示:
char *root_fs="/"; typedef int (*readdir_t)(struct file *,void *,filldir_t); readdir_t orig_root_readdir=NULL; int patch_vfs(const char *p,readdir_t *orig_readdir,readdir_t new_readdir) { struct file *filep; filep=filp_open(p,O_RDONLY,0); if(IS_ERR(filep)) return -1; if(orig_readdir) /*readdir函数的作用好像读取目录,返回目录下的文件名*/ *orig_readdir=filep->f_op->readdir; /*保存原函数指针*/ filep->f_op->readdir=new_readdir; /*指向自己的函数*/ filp_close(filep,0); return 0; }
2、利用IDT表及/dev/kmem来找到sys_call_table的地址。
首先在idtr寄存器可以得到IDT表的基址,从IDT表的第0x80项(基址+8*0x80,每个中断描述符为8个字节)可以得到system_call()函数的地址,然后从/dev/kmen中读取此函数的机器码,从中搜索CALL指令的机器码(0xff 0x14 0x85)就可以确定sys_call_table的地址。
先看两个数据结构:
struct{
unsigned short limit;
unsigned int base;
} __attribute__ ((packed)) idtr; /*这个结构表示IDTR寄存器*/
struct{
unsigned short off1;
unsigned short sel;
unsigned char none,flags;
unsigned short off2;
} __attribute__ ((packed)) idt; /*中断门描述符*/
下面是主要的代码:
unsigned sys_call_off; int kmem_fd; unsigned sct; char sc_asm[CALLOFF],*p; asm ("sidt %0" : "=m" (idtr)); /*获得idrt寄存器的值*/ kmem_fd = open ("/dev/kmem",O_RDONLY); /*打开kmen*/ if (kmem_fd<0) { perror("open"); return 1; } readkmem (kmem_fd, &idt,idtr.base+8*0x80,sizeof(idt)); /*从IDT读出0x80向量,idtr.base+8*0x80 表示80中断描述符的偏移*/ sys_call_off = (idt.off2 << 16) | idt.off1; /*idt.off2 表示地址的前16位,得到system_call()的地址*/ readkmem (kmem_fd, sc_asm,sys_call_off,CALLOFF); /*查找sys_call_table的地址,CALLOFF可以自己指定长度,*/ /*100字节左右就必定包含目的机器码*/ p = (char*)memmem (sc_asm,CALLOFF,"\xff\x14\x85",3); /*call的机器码是0xff 0x14 0x85,搜索这个字符串*/ sct = *(unsigned*)(p+3); /*sys_call_table地址就在0xff 0x14 0x85之后*/
unsigned long readkmem (int fd, void * buf, size_t off, unsigned int size) { size_t moff, roff; size_t sz = getpagesize(); char * kmap; moff = ((size_t)(off/sz)) * sz; roff = off - moff; kmap = mmap(0, size+sz, PROT_READ, MAP_PRIVATE, fd, moff); /*mmap将一个文件或者其他对象映射入内存,具体参数意义可百度*/ if (kmap == MAP_FAILED) { perror("readkmem: mmap"); return 0; } memcpy (buf, &kmap[roff], size); if (munmap(kmap, size) != 0) { perror("readkmem: munmap"); return 0; } return size; }
这种方法跟2的开始差不多,通过idtr获得IDT表,然后就是把第0x80项替换成自己的描述符,让它指向我们自己定义的处理函数。不过自己定义的处理函数有具有如下的功能:
1、保存寄存器环境
2、执行一些自定义操作
3、恢复寄存器环境
4、调用原来的中断处理函数(或不调用)
这种方法不依赖于对/dev/kmen的搜索,一次对所有的系统调用进行截获,效率高。
4、一个有趣的现象。
sys_call_table往往在loops_per_jiffy与boot_cpu_data两个符号之间,因此可以在此之间通过搜索找到sys_call_table的地址。
主要代码如下所示,在2.6内核下编写内核模块测试此方法,的确是有效的。
unsigned long **find_sys_call_table(void) { unsigned long **sctable; unsigned long ptr; extern int loops_per_jiffy; sctable = NULL; for (ptr = (unsigned long)&loops_per_jiffy; ptr < (unsigned long)&boot_cpu_data; ptr += sizeof(void *)) { unsigned long *p; p = (unsigned long *)ptr; if (p[__NR_close] == (unsigned long) sys_close){ sctable = (unsigned long **)p; return &sctable[0]; } } return NULL; }