消息队列msg学习&msg利用
之前谢哥发了我两道kernelpwn的题目,都是比较简单的堆漏洞,但是堆的size不再是很好利用的0x20或者0x2e0了,然后搜了搜kernelpwn通用结构体发现还是没有当前size下可以利用的内核结构,经过谢哥提醒msg可以使用,但是看了会msg发现还是比较复杂的,然后就摆了,一摆就摆到了现在hh,痛定思痛,开始学习。
msg学习
基础介绍
消息队列msg和共享内存一样是linux提供的一种进程间通信方式(IPC),一般称他为IPC对象,在Linux中使用key来唯一标识,而且他们是可持续化,当进程创建了一个IPC对象之后,这个对象不会因为进程的退出而销毁,而是一直存在,直到调用IPC删除函数来删除。
消息队列的IPC对象,key和id之间的关系如下图,其中key是唯一的,唯一确定一个IPC对象,但是每个进程的id是可以变化的,id就相当于文件描述符,key就相当于文件名,IPC对象相当于文件内容。

常用函数介绍
ftok()
产生键值key_t ftok(const char *pathname, int proj_id);
msgget()
得到ipc对象的id值或者创建一个消息队列,当第一个参数可以是ftok创建的key或者IPC_PRIVATE,第二个参数控制创建消息队列的操作和读写权限。
1 | int msgget(key_t key, int msgflg); |
相关结构体
1 | struct msg_queue { |
当调用了msgget()函数的时候,内核会调用ksys_msgget()函数
1 | long ksys_msgget(key_t key, int msgflg) |
然后调用ipcget函数
1 | int ipcget(struct ipc_namespace *ns, struct ipc_ids *ids, |
当key=IPC_PRIVATE的时候,就会调用ipcget_new()创建一个新的消息队列
1 | static int ipcget_new(struct ipc_namespace *ns, struct ipc_ids *ids, |
这个函数会调用ops->getnew(),在 ksys_msgget函数中,这个函数指针被赋值成newque,也就是会调用newque函数,这个函数主要就是初始化结构体msg_queue
1 | static int newque(struct ipc_namespace *ns, struct ipc_params *params) |
msgsnd()
msgsnd函数会向指定id对应的消息队列发送消息
1 | int msgsnd(int msqid , const void * msgp , size_t msgsz , int msgflg ); |
相关结构体
1 | struct msg_msg {//主消息段头部 |
内核和执行ksys_msgsnd()函数
1 | long ksys_msgsnd(int msqid, struct msgbuf __user *msgp, size_t msgsz, |
函数调用do_msgsnd()函数
1 | static long do_msgsnd(int msqid, long mtype, void __user *mtext, |
函数首先对msgtype和msgsz进行了检查,然后调用load_msg(mtext, msgsz)来初始化msg_msg结构体
1 | struct msg_msg *load_msg(const void __user *src, size_t len) |
这个函数会先调用alloc_msg来请求msg_msg结构体所需要的空间.
1 |
|
通过这个函数可以看出msg结构体是可以扩充的,可以从0x40扩充到0x1000,这也是为什么msg_msg利用的范围这么广了。
而且当消息长度超过了0x1000-0x30还可以吧消息进行分段,最多分三段,结构图如下,所以一个消息的长度理论上最多是0x3000-0x30-0x8-0x8

函数申请完所有空间就返回到load_msg函数。
然后load_msg把用户空间的消息全部复制到刚申请的msg_msg和msg_msgseg上,就返回到do_msgsnd函数中,这个函数剩下的工作就是经过一堆检查然后把msg_msg链接到对应的消息队列中去,消息队列示意图如下。

msgrcv()
1 | ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); |
参数msqid:指定消息队列id,由msgget 返回。
参数msgp:接收消息用的结构体指针
参数msgsz:接收消息用的结构体大小
参数msgtyp:三种情况:
=0 : 读取消息队列中第一个消息0 : 读取消息队列中类型为msgtyp 的第一个消息,如果msgflg 设置了MSG_EXCEPT 则会读取非msgtyp类型的第一个消息, 这个消息类型在msgsnd 里用msgbuf 结构体指定的;如果msgflg 设置了MSG_COPY则会读取队列中的第msgtyp个消息。
<0 : 读取消息队列中最小类型且小于等于msgtyp 绝对值的消息。
参数msgflg:通常使用下面的一些flag:
IPC_NOWAIT : 消息队列为空则不会阻塞。
MSG_EXCEPT : 跟上面msgtyp 联用,读取类型不是msgtyp 的第一条消息。
MSG_NOERROR : 消息长度超过msgsz 时截断消息。
MSG_COPY : 漏洞利用中会用到,内核会把消息队列中的消息拷贝一份返回用户空间而不会释放该条消息结构。
返回值:成功时返回读取的消息字节数,失败返回-1。
内核会调用do_msgrcv()函数,这个函数就会根据msgtype和msgflg来搜索到msg,然后把这个msg拷贝到用户空间的buf处,当msgflg!=MSG_COPY的时候 ,找到msg后就会把这个msg从消息队列中unlink,然后把信息复制给用户空间,然后再free这个msg。
但是当msgflg=MSG_COPY的时候,会先申请一个新的msg,然后在消息队列中找到一个msg,然后把这个msg拷贝到新的msg中。然后再把新的msg的内容拷贝到用户态,最后释放新的msg.
1 | static long do_msgrcv(int msqid, void __user *buf, size_t bufsz, long msgtyp, int msgflg, |
msg利用
只要能控制了msg_msg后确实可以达到任意地址写和任意地址读,而且堆块范围是0x40~0x1000,感觉非常好用。
本文通过corCTF 2021两道内核题学习对msg_msg的利用。应该会很有难度,预计复现至少两天。(看完整个解题思路和长达400多行以及700多行的exp,🤦♀️坏了,不只是需要两天了。至少一周或者更久。。。
fire_of_salvation
题目给了源代码十分好审计,主要结构体如下。
1 | typedef struct |
漏洞
漏洞出现在了dup()函数下,在全局指针数组中可以存储两次指向同一个堆块的指针,这导致可以释放两次同一个堆块,既可以doublefree又可以uaf.
填坑
令人感叹,距离写下上一段话到现在已经过了半个月了,确实懒狗了这下,本来想着学完mit6.s081后再继续学习二进制的,但是肝了一周了有点肝不动了,前面还好说,后面的课确实沾点难度了,一个半小时的课我得看三个小时才能啃下来,想起了msg还没学完,那就换个东西折磨我吧。
漏洞利用
由于开启了kaslr和fg_kaslr,所以肯定得先获取内核地址,但是注意由于开启了fg_kaslr之后就没有那么简单了,fg_kaslr会在kaslr上以函数粒度对地址再进一步打乱,这个打乱也是随机的,所以函数到内核基地址的偏移是随时发现变化的,但是fg_kaslr有些区域不会被打乱
1..text段
2.data段
3.__ksymtab
知道了上面区域的一些地址就可以知道内核的基地址了。在这道题中选择使用shm_file_data结构体来泄露内核data段地址进而知道内核基地址。其中ipc_namespace就指向内核data段。
1 | struct shm_file_data |
泄露地址可以分为一下几步
- 1.首先构造一个4kb的uaf然后申请一个
0xfd8大小的msg_msg,此时msg结构体如下图

- 2.堆喷,这样
shm_file_data就可能落到struct msg_msg下面了 - 3.利用
uaf修改m_ts,这个变量记录了这个消息的大小,改大之后再MSG_COPY这个消息队列就可以可以得到struct msg_msg后面的堆的信息了,也就得到了内核基地址。 - 4.利用基地址就可以算出
init_cred和init_task了,这两个结构体都在内核data段中,可以直接算出。
得到上面的地址就可以利用init_task来找到这个进程的task_struct了。
每个进程都有一个task_struct,内核使用双向循环表来组织这个结构体,字段为struct list_head tasks;
结构体如下
1 | struct list_head { |
所以可以通过init_task的prev向上寻找,直到找到这个进程的task_struct。
说白了就是通过修改msg的next字段完成任意读操作,关键是如何构造msg了,下面是作者给出的构造示意图,我也是按照这个进行构造的,我们得覆盖next让其指向task_struct的某一个地方,然后再读这个task_struct,但是对next的覆盖是有要求的,他所指向的地址的前八个字节必须为空,不然读取和写入的时候就会报错,具体原因看看源码就懂了。
其中struct list_head tasks在位于task_struct的0x298偏移出,其前八个字节刚好是null,所以可以让next指向task_struct_addr+0x290处。这样就可以读到task_struct的prev和pid了,pid位于task_struct的0x398处,然后整一个循环读到当前进程的task_struct了。

注意prev并不指向上一个进程的task_struct的开头,而是指向其中的字段struct list_head tasks,所以减去0x298就得到当前task_struct的基地址了。
得到当前进程task_struct的基地址后就得构造任意地址写来完成对当前进程task_struct中的real_cred 和 cred指针的覆写,覆写成init_cred指针。
首先考虑msg的next应该指向哪里,read_cred和cred对于task_struct的偏移是0x538和0x540。real_cred的前八个字节刚好还是null,所以可以让next指向task_struct+0x538-0x8.

只要考虑清楚next的的取值问题,任意地址写就是套userfaulted的板子了,步骤如下
- 先mmap一段ox2000的内存,然后在0x1000-0x8处填上mtype,把0x1000注册缺页处理,然后
send_msg(msg_id, msg_buff, size - 0x30, 0); - 当msg进入缺页处理函数的时候,准备好
page,其0xfd0和0xfd8处准备好init_cred,然后利用设备的edit修改msg的next指针
exp
1 |
|
效果
1 | ctf@CoRCTF:/exp$ id |
总结
学习到了在拥有了4kb的uaf情况下如何使用msg进行任意地址读和任意地址写的手段,还对task_struct进行了直观的了解,当然msg不仅能用于4kb情况下的uaf,它的功能非常强大。