Begin
又有 kernel 啦
好久没看 kernel 了,刚好 SCTF 上就来了一道简单题。结果由于生疏了打的巨慢,连血都没拿到😭(4血)上图为比赛时打通的截图。
由于题目比较简单,所以我打算用多种打法来解决这道题目,同时也当作是对 kernel 的康复训练。这篇文章只是对题目进行各种攻击手段的分析,不会对每个内核结构体的结构以及攻击手法的具体原理进行详细的讲解。本篇文章我会逐步增加题目的限制,然后从低级到高级用不同的攻击手段对题目进行求解。
题目分析
关键代码:
ioctl
1 | __int64 __fastcall my_module_ioctl(__int64 a1, int a2, __int64 a3) |
write
1 | __int64 __fastcall module_write(__int64 a1, __int64 a2, unsigned __int64 a3) |
随机数绕过
可以看到题目会先生成一个 32 位的随机数然后和我们自己的字符串进行比较,要比较通过才可以进行后面的操作。这里听说有挺多种绕过方法,但由于这不是重点,所以这里我只用我自己的方法。我们可以赌随机数的第一位是 \x00,这个时候比较就会只比较第一位 \x00,写个循环爆破即可,我的 add 和 del 函数如下:
1 | char v14[0x100]; |
漏洞分析
可以发现这个内核驱动并没有上锁,所以我们可以利用条件竞争来创造出 UAF,即在 write 函数执行 copy_from_user 前调用 kfree 讲堆块释放掉,这个时候就会出现 UAF
在进行堆块创建的时候,可以看见有一个 copy_to_user 函数被调用,我们可以利用这个来泄露出堆地址
notes leak + userfaultfd + tty_struct + rt_regs
思路分析
这是笔者我比赛时所使用的解法,同时也应该是最好想、最直接、最预期(我猜的)的解法。从一个内核初学者的角度来想,做 kernel pwn 肯定要先泄露内核地址,往往这并不简单,但这题的内核基址是白给的!!!具体原理可以参考我半年前的博客When ELF notes reveal too much | Qanux’s space
简单的来说,我们可以直接从 /sys/kernel/notes 中直接获取内核的地址。其次是堆地址,堆地址的获取方法已经写在题目分析部分了,这里不再进行讲述。
接下来我们关注到的是他的 add 操作是通过 kmalloc 申请了 736 字节的内存,这个大小刚好是 tty_struct 结构体的大小,这不是摆明着要我们通过劫持 tty_struct 的 ops 来实现程序流的控制?我们先来查看内核的版本
可以发现内核的版本比较低,我们可以通过 userfaultfd 来将程序卡在 copy_from_user。接下来我们的目标就是在内核的某个位置写上 kernel rop 然后通过劫持 tty_struct 栈迁移到我们的 kernel rop 上去。其实这里可以直接将 kernel rop 写道 tty_struct 上然后进行两次栈迁移跳转到我们的 rop 上,可是我懒,完全没考虑要这么做😇
然后我幸运的发现,这个内核的 pt_regs 并没有开启随机化,也就是说当内核执行 tty_struct 虚表上的函数时该结构与 rsp 的距离不会变,所以我们可以直接将 kernel rop 布局到这个位置然后伪造 tty_struct ops 直接栈迁移到我们布局好的 kernel rop 上实现提权
exp
由于比赛时比较紧张(想拿血),而且遇到了很多意外心态有点小崩,所以 exp 写的有点难看,可是不想改了 :-)
1 | // musl-gcc exp.c --static -masm=intel -lpthread -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
notes leak + userfaultfd + tty_struct + modprobe_path
思路分析
好,我这个人比较懒,不想进行两次栈迁移,而且现在假设这道题目的 pt_regs 开启了随机化(CONFIG_RANDOMIZE_KSTACK_OFFSET=y),每次在栈上的偏移都不一样,这个时候就有请我们的 modprobe_path 登场了。只要我们能够修改这个地方的值,我们就能够变向的将 flag 的权限提升到普通都可以读取,那我们要如何修改这个地方的值呢?我们找到了一个很好用的 gadget
1 | 0xffffffff810f69f1: mov qword ptr [rdx + 8], rsi; ret; |
当我们执行 iotcl(tty_fd, var1, var2) 的时候,rdx 和 rsi 是可控的,也就是说我们可以通过《类似于》 ioctl(tty_fd, "/tmp/sh", modprobe_path - 8); 来实现对 modprobe_path 的修改
由于 ioctl 的 rsi 传进去的最终只有四字节,所以我们要分段传两次
exp
1 | // musl-gcc exp.c --static -masm=intel -lpthread -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
求解效果
userfaultfd + msg_msg + pipe_buffer
思路分析
现在我们在上面的情况中加点限制,即 /sys/kernel/notes 里面不再给我们提供内核地址,那我们能否继续获取内核的地址呢?答案是可以的,这里需要 msg_msg 和 pipe_buffer 两个结构体来相互协作,因为他们都可以让堆管理器取出 0x400 大小的 objcet。可能有的师傅会问我这里为什么不能使用 tty_struct,这里我后面会进行解释。
通过调试我们可以发现内核并没有开启 CONFIG_SLAB_HARDENED=y 选项,可就是说我们堆块的布局可预测。当然如果开启了我们也可以通过堆喷来达到我们想要的效果。我们可以相邻的布局一个 msg_msg 结构体 和一个 pipe_buffer 数组,然后通过 uaf 来将 msg_msg.m_ts 来改大,同时将 msg_msgseg *next 指向 msg_msg +0x300 的位置(后面解释),这个时候就可以实现结构体的越界读。同时 msg_msg 结构体的下方有个 pipe_buffer 数组,可以通过该结构的 ops 来获取到内核的地址。此时的堆布局如下:
当我们调用 msgrcv 来读取 msg_msg 中的内容时,内核会调用 free_msg 遍历 next 依次释放 msg_send 并最终释放 msg_msg
1 | void free_msg(struct msg_msg *msg) |
其遍历的终止条件为 next 指针为空。由于我们 msg_msg 的 next 指针指向 msg_msg + 0x300 的位置(注意,这个位置必须为空,这是为了终止 free_msg 对 next 指针的遍历),所以当我们读取 msg_msg 的数据泄露内核地址后 msg_msg + 0x300 会被当成一个堆块进行释放,最终堆的布局会变成下面这种情况:
可以看见出现了叠堆,也就是说我们可以再次申请 0x400 的 object 就能修改到下方的 pipe_buffer。接下来就是一条龙服务了,修改 ops 然后两次栈迁移打 kernel rop。这里我为什么没用 tty_struct 呢?因为当我使用 tty_struct 时,我叠堆修改 tty_struct 时前 8 字节必须为空,否则就会 kernel panice,可是改 pipe_buffer 时前 8 字节确可以有数据。我们都知道,tty_struct 前 8 字节是魔术字 0x100005401,如果改字段损坏 tty_struct 就不会执行他虚表上的函数,也就是说我们无法控制程序流。所以到底为什么会 panic😇,如果有师傅知道原因可以加笔者微信或 QQ 教教鼠鼠😭
exp
1 | // musl-gcc exp.c --static -masm=intel -lpthread -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
求解效果
userfaultfd + pipe_buffer + page UAF
思路分析
现在我们来假设一种比较极端的情况,我们 /sys/kernel/notes 给打了补丁无法泄露地址,而且题目不再给出堆地址,以及内核编译时开启了 CONFIG_CFI_CLANG(可以防止攻击者进行 rop 攻击),这个时候就需要我们的 page uaf 登场了!!!
这玩意我是从 a3 师傅的博客学的,十分建议每位内核爱好者去反复阅读那篇文章:【CTF.0x08】D^ 3CTF2023 d3kcache 出题手记 - arttnba3’s blog,下面会借用 a3 师傅博客中的一些图片:-)
同样是利用 userfaultfd 来制造出 uaf,然后修改 pipe buffer 的 page 指针的第一个字节,使其出现两个 pipe buffer 指向同一个 page 的情况。
此时释放掉其中一个 pipe buffer,就会直接释放掉一整个页,出现页级 UAF !!!
在 gdb 中所看到的情况如下:
后面的操作就和题目的本身没有什么关系了(和用户态的 house of some 一样😇)。我本来的思路是将被释放的页重新取出来用于存储另一个 pipe buffer 数组,然后修改其中一个 pipe buffer 的 flag 为 0x10,实现任意文件的越权写入。可是喷了半天也没成功的命中。然后看到了 tplus 师傅的 wp,其思路更加的简单,即制造出 page uaf 后不断的喷射 target file(反复 open),然后修改 file → f_mode,也能实现越权写任意文件。
有了越权写任意文件后我是想着写 /etc/passwd 文件,可是这道题目没有这个文件再加上我也不是很熟悉这个东西,于是我选着修改 /sbin/poweroff。应为这个文件是连接着 busybox 的。当 qemu 关闭的时候会以 root 权限来指向这个文件。因此我们可以将 poweroff 文件修改为 readflag,然后输入 exit 即可类似于用户态的 orw 一样获取 flag
exp
1 | // musl-gcc exp.c --static -masm=intel -lpthread -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
求解效果
punching hole + pipe_buffer + page UAF
解题思路
这里我们对题目再进行最后一次升级,我们假设 kernel 版本为 6.6+,这个时候 userfaultfd 就只运行特权用户使用了。也许我们会想到用 fuse,可是在 kernel pwn 这种环境残缺的情况很难使用,这个时候我们就可以使用 punching hole。这个打法笔者是第一次听,网上也找不到什么资料,最后也是请教 Csome 师兄和 cnitlrt 师傅。大致意思就是把线程丢到一个等待队列,令一个线程休眠,其触发条件和 userfaultfd 一样也是通过 copy 类函数触发。将 userfaultfd 改用 punching hole 后其余操作和上面那个 exp 一致,不过由于使用了 punching hole 后堆的布局有了点变化,所以我 pipe_buffer 在堆块 uaf 的前面和后面各喷了一次以来提高命中率。
exp
1 | // musl-gcc exp.c --static -masm=intel -lpthread -idirafter /usr/include/ -idirafter /usr/include/x86_64-linux-gnu/ -o exp |
求解效果
总结
这里想分享一下我做题时的心理变化。当时我看见 uaf 直接想着用 USMA 给他秒了,所以调试都懒的调直接一口气把整个 exp 给写完,结果一运行发现内核编译时关闭了某些选项,导致无法执行:
1 | unshare(CLONE_NEWNS | CLONE_NEWUSER | CLONE_NEWNET); |
也就是说无法使用 USMA,于是我就用正常点打打法去劫持 tty_struct 然后使用 work_for_cpu_fn 一把梭,结果内核直接卡在 work_for_cpu_fn 里面了,想了半天没想明白,最后发现我的 vmlinux 是来自别的题目的😇🤣,当场裂开。把所有地址替换正确也花了点时间,可是程序还是卡在 work_for_cpu_fn 里面,我觉得是我把 fake ops 写到了 tty_struct 占用了某些变量的位置造成的,于是我就用 ret2hbp 想把 fake ops 写到 db_stack 上,结果感觉又是编译时关闭了某些选项?写半天写不上去,直接心态崩了。后面有人提醒我看看能不能用 pt_regs,我看了眼偏移是固定了,于是马上在 pt_regs 上写好 kenrel rop 直接栈迁移过去,中间为了找 init_cred 的地址又不得不把 vmlinux 丢进 ida 里面慢慢找,最后成功把血给搞没了🤣
感觉自己的水平还是太低了,我还能变得更强把 :-(