type
status
date
summary
tags
category
icon
password
IO_FILE结构
1.在 C 语言中,我们使用
FILE*
指针来操作文件(如 fopen, fread, fprintf 等)。这个 FILE 实际上就是 IO_FILE
结构体(在早期版本中直接定义为FILE,现代Glibc中FILE是 struct _IO_FILE
的别名)。简单来说,
IO_FILE
结构是 Glibc 用于管理文件流(file stream)的内部数据结构。它包含了处理一个文件所需的所有信息:文件描述符、缓冲区指针、缓冲区大小、当前读写位置、错误和结束标志等。
每一个你用fopen()打开的文件,在用户空间的 Glibc 层都会有一个对应的 IO_FILE
结构体实例。我们常见的标准输入、输出、错误流(stdin
, stdout
, stderr
)也是三个预定义好的 IO_FILE
结构体。不过需要注意的是这三个文件流位于 libc.so 的数据段。而fopen创建的文件流是分配在堆内存上的。
2.FILE结构定义在libio.h中,具体结构如下:相关参数说明:
- _flags:
- 这是一个非常重要的字段,它包含了各种状态标志。
- 标志位定义了流的属性,例如:
- _IO_NO_READS (0x0004): 流不可读。
- _IO_NO_WRITES (0x0008): 流不可写。
- _IO_EOF_SEEN (0x0010): 已到达文件末尾(EOF)。
- _IO_ERR_SEEN (0x0020): 发生了错误。
- _IO_USER_BUF (0x0001): 缓冲区由用户提供,而非库分配。
- 最重要的是,它的高16位必须等于魔数
_IO_MAGIC
(0xFBAD0000),用于验证这是一个合法的IO_FILE结构。
- 缓冲区指针:
- 这组指针管理着用户的 I/O 缓冲区。fread和fwrite等函数并不是每次都直接调用系统调用,而是先在这个缓冲区中进行操作,满了或空了再与内核交互,极大提高了效率。
_IO_read_base
-> 缓冲区的起始地址。_IO_read_ptr
-> 当前读取的位置。_IO_read_end
-> 缓冲区中有效数据的结束位置。_IO_write_*
系列指针同理,用于输出缓冲区。
- _fileno:
这个整数存储着底层操作系统提供的文件描述符(file descriptor)。例如,stdin的_fileno是0,stdout是1,stderr是2。当你用fopen打开一个文件时,open()返回的fd就存放在这里。
4._offset
记录当前文件的偏移量。
Vtable
为了支持不同类型的文件,Glibc使用了一种类似C++虚函数的机制,每个IO_FILE结构体都包含了一个指向其虚函数表vtable的指针。
vtable是包含众多函数指针的结构体,当调用 fread,fwrite,fclose等函数时,最终会通过这个vtable找到对应的函数来执行具体的操作。
实际上,一个完整的文件流结构是struct_IO_FILE_plus,它包含一个标准的IO_FILE和一个vtable指针
vtable是IO_jump_t类型的指针,IO_jump_t本身是一个结构体,所以vtable本身就是一个巨大的函数指针数组结构。
关键函数指针说明:
__overflow
: 对应于输出缓冲区满时的操作(类似fflush
)。
__underflow
: 对应于输入缓冲区空时的操作(类似fillbuf
)。
__finish
: 对应于流关闭时的操作。
__close
: 底层的关闭操作。
__read
和__write
: 底层的读和写系统调用包装。
对于普通文件,这个vtable指针指向一个名为
_IO_file_jumps
的全局结构体。对于内存流,它可能指向 _IO_mem_jumps
。示意图总结:
常见的IO函数
fopen
fopen在标准IO库中用于打开文件,函数原型:
filename是打开文件路径,type指打开方式,返回一个文件指针。其具体实现逻辑如下:
实现流程为:
1.调用malloc分配FILE空间,分配了一个struct locked_FILE大小的结构体,并将返回的地址赋给了new_f变量。它包含三种变量::_IO_FILE_plus、_IO_lock_t、IO_wide_data,其中_IO_FILE_plus为使用的IO_FILE结构体。
2.调用IO_no_init对FILE初始化
3.调用IO_file_init将结构体链接到IO_list_all链表
IO_list_all:
IO_list_all是一个指向IO_FILE_plus结构体的指针。所有的FILE并非孤立的存在,而是通过 _IO_FILE 结构体内部的chain字段连接形成一个单链表。IO_list_all就是这个链表的头指针。
4.调用IO_file_fopen根据用户传入的模式,进行系统调用打开文件
fread
fread是标准IO库函数,作用是从文件流中读数据,函数原型:
buffer是存放读取数据的缓冲区,size是指定读取的每个记录的长度,count是指定记录的个数,stream是文件流,返回读取到缓冲区的记录个数。具体实现逻辑:
逻辑就是计算总字节数,加锁,然后调用
_IO_sgetn
,最后解锁并返回结果。_IO_sgetn
是一个定义在vtable中的函数指针。该函数又指向IO_FILE_xsgetn,这个函数是高效读取的核心:
__underflow是vtable中的另一个关键函数指针。对于普通文件,它指向_IO_new_file_underflow。
通过 _IO_SYSREAD 宏调用底层__read函数。这个宏展开后就是fp->vtable->__read(...)
fwrite
fwrite是标准IO库函数,作用是向文件流写入数据,函数原型如下:
buffer是写入数据的地址,size是写入内容的单字节数,count是数据项个数,stream是目标文件指针,返回写入的数据项的个数。实现逻辑:
总体逻辑:计算总字节数,加锁,然后调用IO_sputn,最后解锁并返回结果。
这里IO_sputn是vtable中的一个函数指针,对于一个普通文件,它指向IO_FILE_xsputn:
重点是__overflow,它是vtable中与__underflow相对应的函数指针,也是FSOP攻击的核心目标。对于普通文件,它指向 _IO_new_file_overflow。(FSOP到时候写在另一篇文章里)
_IO_do_write
是一个封装函数,它最终会调用vtable中的 __write函数:对于普通文件,__write 指向 _IO_file_write:
Vtable伪造
由前面的内容我们知道,一些常见的IO函数都需要经过FILE结构的处理,会通过IO_FILE_plus中的vtable指针对一些函数进行调用,所以我们伪造的方向可以分为两种:
- 一种是直接改写vtable指针,让它指向一个伪造的虚表,再对特定偏移处写入我们想让程序执行的指令
- 另一种是改写vtable中特定偏移处的函数指针,但是stdin/stdout/stderr的vtable是存放在数据段的不可改写,这种方法不适用
这里还是主要讲一下面向对象的vtable:
C++为了实现运行时多态,引入了虚函数的概念。当一个基类指针指向一个派生类对象,并调用一个虚函数时,实际调用的是派生类中重写的那个函数版本。
编译器是如何实现这一神奇功能的呢?答案就是虚函数表(VTable)。
- 是什么:VTable是一个函数指针数组。每个包含虚函数的类(或者从包含虚函数的类派生而来)都会有一个或多个对应的VTable。
- 内容:VTable中的每一个条目(slot)都指向该类的一个虚函数的具体实现地址。
- vptr(虚表指针):当一个类包含虚函数时,编译器会隐式地在每个对象实例的内存布局的最开始添加一个隐藏的成员变量——
vptr
。这是一个指针,指向该对象所属类的VTable。
示例:
对于每一个Derived对象,它的大致布局如下:
+---------------------------+ <-- derived_obj 地址
| vptr (指向 Derived的vtable)| (8字节,在64位系统上)
+---------------------------+
| Base::a | (4字节,来自基类)
+---------------------------+
| Derived::b | (4字节,来自派生类)
+---------------------------+
其中vtable结构如下:
Derived's vtable:
+-------------------------------+
| &Derived::func1 (覆盖了Base) | // 条目0
+-------------------------------+
| &Base::func2 (从Base继承) | // 条目1
+-------------------------------+
| &Derived::func3 (新增的虚函数) | // 条目2
+-------------------------------+
当调用 derived_obj->func1() 时,会发生:
- CPU 从 derived_obj 开头取出vptr。
- 通过vptr找到vtable。
- 在vtable的第0个条目找到func1的地址(&Derived::func1)。
- 跳转到该地址执行。
虚函数调用完全依赖于对象的vptr所指向的内存数据。
例题补充(未完待续)
这是2025sekaictf的一道题,题目给处了源码,直接看源码:
这个程序是一个宠物管理系统,用户可领养狗、猫、鹦鹉、马等宠物,通过菜单进行与宠物玩耍、喂食、让其休息等互动;每次选择之后,update函数会让宠物年龄+1,饱腹感-1,年龄达到最大值或者饱腹感为0时会死亡;用户在仍有宠物时无法退出。每个宠物对象初始年龄为0,饱腹感为10,喂食会让其饱腹感为20。
由于我们控制的是堆结构,具体是哪种宠物对我们来说不重要,这里可以选择年龄上限最大的马进行创建。Animal类结构:
+-----------------------+
| vptr (8字节) | --> 指向 Animal 的虚函数表
+-----------------------+
| name[0x100] (256字节)|
| |
| |
+-----------------------+
| age (4字节) |
+-----------------------+
| fullness (4字节) |
+-----------------------+
| status (4字节) |
+-----------------------+
| 填充 (4字节) | // 为了8字节对齐
+-----------------------+
Animal vtable:
+-----------------------+
| &Animal::eat |
+-----------------------+
| &Animal::sleep |
+-----------------------+
| &Animal::play |
+-----------------------+
| &Animal::get_max_age| (纯虚函数,实际为0或占位符)
+-----------------------+
HORSE vtable:
+-----------------------+
| &Horse::eat | // 重写Animal::eat
+-----------------------+
| &Horse::sleep | // 重写Animal::sleep
+-----------------------+
| &Horse::play | // 重写Animal::play
+-----------------------+
| &Horse::get_max_age| // 实现纯虚函数
+-----------------------+
观察函数,Animal类中set_name()的std::cin >> this->name没有控制字节,存在堆溢出;die()函数会输出name的内容,并且name起始位置实在堆地址+8的位置,故无法通过tachebin或者fastbin泄露fd,于是想到unsortedbin泄露bk,从而获取libc。动调很容易看出一个HORSE对象占用0x120字节,堆溢出可以修改size,想要其分配到unsortedbin至少大小为0x480,分配4个堆块b、c、d、e,这四个堆块之前分配一个堆块a用于溢出改写下一个chunk的size,最后面还要分配一个堆块f防止和top_chunk合并,一共分配6个堆块。泄露libc大致过程:
- 分配a,b,c,d,e,f堆块,每个堆块可以溢出控制其age和fullness为合适的值让其在某一时刻被free
- 6次操作后a刚好free掉,再次new()可复用a,溢出改写b的size为0x481
- b被free,b、c、d、e一起进入unsortedbin
- new()操作unsortedbin分割,b被复用,c堆块被写入fd、bk,也就是name开始8字节变成bk的值
- c的fullness变成0,输出其name再被free,成功泄露libc
后面伪造vtable暂时还没写出来,后面补充。
补充来了👇:
这题既然题目给出libc和动态链接器,所以就用题目的吧,先patch一下,先把libc和动态连接器文件放到题目可执行文件所在目录下,接着输入:
- Author:Estara
- URL:http://preview.tangly1024.com/article/25c3139c-9ee7-800e-b426-e9da6df39ab5
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts