重学IO:利用_IO_2_1_stdout泄露libc
前言:
这几天做IO类的题,发现自己是真的菜,决定暂时放一放apple1的学习,重新学习一下IO流。
本篇源码还是glibc-2.35。
FILE结构
_IO_FILE_plus
我们先来看一下查看结构体_IO_FILE_plus的组成:
分别看一下他的两个组成成分:
FILE
_IO_jump_t
现在我们已经对这个结构体有了深刻认识了,接下来就是认识一下_IO_2_1_stdout以及和他相关联的结构体了:
程序进程中的FILE结构会通过_chain域彼此连接形成一个链表,链表头部用全局变量_IO_list_all表示,通过这个值可以遍历所有的FILE结构,大致的链表结构如下图:
在标准I/O库中,每个程序启动时这三个文件流是自动打开的:stdin、stdout、stderr。三个文件流位于的是libc.so的数据段,有的时候也会位于bss段,根据经验观察似乎在使用setvbuf时会出现这种情况,个人理解是因为stdin等三个变量是未初始化的全局变量, 所以他们被分配到bss段. 在执行setvbuf时, 经过两次重定位将libc地址填入其中。也就是说在不在bss段并不影响他的值和遍历过程。
_flags规则
这里我们将深入的讲解一下FILE结构体中的第一个成员变量_flag,这个成员变量在后续利用_IO_2_1_stdout泄露libc的时候具有重要作用。
先简介一下_flag的规则,_flag的高两位字节是由libc固定的,不同的libc可能存在差异,但是基本上都为0xfbad0000。高两位字节其实就是作为一个标识,标志这是一个什么文件。而低两位字节的位数规则决定了程序的执行状态,低两位的规则如下:
在执行流程中会将_flag和定义常量进行按位与运算,并根据与运算的结构进行判断如何执行。
puts()函数执行流程
在栈的时候,我们经常使用puts去泄露地址,这里我们通过源码来明确一下puts是如何达到输出数据的功能的:
_IO_puts --> _IO_new_file_xsputn
puts()函数在源码中的表现形式为_IO_puts,我们一起来看一下源码:
这里可以看到_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_LINE_BUF标志位表示流是以行缓冲模式打开的,即输出会在遇到换行符(\n)时自动刷新到目标设备。其次,_IO_CURRENTLY_PUTTING标志位表示当前流正在执行写入操作。
count = f->_IO_buf_end - f->_IO_write_ptr;:这行代码计算了当前流内部缓冲区中剩余的空间量。_IO_buf_end是指向缓冲区末尾的指针,而_IO_write_ptr是指向下一个写入位置的指针。两者之差即为剩余空间的大小。
接下来的if语句检查剩余空间是否足够大,能够容纳即将写入的n个字节(count >= n)。如果是,那么代码会进一步检查这些即将写入的字节中是否包含换行符(\n),因为如果是行缓冲模式,遇到换行符时应该刷新缓冲区。
for (p = s + n; p > s; ):这个循环从即将写入的字节序列的末尾开始向前遍历,直到序列的开头。在循环体内,if (*--p == '\n')检查当前字符是否为换行符。如果是,则执行以下操作count = p - s + 1;:更新count为从原始字符串s到换行符(包括换行符)之间的字节数。这意味着,如果找到换行符,则只将换行符之前的字节写入缓冲区,因为换行符会触发缓冲区的刷新。must_flush = 1;:设置must_flush标志为1,表示需要刷新缓冲区。注意,这里假设must_flush在之前的代码段中已经被声明并初始化为0或适当的值。break;:跳出循环,因为已经找到了换行符,无需继续检查。
检查在当前的缓冲区中,从 _IO_write_ptr
到 _IO_write_end
之间有多少空间是可用的,即还有多少字节可以被写入缓冲区而不需要刷新(即不需要实际写入到文件中)。这个空间量被存储在 count
变量中。
首先,通过 if (count > 0) 检查当前缓冲区中是否还有空间可用于写入。如果没有空间(count 不大于 0),则不执行任何操作。
接着,通过 if (count > to_do) 检查缓冲区中剩余的空间量是否大于还需要写入的数据量。如果是,那么实际写入的数据量应该是 to_do,而不是 count,因为没有必要写入比需要更多的数据。因此,将 count 设置为 to_do。
然后,使用 __mempcpy 函数将 s 指向的数据复制到缓冲区的当前写入位置(f->_IO_write_ptr 指向的位置)。__mempcpy 函数类似于 memcpy,但它返回目标缓冲区中最后一个被复制字节之后的位置,这里正是更新 f->_IO_write_ptr 所需要的,以便下一次写入知道从哪里开始。
随后,更新 s 指针,使其指向已经复制到缓冲区中的数据的下一个字节,为下一次可能的写入操作做准备。
最后,更新 to_do 变量,减去已经写入缓冲区的数据量,以反映还需要写入多少数据。
- 条件判断:首先,通过if (to_do + must_flush > 0)判断是否有数据需要写入或者必须刷新缓冲区。to_do表示还有多少字节的数据需要写入。
- 缓冲区刷新:如果条件满足,代码首先尝试刷新缓冲区。通过_IO_OVERFLOW(f, EOF)尝试刷新缓冲区。如果返回EOF(表示失败),则根据to_do的值决定是否返回EOF。如果to_do为0(即没有数据需要写入),则返回EOF以指示失败;否则,返回已写入字节的补数(n - to_do),这里n可能是原始需要写入的字节总数。_IO_OVERFLOW就是vtable中的__overflow。
- 计算写入块大小:接下来,他尝试通过保持对齐来优化写入操作。它首先计算缓冲区的大小(block_size),然后计算一个do_write值,这个值是基于to_do和block_size的,目的是尽可能写入一个完整的块(如果block_size大于或等于128,则do_write是to_do减去to_do对block_size的余数)。
- 写入部分数据:如果do_write不为0,即有需要写入的数据块,则通过new_do_write函数(可能是自定义的或特定于实现的)尝试写入这些数据。如果实际写入的字节数少于do_write指定的字节数,则函数返回已写入的字节数的补数(n - to_do)。
- 写入剩余数据:最后,如果还有剩余的数据(to_do不为0),则通过_IO_default_xsputn函数写入剩余的数据。这个函数负责处理一般情况的写入,包括线缓冲(line-buffered)文件的复杂情况。
- 函数返回:无论写入操作是否完全成功,函数最后都返回已写入字节的补数(n - to_do),这表示了原始请求中成功写入的字节数。
_IO_new_file_overflow --> _IO_do_write
调用 __overflow确实是去调用 _IO_new_file_overflow,位置在:glibc/libio/fileops.c:
我们希望利用的便是下面这一段代码中的_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_new_do_write --> _new_do_write
可以看到_IO_new_do_write并没有做太多的操作,就调用了new_do_write函数,并且new_do_write函数的参数和传入的参数是一样的,第一个参数是stdout结构体,第二个参数是输出缓冲区起始地址,第三个参数是输出长度
new_do_write --> _IO_SYSWRITE
绕过检查:
我们先来看一下整个函数调用流程:
那我们从头来看一下需要绕过的检查:
_IO_puts --> _IO_new_file_xsputn
第一个(_IO_vtable_offset (stdout) != 0 || _IO_fwide (stdout, -1) == -1) 我们需要他判断为真,但是_IO_vtable_offset (stdout) != 0 没法成立,_IO_vtable_offset这个宏强制将结果赋值成0:
那只能去看_IO_fwide这个宏操作:
这个宏操作其实就是去检查了 (stdout)->_mode == 0 为0就把他赋值成-1,之后result==(stdout)->_mode,也就是说要想result=-1,我们需要将_mode=0或者-1。
_IO_new_file_xsputn --> _IO_OVERFLOW
这一步没有什么需要注意的,只需要to_do>0即可,
_IO_new_file_overflow --> _IO_do_write
这里一共有两个绕过点:
一、f->_flags & _IO_NO_WRITES == 0
即:
二、f->_flags & _IO_CURRENTLY_PUTTING != 0 && f->_IO_write_base != NULL
让这个条件为假的原因是,如果判断成功,也就是检查输出缓冲区为空,便会进行分配空间,并且会初始化指针。一旦进行初始化操作,那么就会覆盖掉我们事先在stdout的_IO_write_base的数据,导致无法掌控输出内容。
至于后半部分f->_IO_write_base == NULL的判断,由于我们会在_IO_write_base中部署数据,所以后半部分的条件判断一定为假。那么这样一来我们将前半部分也为假,即f->_flags & _IO_CURRENTLY_PUTTING = 1,则整个判断就为假:
即:
_IO_new_do_write --> new_do_write
因为to_do是我们要输出的内容的长度,所以一定不为0,也就是会正常走到new_do_write函数。
new_do_write --> _IO_SYSWRITE
这里面一共有两个判断:
else这条分支我们尽可能的不碰,原因是因为
一般在做这种题的时候都会伴随着随机化保护的开启,进行攻击的时候,我们一般采用的都是覆盖末位字节的方式造成偏移,因为即使随机化偏移也会存在0x1000对齐。但是这时候就会遇到一个很尴尬的情况,_IO_read_end和_IO_write_base存放的地址是由末位字节和其他高字节共同组成的,其他高字节由于随机化的缘故无法确定,基本上这个地方很难绕过。而一旦进行到else if 内那随后执行的_IO_SYSSEEK函数会因为fp->_IO_write_base - fp->_IO_read_end难以控制导致执行失败。
而if分支相对来说造成的影响就比较小了,内部仅仅将偏移设置为标准值,不会影响后续的输出流程。并且if判断的条件也很容易满足,我们只需要将fp->_flags & _IO_IS_APPENDING != 0即可:
总结:
综上所述,我们需要绕过的点有:
- 设置_flags & _IO_NO_WRITES = 0
- 设置_flags & _IO_CURRENTLY_PUTTING = 1
- 设置_flags & _IO_IS_APPENDING = 1
- 设置_flags = 0xFBAD1800
- 设置_IO_write_base指向想要泄露的位置,_IO_write_ptr指向泄露结束的地址(不需要一定设置指向结尾,stdout结构中自带地址也足够泄露libc)
- _mode=0或者-1
- _fileno为1(因为最后要输出内容到屏幕,所以文件描述符应该为1)
__EOF__

本文链接:https://www.cnblogs.com/mazhatter/p/18475598.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY