IO利用之stdout任意读
基础知识
bss段与stdin、out、err
- 在有些情况,程序中的
bss段其实保存着stdin、stdout、stderr这三个IO结构体的地址。 - 大概率应该是因为这三句初始化输入输出的语句,需要用到
stdin、stdout、stderr。
1 | setvbuf(stdin, 0LL, 2, 0LL); |

缓冲区
缓冲区是一块用于临时存储数据的区域,通常用于平衡数据生产者和数据消费者之间速度的差异。例如当我们调用printf()、fgets这种比较上层分装的IO函数。它们就会先将数据读(写)入缓冲区(这一部分是由stdin(stdout)管理),然后再从中取出数据,放进对应的地址中。当满足一定条件时就会触发缓冲区刷新也就会将在缓冲区的数据读(写)入对应的目标地址中去。这样就完成了一次与设备与内存之间的(读)写。
缓冲区刷新可以在下面这几种情况下发生:
- 缓冲区满:当缓冲区写满的时候,系统会自动将缓冲区中的数据写入到实际的输出设备,并清空缓冲区,接着等待之后的数据进入缓冲区
- 手动刷新:C语言程序提供一个函数
fflush(FILE *stream)函数来手动刷新缓冲区。 - 正常退出:当程序正常退出的时候(即调用
glibc中的exit(),而不是直接syscall exit)此时所有打开的文件都会自动刷新缓冲区 - 行缓冲模式:对于行缓冲模式(通常是标准输出
stdout在交互模式下默认的模式),在输出新行字符\n时会自动刷新缓冲区。
输入输出缓冲模式
- 对于上面的一个
setvbuf()函数到底是什么东西呢?其实该函数是用来设置输入、输出缓冲模式的。缓冲模式分为以下三种:无缓冲模式:调用上层IO函数时会,会直接写入或读入到目标位置,不会数据不会呆在缓冲区。行缓冲模式:输入输出的数据一开始会被放入到缓冲区中,但是当缓冲区中有\n存进来就会立即刷新缓冲区,将数据写入或读入到目标地址。全缓冲模式:只有当缓冲区数据存储满后,才会进行刷新缓冲区的操作。或者当退出程序的时候libc_start_call_main会调用libc中的exit函数,这样它就会刷新缓冲区。
- 对与这三种模式,在
stdio.h头文件中有这几个函数可以用于设置的,可以在Linux中使用man函数查看,其中setvbuf()这个函数比较全面,对于全缓冲还可以设置缓冲区的地址:void setbuf(FILE *stream, char *buf)void setbuffer(FILE *stream, char *buf, size_t size)void setlinebuf(FILE *stream)int setvbuf(FILE *stream, char *buf, int mode, size_t size)
实验
- 对于利用
stdout实现地址任意读,主要关注IO_FILE的这几个成员,对于实验中的几个lab,将分别设置stdout为无缓冲、行缓冲、全缓冲模式,观察这三种模式下调用上层的输入输出函数如下的几个成员数据的变化。最后再做一个使用stdout实现任意地址读的这些数据应该如何构造的一个总结。
1 | int _flags; // 文件状态标志(高位是 _IO_MAGIC,其余是标志位) |
lab1_无缓冲模式
1 |
|
- 编译完之后使用
gdb进行调试,在还没有调用setvbuf之前,stdout的结构体成员中的数据如下:
1 | //其中 _flags = 0xFBAD2084 |

- 当我们调用
setvbuf设置成无缓冲模式后,stdout的IO_FILE结构体如下:
1 | // 发现_flags = 0xFBAD2087 该值被改变了 |

- 当调用
printf这个上层函数会做什么操作呢?接下来看看
1 | // _flags的值又被改变了,变成了0xFBAD2887 |

lab2_行缓冲模式
1 |
|
- 接下来再看看行缓冲模式
stdout的IO_FILE情况
1 | //其中 _flags = 0xFBAD2084 |

- 当使用
setvbuf()将其设置为行缓冲模式,其IO_FILE就会出现如下情形
1 | //其中 _flags = 0xFBAD2284 |

- 之后先调用一次
printf函数,发现调用printf函数时程序并不会马上输出printf函数所要打印的值

- 但是
stdout的IO_FILE里面的值却有变化了,并且程序还调用了malloc申请了一个chunk当做缓冲区
1 | // _flags = 0xFBAD2A84 |


- 现在调用一下
puts函数,看看会出现什么结果,调用结束后发现会将之前printf的输出内容也输出出来,这其实就是遇到\n缓冲区刷新了。

- 现在查看一下
stdout的IO_FILE的值
1 | // _flags = 0xFBAD2A84 |

lab3_全缓冲模式
1 |
|
- 在调用
setvbuf之前stdout结构体如下:
1 | //其中 _flags = 0xFBAD2084 |

- 在调用
setvbuf将stdout设置为全缓冲后,stdout的结构体如下:
1 | //其中 _flags = 0xFBAD2084 |

- 当我们调用
printf函数进行输出时会发现没有内容输出,再看stdout这个结构体,会发现_IO_read_ptr到_IO_write_base的值已经改变


lab4_任意地址读
- 在做该实验之前,再了解一下缓冲区的宏定义,首先需要明确一点,要将一些东西附带输出出来,修改
_flag位时就必须使用如下几个宏定义:#define _IO_MAGIC 0xFBAD0000#define _IO_CURRENTLY_PUTTING 0x800#define _IO_IS_APPENDING 0x1000 /* Is appending 处于附加模式(在文件末尾追加内容) */- 当然其实也可以像
lab2和lab3那样修改缓冲模式,但是修改_flag的值更一般是0xFBAD1800(通过比较多的实验后,得出使用0xFBAD1800这个值作为_flag的值会更好会更方便,有的时候只需要改2处或者3处即可。)
1 |
- 理解了
无缓冲、行缓冲、全缓冲模式下stdout结构体成员值的一些变化后,我们是否能修改stdout结构体成员里面的值,使得在调用上层的输出函数时,能将一些libc地址等给顺带打印出来呢?接下来就看看下面的实验示例。 lab4主要介绍的是无缓冲模式下如何修改stdout结构体的值(一般题目都是无缓冲模式,其他模式比较少见)首先是在不修改_flag值下修改其他相关成员,修改后其实就会再程序结束调用exit()后将内容都打印出来
1 |
|
接下来调试一下,在执行printf("Hello world");的时候stdout已经被修改成了这样

- 调用完
printf("Hello world");后发现要打印的字符串内容并没有输出

- 当调用
exit退出的时候才会输出

- 接下来如果我们修改
_flag为0xFBDA1800呢,接下来查看如下代码:
1 |
|
- 编译运行一下发现是可以输出出来内容的:

- 接下来调试看看,在调用
puts("hello world")之前已经将stdout变成了这样,其实只要修改_IO_write_ptr、base、end即可

- 调用执行
puts("Hello world");这句后马上就能将flag输出出来,如果puts换成printf其实也是可以的。 - 还有
_IO_read相关指针其实设置为0也是可以的,_IO_buf相关指针设置为0也是能输出的

题目1_MoeCTF_2023 feedback(改)
- 这题给了源码,但是
libc版本是2.31的,所以我稍微修改了一下源码,在glibc2.23的环境下进行编译。改编后的题目会比原题简单,主要是为了理解stdout如何实现任意地址写。 - 改编后的代码如下:
1 |
|
题目1_分析1
- 先查看一下保护机制

- 直接使用
IDA pro逆向这个程序。main函数调用了这三个函数

init其实就是初始化输入输出

read_flag这个函数- 打开了
flag文件,并且申请了两个堆块 - 将
flag文件的内容输入到其中一个堆块中 - 将堆块的地址告诉我们
- 打开了

vuln()函数如下:- puts输出没什么作用的菜单
- 然后申请三个堆块并将三个堆块的地址放入到
feedback_list中 - 之后让用户选择一个堆块索引,并且向堆块索引中向指定堆地址写入用户想要的内容,注意用户输入的时候可以输入一个负值,导致数组越界

- 在每次
read_str后程序还会将3个堆块中的数据全部打印出来

- 接下来再看看
.bss段这边,其实我们会发现feedback_list上面一点其实就存放着stdout的地址,此时我们可以使用数组越界来对stdout这个FILE结构体进行修改,使得print_feedback()会将flag顺带打印出来

题目1_分析2
- 首先我们先将
gift接收一下和feedback_list到stdout的偏移计算一下
1 | from pwn import * |
- 现在先来确定一下
flag的位置,其实flag的起始地址就是gift发送过来的地址

- 那么我们其实可以设置
_IO_write_base = buf,而_IO_write_ptr = buf+len(flag)至少要设置到这么多,之后_IO_write_end = buf + 0x50,注意一般输出逻辑是将从base输出到ptr,而不是从base输出到end。 - 并且需要满足
base <= ptr <=end否则输出的时候会有很多问题。所以接下来进行如下stdout修改
1 | payload = p64(flag) # _flag |

- 修改后,在调用
puts("Which list do you want to write?");的是就会将flag打印出来

题目1_exp
- 最终的
exp如下:
1 | from pwn import * |
题目2_MoeCTF_2023 feedback(原)
- 接下来直接使用
Docker文件,在glibc2.31分析一下原题。与修改的题相比,原题还需要先泄露一下libc的地址。
题目2_分析1
- 原题目的程序逻辑与题目1的程序逻辑差不多,主要就是
read_flag这个地方,这个地方并不是将flag放到malloc申请的堆块中,而是放到libc中的某个位置,其他的都没什么变化

题目2_分析2
- 接下来就调试一下这个程序,调试前先查看一下题目附件看看程序保护机制如何,发现是保护全开的。

- 保护全开就导致了没有办法知道程序的基地址,而现在我们又不知道libc地址,所以一开始不知道
libc的地址应该如何操作呢,接下来就动态调试一下。

- 在调试的过程中,其实可以发现
_IO_write_base等一些列IO结构体成员其值其实为0x7f50e8beb723 <_IO_2_1_stdout_+131> - 并且
write的顺序其实是_IO_write_base`_IO_write_ptr`_IO_write_end这样的一个顺序,所以溢出修改的时候其实也是按照这个修改顺序。 - 这样一来的话其实很好操作,溢出只
_IO_write_base的最末尾一个字节,由于_IO_write_ptr指向的是_IO_2_1_stdout_+131,那么其实输出的时候就会将0x7fxxxxxx00到0x7fxxxxxx723之间的数据输出

- 这样一来其实就能利用
off-by漏洞导致libc的地址泄露,记下来先进行泄露,泄露的其实是这样的

- 但是在调试的时候会发现,泄露的一个
0x7fxxx的地址其实是stdin的地址


- 这下就成功接收到了
stdin的地址

- 这样其实就能确定
libc的基地址,从而确定puts函数的地址。现在puts的地址已经有了直接就可以计算得到flag的地址
1 | libc_base = stdin_addr + 3600 - libc.symbols['stdin'] # 注意libc.symbols['stdin']并不是stdin结构体实例的地址还需要计算一下偏移 |
- 得到
flag的地址其实就可以继续修改stdout的结构体。实际上flag的真实地址与程序read的地址会有点偏差


- 最后泄露出
flag

- 打远程看看,打远程由于偏移的原因需要稍微的爆破才可以

题目2_exp
1 | from pwn import * |
题目3_de1ctf_2019_weapon
- 该题在
buuctf有BUUCTF在线评测,可以去buu上打远程,这题就是与堆题结合了。 - 考点:
堆分水、unsorted_bin_attack、fast_bin_double_free、stdout泄露libc
题目3_分析1
- 先查看一下保护机制,发现保护全开

- 然后再查看一下反编译的程序
main函数如下,经典的堆菜单题目。

- 恢复结构题和修改变量和函数名称后
add()函数如下:- 输入要申请的堆块大小和存储堆块地址和大小的索引
- 然后往堆块中输入内容
- 注意:这里有机会出现负索引

- 再来看看
del()函数- 其实就是输入一个索引,释放索引中的堆块
- 但是这里释放后并没有将堆块的地址设置为
0,所以存在UAF漏洞

- 在查看一个
rename相关功能:- 输入一个堆块索引,向堆块地址中写入内容
- 但是这里注意该索引其实可以是负索引

题目3_分析2
-
这题有一个与平时堆题比较不一样的地方就是没有
show功能,所以泄露地址其实就没那么容易了。但是由于存在负索引,这就使得在调用rename()函数的时候可以修改到stdout结构体,这样就能泄露libc的地址了。 -
接下来先写一下程序的交互逻辑
1 | from pwn import * |
- 这里先说一下大致思路:
- 先利用
fastbin_double_free修改堆块的size位,使得程序在free()被修改size位的堆块后能被放入unsortedbin(这样才有libc的地址) - 之后使用
unsorted_bin_attack修改chunk->fd为main_arena+88这个地址的值,这样通过偏移计算出stdout结构体的地址,修改chunk->fd的最低俩个字节,这样就可以申请到stdout结构体附近的地址,从而可以修改stdout结构体,这样就可以造成libc的泄露 - 最后再使用一次
fastbin_double_free,劫持malloc_hook为one_gadget,这样其实就可以getshell了。
- 先利用
- 接下来详细说明,先申请
5个堆块,申请的大小依次为0x50,0x50,0x60,0x50,0x50
1 | add(0x50,0,b'a') |

- 接下来先修改一下
index=0的堆块内容,这个0x555555603050其实就是我们要申请的地址
1 | payload = p64(0)*9 + p64(0x61) |

- 由于堆的低位偏移是不变的,所以此时利用
fastbin_double_free的时候只需要修改fd的低位即可,下图就是rename()后的fd的值,这样使用double_free时就可以绕过size的检查成功申请到目标位置。
1 | delete(1) |

- 申请到目标位置后就需要修改
index=1这个堆块的size位(这里修改size=0xd1,刚好是idx=1、idx=2这俩个堆块的合并位置),这样就可以构造一个堆叠,并且很轻松的可以绕过检查。 - 构造完堆叠后,就可以将
idx=1的堆块释放,此时该堆块就会被放入到unsorted_bin中,还需要将idx=2的堆块释放,这样idx=2的堆块就会被放入到fastbin[0x70]这个位置处
1 | add(0x50,6,b'a') |

- 接下来就是写
main_arena+88到idx=2这个堆块的fd指针,这个时候其实申请俩个0x20大小的堆块其实就可以将main_arena+88的位置写入到fd指针,因为申请的0x20大小的堆块fastbin列表是空的,所以会去unsortedbin切割。切割后就会出现下图的情况:
1 | add(0x20,9,b'a') |

- 有了libc地址后,现在就要查看一下
stdout结构体的地址以及main_arena+88这个结构体:- 发现
stdout的地址为0x7ffff7dd2620,而main_arena+88的这个地址为0x7ffff7dd1b78 - 如果开启随机地址偏移会发现倒数第二字节的高4位是存在随机偏移的,即stdout中
0x2620中这个2是随机的,这里本地调试就关闭了随机地址偏移
- 发现

- 接下来寻找附近有没
0x7f的堆地址,这样我们就可以修改fd指针到0x7f这个地址处,申请fastbin列表中的堆块就能申请到该位置,这里选用的是下图的地址

- 现在我们就进行一下劫持
fd、修改stdout结构体的操作- 下面的图一就是劫持
fd的操作 - 下面的图二就是修改
stdout结构体后的结果 - 下面的图三就说明修改
stdout结构体后已经将libc的地址打印出来了,打印出来后我们就可以处理一下接收
- 下面的图一就是劫持
1 | rename(2,p16(0x25e5-0x8)) |



题目3_分析3
- 通过分析
2其实就已经将libc的地址泄露出来了,接下来就是继续申请0x60大小的堆块,并且释放堆块,看看程序是否能正常运行。申请和释放完后发现可以正常运行,申请的俩个堆块如下图所示:
1 | add(0x60,13,b'1') |

- 那接下来就直接构造
double_free打malloc_hook即可,这里就不多说了。
1 | ogg = [0x4527a,0xf03a4,0xf1247] # 由于本地和远程的libc有点不同,所以ogg不太一样 |
- 本地打的完整
exp如下:
1 | from pwn import * |
- 但是由于
半个字节的随机地址偏移,这就使得打远程需要爆破,尝试一下打远程,打远程使用打本地的偏移其实就能爆破出来:

题目3_exp
1 | from pwn import * |
题目4_2025上海市磐石CTF_user
- 题目附件:
1 | 下载:https://wwsq.lanzoue.com/iAqJG33zvk0d 密码:i2om |
- 在讲解这题之前,先要说明一个小
trick,如果不知道这个trick的话是没办法做出这题的。 - 在
bss段中存在一个这样的指针,这个指针指向的是自己,这个指针的位置在bss段中&stdout - 0x18这个位置。- 此时如果存在索引值为负数,我们就能动用这个指针利用这个指针,可以有一次任意地址写的机会。
- 可以修改它为
free_hook的地址,向free_hook写入system函数的地址,之后再释放堆块,从而执行system("/bin/sh")

题目4_分析1
- 查看一下程序保护机制,发现保护全开

- 然后分析程序逻辑,发现其实是一个经典堆菜单的题目,程序逻辑就不逆了。但是没有堆溢出漏洞,也没有
UAF漏洞。

- 漏洞点其实在
edit这边,有一个数组越界的漏洞

- 并且这题没有
show()函数

题目4_分析2
- 由于最近刚学完
stdout泄露libc所以这题很明显就是要修改IO_FILE泄露libc。泄露是泄露出来了,但是就卡在如何利用这边。看了别人的wp才知道其实heap[-11]这个索引有问题。 - 动态调试会发现
heap[-11]=heap[-11],所以我们可以利用heap[-11]去修改heap[-11]这个地方,将其修改为free_hook - 这样再调用一次
edit(heap[-11])就能将system_addr写入到free_hook这边,之后再申请一个堆块写入/bin/sh即可getshell

题目4_exp
- 打本地的
exp
1 | from pwn import * |
- 打远程的exp:
1 | from pwn import * |
- flag如下:
1 | flag{7ZjaDWxsy4lvcinVgKqeUXHJwrLm16Op} |
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 iyheart的博客!

