CTF Pwn题ezfile:利用fileno()伪造FILE结构体实现任意文件描述符读写

发布时间:2026/10/14 0:06:33
CTF Pwn题ezfile:利用fileno()伪造FILE结构体实现任意文件描述符读写 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-FreeUAF。更关键的是程序在某个功能中会调用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 --fileezfile # 运行程序看看交互界面 ./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()会返回3read(3, buf, 0x100)会从flag文件读取内容然后write(1, buf, 0x100)把内容输出到终端。我们就看到了flag。4.4 关键参数计算在构造伪造FILE结构体时有几个关键参数需要精确计算字段偏移建议值说明_flags0x000xfbad2088表示可读可写流_IO_read_ptr0x080读指针设为0避免干扰_IO_read_end0x100读结束指针_IO_buf_base0x380缓冲区基址_IO_buf_end0x400缓冲区结束_fileno0x70目标fd关键字段_mode0x740通常为0_unused20x780填充注意不同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的布局就能快速定位是哪个字段出了问题。这个方法帮我省了很多调试时间。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询