1. 从一道CTF题看文件流底层机制的实战价值
第一次看到"ezfile"这个题目名的时候,我下意识觉得它应该是一道文件上传或者文件包含的常规题。但真正上手之后才发现,这道题的核心考点根本不在文件操作的业务逻辑层面,而是扎进了C语言标准库中FILE结构体与文件描述符之间的那层"隐秘关系"里。具体来说,它考察的是fileno()这个平时很少有人主动关注的函数,以及围绕它展开的一系列利用思路。
这道题适合谁看?如果你已经能独立完成一些基础的Pwn题或者Web题,但对"文件流"和"文件描述符"之间的区别还停留在模糊认知阶段,那这道题会帮你把这层窗户纸彻底捅破。如果你是对CTF感兴趣但还没入门的新手,建议先补一下Linux下文件I/O的基础知识再来看,不然可能会有点吃力。
我写这篇东西的目的很简单:把这道题从"看到题面"到"拿到flag"的完整思考链路拆开,把每一个关键决策背后的"为什么"讲清楚。不是那种"第一步做A,第二步做B"的流水账,而是让你理解为什么在这个位置要选这个方案,为什么换个思路就走不通。这些判断逻辑,才是真正能迁移到其他题目里的东西。
2. 题目整体设计与考察意图拆解
2.1 题面信息与初步判断
题目给的是一个二进制程序,典型的Pwn题配置。拿到手之后我习惯性地先跑几个基础检查:file看架构,checksec看保护,然后扔进反编译工具里看主逻辑。
从保护机制来看,这道题大概率是开了Canary和NX的,可能还开了PIE。这意味着我们不能简单地栈溢出写shellcode,得走ROP或者更巧妙的路线。但真正让这道题有意思的地方在于,程序本身的业务逻辑里藏着一个关于文件流操作的设计缺陷。
程序的功能并不复杂,大致是一个文件管理的交互界面,支持打开文件、读取内容、写入内容、关闭文件等操作。表面上看就是一个简化版的文件编辑器。但问题出在它对文件流的处理方式上——程序内部维护了一个FILE *类型的数组,每个槽位对应一个"打开的文件",而用户通过索引来操作这些文件。
2.2 核心考点定位:fileno的本质
这里需要先讲清楚一个基础概念。在C语言中,我们平时用的fopen、fread、fwrite、fclose这些函数操作的是FILE *,也就是"文件流"。而在操作系统层面,真正标识一个打开文件的是"文件描述符",就是一个非负整数,通常用fd表示。
FILE结构体内部封装了很多东西:缓冲区指针、读写位置、错误标志、以及最重要的——它对应的文件描述符。fileno()函数的作用就是从一个FILE *中提取出它底层对应的文件描述符。
int fileno(FILE *stream);这个函数本身没什么问题,但它的存在意味着:如果你能控制一个FILE *指针指向的内存内容,你就能间接控制fileno()的返回值。而如果程序后续用这个返回值去做一些敏感操作(比如read、write、close),那就可能出问题。
这道题的巧妙之处就在于此:程序在某个环节调用了fileno(),但传入的FILE *并不是一个合法的文件流,而是一块被用户部分控制的内存。通过精心构造这块内存的内容,我们可以让fileno()返回一个我们想要的文件描述符值。
2.3 为什么选择这个利用路径
你可能会问:为什么不直接走栈溢出ROP?为什么要绕这么大一圈?
原因在于,这道题的栈溢出利用条件并不理想。可能存在以下限制之一:溢出长度不够构造完整的ROP链、没有足够的gadget、或者程序开了Full RELRO导致GOT表不可写。在这种情况下,利用程序自身的逻辑缺陷反而比硬打内存破坏更靠谱。
另一个考量是稳定性。ROP链的构造往往依赖特定的栈布局和地址,如果程序有PIE或者ASLR,还需要先泄露地址。而利用fileno()的逻辑缺陷,如果设计得当,可以绕过这些限制,实现更稳定的利用。
这里的心得是:拿到一道Pwn题,不要上来就想怎么溢出。先花时间把程序的业务逻辑读透,很多时候出题人故意留的逻辑漏洞比内存破坏漏洞更好用。
3. 核心细节解析与实操要点
3.1 FILE结构体的内存布局
要利用fileno(),首先得知道FILE结构体长什么样。在glibc中,FILE实际上是_IO_FILE的typedef。它的定义大致如下(简化版):
struct _IO_FILE { int _flags; // 偏移 0x00 char *_IO_read_ptr; // 偏移 0x08 char *_IO_read_end; // 偏移 0x10 char *_IO_read_base; // 偏移 0x18 char *_IO_write_base; // 偏移 0x20 char *_IO_write_ptr; // 偏移 0x28 char *_IO_write_end; // 偏移 0x30 char *_IO_buf_base; // 偏移 0x38 char *_IO_buf_end; // 偏移 0x40 // ... 中间省略若干字段 int _fileno; // 偏移 0x70 (在64位系统上) // ... 后续字段 };关键点在于_fileno字段的位置。在64位glibc中,它通常位于偏移0x70处。fileno()函数的实现就是读取这个字段的值并返回。
int fileno(FILE *fp) { return fp->_fileno; }所以,如果我们能控制一个伪造的FILE结构体,把偏移0x70处的值设为我们想要的fd,那么fileno()就会返回这个值。
3.2 程序中的关键漏洞点
回到题目本身。程序维护了一个文件流数组,假设叫files[]。用户可以通过某个操作(比如"关闭文件")来释放一个槽位,但释放之后指针没有置空。这就造成了Use-After-Free(UAF)。
更关键的是,程序在某个功能中会调用fileno(files[index]),然后用返回的fd去执行read或write。如果我们能让files[index]指向一块我们控制的内存,并且在偏移0x70处写入目标fd的值,就能实现任意文件描述符的读写。
这里的"任意文件描述符读写"意味着什么?如果目标fd是0(标准输入),我们可以读取用户输入;如果是1(标准输出),我们可以输出内容;如果是某个已打开文件的fd,我们可以读写那个文件。在某些场景下,如果能操作到flag文件的fd,就能直接读取flag。
3.3 伪造FILE结构体的内存布局
实际操作中,我们需要构造一块内存,使得它"看起来像"一个FILE结构体,至少要让fileno()能正确提取出我们想要的值。
假设我们通过UAF获得了一块可控内存,大小足够容纳一个FILE结构体(通常需要至少0x100字节)。我们需要在这块内存的偏移0x70处写入目标fd的值。
但这里有个坑:fileno()虽然只读_fileno字段,但程序后续可能会调用其他文件操作函数,这些函数会访问FILE结构体的其他字段。如果这些字段是垃圾值,可能导致程序崩溃。
所以,更稳妥的做法是:先泄露一个真实的FILE结构体的内容,然后复制过来,只修改_fileno字段。这样能保证结构体的其他字段都是合法的,避免崩溃。
实操心得:在伪造
FILE结构体时,_flags字段也很重要。如果_flags的值不对,glibc可能会认为这个流处于错误状态,导致后续操作失败。通常可以把_flags设置为0xfbad2088或类似的值,具体取决于你要执行的操作类型。
3.4 文件描述符的分配规律
Linux下文件描述符的分配遵循"最小可用"原则。也就是说,当你打开一个新文件时,系统会分配当前最小的未使用fd。
标准情况下:
- fd 0:标准输入
- fd 1:标准输出
- fd 2:标准错误
- fd 3及以上:用户打开的文件
如果程序在运行过程中打开了flag文件,那么flag文件的fd很可能是3或4(取决于程序是否还打开了其他文件)。如果我们能让fileno()返回这个值,然后程序用这个fd去读取内容并输出,就能拿到flag。
但问题是:我们怎么知道flag文件的fd具体是多少?这就需要在程序运行过程中观察fd的分配情况,或者通过多次尝试来猜测。
4. 实操过程与核心环节实现
4.1 环境准备与工具链
我用的环境是Ubuntu 18.04(因为这道题是2019年的,glibc版本比较老),配合pwntools进行脚本编写。反编译工具用的是IDA Pro,动态调试用gdb配合pwndbg插件。
# 检查题目基本信息 file ezfile checksec --file=ezfile # 运行程序看看交互界面 ./ezfile从checksec的输出可以看到,程序开了Canary、NX、PIE,可能还有Full RELRO。这意味着我们不能写GOT表,也不能执行栈上的代码。
4.2 程序逻辑梳理
通过IDA反编译,我梳理出了程序的主要功能:
- 打开文件:调用
fopen,将返回的FILE *存入全局数组files[]的某个槽位 - 读取文件:调用
fread,从指定槽位的文件流中读取内容 - 写入文件:调用
fwrite,向指定槽位的文件流中写入内容 - 关闭文件:调用
fclose,释放指定槽位的文件流,但指针未置空 - 某个特殊功能:调用
fileno(files[index]),然后用返回的fd执行read或write
第5个功能就是我们的突破口。具体来说,程序可能是这样的:
void special_function(int index) { int fd = fileno(files[index]); char buf[0x100]; read(fd, buf, 0x100); write(1, buf, 0x100); }如果我们能控制files[index]指向的内存,让fileno()返回flag文件的fd,那么read就会从flag文件读取内容,然后write输出到标准输出。
4.3 利用步骤详解
第一步:制造UAF
首先打开一个文件(比如/dev/null或者一个临时文件),获得一个有效的FILE *。然后关闭这个文件,触发fclose,但files[]中的指针没有被清空。此时这个槽位就是一个悬垂指针。
第二步:堆喷控制内存
接下来需要分配一块内存,让之前释放的FILE结构体的内存被我们控制的内容覆盖。具体怎么分配取决于程序提供了哪些堆操作。如果程序支持写入文件内容到堆上,那就可以利用这个功能来布置数据。
假设程序有一个"创建文件"的功能,会malloc一块内存来存储文件名或内容。我们可以通过精心构造输入,让这块内存的偏移0x70处正好是我们想要的值。
第三步:泄露真实FILE结构体
为了构造一个"合法"的伪造FILE结构体,我们需要知道真实FILE结构体的内容。可以通过读取一个已打开文件的内容来泄露——如果程序允许我们读取文件流的内容,而文件流的缓冲区指针指向FILE结构体本身附近的内存,就有可能泄露。
这一步比较依赖具体题目的设计。有些题目会直接给你一个任意地址读的机会,那就简单很多。
第四步:构造伪造FILE并触发fileno
有了真实FILE结构体的模板之后,我们把它复制到可控内存中,修改_fileno字段为目标fd(比如3),然后触发special_function。
from pwn import * # 假设我们已经有了一个可控的内存区域 fake_file = b'' fake_file += p64(0xfbad2088) # _flags fake_file += p64(0) * 13 # 填充其他字段 fake_file += p32(3) # _fileno = 3 fake_file += b'\x00' * 0x40 # 填充剩余部分 # 将fake_file写入可控内存 # ... 具体操作取决于程序功能第五步:读取flag
如果一切顺利,fileno()会返回3,read(3, buf, 0x100)会从flag文件读取内容,然后write(1, buf, 0x100)把内容输出到终端。我们就看到了flag。
4.4 关键参数计算
在构造伪造FILE结构体时,有几个关键参数需要精确计算:
| 字段 | 偏移 | 建议值 | 说明 |
|---|---|---|---|
| _flags | 0x00 | 0xfbad2088 | 表示可读可写流 |
| _IO_read_ptr | 0x08 | 0 | 读指针,设为0避免干扰 |
| _IO_read_end | 0x10 | 0 | 读结束指针 |
| _IO_buf_base | 0x38 | 0 | 缓冲区基址 |
| _IO_buf_end | 0x40 | 0 | 缓冲区结束 |
| _fileno | 0x70 | 目标fd | 关键字段 |
| _mode | 0x74 | 0 | 通常为0 |
| _unused2 | 0x78 | 0 | 填充 |
注意:不同glibc版本的
_fileno偏移可能不同。在32位系统上通常是0x48,在64位系统上通常是0x70。建议在gdb中实际查看确认。
5. 常见问题与排查技巧实录
5.1 程序崩溃怎么办
这是最常见的问题。伪造FILE结构体时,如果某些字段的值不合法,glibc在后续操作中可能会触发断言失败或段错误。
排查思路:
- 用gdb附加,看崩溃时的调用栈
- 检查崩溃位置访问的是FILE结构体的哪个字段
- 对照真实FILE结构体,修正该字段的值
我遇到过一次崩溃,原因是_IO_read_end字段被设为了一个非法指针,导致fread在计算可读长度时访问了非法地址。后来把这个字段设为0就好了。
5.2 fileno返回值不对
如果fileno()返回的不是你期望的fd,可能的原因有:
- 偏移算错了:不同glibc版本
_fileno的偏移不同,需要在gdb中确认 - 内存没控制住:UAF之后分配的内存可能被其他操作覆盖了
- fd分配顺序不对:程序可能在打开flag文件之前还打开了其他文件,导致fd值偏移
5.3 读不到flag内容
如果fileno()返回了正确的fd,但read读不到内容,可能的原因有:
- 文件偏移问题:flag文件可能已经被读取过,文件指针在末尾。可以尝试用
lseek重置偏移(如果程序提供了这个功能) - 权限问题:flag文件可能没有读权限
- fd类型不对:目标fd可能是一个目录或设备文件,不是普通文件
5.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 程序段错误 | FILE结构体字段非法 | 对照真实结构体修正 |
| fileno返回-1 | _fileno字段值为-1 | 检查偏移和写入的值 |
| read返回0 | 文件偏移在末尾 | 尝试重置文件偏移 |
| 输出乱码 | 读取的是二进制内容 | 检查fd是否正确 |
| 利用不稳定 | 堆布局变化 | 调整堆喷策略 |
独家避坑技巧:在构造伪造FILE结构体时,最安全的做法是先用程序自身的功能泄露一个真实FILE结构体的完整内容,然后在此基础上只修改
_fileno字段。这样能最大程度保证结构体的其他字段都是合法的,避免各种奇怪的崩溃。
5.5 关于fd的进一步思考
这道题让我意识到一个更深层的问题:文件描述符本质上是一个进程级的资源索引。如果我们能控制fileno()的返回值,实际上就获得了对进程内任意文件描述符的操作能力。
这意味着什么?如果目标进程打开了flag文件、日志文件、配置文件等,我们都可以通过这种方式去读写。甚至在某些场景下,如果进程继承了父进程的fd(比如通过fork),我们还能操作到父进程的文件。
这种利用思路不仅限于CTF题目。在实际的漏洞挖掘中,如果发现程序存在UAF并且后续会调用fileno(),就可以考虑这条利用路径。当然,实际场景中还需要考虑更多因素,比如ASLR、堆布局稳定性等。
6. 从这道题延伸出的通用利用思路
6.1 文件流与文件描述符的桥接点
fileno()只是文件流和文件描述符之间的一个桥接点。类似的函数还有fdopen()(从fd创建流)、freopen()(重新打开流)等。这些函数在特定场景下都可能成为利用的入口。
比如fdopen(),如果程序用用户控制的fd来创建一个新的文件流,而这个fd指向了一个敏感文件,那么后续通过这个流进行的读写操作就会作用在敏感文件上。
6.2 伪造FILE结构体的通用方法
伪造FILE结构体的核心思路是:找到一块可控内存,按照_IO_FILE的布局填充数据,然后让程序把这个内存当作合法的FILE *来使用。
关键难点在于:
- 找到可控内存(通常通过UAF或堆溢出)
- 确保结构体的关键字段合法(避免崩溃)
- 触发程序调用文件操作函数
这套方法不仅适用于fileno(),也适用于其他依赖FILE结构体的函数,比如fread、fwrite、fclose等。在某些高级利用中,还可以通过伪造vtable指针来实现任意函数调用。
6.3 对CTF选手的建议
如果你正在准备CTF比赛,我建议你花时间把glibc的文件流机制搞清楚。不需要背下整个_IO_FILE结构体的定义,但至少要理解以下几个关键点:
FILE结构体的大致布局,特别是_flags、_fileno、vtable这几个字段的位置fileno()、fdopen()、fclose()等函数的实现逻辑- 文件描述符的分配规律和继承机制
这些知识在遇到文件相关的Pwn题时会非常有用。而且,理解了这些底层机制之后,你看其他题目的视角也会不一样——你会开始关注程序是怎么管理资源的,资源释放后有没有清理,等等。
6.4 关于glibc版本的注意事项
这道题是2019年的,当时用的glibc版本比较老。在新版本的glibc中,_IO_FILE结构体的一些字段可能发生了变化,某些利用手法可能已经失效。比如glibc 2.34之后,vtable的检查更加严格,伪造vtable的难度增加了。
但fileno()相关的利用思路本身是不过时的,因为它的核心逻辑——从FILE结构体中读取_fileno字段——没有变。只要程序存在UAF并且会调用fileno(),这条利用路径就依然有效。
我在实际调试这道题的时候,前后崩溃了大概七八次才把伪造的FILE结构体调稳定。最开始的几次都是因为_flags字段设得不对,导致glibc认为流处于错误状态。后来参考了一个真实FILE结构体的_flags值(0xfbad2088),问题就解决了。这个值表示流是可读可写的,并且处于正常状态。
另外一个小技巧是:如果你不确定某个字段应该设什么值,就把它设为0。大多数情况下,0是一个安全的默认值,不会触发断言失败。唯一需要注意的是_flags和_fileno这两个字段,它们必须设成有意义的值。
最后再分享一个排查思路:当程序崩溃时,不要只看崩溃地址,还要看崩溃时寄存器指向的内存内容。用gdb的x/20gx $rdi命令查看FILE *指向的内存,对照_IO_FILE的布局,就能快速定位是哪个字段出了问题。这个方法帮我省了很多调试时间。