☰
PWN入门必刷:PWNABLE fd题从原理到利用,一文吃透文件描述符
2026/10/16 11:16:58 网站建设 项目流程

写一篇直接能用的pwn入门题解文章,我按"拿到题第一眼该看什么——题目背后的系统原理——完整解题实操——进阶延伸——踩坑排查"这条线来写,保证不流水账,每个环节都有能直接照做的内容。

1. 题目初探:PWNABLE第一题到底在考什么

PWNABLE这个训练平台在CTF圈子里几乎成了pwn方向玩家的"新手村"。它的题目编号从[fd]开始,一路到堆利用、内核利用,难度循序渐进。如果你刚开始接触pwn方向,大概率会从这道题入手。题目本身不复杂,但它牵出来的知识点——Linux文件描述符、程序参数传递、标准输入输出流——是后面所有pwn题的地基。花半小时把这题吃透,比闷头刷十道简单题都值。

先说题目代码,这是PWNABLE系列的标准风格,官方会把题目源码直接给你,让你通过分析源码找出漏洞利用方式。fd这道题的C语言代码如下:

#include <stdio.h> #include <stdlib.h> #include <string.h> char buf[32]; int main(int argc, char* argv[], char* envp[]) { if (argc < 2) { printf("pass argv[1] as a number\n"); return 0; } int fd = atoi(argv[1]) - 0x1234; int len = 0; len = read(fd, buf, 32); if (!strcmp("LETMEWIN\n", buf)) { printf("Good Job :)\n"); system("cat flag"); exit(0); } printf("learn about Linux file I/O\n"); return 0; }

很多新手拿到这个代码的第一反应是"这哪里有什么漏洞"。printf、read、strcmp、system,全都是正常函数,没有栈溢出,没有格式化字符串,没有堆溢出,看起来就是个普普通通的程序。但pwn题好玩的地方就在这里:漏洞不一定是内存破坏类的,逻辑层面的"疏忽"同样致命。

这道题的关键在那一行:

int fd = atoi(argv[1]) - 0x1234;

然后紧接着程序调用了read(fd, buf, 32)。read的第一个参数是文件描述符,程序从argv[1]读取一个数字,减去十六进制的0x1234,然后当作文件描述符去读数据。如果你能让fd的值恰好等于0,read就会从标准输入读取数据。那么程序拿到的buf内容就是你自己输入的字符串,再通过strcmp比较,只要输入的是 "LETMEWIN\n",就能触发后面的system("cat flag")。

这个思路其实不复杂,但对刚接触pwn的人来说,难点不在代码本身,而在于你还没建立起"输入的数字可以被用来操控程序行为"这种思维模式。程序把一个外部输入值直接当作系统调用参数来用,这在真实软件里并不罕见,而CTF题把这层关系放大了给你看。

2. 核心原理:文件描述符与read函数的底层逻辑

2.1 文件描述符0、1、2到底是什么

要彻底理解这道题的利用逻辑,先得搞清楚文件描述符这个概念。Linux下一切皆文件——普通文件是文件、目录是文件、管道是文件、socket是文件,甚至键盘输入和屏幕输出也被抽象成文件。每个进程启动后,内核会给它维护一张文件描述符表,这张表里每一项指向一个打开的文件。程序想读写某个文件,就得通过这张表里对应的那个整数编号,这个编号就是文件描述符(file descriptor,简称fd)。

按照POSIX标准,每个进程启动时,前三个文件描述符是约定俗成的:

文件描述符名称默认指向用途
0stdin键盘等输入设备标准输入
1stdout屏幕终端标准输出
2stderr屏幕终端标准错误输出

为什么要专门记这三个数字?因为绝大多数CTF题和真实漏洞利用中,你控制不了程序打开的其他文件描述符,但你几乎总是能控制标准输入、标准输出和标准错误。这道题的fd题眼就在于:read函数的第一个参数如果为0,就是从标准输入读取。

看这道题的代码,read(fd, buf, 32)一次最多读32字节到buf里。如果你直接运行程序而不传参数,代码第一段就拦住了你。传了参数后,atoi(argv[1])会把参数字符串转成整数,然后减去0x1234。这里的0x1234十六进制等于十进制的4660。只要让atoi的结果等于4660,fd就等于0。

2.2 read系统调用的工作方式

read是操作系统提供的系统调用,不是普通的C库函数。用户态程序调用read时,CPU会发生一次模式切换,从用户态陷入内核态,由内核完成从文件描述符对应的文件对象中读取数据的操作。

read函数的原型是:

ssize_t read(int fd, void *buf, size_t count);

三个参数的含义分别是:文件描述符、缓冲区指针、最多读取的字节数。返回值是实际读到的字节数,返回-1表示出错,返回0表示读到文件末尾。

这里有一个容易被新手忽略的细节:read默认是阻塞式的。如果你让fd等于0(标准输入),程序会停下来等你往终端里输入内容,敲完回车后read才会返回。如果你不输入,程序就一直卡在那里。这个"卡住"的现象在远程连题的时候尤其常见,很多新手在远端执行程序后发现自己什么也没看到,其实是程序在等待输入,不是服务器挂了。

2.3 atoi函数的"宽容"与"陷阱"

atoi函数的作用是把字符串开头的数字部分转换成整数。它的处理规则是:跳过前面的空白字符,然后逐个读取数字字符直到遇到非数字字符为止,如果第一个非空白字符就不是数字,返回0。

这道题里,argv[1]传进去的是什么都无所谓,只要atoi解析出来的结果是4660,就能让fd等于0。这意味着你可以传 "4660"、"04660"、"+4660",甚至 "4660abc",只要数字部分是4660就行。但注意,"-4660"不行,因为atoi会解析出负数。这些细节在一些进阶题里可能会变成刁钻的考点,但在这道题里只需要记住:atoi对输入格式很宽容,用它做整数解析时很容易引入逻辑漏洞。

3. 完整解题实操:从计算参数到get flag

3.1 第一步:确定关键参数

代码里read(fd, buf, 32)要从fd读取数据,其中fd = atoi(argv[1]) - 0x1234。想让文件描述符等于0,就得让atoi(argv[1]) = 0x1234。

0x1234转换成十进制:1 * 16^3 + 2 * 16^2 + 3 * 16 + 4 = 4096 + 512 + 48 + 4 = 4660。

所以传入的命令行参数就是4660。这个计算是整个题目的"题眼",连这关都过不去,后面怎么做都白搭。顺便说一句,习惯上在终端里敲参数时直接用十六进制写法也可以:./fd 0x1234,atoi也能正确解析0x1234这种带前缀的十六进制格式吗?答案是能,C语言的atoi在遇到 "0x" 前缀时会按十六进制解析。不过实测中发现不同平台或不同C库的行为可能存在差异,稳妥起见建议手动换算成十进制再传。

3.2 第二步:本地模拟验证

拿到题目先别急着连远端,先去网上把题目文件下载下来(PWNABLE官网每个题目都会提供可执行的二进制文件和对应源码),在本地验证一遍思路。

先执行:

chmod +x fd ./fd

不带参数运行会直接输出 "pass argv[1] as a number",说明代码第一道检查起作用了。

然后带着参数运行:

./fd 4660

程序会卡住,这是在等你输入。这时候输入LETMEWIN然后回车,屏幕会显示 "Good Job :)" 并尝试读取flag文件。

这个过程中的关键一点是:字符串比较的是 "LETMEWIN\n",也就是带换行符的。所以你必须先输入LETMEWIN,再敲回车,让输入缓冲区里的内容变成LETMEWIN\n。如果直接在管道里用echo传参,echo默认会带一个换行符,正好满足要求。

3.3 第三步:远程连接并提交

本地验证通过后,远程连接题目服务器。以PWNABLE的传统端口映射方式为例,题目端口通常是一种非标准端口,对应服务器的某个服务:

nc pwnable.kr 2222

然后根据提示ssh登录或直接执行。连接成功后按远程服务器的题目说明操作,一般流程是:下载题目文件到本地分析,或者直接在服务器上运行题目程序。

执行:

./fd 4660

等待提示后输入LETMEWIN,回车。远端返回flag那一刻,这道题就算正式通关了。

需要提醒的是,远程环境下的输入时机很重要。当你通过ssh连接到题目服务器后,执行./fd 4660出现"卡住"的现象是正常的,说明程序在等你输入。这时候在终端里敲入LETMEWIN并回车即可。很多人初次连远程题时输得太快,程序还没到read那一步就回车了,导致输入内容丢了,然后抱怨"代码明明是对的为什么没反应",其实就是这个原因。

4. 从fd题延伸:pwntools入门与脚本化利用

4.1 为什么需要pwntools

这道题用纯手工交互完全可以解,但pwn题到了后面,交互过程只会越来越复杂。手动敲字的方式无法应对需要精确控制发送时机、处理大量数据、多次交互的题目。pwntools就是为此而生的Python库,它把socket通信、汇编反汇编、ELF解析、格式化字符串、ROP链构造等常用功能全部封装好了。

fd这道题用pwntools可以做一次入门级的练习。先把交互流程写成脚本,后面的题就能照葫芦画瓢。

安装pwntools:

pip install pwntools

如果是Ubuntu系统,建议用Python3的pip3:

pip3 install pwntools

4.2 脚本化exploit全流程

针对fd题,最简单的pwntools脚本是这样的:

from pwn import * # 本地测试 # p = process('./fd') # 远程连接 p = remote('目标地址', 端口) # 发送参数,注意这里用到了字符串拼接 # 其实是执行:./fd 4660 p.sendline(b'4660') # 这段写法是示意,真实远程场景需要构造正确交互 # 发送要输入的内容 p.sendline(b'LETMEWIN') # 接收全部输出 print(p.recvall().decode())

但这个脚本有一个问题:pwntools的remote连接是直接连接到socket的,它不像本地终端那样有shell环境。你需要在这种连接之上模拟一个shell,或者通过ssh连接后再执行题目程序。PWNABLE平台的远程环境通常提供了ssh登录方式,pwntools的ssh模块可以直接处理这种情况:

from pwn import * s = ssh(user='fd', host='pwnable.kr', port=2222, password='guest') p = s.process(['./fd', '4660']) p.sendline(b'LETMEWIN') print(p.recvall().decode())

这就是完整可运行的脚本。这里要注意:PWNABLE的入门题账号密码通常写在题目描述里,一般是用户名就是题目名,密码是guest。脚本里把用户名、地址、密码替换成实际值就能跑通。

运行脚本,你会看到远端返回flag。写脚本的过程虽然比手动交互费事,但它帮你建立了"用工具解决CTF交互"的正确姿势。后面的题目里,只要是涉及到多次交互的,手动操作会极其痛苦,脚本化是唯一出路。

4.3 从这道题到真实漏洞利用的桥梁

fd这道题的技术点虽然基础,但它的思维方式在整个pwn方向里反复出现:程序将一个本不该被用户完全控制的值(文件描述符)交给了用户控制。真实世界的漏洞利用中,这种"信任输入"的问题经常以各种形式出现。

文件描述符本身在漏洞利用里也扮演着重要角色。经典的"文件描述符劫持"在现代Web服务、容器逃逸、内核利用中都是热门话题。一个进程如果错误地关闭了某个重要文件描述符,然后又打开了不可信的文件,就可能让程序读写错误的目标。这道题从最表层让你感知到这个概念:文件描述符不过是个整数,而整数是可以被算术运算改变的。

更进一步看,这道题还体现了CTF题目里的一种出题模式:利用函数参数之间意想不到的联动关系。atoi解析用户输入后作为read的文件描述符,这种"输入值 -> 系统调用参数"的链路,在后面很多堆题、栈题里都能看到变体。

5. 常见问题与踩坑记录

5.1 参数4660为什么没效果

最常见的问题之一:执行./fd 4660后,程序依然输出 "learn about Linux file I/O",并没有出现输入提示。

排查思路很简单:算一下 fd 的值。atoi("4660") - 0x1234 = 0,理论上没问题。但如果你的参数拼写错误,比如写成了./fd 466或者./fd 0x1234(某些环境下atoi不支持十六进制前缀),fd就不是0而是负数或极大的数,read会直接返回-1,缓冲区内容不变,strcmp自然不匹配。

我自己调试这类问题时,习惯用strace看系统调用:

strace -e trace=read,write ./fd 4660

strace会打印程序实际调用的read/write系统调用,包括参数。如果看到read(0, ..., 32),那就说明fd确实是0,程序在等标准输入。如果看到read(-1876, ..., 32),那肯定是传参算错了。

5.2 本地明明成功了,远程却拿不到flag

本地能过、远程不过,通常是交互时机的问题。利用脚本里如果sendline执行得太早,而这时程序还没运行到read,输入内容就会丢失。解决办法是给程序留出足够的接收输出时间,或者用p.interactive()进入交互模式自己手动敲。

pwntools的做法:

p = s.process(['./fd', '4660']) sleep(1) # 等待程序就绪 p.sendline(b'LETMEWIN') p.recvall()

5.3 提示 "learn about Linux file I/O" 但我的输入明明是对的

如果确认参数没问题、输入也没问题,但程序还是走到了printf分支,这时候要检查buf的内容。strcmp比较的是"LETMEWIN\n"和buf的内容,read从标准输入读到的数据如果包含额外的字符(比如回车前多了空格、终端回显带来的额外字符),就会导致比较失败。

有个很隐蔽的坑:有些终端环境下,输入的内容末尾不是单纯的\n,可能会带上\r\n(Windows风格的换行)。如果你的输入方式是通过Windows的管道或脚本发送,收尾可能是\r\n,那strcmp必然失败。解决方法是确保发送时只带\n,或者用p.sendline(b'LETMEWIN')这种明确只加\n的方式。

5.4 常见错误速查表

问题现象可能原因解决办法
运行提示 "pass argv[1] as a number"没传参数执行./fd 4660
程序直接退出并打印 "learn about Linux file I/O"atoi解析结果不等于4660确认参数换算成十进制4660
程序卡住不动在等待标准输入输入LETMEWIN并回车
输入后仍提示 learn about Linux file I/O输入内容多了空格或换了行只输入LETMEWIN后回车
远程脚本无回显输入时机太早或交互方式不对用pwntools的ssh.process并加入等待时间

6. 个人总结与题目扩展的方向

fd是PWNABLE的第一题,也是很多人pwn生涯的第一步。它没有复杂的内存破坏,没有花哨的利用链,唯一的考点就是文件描述符这个基础概念。但恰恰是这种基础,让它在pwn入门题目里的地位经久不衰。

做完这道题,可以沿着这几个方向继续扩展学习路径:先去把PWNABLE的[collision]题做一遍,体会一下"输入哈希碰撞"这类逻辑型pwn题;然后做[bof]题,开始接触真正的栈溢出和栈缓冲区溢出利用;再往后是[passcode]、[random]这些题目,逐步引入更复杂的程序逻辑漏洞。每一道题都会用到这道题里学到的"分析源码 -> 寻找可控输入 -> 构造利用条件"的解题闭环。

最后分享一个我在指导新人时的习惯:做CTF题一定要写解题笔记。不用写得多正式,哪怕只是记录计算过程、踩过的坑、最终脚本,后面回头看也是宝贵的学习资料。很多看似零散的知识点,就是通过这种复盘连成了完整的知识网络。pwntools脚本写完也别删,累积起来就是你自己的exp库,后面做题会越来越轻松。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询