CVE-2021-22555
环境搭建
参考文章:
CVE-2021-22555 2字节堆溢出写0漏洞提权分析 - 安全客,安全资讯平台 (anquanke.com)
或者我写的菜鸡项目:
🔺注:
注意的是,在我写的项目里的CVE环境中,去掉了配置:CONFIG_SECURITY=n
,原因是在load_msg()
函数中申请msg_msg
结构体时,如下所示,会调用到security_msg_msg_alloc()
函数,给msg_msg
结构体中的security
指针赋值,导致下面漏洞利用时读取伪造msg_msg
结构体由于检测security
导致出错。
1 | //v5.11.14 /ipc/msgutil.c |
而去掉了配置:CONFIG_SECURITY=n
,可以不用security
指针,这样就不会出错了
1 | //v5.11.14 /include/linux/security.h |
但是在bsauce
师傅提供的环境中有添加该配置,而security
指针的值却还是为空。简单看了一下源码,如下函数链
1 | load_msg()->security_msg_msg_alloc()->lsm_msg_msg_alloc() |
对于lsm_msg_msg_alloc()
函数如下定义
1 | static int lsm_msg_msg_alloc(struct msg_msg *mp) |
可以看到这里进行相关赋值,如果满足blob_sizes.lbs_msg_msg == 0
那么其security
指针为空,后续检测时也依据此判断不检测。而对于这个blob_sizes.lbs_msg_msg
不是很熟悉,可能是我的相关配置问题吧。这里为了方便,我就直接将这个配置去掉了。
此外经过实际测试,源码也可以看出来,其实security
也就是一个堆地址(以0x8递增),是不断变化的,但是如果能泄露出其中一个,那么后续检测就能都通过了。
前置知识
完成这个漏洞的利用还是需要一些前置知识的,刚好利用这个漏洞重新完善一下相关的知识点。
1.msg_msg结构体—kmalloc-16至kmalloc-1024
这个在之前也总结过,不过总结得有些错误,也不太完善,这里再好好总结一下
参照:【NOTES.0x08】Linux Kernel Pwn IV:通用结构体与技巧 - arttnba3’s blog
Linux内核中利用msg_msg结构实现任意地址读写 - 安全客,安全资讯平台 (anquanke.com)
Linux的进程间通信 - 消息队列 · Poor Zorro’s Linux Book (gitbooks.io)
《Linux系统编程手册》
虽然写的是最大kmalloc-1024
,但是在堆喷时,可以连续kmalloc(1024)
从而获得连续的堆内存分布,这样都释放掉之后再经过回收机制就可以申请到更大的kmallo-xx
了。
(1)使用方法
①创建
首先创建
queue_id
管理标志,对应于内核空间的msg_queue
管理结构1
2
3
4
5
6
7
8
9
10
11
12
13
14
15//key要么使用ftok()算法生成,要么指定为IPC_PRIVATE
//代表着该消息队列在内核中唯一的标识符
//使用IPC_PRIVATE会生成全新的消息队列IPC对象
int32_t make_queue(key_t key, int msg_flag)
{
int32_t result;
if ((result = msgget(key, msg_flag)) == -1)
{
perror("msgget failure");
exit(-1);
}
return result;
}
int queue_id = make_queue(IPC_PRIVATE, 0666 | IPC_CREAT);使用简单封装的
msgget
函数或者系统调用号__NR_msgget
,之后保存数据的消息就会在这个queue_id
管理标志,以及内核空间的msg_queue
管理结构下进行创建
②数据传输
写入消息:
然后就可以依据
queue_id
写入消息了,不同于pipe
和socketpair
,这个需要特定的封装函数(msgsnd/msgrcv
)或者对应的系统调用(__NR_msgrcv/__NR_msgsnd
)来实现。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22typedef struct
{
long mtype;
char mtext[1];
}msgp;
//msg_buf实际上为msgp,里面包含mtype,这个mtype在后面的堆块构造中很有用
void send_msg(int msg_queue_id, void *msg_buf, size_t msg_size, int msg_flag)
{
if (msgsnd(msg_queue_id, msg_buf, msg_size, msg_flag) == -1)
{
perror("msgsend failure");
exit(-1);
}
return;
}
char queue_send_buf[0x2000];
m_ts_size = 0x400-0x30;//任意指定
msg *message = (msg *)queue_send_buf;
message->mtype = 0;
send_msg(queue_id, message, m_ts_size, 0);读取消息:
之后即可依据
queue_id
读取消息1
2
3
4
5
6
7
8
9
10
11
12
13void get_msg(int msg_queue_id, void *msg_buf, size_t msg_size, long msgtyp, int msg_flag)
{
if (msgrcv(msg_queue_id, msg_buf, msg_size, msgtyp, msg_flag) < 0)
{
perror("msgrcv");
exit(-1);
}
return;
}
char queue_recv_buf[0x2000];
m_ts_size = 0x400-0x30;//任意指定
get_msg(queue_id, queue_recv_buf, m_ts_size, 0, IPC_NOWAIT | MSG_COPY);mtype
可通过设置该值来实现不同顺序的消息读取,在之后的堆块构造中很有用
- 在写入消息时,指定
mtype
,后续接收消息时可以依据此mtype
来进行非顺序接收 - 在读取消息时,指定
msgtyp
,分为如下情况msgtyp
大于0:那么在find_msg
函数中,就会将遍历寻找消息队列里的第一条等于msgtyp
的消息,然后进行后续操作。msgtyp
等于0:即类似于顺序读取,find_msg
函数会直接获取到消息队列首个消息。msgtyp
小于0:会将等待的消息当成优先队列来处理,mtype
的值越小,其优先级越高。
- 在写入消息时,指定
msg_flag
可以关注一下MSG_NOERROR
标志位,比如说msg_flag
没有设置MSG_NOERROR
的时候,那么情况如下:
假定获取消息时输入的长度m_ts_size
为0x200
,且这个长度大于通过find_msg()
函数获取到的消息长度0x200
,则可以顺利读取,如果该长度小于获取到的消息长度0x200
,则会出现如下错误
但是如果设置了MSG_NOERROR
,那么即使传入接收消息的长度小于获取到的消息长度,仍然可以顺利获取,但是多余的消息会被截断,相关内存还是会被释放,这个在源代码中也有所体现。
1 | //v5.11 /ipc/msg.c do_msgrcv函数中 |
此外还有更多的msg_flag
,就不一一举例了。
③释放
这个主要是用到msgctl
封装函数或者__NR_msgctl
系统调用,直接释放掉所有的消息结构,包括申请的msg_queue
的结构
1 | //其中IPC_RMID这个cmd命令代表释放掉该消息队列的所有消息,各种内存结构体等 |
不过一般也用不到,可能某些合并obj的情况能用到?
此外还有更多的cmd
命令,常用来设置内核空间的msg_queue
结构上的相关数据,不过多介绍了。
总结
总结一下大致的使用方法如下
1 | typedef struct |
(2)内存分配与释放
①创建
A.内存申请
还是需要先创建
msg_queue
结构体,使用msgget
函数,调用链为1
msgget(key,msg_flag)->ksys_msgget()->ipcget()->ipcget_new()->newque()
主要还是关注最后的
newque()
函数,在该函数中使用kvmalloc()
申请堆块,大小为0x100,属于kmalloc-256
,(不同版本大小貌似不同)。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//v5.11 /ipc/msg.c
static int newque(struct ipc_namespace *ns, struct ipc_params *params)
{
struct msg_queue *msq;
int retval;
key_t key = params->key;
int msgflg = params->flg;
//这个才是实际申请的堆块内存
msq = kvmalloc(sizeof(*msq), GFP_KERNEL);
if (unlikely(!msq))
return -ENOMEM;
msq->q_perm.mode = msgflg & S_IRWXUGO;
msq->q_perm.key = key;
msq->q_perm.security = NULL;
//进行相关注册
retval = security_msg_queue_alloc(&msq->q_perm);
if (retval) {
kvfree(msq);
return retval;
}
//初始化
msq->q_stime = msq->q_rtime = 0;
msq->q_ctime = ktime_get_real_seconds();
msq->q_cbytes = msq->q_qnum = 0;
msq->q_qbytes = ns->msg_ctlmnb;
msq->q_lspid = msq->q_lrpid = NULL;
INIT_LIST_HEAD(&msq->q_messages);
INIT_LIST_HEAD(&msq->q_receivers);
INIT_LIST_HEAD(&msq->q_senders);
//下面一堆看不懂在干啥
/* ipc_addid() locks msq upon success. */
retval = ipc_addid(&msg_ids(ns), &msq->q_perm, ns->msg_ctlmni);
if (retval < 0) {
ipc_rcu_putref(&msq->q_perm, msg_rcu_free);
return retval;
}
ipc_unlock_object(&msq->q_perm);
rcu_read_unlock();
return msq->q_perm.id;
}创建的结构体如下所示
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20//v5.11 /ipc/msg.c
struct msg_queue {
//这些为一些相关信息
struct kern_ipc_perm q_perm;
time64_t q_stime; /* last msgsnd time */
time64_t q_rtime; /* last msgrcv time */
time64_t q_ctime; /* last change time */
unsigned long q_cbytes; /* current number of bytes on queue */
unsigned long q_qnum; /* number of messages in queue */
unsigned long q_qbytes; /* max number of bytes on queue */
struct pid *q_lspid; /* pid of last msgsnd */
struct pid *q_lrpid; /* last receive pid */
//存放msg_msg相关指针next、prev,比较重要,通常拿来溢出制造UAF
//和该消息队列里的所有消息组成双向循环链表
struct list_head q_messages;
struct list_head q_receivers;
struct list_head q_senders;
} __randomize_layout;接着当使用
msgsnd
函数传递消息时,会创建新的msg_msg
结构体,消息过长的话就会创建更多的msg_msgseg
来存储更多的消息。相关的函数调用链如下:1
msgsnd(msg_queue_id, msg_buf, msg_size, msg_flag)->do_msgsnd()->load_msg()->alloc_msg()
主要还是关注在
alloc_msg()
函数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//v5.11 /ipc/msgutil.c
static struct msg_msg *alloc_msg(size_t len)
{
struct msg_msg *msg;
struct msg_msgseg **pseg;
size_t alen;
//最大发送DATALEN_MSG长度的消息
//#define DATALEN_MSG ((size_t)PAGE_SIZE-sizeof(struct msg_msg))
//这里的PAGE_SIZE为0x400,即最多kmalloc-
alen = min(len, DATALEN_MSG);
//使用正常
msg = kmalloc(sizeof(*msg) + alen, GFP_KERNEL_ACCOUNT);
if (msg == NULL)
return NULL;
//如果传入消息长度超过0x400-0x30,就再进行申请msg_msgseg。
//使用kmalloc申请,标志为GFP_KERNEL_ACCOUNT。
//最大也为0x400,也属于kmalloc-1024
//还有再长的消息,就再申请msg_msgseg
msg->next = NULL;
msg->security = NULL;
len -= alen;
pseg = &msg->next;
while (len > 0) {
struct msg_msgseg *seg;
//不知道干啥的
cond_resched();
alen = min(len, DATALEN_SEG);
seg = kmalloc(sizeof(*seg) + alen, GFP_KERNEL_ACCOUNT);
//申请完之后,将msg_msgseg放到msg->next这个单向链表上
if (seg == NULL)
goto out_err;
*pseg = seg;
seg->next = NULL;
pseg = &seg->next;
len -= alen;
}
return msg;
out_err:
free_msg(msg);
return NULL;
}msg_msg
结构体如下,头部大小0x30
1
2
3
4
5
6
7
8
9//v5.11 /include/linux/msg.h
struct msg_msg {
struct list_head m_list;//与msg_queue或者其他的msg_msg组成双向循环链表
long m_type;
size_t m_ts; /* message text size */
struct msg_msgseg *next;//单向链表,指向该条信息后面的msg_msgseg
void *security;
/* the actual message follows immediately */
};如下所示
msg_msgseq
结构如下,只是一个struct msg_msgseg*
指针1
2
3
4
5//v5.11 /ipc/msgutil.c
struct msg_msgseg {
struct msg_msgseg *next;
/* the next part of the message follows immediately */
};如下所示
相关内存结构:
在一个msg_queue
队列下,消息长度为0x1000-0x30-0x8-0x8-0x8
一条消息:
两条消息:
以
msg_queue
的struct list_head q_messages;
域为链表头,和msg_msg
结构的struct list_head m_list
域串联所有的msg_msg
形成双向循环链表
同理,同一个msg_queue
消息队列下的多条消息也是类似的
内存申请总结:
- 使用
msgget()
函数创建内核空间的消息队列结构msg_msgseg
,返回值为消息队列的id
标志queue_id
msg_msgseg
管理整个消息队列,大小为0x100,kmalloc-256
。- 其
struct list_head q_messages;
域为链表头,和msg_msg
结构的struct list_head m_list
域串联所有的msg_msg
形成双向循环链表
- 每次在该消息队列
queue_id
下调用msgsnd()
函数都会申请内核空间的msg_msg
结构,消息长度大于0x400-0x30
就会申请内核空间的msg_msgseg
结构msg_msg
为每条消息存放消息数据的结构,与msg_queue
形成双向循环链表,与msg_msgseg
形成单向链表大小最大为0x400,属于kmalloc-64
至kmalloc-1024
msg_msgseg
也为每条消息存放消息数据的结构,挂在msg_msg
单向链表中,大小最大为0x400
,属于kmalloc-16
至kmalloc-1024
,当消息长度很长时就会申请很多的内核空间的msg_msgseg
结构。
B.数据复制
调用完alloc_msg()
函数后,回到load_msg()
函数接着进行数据复制,函数还是挺简单的。
1 | struct msg_msg *load_msg(const void __user *src, size_t len) |
②释放
相关的函数调用链
1 | msgrcv(msg_queue_id, msg_buf, msg_size, msgtyp, msg_flag)->SYS_msgrcv()->ksys_msgrcv()->do_msgrcv()->do_msg_fill()->store_msg() |
首先关注一下do_msgrcv()
函数,里面很多东西都比较重要
1 | static long do_msgrcv(int msqid, void __user *buf, size_t bufsz, long msgtyp, int msgflg, |
A.非堆块释放的数据读取
一般而言,我们使用msg_msg
进行堆构造(比如溢出或者其他什么的)的时候,当需要从消息队列中读取消息而又不想释放该堆块时,会结合MSG_COPY
这个msgflg
标志位,防止在读取的时候发生堆块释放从而进行双向循环链表的unlink
触发错误。
1 | //v5.11 do_msgrcv()函数中的 |
使用这个标志位还需要在内核编译的时候设置CONFIG_CHECKPOINT_RESTORE=y
才行,否则还是会出错的
1 | //v5.11 /ipc/msgutil.c |
🔺注:还有一点不知道是不是什么bug,在某些内核版本中,至少我的v5.11
中,MSG_NOERROR
和MSG_COPY
(后续会讲到)没有办法同时生效,关键点在于copy_msg()
函数中,转化成汇编如下:
注意到红框的部分,获取rdi(msg)
和rsi(copy)
对应的m_ts
进行比较,而copy
的m_ts
是从用户传进来的想要获取消息的长度,如果小于实际的msg
的m_ts
长度,那就标记错误然后退出。可以这个比较应该是在后面才会进行的,但是这里也突然冒出来,就很奇怪,导致这两个标志位没办法同时发挥作用。
B.释放堆块的消息读取
同理如果不指定MSG_COPY
这个标志时,从消息队列中读取消息就会触发内存释放,这里就可以依据发送消息时设置的mtype
和接收消息时设置的msgtpy
来进行消息队列中各个位置的堆块的释放。
C.数据复制
不管什么标志位,只要不是MSG_NOERROR
和MSG_COPY
联合起来,并且申请读取消息长度size
小于通过find_msg()
函数获取到的实际消息的m_ts
,那么最终都会走到do_msgrcv()函数的末尾,通过如下代码进行数据复制和堆块释放
1 | bufsz = msg_handler(buf, msg, bufsz); |
(3)利用
越界读取
这样,当我们通过之前提到的double-free/UAF
,并且再使用setxattr
来对msg_msgmsg
中的m_ts
进行修改,这样在我们调用msgrcv
的时候就能越界从堆上读取内存了,就可能能够泄露到堆地址或者程序基地址。
使用setxattr
的时候需要注意释放堆块时FD的位置,不同内核版本开启不同保护下FD的位置不太一样
为了获取到地址的成功性更大,我们就需要用到单个msg_queue
和单个msg_msg
的内存模型
可以看到单个msg_msg
在msg_queue
的管理下形成双向循环链表,所以如果我们通过msgget
和msgsnd
多申请一些相同大小的且只有一个msg_msg
结构体的msg_queue
,那么越界读取的时候,就可以读取到只有单个msg_msg
的头部了
而单个msg_msg
由于双向循环链表,其头部中又存在指向msg_queue
的指针,那么这样就能泄露出msg_queue
的堆地址了。
任意读取
完成上述泄露msg_queue
的堆地址之后,就需要用到msg_msg
的内存布局了
由于我们的msg_msg
消息的内存布局如下
相关读取源码如下:
1 | //v4.9----ipc/msgutil.c |
所以如果我们可以修改next
指针和m_ts
,结合读取msg
最终调用函数store_msg
的源码,那么就能够实现任意读取。
那么接着上面的,我们得到msg_queue
之后,可以再将msg_msg
的next指针指回msg_queue
,读出其中的msg_msg
,就能获得当前可控堆块的堆地址。
这样完成之后,我们结合userfaultfd
和setxattr
频繁修改next指针就能基于当前堆地址来进行内存搜索了,从而能够完成地址泄露。
同时需要注意的是,判断链表是否结束的依据为next是否为null,所以我们任意读取的时候,最好找到一个地方的next指针处的值为null。
任意写
同样的,msg_msg
由于next指针的存在,结合msgsnd
也具备任意地址写的功能。我们可以在拷贝的时候利用userfaultfd
停下来,然后更改next指针,使其指向我们需要的地方,比如init_cred
结构体位置,从而直接修改进行提权。
2.pipe管道—kmalloc-1024/kmalloc-192
参照:(31条消息) Linux系统调用:pipe()系统调用源码分析_rtoax的博客-CSDN博客_linux pipe 源码****
通常来讲,管道用来在父进程和子进程之间通信,因为fork
出来的子进程会继承父进程的文件描述符副本。这里就使用当前进程来创建管道符,从管道的读取端(pipe_fd[0]
)和写入端(pipe_fd[1]
)来进行利用。
(1)使用方法
①创建
1 |
|
其中pipe2
函数或者系统调用__NR_pipe2
的flag
支持除0之外的三种模式,可用在man
手册中查看。
如果传入的flag
为0,则和pipe
函数是一样的,是阻塞的。
阻塞状态:即当没有数据在管道中时,如果还调用read
从管道读取数据,那么就会使得程序处于阻塞状态,其他的也是类似的情况。
会默认创建两个fd文件描述符的,该fd文件描述符效果的相关结构如下
1 | //v5.9 /fs/pipe.c |
放入到pipe_fd
中,如下
1 | int pipe_fd[2]; |
效果如下:
之后使用write/read
来写入读取即可,注意写入端为fd[1]
,读取端为fd[0]
1 | char buf[0x8] = {0}; |
②释放
由于pipe
管道创建后会对应创建文件描述符,所以释放两端对应的文件描述符即可释放管道pipe
管道
1 | close(pipe_fd[0]); |
需要将两个文件描述符fd都给释放掉或者使用read
将管道中所有数据都读取出来,才会进入free_pipe_info
函数来释放在线性映射区域申请的相关内存资源,否则还是不会进入的。
(2)内存分配与释放
①分配
发生在调用pipe
/pipe2
函数,或者系统调用__NR_pipe
/__NR_pipe2
时,内核入口为
1 | SYSCALL_DEFINE2(pipe2, int __user *, fildes, int, flags) |
函数调用链:
1 | do_pipe2()->__do_pipe_flags()->create_pipe_files()->get_pipe_inode()->alloc_pipe_info() |
调用之后会在内核的线性映射区域进行内存分配,也就是常见的内核堆管理的区域。分配点在如下函数中:
1 | //v5.9 /fs/pipe.c |
相关的pipe_inode_info
结构如下
1 | //v5.9 /include/linux/pipe_fs_i.h |
②释放
直接使用close
函数释放管道相关的文件描述符fd两端。
函数链调用链:
1 | pipe_release()->put_pipe_info()->free_pipe_info() |
需要注意的时,在put_pipe_info
函数中
1 | //v5.9 /fs/pipe.c |
只有pipe_inode_info
这个管理结构中的files
成员为0,才会进行释放,也就是管道两端都关闭掉才行。
相关释放函数free_pipe_info
1 | //v5.9 /fs/pipe.c |
(3)利用
①信息泄露
pipe_buffer
结构的buf
1 | //v5.9 /include/linux/pipe_fs_i.h |
其中的ops
成员,即struct pipe_buf_operations
结构的pipe->bufs[i]->ops
,其中保存着全局的函数表,可通过这个来泄露内核基地址,相关结构如下所示
1 | //v5.9 /include/linux/pipe_fs_i.h |
②劫持程序流
当关闭了管道的两端时,调用到free_pipe_info
函数,在清理pipe_buffer
时进入如下判断:
1 | if (buf->ops) |
当管道中存在未被读取的数据时,即我们需要调用write
向管道的写入端写入数据
1 | //v5.9 /fs/pipe.c |
然后不要将数据全部读取出来,如果全部读取出来的话,那么在read
对应的pipe_read
函数中就会如下情况
1 | //v5.9 /fs/pipe.c |
从而调用pipe_buf_release
将buf->ops
清空。
🔺注:(其实这里既然调用到了pipe_buf_release
函数,那么我们直接通过read
将管道pipe
中的所有数据读取出来,其实也能执行该release
函数指针的,从而劫持程序控制流的。)
那么接着上述的情况,那么在关闭两端时buf->ops
这个函数表就会存在
而当buf->ops
这个函数表存在时,关闭管道符两端进入上述判断之后,就会调用到其中的pipe_buf_release
函数,该函数会调用到这个buf->ops
函数表结构下对应的relase
函数指针,该指针在上述的pipe_buf_operations
结构中有提到
那么如果劫持了buf->ops
这个函数表,就能控制到release
函数指针,从而劫持控制流程。
不过pipe
管道具体的保存的数据放在哪里,还是不太清楚,听bsauce
说是在struct pipe_buffer
结构下buf
的page
里面,但是没有找到,后续还需要继续看看,先mark一下。这样也可以看出来,每写入一条信息时,内核的kmalloc
对应的堆内存基本是不发生变化的,与下面提到的sk_buff
有点不同。
3.sk_buff—kmalloc-512及以上
参考:(31条消息) socketpair的用法和理解_雪过无痕_的博客-CSDN博客_socketpair
和该结构体相关的是一个socketpair
系统调用这个也算是socket
网络协议的一种,但是是在本地进程之间通信的,而非在网络之间的通信。说到底,这个其实和pipe
非常像,也是一个进程间的通信手段。不过相关区分如下:
- 数据传输模式
pipe
:单工,发送端fd[1]
发送数据,接收端fd[0]
接收数据socketpair
:全双工,同一时刻两端均可发送和接收数据,无论信道中的数据是否被接收完毕。
- 模式
pipe
:由flag
来定义不同模式socketpair
:默认阻塞状态
此外在《Linux系统编程手册》一书中提到,pipe()
函数实际上被实现成了一个对socketpair
的调用。
(1)使用方法
①创建
1 |
|
然后和pipe
管道一样,使用write/read
即可,不过这个的fd两端都可以写入读取,但是消息传递的时候一端写入消息,就需要从另一端才能把消息读取出来
1 | char buf[0x8] = {0}; |
②释放
1 | close(socket_fd[0]); |
可以看到和pipe
是很相似的。
(2)内存分配与释放
在调用socketpair
这个系统调用号时,并不会进行相关的内存分配,只有在使用write
来写入消息,进行数据传输时才会分配。
①分配
在调用write
进行数据写入时
函数链:
1 | write -> ksys_write() -> vfs_write() -> new_sync_write() -> call_write_iter() -> sock_write_iter() -> sock_sendmsg() -> sock_sendmsg_nosec() -> unix_stream_sendmsg()->内存申请/数据复制 |
在unix_stream_sendmsg
开始分叉
1 | //v5.9 /net/unix/af_unix.c |
A.内存申请
先进行相关内存申请,即sock_alloc_send_pskb() -> alloc_skb_with_frags() -> alloc_skb() -> __alloc_skb()
还是挺长的,但是最重要的还是最后的__alloc_skb
函数,
1 | //v5.9 /net/core/skbuff.c |
内存申请总结:
sk_buff
为数据的管理结构从专门的缓存池skbuff_fclone_cache/skbuff_head_cache
中申请内存,没办法进行控制skb->data
为实际的数据结构size
:0x140+n*0x40
(0x40的倍数补齐)。即如果传入的数据长度为0x3f,则n为1,传入数据为0x41,则n为2。- 堆块申请:走
kmalloc
进行申请,比较常见的种类,方便堆喷。
- 每调用
wirte
函数写入一次数据,都会走一遍流程,申请新的sk_buff
和skb->data
,不同消息之间相互独立。
B.数据复制
相关内存申请完成之后,回到unix_stream_sendmsg
函数,开始进行数据复制skb_copy_datagram_from_iter
,即上述提到的。
1 | //v5.9 /net/core/datagram.c |
②释放
当从socker
套接字中读取出某条信息的所有数据时,就会发生该条信息的相关内存的释放,即该条信息对应sk_buff
和skb->data
的释放。同样的,如果该条信息没有被读取完毕,则不会发生该信息相关内存的释放。
在read
时进行的函数调用链:
1 | read -> ksys_read() -> vfs_read() -> new_sync_read() -> call_read_iter() -> sock_read_iter() -> sock_recvmsg() -> sock_recvmsg_nosec() -> unix_stream_recvmsg() -> unix_stream_read_generic() |
同样的在unix_stream_read_generic
处开始分叉,也是分为两部分,下面截取重要部分
1 | //v5.9 /net/unix/af_unix.c |
A.数据复制
之后的函数调用链为
1 | unix_stream_read_actor() -> skb_copy_datagram_msg() -> skb_copy_datagram_iter() -> __skb_datagram_iter() |
最终进入__skb_datagram_iter
,
1 | //v5.9 /net/core/datagram.c |
这里使用了感觉很复杂的机制,不是很懂。
B.内存释放
进入内存释放的函数调用链为
释放
skb->data
部分:1
consume_skb()->__kfree_skb()->skb_release_all()->skb_release_all()->skb_release_data()->skb_free_head()
对应函数如下:
1
2
3
4
5
6
7
8
9
10
11
12
13//v5.9 /net/core/skbuff.c
static void skb_free_head(struct sk_buff *skb)
{
//其实head和data是一样的
unsigned char *head = skb->head;
if (skb->head_frag) {
if (skb_pp_recycle(skb, head))
return;
skb_free_frag(head);
} else {
kfree(head);
}
}可以看到使用的正常的
kfree
函数释放
skb
部分:1
consume_skb()->__kfree_skb()->kfree_skbmem()
相关函数如下
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//v5.9 /net/core/skbuff.c
static void kfree_skbmem(struct sk_buff *skb)
{
struct sk_buff_fclones *fclones;
//克隆体相关的,没有fork之类的话一般不用太管的
switch (skb->fclone) {
case SKB_FCLONE_UNAVAILABLE:
//用专门的cache(skbuff_head_cache)进行回收
kmem_cache_free(skbuff_head_cache, skb);
return;
case SKB_FCLONE_ORIG:
fclones = container_of(skb, struct sk_buff_fclones, skb1);
/* We usually free the clone (TX completion) before original skb
* This test would have no chance to be true for the clone,
* while here, branch prediction will be good.
*/
if (refcount_read(&fclones->fclone_ref) == 1)
goto fastpath;
break;
default: /* SKB_FCLONE_CLONE */
fclones = container_of(skb, struct sk_buff_fclones, skb2);
break;
}
if (!refcount_dec_and_test(&fclones->fclone_ref))
return;
fastpath:
//用专门的cache(skbuff_fclone_cache)进行回收克隆的skb
kmem_cache_free(skbuff_fclone_cache, fclones);
}这个就不太好利用了。
同样的,当关闭的信道的两端,该信道内产生的所有的
sk_buff
和skb->data
都会得到释放
内存释放总结:
当从信道中将某条消息全部读取完之后,会发生该条消息对应的
sk_buff
和skb->data
的内存释放,且sk_buff
释放到专门的缓存池中,skb->data
使用正常的kfree
释放当关闭信道两端,该信道内产生的所有的
sk_buff
和skb->data
都会得到释放,具体的调用链为:1
sock_close()->__sock_release()->unix_release()->__kfree_skb()
后面就类似了。
一、漏洞分析
前言
由于我编译环境的时候老是出问题(后面才解决的),所以直接拿bsauce
师傅提供的环境来用了,但是又没有带DEBUG的vmlinux
,所以我使用vmlinux-to-elf简单获取下符号就开始逆向了(xs),所以下面漏洞分析提到的地址为bsauce
师傅环境的地址。
CVE-2021-22555 2字节堆溢出写0漏洞提权分析 - 安全客,安全资讯平台 (anquanke.com)
相关的Netfilter
分析就不做了,也不太会,可以看看bsauce
师傅的,这里主要关注数据的传输过程的一些东西。
通过Netfilter
的setsockopt
系统调用,传入用户数据&data
,可依据该&data
中的相关数据进行不同大小的堆块申请。完成申请后,还会对该堆块进行一定的处理,其中就有向堆块末尾填充数据的操作。
1 | memset(t->data + target->targetsize, 0, pad); |
其中t->data+target->targetsize
即为申请的堆块上末尾处的某个地址,pad
为如下定义
1 | pad = XT_ALIGN(target->targetsize) - target->targetsize; |
其实pad
的值即为8 - (target->targetsize mod 8)
,就是所谓的8字节对齐。
并且t->data
的地址偏移和target->targetsize
的值都可被我们直接或间接地控制,那么就可以存在堆块溢出写0的操作了,这里最多溢出4个字节填充为0。
下面是具体的关键函数调用链和相关分析
1.nf_setsockopt()
句柄定义
1 | //v5.11.14 net/ipv4/netfilter/ip_tables.c |
这样到调用setsockopt
系统调用时,就会调用到do_ipt_get_ctl
函数。
2.do_ipt_set_ctl()
参数:
1
(struct sock *sk, int cmd, sockptr_t arg, unsigned int len)
调试如下
这个
&data
即为用户传入的,赋值给sockptr_t arg
,从而依据sockptr_t arg
来进行堆块申请和相关的漏洞填充操作。地址:
0xffffffff81b0bd20
介绍:该函数由
nf_sockopt_ops ipt_sockopts
进行句柄定义1
2
3
4
5static struct nf_sockopt_ops ipt_sockopts = {
....
.get = do_ipt_get_ctl,
....
};即系统调用
setsockopt
实际调用到与漏洞方面有关的最早的函数,传入的sockptr_targ
即为用户参数&data
,后续会调用到compat_do_replace
,传入sockptr_t arg
3.compat_do_replace()
通过_copy_from_user
复制&data
的0x5c
字节给tmp
参数:
1
(struct net *net, sockptr_t arg, unsigned int len)
地址:
0xffffffff81b0baf0
介绍:
主要关注变量:
1
2
3
4
5
6//传入的
sockptr_t arg;
//自定义的
struct compat_ipt_replace tmp;//保存size
struct xt_table_info *newinfo;
调用
translate_compat_table()
,传入本函数定义的tmp
作为compatr
,该变量tmp
由函数copy_from_sockptr(&tmp, arg, sizeof(tmp))
进行赋值- 相关函数链:
copy_from_sockptr->copy_from_sockptr->copy_from_sockptr_offset->copy_from_user
1
2
3
4
5
6
7
8
9
10//v5.11.14 /include/linux/sockptr.h
static inline int copy_from_sockptr_offset(void *dst, sockptr_t src,
size_t offset, size_t size)
{
if (!sockptr_is_kernel(src))
return copy_from_user(dst, src.user + offset, size);
memcpy(dst, src.kernel + offset, size);
return 0;
}这里的
dst
即为tmp
,src
即为arg
,也就是会依据arg(&data)
的内容来给tmp
赋值。即最后的compatr
的来源为上述提到的sockptr_t arg
,也就是用户传入的参数&data
。从
&data
中复制0x5c(sizeof(struct compat_ipt_replace))
大小的给到tmp(compatr)
,如下代码所示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//v5.11.14 /net/ipv4/netfilter/ip_tables.c
static int
compat_do_replace(struct net *net, sockptr_t arg, unsigned int len)
{
//.....
if (copy_from_sockptr(&tmp, arg, sizeof(tmp)) != 0)
return -EFAULT;
///....
//这里的tmp.size即为0xfb6,传入的data.replace.size,也是申请了堆块的。
//不过这个堆块不用太过关注,但是这个不能随便设置,不然会在如下检查出错误
//然后跳转out_unlock从而无法进入漏洞点
/*
//translate_compat_table函数中
//Walk through entries, checking offsets.
xt_entry_foreach(iter0, entry0, compatr->size) {
ret = check_compat_entry_size_and_hooks(iter0, info, &size,
entry0,
entry0 + compatr->size);
if (ret != 0)
goto out_unlock;
++j;
}
*/
//需要注意的是这个newinfo和下面函数中的newinfo不是同一个
newinfo = xt_alloc_table_info(tmp.size);
//......
ret = translate_compat_table(net, &newinfo, &loc_cpu_entry, &tmp);
//.....
}复制的这些数据中就包含定义好的size,用来完成之后的堆块申请。
4.translate_compat_table()
参数:
1
(struct net *net,struct xt_table_info **pinfo,void **pentry0,const struct compat_ipt_replace *compatr)
地址:
0xffffffff81b0b3e0
介绍:
主要关注变量:
1
2
3
4
5
6
7
8//传入的
const struct compat_ipt_replace *compatr;
//自定义的
unsigned int size;
struct xt_table_info *newinfo;
void *pos, *entry1;
struct compat_ipt_entry *iter0;size
:size = compatr->size;
newinfo
:依据size
即上述的compatr->size
申请堆块,漏洞点就出在这个申请的堆块上面。1
2
3
4
5
6
7
8
9
10
11
12translate_compat_table(struct net *net,
struct xt_table_info **pinfo,
void **pentry0,
const struct compat_ipt_replace *compatr)
{
//.....
size = compatr->size;
//....
//这个堆块就是漏洞堆块了。
newinfo = xt_alloc_table_info(size);
//.....
}通过
xt_alloc_table_info
来申请堆块,其中有如下代码1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18//v5.11.14 /net/netfilter/x_tables.c
struct xt_table_info *xt_alloc_table_info(unsigned int size)
{
struct xt_table_info *info = NULL;
size_t sz = sizeof(*info) + size;//加上0x40大小
if (sz < sizeof(*info) || sz >= XT_MAX_TABLE_SIZE)
return NULL;
//实际申请的堆块大小为0xffe,即kmalloc-4096,这个堆块就是漏洞堆块了。
//结构为struct xt_table_info
info = kvmalloc(sz, GFP_KERNEL_ACCOUNT);
if (!info)
return NULL;
memset(info, 0, sizeof(*info));
info->size = size;
return info;
}可以看到使用
kvmalloc
,申请标志为GFP_KERNEL_ACCOUNT
,并且XT_MAX_TABLE_SIZE
定义如下,也就是在kmalloc-512到kmalloc-81921
#define XT_MAX_TABLE_SIZE (512 * 1024 * 1024)
pos/entry1
:1
2entry1 = newinfo->entries;
pos = entry1;即
pos/entry1
的值为newinfo_addr+0x40(0x4*3+0x14+0x14+0x4+0x8)
调用如下函数进行下一步:
1
2compat_copy_entry_from_user(iter0, &pos, &size,
newinfo, entry1);
5.compat_copy_entry_from_user()
参数:
1
2
3(struct compat_ipt_entry *e, void **dstptr,
unsigned int *size,
struct xt_table_info *newinfo, unsigned char *base)地址:不太清楚
介绍:
主要关注变量:
1
2
3
4
5
6//传入的
//即保存pos的栈地址,值为newinfo->entries(newinfo_addr+0x40)
void **dstptr;
unsigned int *size;
struct xt_table_info *newinfo;相关操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14compat_copy_entry_from_user(struct compat_ipt_entry *e, void **dstptr,
unsigned int *size,
struct xt_table_info *newinfo, unsigned char *base)
{
//....
//即pos加上0x70,值为newinfo_addr+0x40+0x70
*dstptr += sizeof(struct ipt_entry);
*size += sizeof(struct ipt_entry) - sizeof(struct compat_ipt_entry);
xt_ematch_foreach(ematch, e)
xt_compat_match_from_user(ematch, dstptr, size);
//.....
xt_compat_target_from_user(t, dstptr, size);
//.....
}
6.xt_compat_match_from_user()
这个函数和接下来的漏洞函数xt_compat_target_from_user
可以说基本一致,观察下图即可看到,具体用来干什么不太清楚,但是作用也是相关的pad填充newinfo
上的数据。打了一个循环xt_ematch_foreach
,在我们关注的这个漏洞里,其作用就只是使得*dstptr + n * msize
,也就是在我们关心的最终值为newinfo_addr+0x40+0x70+n * msize
,从而使得在进入xt_compat_target_from_user
之前,*dstptr
上的堆块地址已经移动到末尾了。
做了一个数据对比:
1 | newinfo: 0xffff888006a2a000 |
也就是说经过xt_compat_match_from_user
函数之后,保存在*dstptr
上的漏洞堆的地址已经加上了0xf2a
。
6.xt_compat_target_from_user()
终于来到最后的漏洞函数
参数:
1
2(struct xt_entry_target *t, void **dstptr,
unsigned int *size)地址:
0xFFFFFFFF81A82F75
介绍:
主要关注变量
1
2
3
4
5
6
7
8//传入的
struct xt_entry_target *t;
void **dstptr;
unsigned int *size;
//自定义的
const struct xt_target *target = t->u.kernel.target;
int pad, off = xt_compat_target_offset(target);相关操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19void xt_compat_target_from_user(struct xt_entry_target *t, void **dstptr,
unsigned int *size)
{
const struct xt_target *target = t->u.kernel.target;
int pad, off = xt_compat_target_offset(target);
//.....
//即获取指针为newinfo+0x40+0x70+0xf2a
t = *dstptr;
//.....
//进行8字节对齐
pad = XT_ALIGN(target->targetsize) - target->targetsize;
if (pad > 0)
//target->targetsize为4,则最终传入的地址为
//newinfo+0x40+0x70+0xf2a+0x20+0x4=newinfo+0xffe
//同时pad在经过对齐之后也为4,那么就溢出2个字节
memset(t->data + target->targetsize, 0, pad);
//.....
}
总结
通过上述分析可以看到,其实该漏洞的成因就是
(1)控制堆块大小和偏移
通过控制传入的&data
中的pad
的大小来控制申请的堆块的大小和t->data
的相对偏移地址
1 | struct __attribute__((__packed__)) { |
例子:
比如bsauce师傅提供的EXP中的pad如下,这里使用的是kmalloc-4096
:
1 | char pad[0x108 + PRIMARY_SIZE - 0x200 - 0x2]; |
那么我们尝试使用kmalloc-2048
,在代码中减去0x800得到如下:
1 | char pad[0x108 + PRIMARY_SIZE - 0x200 - 0x2 - 0x800]; |
断点打在xt_alloc_table_info
,在第二次的xt_alloc_table_info
申请漏洞堆块处,查看下CPU0的kmalloc-2048
中freelist
中的堆块。
然后finish
当前函数,查看rax申请到的堆块,即为freelist
中的第一个堆块
可以看到是从CP0的kmalloc-2048
中申请得到的,之后在call memset
的漏洞点打下断点,按c继续运行,断下来
可以看到仍然还是该漏洞堆块,并且相关的地址也类似的,pad为0x4,所以还是存在漏洞点的。
不过具体的细节有点不太清楚,后续还得补一补Netfilter
的相关知识。
(2)控制填充pad
通过控制传入的data.target.u.user.revision
来控制target->targetsize
1 | data.target.u.user.revision = 1; |
不同的version
控制不同的target->targetsize
。
这里经过我自己的实际调试,感觉bsauce师傅说的有点小问题。漏洞点应该是出在上述的t->daii
地址没有0x8对齐的时候,并且target->size
也没有0x8对齐的情况下。
此外,不应该只是2字节溢出,最多应该可以到达4字节溢出,如下设置
1 | char pad[0x108 + PRIMARY_SIZE - 0x200 - 0x2 + 0x2]; |
这样可以溢出4个字节写0,最终效果如下:
如果再加pad的话就会导致申请出kmalloc-8192
的堆块了
二、漏洞利用
1.溢出转化UAF
这里涉及到之前提到的msg_msg
结构体利用。
(1)堆喷内存布局
首先使用msgget
申请多个消息队列,然后往每个消息队列发送两条消息,一条主消息0x1000
,一条辅助消息0x400
。这里发送消息时需要注意下,先遍历每个队列发送主消息,然后再遍历每个队列发送辅助消息。这样进行堆喷构造后,其中就会有部分的消息队列中的主消息连成一整块地址连续的内存,辅助消息也需要地址连成一整块,方便后续泄露地址,但是这里为了好看就没有连一起。比如这里申请三个消息队列,最终形成类似的如下布局
当然这里每条0x1000
的主消息中还有几个struct msg_msgseg*
没有画出来
(2)漏洞溢出构造UAF
这里我们先释放例子中的第二条主消息,虽说在主消息中是由4个kmalloc(0x400)
申请出来的4个堆块,但是如果都释放之后,内存的回收机制发现这四个地址连续且都被释放,那么就会归并成一页page
还给Slub
分配器,其实就是kmalloc-4096
。(里面算法很复杂,不是很懂,后面再来理清楚。)之后再申请0x1000
大小的堆块,就会优先从这里取。
然后我们使用漏洞,调用socketopt
来申请一个0x1000
的xt_table_info
,就会占据到我们刚刚释放的0x1000
大小的堆块上。(这个前面我们分析socketopt
会申请两个0x1000
大小的堆块,那么我们之后就是多释放几条主消息即可)这样在占据之后,发生2字节溢出写0,就可以溢出到下一个消息队列的msg_msg
头部结构的struct list_head m_list.next
指针,从而使得其指向其他位置,如果运气好的话,由于辅助消息也是堆喷形式,且大小为0x400
,那么溢出两字节写0就可能将该next
指针指向其他的辅助消息,从而造成两个消息队列中共存一个辅助消息。
比如图中消息队列3中的主消息头部的struct list_head m_list.next
即被修改(黑色为溢出2字节写0),如红色箭头所示指向了消息队列1中的辅助消息,这样消息队列1和消息队列3都指向了同一个辅助消息,构成了堆块overlap
。之后我们释放消息队列1中的辅助消息,而消息队列3仍然指向该辅助消息,构成了UAF。
🔺注:在实际的利用里,需要进行堆喷布局,申请很多的消息队列,这时候就需要用MSG_COPY
标志位来进行消息读取。利用此标志位读取消息但不释放堆块,然后借助发送消息时自己留下的索引标志来判断到底是哪个辅助消息被两个消息队列所包含,这样就能进行后续的利用。
2.利用UAF
(1)泄露堆地址
首先使用sk_buff
的data
数据块来占据该UAF
堆块。前面提到sk_buff
的结构头使用独有的缓冲池kache
来申请,但是其data
数据块还是使用kmalloc
常规路线来申请释放(使用正常的发包收包即可完成申请释放),并且size
和data
内容完全可控,这样我们就可以完全控制该UAF
堆块。
之后伪造一个fake_msg_msg
结构体,结构如下
1 | //v5.11 /include/linux/msg.h |
改大其m_ts
域,就可以读取出消息队列2的辅助消息头部指针struct list_head m_list.next
的值,从而泄露消息队列2的msg_msg_queue
的struct list_head m_list
域的地址,为一个堆地址。
之后我们修改fake_msg_msg
的struct msg_msgseg *next
指针,指向上述获得的消息队列2的struct list_head m_list
域的地址,就能读出该struct list_head m_list
域的prev
指针,即为消息队列2的辅助消息的地址,减去0x400
即为UAF
堆块的地址
(2)泄露内核基地址
接下来利用到pipe
管道,主要是其中struct pipe_inode_info
的struct pipe_buffer *bufs;
数组,总大小为0x280
,使用kmalloc-1024
,满足当前的UAF
(同样使用正常的read/write
即可完成申请释放)。其结构为
1 | //v5.11.14 /include/linux/pipe_fs_i.h |
利用如下操作读取const struct pipe_buf_operations *ops;
指针,即可泄露内核基地址
- 利用
sk_buff
修复UAF
处的辅助消息,之后从消息队列中接收该辅助消息,此时该UAF
对象重回slub
的kmalloc-1024
的freelist
中,但sk_buff
仍指向该UAF
对象 - 喷射
pipe_buffer
,就会将该UAF
对象申请回来,将pipe_buffer
写入到该UAF
对象上,之后再接收sk_buff
数据包,即可获取pipe_buffer
上的数据,得到const struct pipe_buf_operations *ops;
指针,即可泄露内核基地址
(3)劫持程序执行流
之前也提到过,当我们关闭管道pipe
两端或者从管道pipe
中读取出所有数据之后,会调用到pipe_buf_release()
函数进行清理,其中会调用struct pipe_buffer *bufs;
下的const struct pipe_buf_operations *ops;
对应函数表中的release
函数指针。
1 | static inline void pipe_buf_release(struct pipe_inode_info *pipe, |
现在我们就可以通过sk_buff
来劫持劫持ops
指针函数表,修改其中的release
函数指针,完成劫持程序流。并且此时的rsi
即为buf
为我们的UAF
对象,而sk_buff
又可以使得UAF
对象里的数据完全可控。如果找到一个可以将rsp
劫持为rsi
的gadget
,那么就可以完全操控程序流程了。
3.EXP解析
这个其实也没有什么好讲的,看懂漏洞利用过程其实也很容易写出来的,主要提一下某些比较偏的知识点,也防止忘记。
(1)绑定CPU分配
通常是用来进行堆块分配时查看堆块内存的,防止堆块申请的时候东一个西一个的,方便调试,同时也是为了提高堆喷射的稳定性
1 | //bind the cpu0 |
(2)命名空间
1 | int setup_sandbox(void) { |
EXP
原作者称:当IPT_SO_SET_REPLACE
或IP6T_SO_SET_REPLACE
在兼容模式下被调用时(需要CAP_NET_ADMIN
权限)。
这个在源代码do_ipt_set_ctl()
函数中有所体现
1 | //v5.11.14 /net/ipv4/netfilter/ip_tables.c |
而用户空间隔离出独立的命名空间后就能拥有CAP_NET_ADMIN
权限,所以需要,其实也不是太懂这个干啥的。
其他的好像也没有什么了,就是最后的ROP
链条方面的东西,由于最后触发劫持程序流的时候,rsi
为UAF
对象地址,所以利用gadget
先进行栈劫持rsp
,然后使用利用commit_creds(&init_cred)
获取ROOT权限,之后使用SWAPGS_RESTORE_REGS_AND_RETURN_TO_USERMODE
绕过KPTI
和SMEP
即可。
参考:【CVE.0x07】CVE-2021-22555 漏洞复现及简要分析 - arttnba3’s blog
Linux Kernel KPTI保护绕过 - 安全客,安全资讯平台 (anquanke.com)
(3)最终EXP
主要是bsauce师傅的EXP和arttnba3师傅的EXP,然后改巴改巴,加了点东西,替换了一下ROP链条什么的。
1 | //compile exp: $ gcc -m32 -static -masm=intel -o exploit exploit.c |
效果:
中间有个getchar()
,按下回车即可,本来放这是为了方便调试的。
不行的话可以多尝试几次
然后逃逸容器的我没尝试,也不太会,可以参考arttnba3师傅的容器逃逸EXP
参考
CVE-2021-22555 2字节堆溢出写0漏洞提权分析 - 安全客,安全资讯平台 (anquanke.com)
【CVE.0x07】CVE-2021-22555 漏洞复现及简要分析 - arttnba3’s blog
CVE-2021-22555: Turning \x00\x00 into 10000$ | security-research (google.github.io)
太多了,有点贴不过来了….