1.漏洞概况:
漏洞距今已经7年多了。我为什么会再次选择这个漏洞来看一看呢?因为CVE-2019-9213的出现,这个CVE涉及到对/proc/self/mem的写,而/proc/$pid/mem这个pseudo-file在设计之初设定是readonly,就是说只能读不能写。
而发生CVE-2012-0056的时候,就是刚好linux官方删除对/proc/$pid/mem
可写限制commit的时候,commit: 198214a7ee50375fa71a65e518341980cfd4b2f0 ,漏洞成因就出现在关于对/proc/$pid/mem
的写过程中的检查上,很巧妙了绕过了其中2个检查。
2.具体成因:
在linux中一切皆文件,对/proc/$pid/mem
读写也不例外,首先需要关注是/mem
的file_operations
结构,在fs/proc/base.c
下。
static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
};
首先来看第一步mem_open
static int mem_open(struct inode* inode, struct file* file)
{
file->private_data = (void*)((long)current->self_exec_id);
/* OK to pass negative loff_t, we can catch out-of-range */
file->f_mode |= FMODE_UNSIGNED_OFFSET;
return 0;
}
open操作很简洁,值得注意是保存了打开文件进程的self_exec_id
,这个进程属性,在整个系统中引用的地方并不多,发生改变的地方,有以下几处:
void setup_new_exec(struct linux_binprm * bprm)
{
....
current->self_exec_id++;
flush_signal_handlers(current, 0);
flush_old_files(current->files);
}
EXPORT_SYMBOL(setup_new_exec);
这处是exec执行新二进制程序的时候,self_exec_id
会发生自增。还有一处是发生在fork进程的时候,子进程会保留父进程的self_exec_id
,self_exec_id
初始化的过程有些不同。
再接着看第二步,mem_write
过程,我们重点关注其中的check点:
static ssize_t mem_write(struct file * file, const char __user *buf,
size_t count, loff_t *ppos)
{
int copied;
char *page;
struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode); //#1
unsigned long dst = *ppos;
struct mm_struct *mm;
copied = -ESRCH;
if (!task)
goto out_no_task;
...
mm = check_mem_permission(task);//#2
copied = PTR_ERR(mm);
if (IS_ERR(mm))
goto out_free;
...
copied = -EIO;
if (file->private_data != (void *)((long)current->self_exec_id))//#3
goto out_mm;
out_mm:
mmput(mm);
out_free:
free_page((unsigned long) page);
out_task:
put_task_struct(task);
out_no_task:
return copied;
}
代码中标注了三个点,首先看第一个点,获取task的过程:
struct task_struct *get_pid_task(struct pid *pid, enum pid_type type)
{
struct task_struct *result;
rcu_read_lock();
result = pid_task(pid, type);
if (result)
get_task_struct(result);
rcu_read_unlock();
return result;
}
task的获取过程和被写的进程pid是紧密联系在一起的,无关是谁最先打开了file。接着再看第一个check check_mem_permission
:
static struct mm_struct *__check_mem_permission(struct task_struct *task)
{
struct mm_struct *mm;
mm = get_task_mm(task);
if (!mm)
return ERR_PTR(-EINVAL);
/*
* A task can always look at itself, in case it chooses
* to use system calls instead of load instructions.
*/
if (task == current)
return mm;
if (task_is_stopped_or_traced(task)) {
int match;
rcu_read_lock();
match = (ptrace_parent(task) == current);
rcu_read_unlock();
if (match && ptrace_may_access(task, PTRACE_MODE_ATTACH))
return mm;
}
mmput(mm);
return ERR_PTR(-EPERM);
}
这里存在两个条件第一个条件是被写的进程和发起写的进程是同一个进程,就是说一个进程是可以直接写/proc/self/mem
的。第二个条件是相当于写其他经常进程之前要被ptrace挂起。这个check看起来还是比较苛刻的。继续看第二个check:
if (file->private_data != (void *)((long)current->self_exec_id))
goto out_mm;
这个check关系到了前面提到的self_exec_id
,这个check点的意义相当于把打开/mem
的进程和写/mem
进程稍微联系起来了,这里用了稍微这个词,显然我觉得这个check再这里并没什么意义。
现在再来组合起来看漏洞的成因,如何利用/proc/self/mem
来提权?如果我们能写setuid的进程内存,就可以到达提权的效果,具有setuid权限的二进制程序最常见的就是su
,而且su有一个标准错误的输出,当使用su not_exist_user
的时候会有一下类似的输出:
[email protected]:~# su not_exist_user
No passwd entry for user 'not_exist_user'
不同版本的su输出不太一样,但是这里not_exist_user
都会一样输出。这样一来就可以控制写的内存,一个比较好的想法就随之而来:
fd = open('/proc/self/mem');
dup2(2,7);
dup2(fd,2);
lseek(fd,awesome_place,SEEK_SET);
execl('/bin/su',"su",shellcode);
但是这里不能这样简单处理,注意第二个check点,让打开/mem
的进程和写/mem
的进程有那么一点小联系。显然这里经过execl以后,导致了self_exec_id++
和mm_open
里面的self_exec_id
不相等了,前面也说这个check有问题,现在如果再execl之前先fork一个子进程,再让子进程execl一下,再通过子进程打开/proc/$ppid/mem
,现在在mm_open
这一步设置self_exec_id
的时候是在原理的基础上加一了,再通过unix socket把打开的/proc/$ppid/mem
回传给父进程。这样就成功绕过了第二个check,导致shellcode写入目标内存。
再来看一看忘哪写?如何去劫持su的程序流弹shell,当su 输出错误以后,之后会执行exit,所以理所当然我们劫持exit地址的内存,这要说到另外一个点,为什么选择su
,su除了可以输出可控的字符串,早期的su
是静态编译的,没有重定位的过程,也没有PIE,所以这里你不用去考虑aslr带来的影响,这里也提出还有其他非PIE编译的具有setuid的二进制比如gpasswd
关于此处的修复。在mm_open
处做了额外的处理:
static int mem_open(struct inode* inode, struct file* file)
{
- file->private_data = (void*)((long)current->self_exec_id);
+ struct task_struct *task = get_proc_task(file->f_path.dentry->d_inode);
+ struct mm_struct *mm;
+
+ if (!task)
+ return -ESRCH;
+
+ mm = mm_access(task, PTRACE_MODE_ATTACH);
+ put_task_struct(task);
+
+ if (IS_ERR(mm))
+ return PTR_ERR(mm);
+
/* OK to pass negative loff_t, we can catch out-of-range */
file->f_mode |= FMODE_UNSIGNED_OFFSET;
+ file->private_data = mm;
+
return 0;
}
在open_的时候就判断了对目标内存的读写权限。而且file->private_data
直接保存了目标内存的结构,而不是在mm_write
的时候动态获取。显然现在无法用execl来替换/proc/self/mem
的目标内存了,也符合我的预期修复方式,让打开/mem
进程和写/mem
进程更紧密的联合在一起。
这里关于找su
里面[email protected]
位置的也比较有意思,开始时设想用objdump找。但是可能目标系统上没有它,exploit直接穿插了一段用ptrace调试su
来找[email protected]
的位置也比较有趣。
如果开了PIE和aslr的setuid的二进制这里时候似乎会变的异常的复杂。可能我们需要把su挂起来。这里我没有想到能绕aslr的方法。。。还是太菜了,无力。
同时也学到了用unix socket来传递父子进程间fd的方法。惊叹于作者对进程间理解,也感叹自己菜的真实。