东森平台ADLab:Linux内核CVE-2017-11176漏洞分析与复现
宣布时间 2019-01-04Linux内核中的POSIX 消息行列实现中存在一个UAF漏洞CVE-2017-11176。攻击者可以利用该漏洞导致拒绝服务或执行任意代码。本文将从漏洞成因、补丁分析以及漏洞复现等多个角度对该漏洞进行详细分析。
漏洞分析
Posix消息行列允许异步事件通知,当往一个空行列放置一个消息时,Posix消息行列允许发生一个信号或启动一个线程。这种异步事件通知调用mq_notify函数实现,mq_notify为指定行列建立或删除异步通知。由于mq_notify函数在进入retry流程时没有将sock指针设置为NULL,可能导致UAF漏洞。
接下来看看漏洞起因,这里以4.1.0版本源码为例。
在mq_notify函数中, u_notification是从用户层传进来的,1193行判断u_notification是否为空,如果非空,通过copy_from_user将u_notification中的数据拷贝到notification中,这里将数据从用户层拷贝到了内核层。如果拷贝失败,直接退出。
接下来,nc和sock分别置空。行1203,如果u_notification不为空,首先依次判断notification.sigev_notify必须为SIGEV_NONE或SIGEV_SIGNAL或SIGEV_THREAD。如果notification.sigev_notify为SIGEV_SIGNAL,就判断该信号是否合法。
行1212,如果notification.sigev_notify为SIGEV_THREAD,进入要害代码块。行1216,通过alloc_skb创建一个notify_skb,用于接收数据。行1221,通过copy_from_user将notification.sigev_value.sival_ptr指向的数据拷贝到nc->data中。这里必须乐成,否则直接退出;行1229,调用skb_put设置消息数据头部。行1231到行1248是retry循环体。行1232,调用fdget函数获取文件描述符。行1237,调用netlink_getsockbyfilp函数通过文件描述符获取netlink_sock,具体看一下netlink_getsockbyfilp函数。
调用file_inode通过filp找到对应的inode节点,然后通过SOCK_I函数处置inode节点。
这里通过宏container_of在socket_alloc结构体中找出socket成员。这里解释一下,SOCKET_I返回值是socket结构体。其实sock结构体中第一个成员sock_common也是socket类型,是一个迷你版socket。
下面看一下sock_common结构体。
行1609,获取到sock后,然后判断sock->sk_family是否即是AF_NETLINK。行1613,接着调用sock_hold增加引用计数。sock_hold函数如下:
这里atomic_inc进行sk_refcnt加1。netlink_getsockbyfilp函数返回sock,这时sock的引用计数加1。接下来,行1246,调用netlink_attachskb。这是个要害函数,该函数功效是将skb绑定到netlink socket上,具体要害代码如下:
行1683,调用sock_put减少引用计数一次,最后return 1,函数返回,直接goto到retry标签地方。
这里行1237和行1246,这两处调用正好进行了引用计数抵消。行1247的if语句中并没有将sock置空,再看行1233,如果f.file为空,那就直接goto到out标签。out标签代码如下:
行1306,判断sock是否为空,如果不为空,调用netlink_detachskb函数。
释放skb,并减少sk引用计数,进行释放。 那么就有问题了,如果我们创建A线程保持netlink_attachskb返回1,并重复retry逻辑,这个时候sock的引用计数是保持平衡的,一加一减,但是sock并不是为空。同时再创建B线程去关闭netlink socket对应的文件描述符。由于B线程关闭了netlink socket的文件描述符,那A线程在retry逻辑中,行1232,调用fdget时会失败,然后直接goto到out标签,进行释放,进行了二次释放,导致漏洞。这个漏洞是属于条件竞争型的二次释放漏洞,只在一个线程中,是无法触发漏洞。
行1657,通过nlk_sk函数通过sk获取netlink_sock。这里的nlk_sk如下。
通过调用宏container_of获取netlink_sock。netlink_sock结构体如下:
netlink_sock结构体第一个成员是sock类型,而sock结构体的第一个成员是socket。行1660,第一个if判断必须得进入。
!netlink_skb_is_mmaped(skb)肯定返回true,要害是sk->sk_rmem_alloc>sk->sk_rcvbuf || test_bit(NETLINK_CONGESTED, &nlk->state)结果必须是true。
假如if判断不通过,接着调用netlink_skb_set_owner_r函数,如下所示。
行878,调用宏atomic_add,该宏执行原子加操作。这行代码的含义是:在sk->sk_rmem_alloc的基础上加上skb->truesize。等同于sk->sk_rmem_alloc += skb->truesize。既然该函数里这行代码可以直接增加sk->sk_rmem_alloc的巨细,那么可不行以多次调用netlink_skb_set_owner_r函数增加sk->rmem_alloc的值?理论上是完全可以的,看看如何从用户层到达这个函数。
通过understand工具可以快速找到netlink_skb_set_owner_r的调用链:netlink_sendmsg->netlink_unicast->netlink_attachskb->netlink_skb_set_owner_r。
行2285,首先判断msg->msg_flag不能为MSG_OOB,继续往下看。
行2292,判断msg->msg_namelen的长度,这里必须不为空,虽然也不会为空。进入if后,判断addr->nl_family是否即是AF_NETLINK。行2299,判断dst_group或dst_portid不为空,dst_group体现多播模式,dst_portid来自于addr->nl_pid,因此保证dst_portid不为空比力容易。接下来:
行2320,判断了msg->msg_iter.iov->iov_base不能为空。而且len不行以大于sk->sk_sndbuf-32。
其实整个函数中,用户层可控的只有这么多。直接看netlink_unicast的调用。
netlink_unicast函数实现如下:
整个函数中,用户能控制的不多。行1783,设置了timeo,这里要保证nonblock为msg->msg_flags&MSG_DONTWAIT,这样线程才不会被block。行1790,判断sk是否为内核版的sk,在用户层创建socket时应使用NETLINK_USERSOCK。行1793,判断是否有sk_filter,这里保证不进入该if语句,不要设置过滤器。行1800,直接调用netlink_attachskb,乐成到达netlink_skb_set_owner_r函数。这算是通过调用netlink_sendmsg来增加sk->sk_rmem_alloc的过程。其实我们不光可以增加sk->sk_rmem_alloc,还可以减小sk->sk_rcvbuf。
行773,sk->sk_rcvbuf取val*2和SOCK_MIN_RCVBUF之间的最大值。行755,val取val和sysctl_rmem_max之间的最小值。行749,这个case为SO_RCVBUF。继续往上看。
行693,要保证optlen不小于sizeof(int)。行696,将optval赋值到val中,这里optval是用户可控的。行703,switch分发optname,所以要保证optname为SO_RCVBUF。这样就可以保证顺利到达修改sk->rcvbuf的代码处。
到这里,我们通过两种方式进行绕过netlink_attachskb函数中的第一个check。
(2)通过sock_setsockopt尽可能地减小sk->rcvbuf的值。
这段代码会让当前线程进入期待状态,直接block。如果不想进入期待状态,只有设置sock_flag为SOCK_DEAD。但是如果把sock_flag设置成SOCK_DEAD,那后面也没有须要进行,因此这里是一定要进入期待状态的。一种巧妙的要领是直接调用wake_up_interruptible强行唤醒线程。那如何调用wake_up_interruptible呢?函数调用链非常简短:netlink_setsockopt->wake_up_interruptible。
行2182,调用wake_up_interruptible唤醒线程。行2178,case为NETLINK_NO_ENOBUFS。
行2131,判断level必须为SOL_NETLINK,行2134,判断optname不能为NETLINK_RX_RING和NETLINK_TX_RING,同时保证optlen大于即是sizeof(int)。行2139,switch分发optname,这里要保证optname为NETLINK_NO_ENOBUFS。到这里,基本上就可以保证netlink_attachskb返回1。
行1232,通过fdget获取notification.sigev_signo的fd。Notification.sigev_signo是用户态传进来的,因此完全可以在用户层直接close这个socket。在用户层close这个socket后,行1233,进入if逻辑,然后跳到out标签。
这个时候sock是非空的,if判断为真,进入netlink_destachskb,接着就是free瓦解。
漏洞复现
凭据内核工具内存分配规则, netlink_sock工具应该从kmalloc-1024这个缓存中进行分配。
slab分配器在分配工具时,遵守后进先出的规则。下面是slab分配器释放工具的过程。
要释放的工具objp放在了ac->entry[]的末端。下面是slab分配器分配工具的过程:
分配工具直接从ac->entry[]末端弹出一个工具。
所以一个刚刚被释放的工具是排在链表末段,如果此时恰好在同一缓存中进行工具分配,那刚刚释放的工具就会被重新分配出去,这就泛起两个指针指向同一块内存地址。要想保证申请的内存正好落在漏洞工具的内存位置中,需要掌握住几点:
堆喷工具使用的内核缓存应该和漏洞工具内存在同一个缓存中。即巨细必须落在同一个kmalloc-X中。
ac自己是array_chche结构体,该结构体是当地高速缓存,每个CPU对应一个,所以还要保证堆喷申请的工具和漏洞工具在同一个CPU当地高速缓存中。
如果堆喷申请的工具只是短暂驻留,当该函数返回时将申请的工具进行了释放,导致无法正确占位。所以要能保证申请的工具不被释放,至少保证在使用漏洞工具时不被释放,这里要接纳驻留式内存占位,可以接纳让某些系统调用过程阻塞。
slab缓存碎片化问题,这里要占位的工具巨细为1008,工具尺寸比力大,占据四分之一页,比力整齐,应该没有碎片化问题。
那么如何判断堆喷是否乐成呢?
接纳getsockname系统调用获取数据,getsockname会调用netlink_getname。具体看一下netlink_getname函数:
代码1576行,将netlink_sock工具中的portid复制给nladdr->nl_pid。代码1577行,如果nlk->group为0,将nladdr->nl_groups赋值为NULL,这里制止解引用nlk->groups指针,直接可以在结构堆喷工具时将groups域填零。而nladdr是从addr转换过来的,addr就是从用户层传入的缓冲区。
通常情况是笼罩结构体中的函数指针或者包罗函数指针的结构体成员,这视情况而定。这里选择笼罩wait期待行列。netlink_sock结构体如下:
wait_queue_haed_t结构体如下:
task_list成员是一个双向循环链表头,task_list中链接的每一个成员都是需要处置的期待例程元素。那该如何使用这个成员?看如下代码。
这是netlink_setsockopt函数中的代码片段,前面恢复线程复生分析过,这里将会调用netlink_sock工具中的期待例程,直接使用参数nlk->wait。继续深入分析:
调用__wake_up_common函数:
代码70行,宏list_for_each_entry_safe遍历q->task_list中的成员,返回到curr。代码68行,curr为wait_queue_t指针,说明q->task_list链表中存的是wait_queue_t类型的元素,wait_queue_t结构体如下:
wait_queue_t结构体中有一个函数指针func。再看__wake_up_common函数中,代码73行,直接执行curr>func函数,可以通过结构__wait_queue的func参数控制RIP。再回过头看list_for_each_entry_safe宏:
pos是__wait_queue元素,代码62行,对pos->member.next进行了解引用,这里的pos->member就是__wait_queue中的task_list。__wait_queue中的task_list也是一个链表头,需要指向一个list_head,所以还必须要结构一个假的list_head以便于该宏进行解引用。测试如下:
接下来就是通过ROP链绕过SMEP执行提权代码。乐成提权后如下所示:
