2022D3CTF 2道KernelPwn WP
这次比赛做出来2道kernelpwn,但是都是非预期解,所以这里深入分析一下正解
D3kheap
题目分析
这题极其简陋
ioctl 0x1234可以分配一个index为10的chunk,根据原来找的表,index10是512~1024范围
输入dead可以free掉这个chunk,read write等函数都没实现,
漏洞分析
这题的源头是CVE-2021-22555,核心结构体是msg_msg
隐藏十五年的漏洞:CVE-2021-22555 漏洞分析与复现 - FreeBuf网络安全行业门户
CVE-2021-22555: Turning \x00\x00 into 10000$ | security-research (google.github.io)
CVE-2021-22555 2字节堆溢出写0漏洞提权分析 - 安全客,安全资讯平台 (anquanke.com)
因为利用要用到msg结构体,所以需要围绕消息队列函数
msg源码分析
(23条消息) 消息队列函数(msgget、msgctl、msgsnd、msgrcv)及其范例_guoping16的专栏-CSDN博客_msgsnd
msg结构体
1 | struct msg_msg { |
Linux内核提供了两个syscall来进行IPC通信,msgsnd()和msgrcv(),内核消息包含两个部分,消息头msg_msg结构和紧跟的消息数据,长度从64到4096
msgsnd
调用链如下
msgsnd -> ksys_msgsnd -> do_msgsnd ->load_msg -> alloc_msg分配消息头和消息数据,然后调用load_msg -> copy_from_user把用户数据拷贝到内核
先msgget创建一个消息队列,返回值是队列句柄,用同一个句柄发送和接受消息才共用一个消息队列
1 | struct msgbuf |
用户层发消息需要构建一个msgbuf,mtype是消息类型,mtext不定长,存放消息内容
alloc_msg负责在内核中创建消息
如果消息长度超过0xfd0,则分段存储,采用单链表连接,第1个称为消息头,用 msg_msg 结构存储;第2、3个称为segment,用 msg_msgseg 结构存储。消息的最大长度由 /proc/sys/kernel/msgmax
确定, 默认大小为 8192 字节,所以最多链接3个成员。
1 | static struct msg_msg *alloc_msg(size_t len) |
load_msg负责把消息从用户层拷贝过来
1 | struct msg_msg *load_msg(const void __user *src, size_t len) |
所以在内核中消息结构长成下面这样
msgrsv
调用链如下 msgrcv -> ksys_msg_rcv ->do_msg_rcv -> find_msg &&do_msg_fill &&free_msg
调用find_msg来定位正确的消息,将消息从队列中unlink,再调用do_msg_fill -> store_msg 来讲内核数据拷贝到用户空间,最后调用free_msg释放消息
1 | long ksys_msgrcv(int msqid, struct msgbuf __user *msgp, size_t msgsz, |
注意这里关键当设置了MSG_COPY之后,就不会走到list_del也就是msg只是拷贝出来但是消息依然在消息队列里面,但是这里还有个疑问,虽然msg没被断链,但是下面调用了free_msg,明显不符合逻辑,这里wake_up_q好像是唤起线程,是不是跟这有关?
do_fill做实际的拷贝工作
do_msg_fill() -> store_msg() 。和创建消息的过程一样,先拷贝消息头(msg_msg
结构对应的数据),再拷贝segment(msg_msgseg
结构对应的数据)。
1 | static long do_msg_fill(void __user *dest, struct msg_msg *msg, size_t bufsz) |
最后简单看一下free_msg
1 | void free_msg(struct msg_msg *msg) |
CVE-2021-22555
CVE-2021-22555 2字节堆溢出写0漏洞提权分析 - 安全客,安全资讯平台 (anquanke.com)
首先用msgget创建4096个消息队列,消息队列数目没有限制,越多越稳定
填充4096个消息队列,填充4096个消息,消息大小为0x1000,得到一个整齐的空间布局,使得msg-msg尽可能的相邻
为每个消息队列添加辅助消息,辅助消息的大小为0x400
这里是消息队列的图,消息的图如下,m_list是链接相同消息队列中的消息,next是链一个消息里的不同块
添加完辅助消息后,内存图长这样
释放部分主消息,比如1024、2048、3072获得0x1000内存空洞,来让程序中的受控结构体获得(xt_table_info),这样就能利用2字节溢出写0
利用2字节溢出,将相邻的msg_msg结构体中msg_msg->m_list->next末尾两字节覆盖为0, 使得该主消息的msg_msg->m_list->next指向其他主消息的辅助消息。
目的:使某个内存被两个主消息引用。
接下来定位一下发生错误的消息队列
方法:直接查看消息内存,如果主消息和辅助消息队列的标识不同,则表示主消息msg_msg->m_list->next成员被修改。为保证查看消息时,避免消息被释放,需使用
需使用MSG_COPY标志接收消息。
假设现在主消息1和主消息2的msg_msg->m_list ->next指向相同的辅助消息
1.主消息1放弃辅助消息msg_msg, skb占据msg_msg
2.主消息2放弃辅助消息msg_msg, victim结构占据msg_msg
3.此时skb与victim结构占据同一内存空间
4.修改skb劫持victim结构内函数指针
5.触发victim结构函数指针,劫持控制流
但是注意到当实现步骤2时,必须伪造msg_msg->m_list->next成员,如果此时主消息2释放msg_msg,辅助消息会被从循环链表msg_msg->m_list中去除,也就是说此阶段会涉及到对于msg_msg->m_list->next的读写,而next在第一次断链应该变成了0,因为开启了smap保护机制,所以在用户态伪造该字段无意义,内核在此处会检查到smap错误,利用失败,所以接下来需要绕过SMAP。
skb堆喷并伪造辅助消息,重新分配的消息msg_msg,伪造其m_ts要大于0x400,这样就可以越界读到下一个辅助消息的结构体
由于m_ts变大,可以越界读取相邻辅助消息的消息头,主要是泄露msg_msg->m_list->next和msg_msg->m_list->prev(相邻辅助消息的主消息堆地址,记为kheap_addr)
释放skb,重新填充该fake辅助消息,msg_msg->next = kheap_addr,因此,某个主消息成了该辅助消息的segment(msg_msgseg结构)。这样就能越界读取主消息的头,主消息的msg_msg->m_list->next指向与之对应的辅助消息,也即fake辅助消息相邻的辅助消息,该内存地址-0x400,即为fake辅助消息的真实地址。
再次释放skb,将fake辅助消息的msg_msg->m_list->next填充为该fake辅助消息的真实地址,即可再次释放fake辅助消息时避免SMAP崩溃。
怎么绕过SMAP?
后来才看懂,第二次free的时候,因为m_list里面的next和prev都是瞎填的值,所以断链的时候会出错,要想不出错,只要把prev和next都改到指向当前chunk,这样双向链表的条件就满足了,也就是都指向自己,这样再断链就可以绕过SMAP
再贴一下两个结构体
1 | struct msg_msgseg { |
1 | struct msg_msg { |
下面绕过KASLR泄露内核基地址
方法:伪造fake辅助消息,msg_msg->m_list->next == msg_msg->m_list->pre == fake辅助消息;利用主消息2释放辅助消息,使用pipefd函数分配pipe_buffer结构体重新占据fake辅助消息堆块;通过读skb泄露anon_pipe_buf_ops地址,绕过KASLR。pipe_buffer结构体中ops成员指向全局变量anon_pipe_buf_ops。
成型的堆图
此时skb与pipe_buffer占据同一块内存,利用skb伪造pipe_buffer->ops指向本堆块,再伪造pipe_buffer->ops->release指向第1个ROPgadget,劫持控制流。
pipe
用到的补充结构
pipe_buffer分配:alloc_pipe_info() —— 分配大小为0x370(默认16个page,16*0x28=0x370),所以位于0x400堆块中。
pipe首先是一个结构体pip_inode_info ,其中的buf指向多个pipe_buffer,默认是16个就形成了评论里说的,0x280
pipe中的operation是全局指针
1 | const struct file_operations pipefifo_fops = { |
所以可以用了泄露内核base
pipe_buffer释放:pipe_release() -> put_pipe_info() -> free_pipe_info -> pipe_buf_release() 调用pipe_buffer->ops->release
函数,可劫持控制流。
1 | static inline void pipe_buf_release(struct pipe_inode_info *pipe, |
skb堆喷
SKB喷射:采用socketpair()
创建一对无名的、相互连接的套接字,int socketpair(int domain, int type, int protocol, int sv[2])
,函数成功则返回0, 创建好的套接字分别是sv[0]和sv[1],失败则返回-1。可以往sv[0]中写,从sv[1]中读;或者从sv[1]中写,从sv[0]中读,相关函数为write()
和read()
。也可以调用sendmsg()
和recvmsg()
来发送和接收数据,用户参数是msghdr
结构。本exp是采用write()
和read()
进行堆喷和释放的。
Nu1l的exp
这里直接分析一下Nu1L的exp
首先这题漏洞是一个double free,前面malloc free一个chunk都没什么问题,问题在于,ref_count计数器的初始值是1,所以这里可以free两次
创建4096个消息队列,创建两组sbk,打开目标驱动
msg结构体,实际就是msg_buffer,虽然这里给的mtext是0x400-0x50但是发送的msg_buffer的大小还得看给了多少大小
这一步spray_1k可以理解为先把堆中的散块alloc一下,这样剩下的堆布局就比较稳(?)
向消息队列0和1里面分别写入一个消息
分配一个buf然后释放,buf用的是1024 也就是0x400
此时分配刚刚释放的buf到消息队列0.
再次调用dead,去free(原来double free是这样用的),然后再分配到消息队列1里面,这样就形成了CVE里分析的两个消息队列里的主消息指向同一个辅助消息
又给消息队列2分配了一堆消息,这些消息是紧挨着这个buf_msg的,留着过SMAP的
释放掉消息队列1里面的msg,也就是被double free的chunk
打堆喷sbk,伪造大小,实现越界读,
取出数据之后,遍历内存,找到0xAAAAAAAA的位置,也就是消息队列2中的msg1,等会prev和next就设置成这个
原CVE是prev和next设置自己,这里是设置到了另一个msg,但是依然能够过SMAP
释放sbk,重新构造带争取prev和next的sbk,堆喷上去,然后通过read_msg把msg读取出来,也就是把其free掉
把free掉的msg堆喷到pipe上
此时sbk跟pipe_buffer就堆喷到同一块地方了
从sbk中泄露出pip_buffer_ops的地址,只有它是内核指针,可以用这个泄露kernel base
最后劫持控制流打一个内核ROP即可
exp上传
其实这两题是第一次成功做出来kernel pwn,以前都是赛后复现,所以exp写出来最后上传这一步都是忽略的,这一次发现问题还很多
1 | from pwn import * |
以上次比赛的exp上传脚本为例,首先目标服务器需要一个token
1 | p.recvuntil("Input your team token: ") |
然后是把exp打包并且编码成base64
1 | os.system("tar -czvf exp.tar.gz ./exp") |
等到目标虚拟机启动完毕回显出来命令行之后,在tmp里面创建b64_exp,因为一般init里面权限只给到tmp目录下,根目录一般我们没权限写
1 | p.recvuntil("/ $ ") |
一行行的传输base64,这里每100行打印一下进度
1 | count = 1 |
最后就是解压exp,然后执行提权,然后再cat flag,这里提完权之后,还有种做法是直接切到interactive,但是有时候显示会很不正常,这里我采用发送单个命令然后打印回显,这种最稳。
1 | data = p.recvuntil("$ ") |
这种传输方法非常慢,网上很少有kernel pwn的文章提到这一点,然而目标一般会在几分钟后关机,所以一般我们是用musl-gcc去编译而不是glibc编译,下图是两种编译的大小差距,
musl编译
手动编译musl库
一般编译命令如下,但是会报错
1 | musl-gcc -static fs/exp.c -o fs/exp |
因为musl-gcc没法识别linux头文件<linux/xxx.h>,2019这篇blog也提过这个问题,也提出了解决方案,既然gcc能找到,就用gcc -E先处理下,命令如下
Google CTF 2021 eBPF (mem2019.github.io)
1 | gcc -E exp.c -o fs/exp.c |
非预期解
这题exp直接打包进放到了tmp目录里,而且还是能用的,估计作者搞忘了,唯一的缺陷就是给的exp应该使用gcc编译的,太大了,会遇到上面说的exp上传的问题,所以我的方法是用IDA F5逆向一份代码去打
D3bpf
题目分析
题目给了如何编译的内核,也就变相的告诉了内核版本5.11
1 | ### get the source |
boot.sh KASLR smep smap都开了
1 |
|
diff patch掉了几个CVE,然后在ebpf的verifier上patch了一段代码,漏洞就在这里
1 | diff --git a/fs/fs_context.c b/fs/fs_context.c |
但是5.11版本的linux内核是出过几个ebpf上的CVE的,这里我也是用现有的CVE直接打的,当时打的时候只是修改了exp上的几个偏移,没有详细分析ebpf,这里就来分析一下CVE-2021-3490,同时也分析一下ebpf
ebpf
主要参考了一下几篇文章
CVE-2021-3490 eBPF 32位边界计算错误漏洞利用分析 - 安全客,安全资讯平台 (anquanke.com)
Kernel Pwning with eBPF: a Love Story - Blog | Grapl (graplsecurity.com)
chompie1337/Linux_LPE_eBPF_CVE-2021-3490 (github.com)
[原创]Linux内核eBPF模块源码分析——verifier与jit-二进制漏洞-看雪论坛-安全社区|安全招聘|bbs.pediy.com
想搞懂这个洞,首先得看看verifier部分的源码分析,然后再去看漏洞成因,源码分析留到以后写吧,这里简单分析一下漏洞
EBPF是linux内核中的一个模块,可以将其看成一个内置虚拟机,用户层可以传入ebpf字节码来执行,这样可以看成暴漏了一个恶意代码注入的攻击面,作为内核模块,ebpf肯定不会允许用户轻易的注入恶意代码来执行,因此ebpf在执行代码前,会有一个verifier函数来对整个传入的ebpf字节码进行全面的安全分析。
其中比较关键的是对指针运算进行检测: verifier会对ebpf字节码中算术运算进行范围检测,
例如ebpf中准许用户创建一个map,这个map存在于内核中,ebpf字节码中可以直接对map进行操作,但是当一个寄存器被识别成是指向map的ptr时,其算术运算就有严格的范围,即不能超过map的大小
3490这个洞就是发生在verifier中,其使得verifier错误识别了寄存器的范围,导致OOB read和write
CVE-2021-3490 分析
verifier分析
流程中用到的所有代码
adjust_scalar_min_max_vals
tnum_and说就是根据位去运算,没什么,然后调用scalar32_min_max_and,这个函数中间由于src_known和dst_known直接就返回了,等于32位的scalar什么都没做
64位的scalar
子函数mark_reg_known
在scalar的末尾
在adjust_scalar_min_max_vals的末尾调用的
按流程分析一下
1 | BPF_ALU64_REG(BPF_AND, R2, R3) |
在adjust_scalar_min_max_vals里面 tnum_add首先被调用,结果是R2的var_off变成{mask=0x1 0000 0000 value = 0x0}
然后scalar32直接return,来到scalar64,由于R2mask的高32位有一个1,所以不会直接return
执行scalar64里面的update_reg_bound,其内部就是两个函数update32和update64
由于u32_max_value = 1(最开始就是1) > var_off.value = 0,所以下面这句就得到u32_max_value = 0
同样的u32_min_value就等于1
最后就来到adjust_scalar_min_max_vals末尾的函数
deduce_bound,deduce_bound也是两个函数
R32的代码,可以看到上下两个分支都没满足,只会执行U32_max_value >= 0 这一个分支,也没改变bound
Reg_bound_offset就不分析了,按blog说不会对bound有改变,结果就成了max 0 < min 1,就错了
在有了exploit_reg之后(min>max),剩下就是想办法利用
类型混淆
该利用可以把一个pointer type的reg混淆到scalar type
首先构造一个exploit_reg,其边界umin_value > umax_value
第一步说是扩展,但是没看出来哪里拓展了(不懂的地方)
这一步ADD之后,OOB_MAP_REG就变成了Scalar type,
因为当发生指针+scalar时,会走到下面的代码片段,正好走到下面的if分支
Mark_reg_unkown怀就坏在把reg改成了SCALAR_VALUE,,pointer变成了scalar,后面就可以随便运算了
回到前面,OOB_MAP_REG是一个map的指针
然后把OOB_MAP_REG 存到STORE_MAP_REG,这是另一个map
然后在user space里面读STORE_MAP_REG里面泄露的kernel address(OOB_MAP_REG)
原语REG
该利用可以构造一个reg,verifier认为其是0,但是在运行时是1
初始exploit_reg的bound u32_max_value = 0 < u32_min_value =1
+1 会简单的扩充bound u32_max_value = 1 and u32_min_value = 2, with var_off = {0x100000000; value = 0x1}.
UNKOWN_VALUE_REG是type为unknown,可以从map里读个0到这个寄存器(runtime),但是verifier会标记其为unknown
那么这里JLE,会在TRUE分支下跳过EXIT,在TRUE分支中,UNKOWN_REG会被设置成u32_min_value = 0, u32_max_value = 1
var_off = {mask = 0xFFFFFFFF00000001; value = 0x0}.
ADD在不越界的情况下,只是把bound相加
所以加完之后exploit_reg边界如下 u32_min_value = 2, u32_max_value = 2
Exploit_reg原先是
var_off = {0x100000000; value = 0x1}
Unkown_reg原先是
var_off = {mask = 0xFFFFFFFF00000001; value = 0x0}
加完之后 upper 32 bits依然是unknown,最低位也是unknown,因为两个bit相加可能是1,也可能是2,所以最低两位都是unknown,所以加完之后
Exploit_reg 的var_off {mask = 0xFFFFFFFF00000003; value = 0x0}
因为边界限定,所以ADD执行之后 {u,s}32_min_value = {u,s}32_max_value = 2
var_off = {mask = 0xFFFFFFFF00000000; value = 0x2}
也就是说现在verifier会认为exploit_reg 低32位有一个known值2
但实际上运行时其值为1
这里又做了一次0拓展,一样不懂
AND,2&1 = 0
所以此时就是verifier认为其值为0,但是实际runtime时是1
OOB read & write
首先要知道map的content并不是自己独自存在在heap chunk中,而是跟一个大结构体bpf_map放在一块的,因此一旦我们能对map有了向上的越界读写,就可以通过修改bpf_map实现exploit
越界读写在有了原语reg之后就很简单,我们可以用原语reg乘以我们想去的偏移量,然后再用map_ptr去减,由于verifier认为原语reg是0,乘以多少也是0,所以verifier会认为map_ptr减的是0,但是到runtime时,已经减到偏移量了
bypass KASLR
这里面最有用的就是ops,ops是kernel里面内置的表,甚至是导出的,不同type的map有不同的ops,根据这个就可以泄露Kernel base
Arbitrary Read
任意读用的是btf,一般情况下用不到btf,所以我们可以随便设置,当我们调用bpf的bpf_map_get_info_by_fd-function命令时就会执行上面的命令,所以我们把btf设置到someaddr - offsetof(struct btf, id)的位置,就可以从someaddr上读4个字节
finding Process strcut
首先我们得找到init_pid_ns,默认的进程命名空间,两种方式去找
1.知道array_map_ops到init_pid_ns的偏移,那直接加偏移就行,这个offset不依赖KASLR,但是不同 的kernel里面不稳定
2.直接搜符号表
找到init_pid_ns之后,直接遍历他的radix tree去找task struct,实际上OS也是这样找的,在task struct里面,cred struct包含user priviledge,还有文件描述符数组,file_operation里面的private_date可以找到bpf_map的地址,我们可以获取到第三个map(explmap)的地址
Arbitrary write
这个函数只要我们控制了key和next_key就可以任意地址写
为了到达这个函数,我们修改explmap的ops里的这个函数指针
Map_push_elem只有在特定MAP 类型里才能调用
设置max_entries是强制过掉俩if
前面为什么要从文件描述符里得到explmap的bpf_map地址,因为这样才能得到map content的地址,我们伪造的ops虚表是要写在这里面的,得不到地址怎么填
Getting Root Privilege
有了任意读和任意写 ,直接修改cred的uid到0就行
3490 exp分析
chompie1337/Linux_LPE_eBPF_CVE-2021-3490 (github.com)
exp在上,由于这题本身就可以用,所以分析这个exp就行
首先创建用到的bpf_map
一个用作任意读和任意写,一个用来劫持控制流
把两个map的value都初始化成0
整个exploit过程用的值都由一个结构体维护
泄露oobmap的指针
构造ebpf程序如下
这里首先用到了exploit primitive1
注释如下
// The exploit primitive is an eBPF program contained into two parts. The first part only triggers the bug, where EXPLOIT_REG will have incorrect 32 bit bounds (u32_min_value=1,u32_max_value=0).
// The second part causes the eBPF verifier to believe EXPLOIT_REG has a value of 0 but actually has a runtime value of 1. It is split into two parts because we only need the first part to leak
// the pointer to the BPF array map used for OOB read/writes.
也就是单纯构造exploit_reg和构造”0,1”寄存器分开了
这里就是构造一个exploit_reg 然后加到OOB_MAP_VALUE上,使得OOB_MAP_value变成scalar值,,再把其存到SToreMap中就可以泄露了
第三步
泄露ops的地址
经过两个原语之后,直接乘以OPS的偏移就行,读是一样的,先存到storemap里面,然后再读
任意读的原语就是前面介绍的原理
1 | int kernel_read_uint(exploit_context* pCtx, uint64_t addr, uint32_t* puiData) |
这里是封装的指定len的read
testread就是读一个uint64试试
接下来两步是search init_pid_ns
后面是find_task_cred
根据pid在ns里面找
倒数第二步时是准备任意写原语
最后就是写cred_struct
任意写,addr填在next_key的位置,由于value会加1,所以这里给的val-1
exp的一些问题
上面这个exp是能用的,但是会出现很多问题,其寻找ns,和task_struct有点慢,因为其实际上并不是通过kernelbase + offset这种ctf上的针对特定版本的方式,由于比赛中目标服务器只有几分钟的时间,所以这里要改成加偏移的方式,而不是暴力搜索
【kernel exploit】CVE-2021-31440 eBPF边界计算错误漏洞(Pwn2Own 2021) — bsauce
这题其实也可以用31440打,而31440提供的exp是加偏移的方式,只不过给的exp偏移不是题目给的kernel里的偏移,所以要修一下
然后3490是遍历idr树去找指定pid的task struct
31440是直接通过task_struct在ns中的偏移获取第一个task-struct
然后通过task_struct的next链表遍历所有的task_struct来找到目标pid的struct
ALU sanitation
这是一个特性,后来看exp感觉,在exp的利用思路下,sanitation并不能阻止阅读读写,但是其会加一个最大偏移map size的设定,所以会相对来说有点小限制,基本没影响
防止在运行时发生OOB,每次跟指针进行算术运算时,会计算一个alu_limit,代表最大能加到,或减在指针上的值,
在每次指针运算时,会patch下面的指令
这段patch
1.alu_limit被装装载到ax中
2.ax = ax-off_reg,如果off_reg > ax,ax的signbit就会被设置
3.ax = ax or offreg ,如果ax是正,off_reg是负,or会设置符号位
4.neg会反向符号位,
5.arsh对ax做算术右移,所以整个ax会被符号位填充
6.根据上面的操作,如果off_reg > ax就是越界了,这里ax and之后就是0
如果没越界,and之后不变
Alu_limit的值现在确实已经出了新的计算方式,但是还没应用到Linux kernel上,先前的alu_limit是根据指针寄存器的boundary来的,如果指针寄存器指向一个map的开始,那么减的alu_limit就是0,加的limit就是size of map
目前,alu_limit取决于off_reg,意味着offreg在运行时会跟在verifier时算出的boundary进行比较
编译带符号的linux kernel
编译内核用到的一些依赖
1 | #apt-get install make |
编译内核参考这篇文章
编译 Linux 内核,qemu + gdb 动态调试 - scriptk1d - 博客园 (cnblogs.com)
编译内核时碰到的错误
compilation - BTF: .tmp_vmlinux.btf: pahole (pahole) is not available - Stack Overflow
完整exp
1 |
|
总结
Kernel Pwn是真鸡儿难,当然由于其过难,这几次kernel pwn的经历也让我发现,国内kernelpwn几乎都是改的CVE,所以下次搜到相关CVE就算成功?