利用_IO_2_1_stdout泄露libc
FILE结构
FILE在linux系统的标准IO库使用来描述文件结构,称之为文件流。这里提及的”流“其实是一种抽象的概念,无论是硬件还是软件其实都没有”流“一说,只是人们为了便于描述数据的流向而创造的名称。比如说当我们要输出磁盘中记录的数据,那么在计算机中首先会将磁盘中的数据加载进内存,那么磁盘–>内存这种流向就被抽象叫做”流“
FILE结构在程序执行fopen函数时会自动进行创建,并分配在堆中。我们常定义一个纸箱FILE结构的指针来接收这个返回值
FILE结构定义在glibc/libio/libio.h中,结构源码如下
看着很长是吧,没有关系,后面会讲解利用_IO_2_1_stdout泄露libc时主要构造的几个成员变量。回来继续,进程中的FILE结构会通过_chain域彼此连接形成一个链表,链表头部用全局变量_IO_list_all表示,通过这个值可以遍历所有的FILE结构,大致的链表结构如下图:
在标准I/O库中,每个程序启动时有三个文件流是自动打开的:stdin、stdout、stderr。这里插一句个人观点,我认为stderr其实并不算是”流“,因为他的作用是在程序运行时发生异常或中断时的告警,并没有流向的动作,所以这个地方我自己是有疑问的,如果看到这的你了解的话还请在评论区留言指点一下。好的回来,因为会自动打开,所以在初始状态下,_IO_list_all指向了一个有这些文件流构成的链表,但是需要注意的是这三个文件流位于的是libc.so的数据段
_IO_FILE_plus结构
事实上_IO_FILE结构外包裹着另一种结构_IO_FILE_plus,其中包含了一个重要的指针vtable(虚表)指向了一系列函数指针:
在 libc2.23 版本下,32 位的 vtable 偏移为 0x94,64 位偏移为 0xd8。位置为glibc/libio/libioP.h
这里说明一下vtable(虚表)是个什么东西,我们在学C语言的时候会学到一个叫做虚函数的东西,具有虚函数的类都会有一张vtable(虚表),其中记录了本类中所有虚函数的函数指针,也就是说是个函数指针数组的起始位置,通常虚表在编程中所具有的作用是为了标识父类。需要注意的是虚表中值班韩虚函数的指针,没有函数体,虚函数表既有继承性又有多态性
vtable 是IO_jump_t 类型的指针,IO_jump_t中保存了一些函数指针,在后面我们会看到在一系列标准 IO 函数中会调用这些函数指针。也就是说,如果使用_IO_FILE_plus去定义一个结构体指针的话,我们既可以使用IO_FILE中的结构体成员变量,也能使用IO_jump_t中的函数指针
1 | void * funcs[] = { |
_flags规则
那么通过上述对IO_FILE和_IO_FILE_plus结构的了解,这里我们将深入的讲解一下IO_FILE结构体中的第一个成员变量_flag,这个成员变量在利用_IO_2_1_stdout泄露libc的时候具有至关重要的作用。
先简单介绍一下_flag的规则,_flag的高两位字节是由libc固定的,不同的libc可能存在差异,但是基本上都一样:0xfbad0000。高两位字节其实就是作为一个标识,标志这是一个什么文件。而低两位字节的位数规则决定了程序的执行状态,低两位的规则如下:
一般在执行流程中会将_flag和定义常量进行按位与运算,并根据与运算的结构进行判断如何执行。后面_IO_2_1_stdout泄露libc章节,我们会一起走一遍输出函数执行流程,在其中就会运用到此处的内容
puts()函数执行流程
由于第二部分的例题中会用到puts()函数,所以这里我们拿puts()函数举例子,其实类似的输出函数比如fwrite函数等执行流程都差不多,区别在于由libc库中运行各个输出函数的.c文件不一样,但是流程都相似,并且都会殊途同归进行输出系统调用
_IO_puts –> _IO_new_file_xsputn
puts()函数在源码中的表现形式为_IO_puts,我们一起来看一下源码位置在:glibc/libio/ioputs.c
这里可以看到_IO_puts在过程当中调用了一个叫做_IO_sputn函数(_IO_fwrite也会调用这个),_IO_sputn其实是一个宏,它的作用就是调用_IO_2_1_stdout_中的vtable所指向的_xsputn,也就是_IO_new_file_xsputn函数
_IO_new_file_xsputn –> _IO_OVERFLOW
_IO_new_file_xsputn函数源码位置在:glibc/libio/fileops.c
由于_IO_new_file_xsputn函数的源码过长,这里就不大篇幅的贴图了。这里简单的描述一下这个函数的执行过程,在关键部分展示代码:首先进入函数之后判断输出缓冲区还有多少空间,这里是由_IO_write_end - _IO_write_base得来的,这两个是FILE结构体中的两个成员变量,分别是输出结束地址和其实输出地址,由于stdout也是FILE结构,所以后面就直接使用成员变量名称来描述了。接下来如果缓冲区有空间,则先把数据载入输出缓冲区并计算目标输出数据是否还有剩余
经过上述最后一步的判断,如果还有剩余则说明输出缓冲区未建立或者空间已满,那么就需要通过_IO_OVERFLOW函数来建立或清空缓冲区,这个函数主要是实现刷新缓冲区或建立缓冲区的功能。在vtable中为__overflow
_IO_new_file_overflow –> _IO_do_write
_IO_new_file_overflow函数的部分源码如下,位置在:glibc/libio/fileops.c
上图即是_IO_new_file_overflow函数的部分代码,我们想要利用的就是最后红色框中的_IO_do_write (f, f->_IO_write_base,f->_IO_write_ptr - f->_IO_write_base),图片上可能会有点看不清,_IO_do_write就是我们需要执行的目标函数,这个函数执行后会调用系统调用write输出输出缓冲区,传入_IO_do_write函数的参数为:stdout结构体、_IO_write_base(输出缓冲区起始地址)和size(_IO_write_end - _IO_write_base计算得来)
如果我们事先在stdout的_IO_write_base的位置部署要输出的起始地址,那么在去利用_IO_do_write函数,即可打印部分内存地址,打印出来的内容就包含我们所需要泄露的libc
如果我们想要利用_IO_do_write函数的话是需要绕过_IO_new_file_overflow函数的检查的,就是上图中蓝色框中的判断条件,我们一步一步的分解_IO_new_file_overflow函数的这两个判断条件:
首先我们来看第一个判断条件,这里判断_flags的标志位是否包含_IO_NO_WRITES,将_flags和_IO_NO_WRITES进行一个按位与的操作,我们可以向前翻一下flag规则的章节,_flag与_IO_NO_WRITES各自定义的常量为:
1 |
可以看到_flag魔数的常量为0xfbad0000,_IO_NO_WRITES不可写标志位的常量为8,我们返回上图的程序中,如果进行按位与操作之后的结果为真,则返回为错误。一旦返回的是错误,那么后续我们想要利用的_IO_do_write函数就不会再被执行了,所以我们要将此处的与运算为假:
1 |
|
这样一来判断条件中与运算就会为假,就不会执行判断中的语句了。接下来我们看一下第二个判断条件:
第二个判断是为了检查输出缓冲区是否为空,如果为空则进行分配空间,并且会初始化指针。一旦进行初始化操作,那么就会覆盖掉我们事先在stdout的_IO_write_base的数据,这样一来我们其实是无法完全掌控的。所以这个判断条件分支尽可能的也不进入,那么我们将if判断条件的值为假即可
1 | if ((f->_flags & _IO_CURRENTLY_PUTTING) == 0 || f->_IO_write_base == NULL) |
我们拆开来看这个判断条件,由两部分组成,并用或连接。先看后半部分f->_IO_write_base == NULL,这里由于会在_IO_write_base中部署数据,所有后半部分的条件判断一定为假。那么这样一来我们将前半部分也为假,即f->_flags & _IO_CURRENTLY_PUTTING = 1,则整个判断就为假:
1 |
|
_IO_new_do_write –> new_do_write
经过前面的_flags的处理,即可顺利执行到_IO_do_write函数,跟进_IO_do_write函数后将会进入_IO_new_do_write函数,我们来看一下这个函数的源码,位置在glibc/libio/fileops.c
可以看到_IO_new_do_write并没有做太多的操作,就调用了new_do_write函数,new_do_write函数的参数其实是和传入的参数是一样的,一参stdout结构体,二参输出缓冲区起始地址,三参输出长度
new_do_write –> _IO_SYSWRITE
我们一起来看一下new_do_write函数中的源码,位置在glibc/libio/fileops.c
可以看到上图即是new_do_write函数中的源码了,红色框中的_IO_SYSWRITE函数即是IO_FILE的最终目标,执行系统调用write。但是同时我们还能看到前面又经过了两次判断,其中第一次if判断与_flags相关,else if中的内容同样需要仔细斟酌一下。由于if和else if是幸福二选一,如果两个判断条件都不满足的话,是不会继续执行到518行的系统调用的
我们先看这个比较复杂的else if判断:
这条分支我们尽可能的不碰,原因有两点:
第一,其实只要满足判断中的条件fp->_IO_read_end = fp->_IO_write_base即可绕过这里的判断,使之相等的操作并不是没有可能,但是在实际操作中实现的几率比较小。一般在做这种题的时候都会伴随着随机化保护的开启,进行攻击的时候,我们一般采用的都是覆盖末位字节的方式造成偏移,因为即使随机化偏移也会存在0x1000对齐。但是这时候就会遇到一个很尴尬的情况,_IO_read_end和_IO_write_base存放的地址是由末位字节和其他高字节共同组成的,其他高字节由于随机化的缘故无法确定,所以何谈使两个成员变量中的地址相等呢
第二,可以看到else if这条分支中调用了_IO_SYSSEEK系统调用,即lssek函数,如果我们将_IO_read_end的值设置为0,那么_IO_SYSSEEK的二参fp->_IO_write_base - fp->_IO_read_end得出的数值就有可能非常大,这就会导致sleek函数执行不成功导致退出,这是因为载入内存的数据范围可能并不大,但是经过sleek函数修改过大的偏移之后超过了数据范围的边界。一旦Sleek函数执行不成功导致退出,那么就不会到达我们想要的_IO_SYSWRITE系统调用了
所以综上所述,我们无法完全掌控_IO_read_end和_IO_write_base中的数值,导致进入else if的分支后程序执行流程不可控
接下来我们看一下if分支:
if分支相对来说造成的影响就比较小了,内部仅仅将偏移设置为标准值,不会影响后续的输出流程。并且if判断的条件也很容易满足,我们只需要将fp->_flags & _IO_IS_APPENDING = 1即可,只对_flag修改不会影响其他部分:
1 |
|
这样就可以到达_IO_SYSWRITE系统调用了!
总结
所以通过上述的讲解,我们只需要满足如下几条对_flags的设定,即可利用_IO_2_1_stdout泄露libc
1 |
1、设置flags & _IO_NO_WRITES = 0
2、设置_flags & _IO_CURRENTLY_PUTTING = 1
3、设置_flags & _IO_IS_APPENDING = 1
1 | flags = 0xFBAD1800 |
4、设置_IO_write_base指向想要泄露的位置,_IO_write_ptr指向泄露结束的地址(不需要一定设置指向结尾,程序中自带地址足够泄露libc)
例子:
1 | from pwn import* |
————————————————
版权声明:本文为CSDN博主「hollk」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/qq_41202237/article/details/113845320