CVE-2010-4258分析&&set_fs(KERNEL_DS)与内核文件读写

在用户态,可以使用open、close、read、write等库调用对磁盘文件进行操作,但是内核没有这样的函数调用.通过跟踪open等函数执行流程,可以看到最终调用内核的sys_read,sys_write函数,但是这两个函数没有导出符号.不过,有vfs_read,vfs_write函数会调用sys_read,sys_write,而且传入的参数基本上相同,因此,可以通过调用vfs_read等实现对文件的读写.

不过,内核默认给出的文件操作是给用户层使用的,所以默认传入的参数都来自用户层,为了避免用户层修改内核层数据,会对传入的参数检查是否越界到内核地址.

因此,当内核调用这类操作函数的时候,就必须传入用户层的地址.但是内核是不能轻易获得用户层的地址的,所以通过set_fs(KERNEL_DS)将当前进程的地址空间上限设为KERNL_DS,就能绕过检查,实现对文件的操作.

file_open,filp_close,vfs_read,vfs_write:

1
2
3
4
5
6
7
8
struct file *filp_open(const char *filename, int flags, umode_t mode) ;

int filp_close(struct file *filp, fl_owner_t id) ;

ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos);

ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) ;

其中的buf参数表明是指向的用户空间,但是我们在内核调用的时候,传入的buf是在内核空间,因此检查参数的时候就会出错,导致我们不能通过内核空间直接传数据到文件里.

为了解决这个问题,我们可以通过set_fs函数解除这个束缚.

1
2
3
4
5
6
7
typedef struct {
unsigned long seg;
} mm_segment_t;

static inline void set_fs(mm_segment_t fs){
current_thread_info()->addr_limit = fs;
}

而fs只有两个值:KERNEL_DS,USER_DS(对于x86系统 KERNEL_DS=0xFFFFFFFF USER_DS=0xC0000000)

一个简单的示例(完整代码见最尾):

1
2
3
4
5
6
7
fp = filp_open("/home/victorv/t.txt", O_RDWR | O_CREAT, 0644);
cur_mm_seg = get_fs();
set_fs(KERNEL_DS);
vfs_write(fp, wbuf, sizeof(wbuf), &fpos);
fpos = 0;
vfs_read(fp, rbuf, sizeof(rbuf), &fpos);
set_fs(cur_mm_seg);`

简述

Nelson Elhage发现了一个内核设计上的漏洞,通过利用这个漏洞可以将一些以前只能dos的漏洞变成可以权限提升的漏洞。线程退出的时候,如果设置了”CLONE_CHILD_CLEARTID”标志,会对线程的clear_child_tid 置零,置零前会检查指针地址是否越界到内核空间.

这个过程本身没什么问题,问题在于,当出现内核OOPS的时候,会执行进程退出操作,又由于大多数的OOPS都伴有set_fs(KERNEL_DS),使得退出流程前并没有及时恢复成USER_DS,从而绕过越界检查,实现对任意地址置零.

如果绕过检查,置零了内核某函数指针的高位,使之指向用户空间,再调用了该函数,就能劫持内核流程到用户空间.

OOPs:当内核检测到问题时,它会打印一个oops消息然后杀死全部相关进程。当oops非常严重,内核决定直接结束系统时,就叫panic.

代码分析

退出流程

如果设置了CLONE_CHILD_CLEARTID标志,就会将child_tidptr赋值给clear_child_tid,而child_tidptr来自用户空间,可以受用户控制,意味着我们可以指向内核地址.

下面这个是clone的函数原型,ctid就是child_tidptr指针

1
2
3
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );

CLONE_CHILD_CLEARTID (since Linux 2.5.49)
在线程退出的时候清除子线程的ctid执行的地址,并唤醒该地址的futex.

其中,put_user(x,ptr)函数将ptr指向的地址修改为x.即对”clear_child_tid”指向的内存置零

__put_user_check的功能是根据ptr的类型大小,利用__put_user_x宏将x拷贝1,2,4,8个字节到ptr所指向的内存

1
2
3
175 #define __put_user_x(size, x, ptr, __ret_pu)                    \
176 asm volatile("call __put_user_" #size : "=a" (__ret_pu) \
177 : "" ((typeof(*(ptr)))(x)), "c" (ptr) : "ebx")

__put_user_x完成两件事,将eax填充为x,将ecx填充为ptr,因为clear_child_tid是int类型,所以这里会调用__put_user_4

1
2
3
4
5
6
7
8
9
10
ENTRY(__put_user_4)
ENTER
mov TI_addr_limit(%_ASM_BX),%_ASM_BX//TI_addr_limit得到当前进程的地址空间上限放在ebx
sub $3,%_ASM_BX
cmp %_ASM_BX,%_ASM_CX //比较要访问的地址是否高于内核地址
jae bad_put_user //如果超过就不拷贝.
3: movl %eax,(%_ASM_CX)//将ptr指向的地址置零
xor %eax,%eax
EXIT
ENDPROC(__put_user_4)

通过分析上述代码,可以得出这样的结论:设置了CLONE_CHILD_CLEARTID标志的进程(线程)退出的时候,会对”child_tidptr”指向的地址执行置零操作,且地址由我们控制.

do_page_fault –> do_exit –> exit_mm –> mm_release –> put_user
因此,找一个执行了set_fs(KERNEL_DS)的漏洞,就可以实现内核任意地址置零操作.

利用漏洞触发OOPS(CVE-2010-3849)

在CVE-2010-3849漏洞里,msg->msg_name的值可以由用户自由控制,而econnet_sendmsg函数会调用这个指针,如果把该指针设为NULL,就会触发OOPS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
1094 ssize_t sock_no_sendpage(struct socket *sock, struct page *page, int offset, size_t size, int flags){ 
1104 msg.msg_name = NULL;
...
1115 old_fs = get_fs();
1116 set_fs(KERNEL_DS);
1117 res = sock_sendmsg(sock, &msg, size);

1118 set_fs(old_fs);
1120 }

500 int sock_sendmsg(struct socket *sock, struct msghdr *msg, int size){
sock->ops->sendmsg(sock, msg, size, &scm);


}

static int econet_sendmsg(struct kiocb *iocb, struct socket *sock,
struct msghdr *msg, size_t len)

{

struct sockaddr_ec *saddr=(struct sockaddr_ec *)msg->msg_name;
...
eb->cookie = saddr->cookie; <<-----------------------

}

追溯一下触发异常的流程如下图(不同版本的内核可能会多一个__sock_sendmsg函数,无大碍):

结合两个漏洞,利用CVE-2010-3849触发oops,再利用CVE-2010-4258实现对内核函数地址的置零,从而控制内核函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
748 static const struct proto_ops econet_ops = {
749 .family = PF_ECONET,
750 .owner = THIS_MODULE,
751 .release = econet_release,
752 .bind = econet_bind,
753 .connect = sock_no_connect,
754 .socketpair = sock_no_socketpair,
755 .accept = sock_no_accept,
756 .getname = econet_getname,
757 .poll = datagram_poll,
758 .ioctl = econet_ioctl,
759 .listen = sock_no_listen,
760 .shutdown = sock_no_shutdown,
761 .setsockopt = sock_no_setsockopt,
762 .getsockopt = sock_no_getsockopt,
763 .sendmsg = econet_sendmsg,
764 .recvmsg = econet_recvmsg,
765 .mmap = sock_no_mmap,
766 .sendpage = sock_no_sendpage,
767 };

然而,在配置econet的时候,需要设置econet的地址

1
ioctl(econet_socket, SIOCSIFADDR, &ifr);

而”SIOCSIFADDR”是个特权操作,为了安全考虑,只接收AF_INET地址,所以我们的econet就不能设置.为了实现地址设置,需要用到CVE-2010-3850

CVE-2010-3850:ec_dev_ioctl函数在内核版本2.6.36前,不需要CAP_NET_ADMIN权限就能允许普通用户绕过权限限制,实现通过ioctl的SIOCSIFADDR来设置econet 地址.

漏洞利用

  1. 获取所需内核函数地址
  2. 提前在地址econet_ioctl对应的用户地址布置提权代码
  3. 通过发送全0的消息,触发调用set_fs(KERNEL_DS)的内核bug(比如非法读取),导致oops,让它把当前线程kill掉(这里使用cve-2010-3849)
  4. do_page_fault将我们精心指向的内核函数地址高位置零.假设由0xcc401234变成0x00401234
  5. 在主进程调用ioctl触发修改过的函数econet_ioctl,执行提权.

具体细节:

splice() 在这里就相当于把/dev/zero通过管道与econet连接起来.
/dev/zero 是一个特殊的文件,当你读它的时候,它会提供无限的空字符(NULL, ASCII NUL, 0x00)。

环境:ubuntu 10.04,内核:2.6.32

断点设置

econet_sendmsg

获取econet_ops的地址

在被调试机机

1
2
$ cat /sys/module/econet/sections/.text 
0xf806a000

如果没有这个模块,就手动加载

调试机加载符号文件

1
$ add-symbol-file econet.ko 0xf806a000

查看econet_create函数

1
$ disassmble econet_create


可以看到econet_ops的地址是0xf806b380
查看一下结构的内容:

对比exploit的get_kernel_symbol函数得到的结果:

地址正确

触发oops

运行exploit,断在econet_sendmsg,继续执行出现非法读取:

这里有个bug,就是在开启kgdb调试的时候,它无法完成正确的处理,导致一直在重复do_page_default函数,然后没办法结束线程.
为了查看调试结果,我在mm_release函数里添加了个判断语句:

1
2
if(tsk->clear_child_tid > USER_DS)
printk("kernel_reset:%p",(int)tsk->clear_child_tid);

查看printk的输出:

成功提权结果

针对4258,在mm_release里添加对tsk->clear_child_tid > USER_DS的判断,如果成立就直接返回,或者在do_exit里添加set_fs(USER_DS)

4528patch:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
diff --git a/kernel/exit.c b/kernel/exit.c
index b64937a..69f4445 100644
--- a/kernel/exit.c
+++ b/kernel/exit.c
@@ -907,6 +907,15 @@ NORET_TYPE void do_exit(long code)
if (unlikely(!tsk->pid))
panic("Attempted to kill the idle task!");

+ /*
+ * If do_exit is called because this processes oopsed, it's possible
+ * that get_fs() was left as KERNEL_DS, so reset it to USER_DS before
+ * continuing. Amongst other possible reasons, this is to prevent
+ * mm_release()->clear_child_tid() from writing to a user-controlled
+ * kernel address.
+ */
+ set_fs(USER_DS);
+
tracehook_report_exit(&code);

/*

官方的做法是恢复界限,避免多余的检查

#总结
虽然这是个很老的漏洞,但是它结合其它漏洞的联合利用的方法,以及对一个漏洞的新开发值得好好学习,也让我对linux的各种处理机制有了更多了解,还学习了如何在内核层操作文件的方法,受益匪浅.

#疑问

简单的文件读写操作

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <asm/uaccess.h>

#define SLAM_FILE_PATH "/home/victorv/slam.txt"

static char wbuf[] = "Hello slam-victorv";
static char rbuf[128];

static int vfs_operate_init(void)
{

struct file * fp;
mm_segment_t cur_mm_seg;
loff_t fpos = 0;

printk("<victorv>in %s!\n",__func__);
fp = filp_open(SLAM_FILE_PATH, O_RDWR | O_CREAT, 0644);
if (IS_ERR(fp)) {
printk("<victorv>filp_open error\n");
return -1;
}

cur_mm_seg = get_fs();
set_fs(KERNEL_DS);
vfs_write(fp, wbuf, sizeof(wbuf), &fpos);
fpos = 0;
vfs_read(fp, rbuf, sizeof(rbuf), &fpos);
printk("<victorv>read content: %s\n", rbuf);
set_fs(cur_mm_seg);

filp_close(fp, NULL);
return 0;
}

static void vfs_operate_exit(void)
{

printk("Bye %s!\n", __func__);
}

module_init(vfs_operate_init);
module_exit(vfs_operate_exit);

MODULE_LICENSE("GPL");

reference:
相关源码引用地址
vfs_read
KERNEL_DS
exploit:full-nelson.c
kernel-exploit
cve-2010-3850
分析参考