记一个linux内核内存提权问题
前些天,linux内核曝出了一个内存提权漏洞。通过骇客的精心构造,suid程序将print的输出信息写到了自己的/proc/$pid/mem文件里面,从而修改了自己的可执行代码,为普通用户开启了一个带root权限的shell。这个过程还是挺有意思的,不得不佩服骇客们的聪明才智,故在此分享一下,以表崇敬之情。
首先,破解过程使用到了suid程序。suid并不是一个程序,而是可执行文件的一种属性。当你执行一个带有suid属性的程序时,在执行suid程序期间,你启动的进程的user将被临时改为suid程序的owner,进程将拥有程序owner所拥有的权限。这一特性经常用于让普通用户临时获得root权限。
在linux系统中,很多功能是需要root权限才能使用的。用户如果想要用到这些功能,可以有两个办法:一是使用root用户登录。但是很可能你没有root密码。就算有,这样做也不安全(误操作是致命的);二是执行一个owner是root的suid程序。这样就可以在不使用root用户登录的情况下,被允许使用一些需要root权限的功能,既方便又安全。
比如我们经常用到的ping命令,它就是这样的一个suid程序。
$ ll /bin/ping
-rwsr-xr-x 1 root root 37312 Aug 6 2008 /bin/ping
(注意s属性,这就是suid标志。)
ping使用了ICMP协议,通过发送ICMP报文来探测网络的连通性。但是因为ICMP是IP层协议,其行为代表的是整个机器(而不是像传输层协议那样,代表机器上的某个应用),故只有root用户才能建立ICMP报文。而我们之所以不使用root用户登录也可以执行ping命令,其原因正是suid。
那么,怎么保证suid程序提供的root权限不被滥用呢?换句话说,通过suid得到root权限跟使用root用户登录有什么不同呢?一般来说,suid程序都是封闭的,只干某件事情(并且干的事情都确定不会有危害),干完就退出(不会节外生枝)。再以ping为例,普通用户可以执行ping命令来发送ICMP报文。但是ping命令只会发送网络探测相关的ICMP报文,普通用户无法利用它来建立任意的ICMP报文,更不可能利用它来完成其他需要root权限才能做的事情,比如删除用户。
能不能通过修改suid程序,使其去做一些越权的事情呢?比如修改ping程序,使其能够删除用户?这也是不可以的。suid程序跟其他文件一样,受访问权限的保护,一般只有其owner才能有权限修改它,其他用户只能读或者执行。
不过,在这次的破解中,骇客却真的修改了suid程序。怎么办到的呢?利用/proc/$pid/mem。
proc文件/proc/$pid/mem是$pid进程的一份内存镜像,能够通过它来读写到进程的所有内存,包括可执行代码(它们已经映射到内存中)。在2.6.39版本以前,这份内存镜像是不可写的,不过后来这个限制被取消了。当然,对/proc/$pid/mem文件的操作也并不是任意的,如果是$pid进程自己写自己的/proc/$pid/mem文件,那么可以允许;如果是调试进程写被调试的进程,也允许。其他情况就不行了。
而骇客的想法是:当我们执行suid程序的时候,它不是会有些输出么?对于有些程序,它输出的内容正好会包含你传递给它的参数。于是,如果我们将suid程序的stderr(或stdout)重定向到/proc/$pid/mem,它在输出信息的时候不就会将你输入的信息改写到自己的内存里去了么!
比如骇客利用的su命令:
$ ll /bin/su
-rwsr-xr-x 1 root root 28336 Oct 31 2008 /bin/su
$ su hahahaha
su: user hahahaha does not exist
输入参数"hahahaha"是一个不存在的用户,su命令会通过stderr输出错误信息,并且信息里面就包含我们的输入参数"hahahaha"。如果输入参数是一段二进制代码,那么它同样也会出现在输出信息中!
然后,跟其他可执行程序一样,su的输出是可以重定向的,比如:
$ su hahahaha 2> ttt
$ cat ttt
su: user hahahaha does not exist
那么,如果将输出重定向到执行su的进程自己的/proc/$pid/mem呢,不就可以达到修改可执行代码的目的了么!
比如这样:
$ su hahahaha 2> /proc/self/mem
不过现在还有两个问题要解决……
第一个还是权限问题。现在已经让执行su的进程自己修改自己的/proc/$pid/mem,不过还不够。再来看看具体还有哪些权限检查。
1、open操作:
static int mem_open(struct inode* inode, struct file* file)
{
file->private_data = (void*)((long)current->self_exec_id);
......
}
没有权限检查,但是会将current->self_exec_id记录下来,后面会对其做校验。
2、write操作:
static ssize_t mem_write(struct file * file, const char __user *buf,size_t count, loff_t *ppos)
{
......
struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode);
......
mm = check_mem_permission(task);
copied = PTR_ERR(mm);
if (IS_ERR(mm))
goto out_free;
......
if (file->private_data != (void *)((long)current->self_exec_id))
goto out_mm;
......
}
有两处检查,一是通过check_mem_permission()检查当前进程是否可以操作该文件,这就是前面所提到的,只会允许本进程或者调试进程的操作。现在这一关已经过了。
另一处检查是对self_exec_id的检查,要求进程在对/proc/$pid/mem进行open()和write()的时候拥有相同的self_exec_id(注意,前面在open的时候已经把当时的self_exec_id记录在了file->private_data中)。另一方面,每当一个进程调用exec()来执行程序时,进程的self_exec_id会自增:
void setup_new_exec(struct linux_binprm * bprm)
{
......
current->self_exec_id++;
......
}
而我们之前的那句shell命令(su hahahaha 2> /proc/self/mem)大致是这样实现的:
int fd = open("/proc/self/mem", O_WRONLY);
dup2(fd, 2);
close(fd);
execve("/bin/su", {"su", "hahahaha"}, {...});
注意,虽然suid程序的错误输出被重定向到了/proc/self/mem,但是由于open()和write()分别发生于execve()的之前和之后,两次的self_exec_id是不同的,所以write()操作无法通过权限检查……内核正是利用self_exec_id来确保/proc/$pid/mem是程序自己打开的,而不是在程序执行之前就被打开的。
不过这里的检查不够健壮,还是有办法突破的。一个办法是一个劲地exec(),直到self_exec_id溢出。不过这样搞就是耗时太长。
另外一个办法是:
1、fork()一下,生成的子进程会拥有跟父进程相同的self_exec_id;
2、在子进程中exec()一下,执行一个自己写的程序,并在程序中open()父进程的/proc/$pid/mem(注意open的时候没有权限检查,所以能够open成功);
3、通过诸如unix socket的方法,将子进程中打开的fd传回给父进程(没想到unix socket还有这么一招吧~ man一下cmsg,看看关于SCM_RIGHTS的内容);
4、由于子进程是exec()之后再open()的,记录在file中的self_exec_id会自增一次。所以父进程exec()执行suid程序之后,write()时的self_exec_id刚好就跟open()时一样了;
OK!之前提到的两个问题,第一个权限问题已经解决了,现在我们已经能让suid程序在自己的内存空间中写一些我们想要的可执行代码。第二个问题,这些可执行代码该写到什么地方去?随便乱写显然是没有意义的。
首先,我们能控制suid程序写文件的位置吗?
可以!比如这样:
int fd = open("/proc/self/mem", O_WRONLY);
dup2(fd, 2);
lseek64(fd, pos_what_we_want, SEEK_SET);
close(fd);
execve("/bin/su", {"su", "hahahaha"}, {...});
然后su就会顺着我们lseek64()设置的位置开始写。
其次,应该选择哪个位置呢?有两个条件:
1、在我们期望的write()之后的必经之路上;
2、程序流程是跳转到这个位置来的,而不是顺序执行下来的。因为像su的输出那样("su: user hahahaha does not exist"),在我们输入的内容前面会有一些其他的信息("su: user "),这些信息肯定会把可执行代码写坏的,唯一的办法就是让程序流程不要执行到它们,而是直接跳转到我们的输入上;
比如骇客选择了exit()函数的入口,就能满足以上两个条件:程序最后都会调用libc库函数exit()来退出、而作为函数的入口点,程序流程是通过call指令跳转过来的。而lseek64()所需要指定的位置,就是exit()入口点减去strlen("su: user ")的位置。
再次,怎么找到exit()的入口点呢?
最简单的办法就是objdump,如:
$ objdump -d /bin/su | awk '$2 == "<exit@plt>:"{print}'
0000000000001c90 <exit@plt>:
还有一点就是要求suid程序被载入内存的时候位置是不能随机的,否则objdump看到的exit()入口点就不是运行时真正的入口点(并且每次运行的入口点都还可能不一样)。
通过"readelf -h $bin"查看输出的"Type"字段可以知道可执行文件$bin是否是按位置无关来编译的(DYN表示位置无关、EXEC则相反)。如果不是位置无关,那么objdump看到的地址就是运行时的地址。骇客使用了su程序来进行破解,正是因为在他的系统上,/bin/su的Type是EXEC。
在别的系统上这个未必成立,比如我的系统:
$ readelf -h /bin/su | grep Type
Type: DYN (Shared object file)
在我的系统中,可以选用umount来进行破解,也是同样的道理。
$ ll /bin/umount
-rwsr-xr-x 1 root root 40208 Nov 26 2008 /bin/umount
$ umount hahahaha
umount: hahahaha is not mounted (according to mtab)
$ readelf -h /bin/umount | grep Type
Type: EXEC (Executable file)
最后,就是要在exit()的入口点写什么内容的问题了。很简单,写一段代码,使用execve()系统调用运行一个shell就行了。suid程序已经带来了root权限,以后想干什么都交给这个shell吧~
不过注意,这里要写的不是C代码、不是汇编代码、而是二进制的机器代码。
骇客的原文见:http://blog.zx2c4.com/749,里面包含了破解代码的链接。还有,linus的补丁也已经出来了,在骇客的那篇文章中也能找到链接。如果本文所讨论的问题你都已经理解了,骇客的blog原文对于你来说也就不会有难度。