Page-level UAF

Page-level UAF 同样是针对 buddy system 的利用手法,这种攻击手法主要指的是对内存页结构体 page 的释放后利用,例如我们可以通过内存页重分配的方式将 UAF page 分配为指定 kmem_cacheslub pages从而实现无需任何限制的跨 kmem_cache 的 UAF 利用

例题:D^3CTF2023 - d3kcache

题目可在此处arrow-up-right下载。

题目分析

题目逆向起来应该还是比较简单的,在模块初始化函数中创建了一个独立的 kmem_cache ,对象大小为 2048:

__int64 sub_529()
{
  printk(&unk_96B);
  major_num = _register_chrdev(0LL, 0LL, 256LL, "d3kcache", &d3kcache_fo);
  if ( major_num >= 0 )
  {
    module_class = _class_create(&_this_module, "d3kcache", &d3kcache_module_init___key);
    if ( (unsigned __int64)module_class < 0xFFFFFFFFFFFFF001LL )
    {
      printk(&unk_A0D);
      module_device = device_create(module_class, 0LL, (unsigned int)(major_num << 20), 0LL, "d3kcache");
      if ( (unsigned __int64)module_device < 0xFFFFFFFFFFFFF001LL )
      {
        printk(&unk_A66);
        spin = 0;
        kcache_jar = kmem_cache_create_usercopy("kcache_jar", 2048LL, 0LL, 67379200LL, 0LL, 2048LL, 0LL);
        memset(&kcache_list, 0, 0x100uLL);
      }
      else
      {
        class_destroy(module_class);
        _unregister_chrdev((unsigned int)major_num, 0LL, 256LL, "d3kcache");
        printk(&unk_A3B);
      }
    }
    else
    {
      _unregister_chrdev((unsigned int)major_num, 0LL, 256LL, "d3kcache");
      printk(&unk_9DE);
    }
  }
  else
  {
    printk(&unk_9AD);
  }
  return _x86_return_thunk(0LL, 0LL, 0LL, 0LL, 0LL, 0LL);
}

自定义的 ioctl 函数提供了分配、追加编辑、释放、读取的一个堆菜单,漏洞便出在追加编辑当中,当写满 2048 字节时存在着一个 \0 字节的溢出:

同时查看题目所提供的内核编译文件,可以发现开启了 Control Flow Integrity 保护

其他的各种常规保护(KPTI、KASLR、Hardened Usercopy、...)基本上都是开启的,这里就不阐述了。

当然,现在做内核漏洞利用自然要默认这些保护都开了:)

漏洞利用

由于题目所在的 kmem_cache 为一个独立的 kmem_cache ,因此我们只能考虑 cross-cache overflow溢出到其他结构体所在页面上完成利用

毕竟你总不能指望在 freelist 相关保护都开启的情况下 free object 的 next 指针刚好在前 8 字节然后覆写又刚好能把 freelist 劫持到有效可控地址上:)

Step.I - 页级堆风水构造稳定跨页溢出布局

页级堆风水的实现细节在上一章中已有讲述,这里不再赘叙。

为了保证溢出的稳定性,这里笔者使用页级堆风水的方法来构造预溢出布局,通过页级堆风水获得对一块连续内存的页级掌控,从而可以这样构造出如下图所示堆布局:

  • 先释放一部分页面,让 victim object 取得这些页面

  • 释放一份页面,向题目模块请求分配对象,从而获得该份页面

  • 再释放一部分页面,让 victim object 取得这些页面

这样题目所在的页面便会被夹在 victim 对象的页面中间,使得溢出的稳定性大幅增加

Step.II - fcntl(F_SETPIPE_SZ) 更改 pipe_buffer 所在 slub 大小,跨页溢出构造页级 UAF

接下来我们考虑溢出的目标对象,由于仅有一个字节的溢出,毫无疑问的是我们需要寻找一些在结构体头部便有指向其他内核对象的指针的内核对象,我们不难想到的是 pipe_buffer 是一个非常好的的利用对象,其开头有着指向 page 结构体的指针,而 page 的大小仅为 0x40 ,可以被 0x100 整除,若我们能够通过 partial overwrite 使得两个管道指向同一张页面,并释放掉其中一个,我们便构造出了页级的 UAF

original state
null-byte partial overwrite
page-level UAF

同时管道的特性还能让我们在 UAF 页面上任意读写,这使得我们可以将该 page 分配为其他 kmem_cache 的 slub pages 后,利用管道对上面的对象实现任意读写

但是有一个小问题,pipe_buffer 来自于 kmalloc-cg-1k ,其会请求 order-2 的页面,而题目模块的对象大小为 2k,其会请求 order-3 的页面,如果我们直接进行不同 order 间的堆风水的话,则利用成功率会大打折扣 。

现在让我们重新审视 pipe_buffer 的分配过程,其实际上是单次分配 pipe_bufspipe_buffer 结构体:

这里注意到 pipe_buffer 不是一个常量而是一个变量,那么**我们能否有方法修改 pipe_buffer 的数量?**答案是肯定的,pipe 系统调用非常贴心地为我们提供了 F_SETPIPE_SZ 让我们可以重新分配 pipe_buffer 并指定其数量

那么我们不难想到的是我们可以通过 fcntl() 重新分配单个 pipe 的 pipe_buffer 数量,

  • 对于每个 pipe 我们指定分配 64 个 pipe_buffer,从而使其向 kmalloc-cg-2k 请求对象,而这将最终向 buddy system 请求 order-3 的页面

由此,我们便成功使得 pipe_buffer 与题目模块的对象处在同一 order 的内存页上,从而提高 cross-cache overflow 的成功率。

不过需要注意的是,由于 page 结构体的大小为 0x40,其可以被 0x100 整除,因此若我们所溢出的目标 page 的地址最后一个字节刚好为 \x00那就等效于没有溢出 ,因此实际上利用成功率仅为 75%

Step.III - 构造二级自写管道,实现任意内存读写

有了 page-level UAF,我们接下来考虑向这张页面分配什么结构体作为下一阶段的 victim object。

由于管道本身便提供给我们读写的功能,而我们又能够调整 pipe_buffer 的大小并重新分配结构体,那么再次选择 pipe_buffer 作为 victim object 便是再自然不过的事情:

接下来我们可以通过 UAF 管道读取 pipe_buffer 内容,从而泄露出 page、pipe_buf_operations 等有用的数据(可以在重分配前预先向管道中写入一定长度的内容,从而实现数据读取),由于我们可以通过 UAF 管道直接改写 pipe_buffer ,因此将漏洞转化为 dirty pipe 或许会是一个不错的办法(这也是本次比赛中 NU1L 战队的解法)

但是 pipe 的强大之处远不止这些,由于我们可以对 UAF 页面上的 pipe_buffer 进行读写,我们可以继续构造出第二级的 page-level UAF

为什么要这么做呢?在第一次 UAF 时我们获取到了 page 结构体的地址,而 page 结构体的大小固定为 0x40,且与物理内存页一一对应,试想若是我们可以不断地修改一个 pipe 的 page 指针,则我们便能完成对整个内存空间的任意读写,因此接下来我们要完成这样的一个利用系统的构造。

再次重新分配 pipe_buffer 结构体到第二级 page-level UAF 页面上,由于这张物理页面对应的 page 结构体的地址对我们而言是已知的,我们可以直接让这张页面上的 pipe_buffer 的 page 指针指向自身,从而直接完成对自身的修改

这里我们可以篡改 pipe_buffer.offsetpipe_buffer.len 来移动 pipe 的读写起始位置,从而实现无限循环的读写,但是这两个变量会在完成读写操作后重新被赋值,因此这里我们使用三个管道

  • 第一个管道用以进行内存空间中的任意读写,我们通过修改其 page 指针完成 :)

  • 第二个管道用以修改第三个管道,使其写入的起始位置指向第一个管道

  • 第三个管道用以修改第一个与第二个管道,使得第一个管道的 pipe 指针指向指定位置、第二个管道的写入起始位置指向第三个管道

通过这三个管道之间互相循环修改,我们便实现了一个可以在内存空间中进行近乎无限制的任意读写系统

Step.IV - 提权

有了内存空间中的任意读写,提权便是非常简便的一件事情了,这里笔者给出三种提权方法。

方法一、修改当前进程的 task_struct 的 cred 为 init_cred

init_cred 为有着 root 权限的 cred,我们可以直接将当前进程的 cred 修改为该 cred 以完成提权,这里iwom可以通过 prctl(PR_SET_NAME, "arttnba3pwnn"); 修改 task_struct.comm ,从而方便搜索当前进程的 task_struct 在内存空间中的位置。

不过 init_cred 的符号有的时候是不在 /proc/kallsyms 中导出的,我们在调试时未必能够获得其地址,因此这里笔者选择通过解析 task_struct 的方式向上一直找到 init 进程(所有进程的父进程)的 task_struct ,从而获得 init_cred 的地址。

方法二、内核页表解析获取内核栈物理地址,利用直接映射区覆写内核栈完成 ROP

开启了 CFI 并不代表我们便不能够在内核空间中进行任意代码执行了。由于 page 结构体数组与物理内存页一一对应的缘故,我们可以很轻易地在物理地址与 page 结构体地址间进行转换,而在页表当中存放的是物理地址,我们不难想到的是我们可以通过解析当前进程的页表来获取到内核栈的物理地址,从而获取到内核栈对应的 page,之后我们可以直接向内核栈上写 ROP chain 来完成任意代码执行

页表的地址可以通过 mm_struct 获取, mm_struct 地址可以通过 task_struct 获取,内核栈地址同样可以通过 task_struct 获取,那么这一切其实是水到渠成的事情。

但这种方法有一个缺陷,我们会有一定概率没法直接写到当前进程的内核栈上(也不知道写哪去了),从而导致 ROP 失败,原因不明

笔者暂时没有发现整个过程的原理存在缺陷的地方,甚至尝试多次重新解析页表(得到的内核栈地址不变)然后写入数据后仍旧无事发生,也不知道究竟是哪出了问题 :(

方法三、内核页表解析获取代码段物理地址,改写内核页表建立新映射实现 USMA

既然我们能够进行内存空间中的任意读写,直接改写内核代码段也是一个实现任意代码执行的好办法,但是直接映射区对应的内核代码段区域没有可写入权限,直接写会导致 kernel panic。

但是改写内核代码段本质上便是向对应的物理页写入数据,而我们又能够读写进程页表,我们直接在用户空间建立一个到内核代码段对应物理内存的映射就能改写内核代码段了。

方便起见,我们可以先通过 mmap() 随便映射一块内存,之后改写 mmap() 的虚拟地址在页表中对应的物理地址即可,这种方法本质上其实就是 用户态映射攻击arrow-up-right

Final Exploitation

最终的完整 exp 如下,同时包含笔者所给出的三种提权手段的代码

REFERENCE

[D^3CTF 2023] d3kcache: From null-byte cross-cache overflow to infinite arbitrary read & write in physical memory space.arrow-up-right

【PWN.0x02】Linux Kernel Pwn II:常用结构体集合arrow-up-right

Last updated