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
下。 1
2
3
4
5
6static const struct file_operations proc_mem_operations = {
.llseek = mem_lseek,
.read = mem_read,
.write = mem_write,
.open = mem_open,
};mem_open
1
2
3
4
5
6
7static 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;
}self_exec_id
,这个进程属性,在整个系统中引用的地方并不多,发生改变的地方,有以下几处: 1
2
3
4
5
6
7
8
9
10void 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);self_exec_id
会发生自增。还有一处是发生在fork进程的时候,子进程会保留父进程的self_exec_id
,self_exec_id
初始化的过程有些不同。
再接着看第二步,mem_write
过程,我们重点关注其中的check点: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31static 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;
}1
2
3
4
5
6
7
8
9
10struct 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;
}check_mem_permission
: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26static 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: 1
2if (file->private_data != (void *)((long)current->self_exec_id))
goto out_mm;self_exec_id
,这个check点的意义相当于把打开/mem
的进程和写/mem
进程稍微联系起来了,这里用了稍微这个词,显然我觉得这个check再这里并没什么意义。
现在再来组合起来看漏洞的成因,如何利用/proc/self/mem
来提权?如果我们能写setuid的进程内存,就可以到达提权的效果,具有setuid权限的二进制程序最常见的就是su
,而且su有一个标准错误的输出,当使用su not_exist_user
的时候会有一下类似的输出: 1
2root@kali:~# su not_exist_user
No passwd entry for user 'not_exist_user'not_exist_user
都会一样输出。这样一来就可以控制写的内存,一个比较好的想法就随之而来: 1
2
3
4
5fd = open('/proc/self/mem');
dup2(2,7);
dup2(fd,2);
lseek(fd,awesome_place,SEEK_SET);
execl('/bin/su',"su",shellcode);/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
处做了额外的处理: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22static 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;
}file->private_data
直接保存了目标内存的结构,而不是在mm_write
的时候动态获取。显然现在无法用execl来替换/proc/self/mem
的目标内存了,也符合我的预期修复方式,让打开/mem
进程和写/mem
进程更紧密的联合在一起。
这里关于找su
里面exit@plt
位置的也比较有意思,开始时设想用objdump找。但是可能目标系统上没有它,exploit直接穿插了一段用ptrace调试su
来找exit@plt
的位置也比较有趣。
如果开了PIE和aslr的setuid的二进制这里时候似乎会变的异常的复杂。可能我们需要把su挂起来。这里我没有想到能绕aslr的方法。。。还是太菜了,无力。
同时也学到了用unix socket来传递父子进程间fd的方法。惊叹于作者对进程间理解,也感叹自己菜的真实。