FILE结构体与glibc封装后的IO
“一切皆文件”
IO_FILE
首先,在用fopen打开文件的时候,为什么会给我们返回一个整数的文件描述符?这是因为在内核对每个进程维护了一个文件表,这个整数可以用下标去访问里面的指针,然后指针会指向内核维护的全部打开的文件,里面会维护文件的offset和另一个inode指针,然后最后再指向真正磁盘操作的结构。而glibc的所谓的这些IO_FILE定义,本质上没有真正操作文件,只是操作了缓冲区。
IO_FILE
是一个结构体,用于表示一个打开的文件。实际使用的时候是这样一个定义:
1 | struct _IO_FILE_plus |
第一个字段是一个文件结构,里面定义了许许多多标识用的指针。glibc为了优化性能减少陷入内核态的次数,将系统调用read, write和open都进行了封装。我们平常调用的fopen,fwrite都是封装后的函数。这些函数在打开文件进行读写的时候会事先在堆上分配缓冲区,一般是一个内存页的大小。利用空间局部性的思想,每次先从文件中预装一个页进来,然后进行读写就会很快。这些指针都是用于标识和操作缓冲区的,只有在执行缓冲区刷新flush的时候才会将buffer的内容真正写入磁盘文件。
flag的高位是0xFBAD
1 | struct _IO_FILE { |
我们平常所说的那些stdin,stdout,stderr都是这样的结构体(或者说缓冲区),链接在_IO_list_all后。不过这三个文件流位于libc的数据段,我们用fopen手动打开的文件会被分配在堆上。
任意读写?
pwntools提供了FileStruct
对象,可以直接手动设置一个文件结构体然后进行覆盖。
任意读
我们先用fopen打开一个文件,然后如果有能通过标准输入覆盖这个结构体的机会(直接将FILE* 传入read,调用read(0,file,0x100)
这种),只要将fileno设置为0,buf_base设置为起始地址,buf_end设置为结束地址,再调用fread,那么输入就会从stdin写入内容到buf_base指向的指定长度的空间。对于一个正常工作的buffer当然要设置最简的攻击设置如下(是的没错不用设置read_ptr什么的)
-
设置flag,0xFBAD00就行
-
buf_end和buf_base需要设置为攻击地址,足够大
-
其他指针都为空没关系,如果要从标准输入读取fileno要为0
任意写
任意写在glibc中有一些额外检查,对于一个正常工作的buffer来说:
-
设置flag
-
将write_base指向要写的起始地址
-
write_ptr指向自己定义的位置,因为write_base到write_ptr中间的内容是“已经写入但还没有刷新”的部分,可以在里面定义需要泄露的内容
-
read_end=write_base来通过libc检查,只有这样才会调用flush
-
buf_end-buf_base足够大但我们攻击同样,只需要设置read_end=write_base和write_ptr就行了!这会将从write_base到write_ptr的内容在flush的时候写进file。
vtable
IO_FILE_plus的第二个字段,这个指针指向一系列的函数指针,包括对于这个file实现的各种操作,这种包装便于实现多态(即对套接字、普通文件、管道等使用同一个结构进行封装)。
1 |
|
目前的理解是,我们平常用的那些puts等等这些IO函数,最后都是通过层层的封装调用的相应文件的vtable中的函数。比如puts的本质是__xsputn
,fread本质是__xsgetn
之类的。这些东西最后都是封装然后接系统调用open,read,write。下面给几个CTFWiki的例子,参考https://ctf-wiki.org/pwn/linux/user-mode/io-file/introduction/:
-
fread
比如fread,核心在_IO_sgetn
1 | bytes_read = _IO_sgetn (fp, (char *) buf, bytes_requested); |
而这个函数核心又是_IO_xsgetn
,也就是vtable中的函数:
1 | _IO_size_t |
-
fwrite
同理,fwrite是_io_xsputn
的封装,同时也会调用缓冲区刷新函数_io_overflow
-
fopen
fopen的内部是先调用了malloc,在堆上分配我们的file结构,然后初始化vtable和相应的file结构,并将新的file结构连接到当前的file链表中,最后调用_IO_file_open
打开文件,而这个函数又是对系统调用的封装。 -
fclose
fclose同理,只是反着来一遍。先从链表中unlink出来,然后调用系统接口close关闭文件,最后调用vtable中的_IO_finish
,其中会调用free释放之前的file结构。
printf什么的也是类似,之前看调用栈看不懂其实是因为printf封装了好几层。首先是vfprintf,然后是vtable中的函数,并且printf如果结尾是\n的纯字符串会被编译器优化为puts,puts也是调用vtable里面的函数,类似于fwrite。
我们怎么攻击?
简单粗暴:覆盖结构体
如果能劫持到_IO_FILE结构体,只要改改里面的指针,我们就能从标准输入向内存任意地址写或者从任意地址读到标准输入。非常简单。
我们想实现的,永远是通过非法的内存修改整蛊恶搞劫持程序的执行流或者控制流。既然文件对象有这么一个vtable存放函数指针,最简单最直接的方式就是通过某些手段篡改vtable,劫持到我们想要的地址。而fopen分配的文件是在堆上的,所以经常会和堆的一些指定地址分配chunk的技术等等一起利用。就像篡改malloc_hook
然后使用malloc('/bin/sh')
这样,我们也可以篡改vtable,然后在exit或是某些程序操作的时候触发对应的函数(IO_flush一类)。
vtable攻击
vtable在IO_FILE_plus的最后,也就是整个FILE结构体的最后面。要从FILE覆盖到vtable,就需要覆盖里面的所有内容。这就要谈到IO_FILE的一个字段:lock。
1 | ... |
这个字段是用于多线程访问用的。我们作为pwn方向只关心他应该怎么设置,简单来说他需要指向一个可写内存地址同时值要为0x0。这是一个互斥锁,加锁的时候会向这个地址写入值,释放的时候会减小这个值,只要指向0x0就不会出问题。
在glibc-2.23以及之前,vtable是几乎没有检查的。也就是说只要劫持了就能调用。只需要:
-
将文件结构体中的vtable指针指向我们布局好的一个fake_vtable。
-
将vtable指向的真正结构体中对应的函数指针替换为我们自己的函数。
这种攻击要知道的先置知识:
-
程序中某些固定触发/我们可以触发的一些函数最终会调用vtable中的哪一项
-
知道这一项在vtable中的偏移,可以查到
-
知道vtable的地址,在glibc2.23中64位下vtable的偏移为文件结构体+0xd8
-
如果要传入参数,我们还要知道vtable中的函数的定义。比如printf会调用的
_IO_xsputn
,传入的第一个参数是文件结构体_IO_FILE_plus
的地址
在2.24之后,libc会检查vtable指向的内存地址是不是libc预留好的vtable area。也就是说我们不能乱搞,但是可以在这个vtable内进行偏移或是选择我们要调用哪个函数。一个常见的手法是尝试触发IO_wfile_overflow
,然后触发do_allocbuf
,将虚表指向FILE->wide_data
,而这个vtable不需要地址验证。这应该是现代高版本libc的一个手法。
-
将file.wide_data->vtable指向我们的exp_vtable
-
覆盖file.vtable让IO_wfile_overflow被调用
-
触发do_allocbuf
-
使用没有检查的wide_data中的vtable进行劫持
这部分可能需要大量用到overlapping struct(结构体重叠)的手法,来构建fake struct(我们只关心使用到的数据的偏移,不需要真的有一个结构体)
FSOP
另外,这些IO_FILE被用chain连接成一个单链表。系统维护这个链表是为了保证程序在退出的时候,所有的缓冲区都被flush。也就是说,在调用exit()
此类的函数时,程序就会遍历一遍这个进程打开的FILE list,然后对所有有写权限的文件buffer执行flush。而flush会触发vtable的相关函数,那么我们就能利用这一点,对多个FILE结构体的vtable都进行篡改,将每个会在关闭前执行flush的FILE结构体视为一个gadget,利用关闭的时候逐个flush的特性,形成一种类似于ROP的控制流,这就是面向文件流编程(File Stream Oriented Programming,FSOP)。
pwn.college推荐了这篇blog:https://blog.kylebot.net/2022/10/22/angry-FSROP/