
很多刚接触 pwn 的师傅刷题列表里出现的第一个名字大概率都是 PWNABLE而 PWNABLE 的第一题又大概率是 fd。这题代码短、逻辑直白被大家叫做“签到题”但它实际上是一道非常典型的分水岭真正理解文件描述符的人十秒拿 flag不理解的人往往卡到怀疑人生。这篇文章就以 fd 题为主线把文件描述符、read 阻塞、atoi 转换这些基础概念串起来讲清楚并给出完整的本地复现和远程拿 flag 操作过程。无论你是第一次听说 pwn还是已经跑通但没想明白底层逻辑这篇都值得花十分钟读完。1. 这题到底在考什么整体设计思路拆解1.1 表面是“送分题”实际是 Linux I/O 基础摸底初看 fd 题你可能会觉得它考的是 C 语言、是 atoi、是字符串比较。但出题人真正想确认的是你是否理解“进程从哪里拿数据”这件事。代码里最关键的一行是read(fd, buf, 32)fd 是一个由atoi(argv[1]) - 0x1234算出来的整数。你要做的不是溢出、不是 shellcode而是让这个 fd 变成一个有效的、可读的输入源。很多新手卡住不是因为不会 C而是不知道 Linux 进程启动时会默认打开三个文件描述符0 是标准输入1 是标准输出2 是标准错误输出。当 fd 等于 0 时read 就会从键盘或输入管道读取内容。这个知识点在课本上可能只是一句话但在这道题里它就是拿 flag 的钥匙。出题人把一个课本概念包装成一个需要计算的参数再塞进一段不到十行的 C 代码里本质上是在摸底选手对进程模型和系统调用的熟悉程度。1.2 为什么选择文件描述符作为 pwn 的切入点文件描述符是整个 Linux 用户态与内核态交互的核心抽象之一。无论是后续的堆溢出、栈溢出、格式化字符串还是更复杂的 SROP、劫持控制流底层都绕不开“程序通过 fd 与外部世界交换数据”这个事实。fd 题选择从系统调用入手对新手来说非常友好因为你可以直接在终端里观察到 read 的阻塞行为不需要任何调试器就能理解“输入来源”这个抽象概念。相比之下直接拿一道堆利用题来入门新手会被 malloc 的内部机制、libc 版本差异、tcache 结构等问题直接劝退。fd 题则把学习曲线压得非常平缓你需要掌握十六进制转换理解 argc/argv知道标准输入是什么然后就能拿 flag。这是典型的“用最少的知识换取最大成就感”的题目设计也是为什么它被放在 PWNABLE 系列乃至很多 CTF 入门清单第一题的原因。1.3 这道题真正检验的三层能力把 fd 题拆开来看它其实在检验三层递进能力。第一层是读代码能力你要能看懂int fd atoi(argv[1]) - 0x1234;这行做了什么知道程序依赖命令行参数运行。第二层是数值计算能力你需要把十六进制的 0x1234 换算成十进制的 4660再反推出应该传入什么参数能让 fd 变成 0。第三层是输入构造能力你需要意识到 read 从标准输入读取时是阻塞等待的程序要求你输入LETMEWIN\n才能触发后续逻辑。这三层能力正好覆盖了 pwn 题解题的基本链路读代码定位关键点、计算构造输入、与程序交互拿到结果。很多师傅在 fd 题上花的时间不超过五分钟但如果你把这五分钟背后的知识体系吃透后面做任何需要“控制程序输入来源”的题目都能少走弯路。这也是为什么我建议新手不要只看 writeup而是亲手把每一步原理都补齐。2. 前置知识文件描述符必须先过关2.1 进程启动时默认打开的三个标准描述符在 Linux 中每个进程启动时内核都会默认给它打开三个文件描述符fd 0 是标准输入stdinfd 1 是标准输出stdoutfd 2 是标准错误输出stderr。这三个描述符不是你在代码里 open 出来的而是操作系统在创建进程时就准备好的。当你打开一个终端敲下命令启动一个程序这个程序天然就拥有了和终端交互的通道。在终端场景下fd 0 通常对应键盘输入fd 1 和 fd 2 都对应屏幕输出。fd 1 给普通输出fd 2 给错误信息这样做的好处是即使程序崩溃你也能在屏幕看到提示。fd 题里的read(fd, buf, 32)之所以能在传 0 的时候读到你输入的内容就是因为标准输入默认绑定在 fd 0 上。你可以把这三个描述符理解成进程出生时自带的“接口”——输入口、输出口、错误口理解了这一点fd 题的核心逻辑就通了一半。2.2 read 函数的行为与阻塞机制read函数的签名是ssize_t read(int fd, void *buf, size_t count);它会从 fd 对应的文件中读取最多 count 字节到 buf 中。当 fd 是标准输入时read 会被阻塞住直到你在终端输入一行内容并按下回车它才返回。这个阻塞等待的行为是 fd 题的一大考点你运行./fd 4660后程序会停在 read 这一行等待你输入字符串如果你不输入它就一直卡在那里。另一个容易忽略的细节是read 和 gets、scanf 不一样它不会自动在 buf 末尾追加\0字符串终止符。它只负责把原始字节搬到内存里读了多少字节就返回多少字节。fd 题里用strcmp(LETMEWIN\n, buf)做比较因为输入时的回车换行也会进入 buf所以字符串别名需要有\n。如果你在本地把代码里的 read 换成 fgets 或 scanf 再调试你会发现输入行为完全不同这正是理解不同输入函数差异的好机会。2.3 十六进制转十进制0x1234 到底是多少fd 题的参数要求是让atoi(argv[1]) - 0x1234的结果等于 0也就是传入的十进制参数等于 0x1234 的十进制值。0x1234 是一个十六进制数可以拆成0x1 * 16^3 0x2 * 16^2 0x3 * 16^1 0x4 * 16^0。算一下1 * 4096得 40962 * 256得 5123 * 16得 484 * 1得 4四个值相加4096 512 48 4 4660。如果你不想手算也可以直接在终端里用 Python 或计算器python3 -c print(0x1234)输出 4660。很多 pwn 题都喜欢用十六进制常量做校验原因一是看起来简洁二是可以考察选手是否具备进制转换的基本功。这道题刻意把 0x1234 放在减法式子里而不是直接要求你传 0就是为了让你多走一步思考程序需要的参数不是 0而是让表达式结果变成 0 的那个数。3. 实操过程从连接到拿 flag 的完整流程3.1 拿到题目后的第一步查看源码PWNABLE 系列的 fd 题通常可以通过远程连接来访问连接成功后会进入一个目录里面至少有三个常见文件二进制程序 fd、源码 fd.c、以及 flag 文件。flag 就是你要拿的最终目标通常只有权限读取直接 cat 会提示权限不够必须通过程序漏洞来读取。第一步永远是ls看一下文件列表然后cat fd.c查看源码。如果环境没有直接给源码也可以自己本地敲一个相同逻辑的程序来调试。代码不长大概十行左右核心部分是一个 main 函数接收命令行参数计算 fd调用 read再做字符串比较。看到源码之后不要急着跑程序先在草稿上把主要逻辑画出来程序从哪读数据、比较什么内容、比较成功后会执行什么命令。3.2 关键代码逐行分析下面是我根据 fd 题逻辑整理的等效代码方便你跟着分析#include stdio.h #include stdlib.h #include string.h char buf[32]; int main(int argc, char *argv[]) { if (argc 2) { printf(pass argv[1] 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(/bin/cat flag); exit(0); } printf(learn about Linux file IO\n); return 0; }程序先检查参数个数如果少于 2 个就直接提示。关键代码是int fd atoi(argv[1]) - 0x1234;atoi 把参数字符串转成整数再减掉一个十六进制常量。下一步read(fd, buf, 32)试图从 fd 对应的输入源读最多 32 字节。最后用 strcmp 比较 buf 和字符串LETMEWIN\n比较成功就读取 flag 文件并输出。这里有一个非常关键的认知strcmp 比较的是字节序列buf 的内容必须严格等于LETMEWIN\n包括最后的换行符。按下回车时输入缓冲里会包含\n所以手动输入LETMEWIN并回车刚好能构造出这个字符串。而如果程序从管道读入没有回车换行比较就会失败。3.3 计算参数与构造输入核心两步现在来解题。要让read(fd, buf, 32)从标准输入读取就要让 fd 等于 0。于是atoi(argv[1]) - 0x1234 0也就是atoi(argv[1]) 0x1234 4660。所以程序运行示例如下./fd 4660接着程序会停在 read等待你输入。你输入LETMEWIN并按下回车程序收到LETMEWIN\n比较成功输出good job :)然后执行cat flag。整个过程几乎是瞬间完成没有任何漏洞利用技巧纯粹是“让程序自己去读标准输入”的思维题。为什么很多人会卡在这一步因为思维惯性。新手往往以为 read 的 fd 必须是 open 过的文件或者以为要构造一个特殊整数来造成越界却忽略了 0 这个最简单的答案。如果你在本地测试没有 flag 文件记得先echo TEST_FLAG flag建一个测试文件否则 system 执行 cat flag 会报错你以为自己解错了其实只是缺文件。3.4 本地复现与远程拿 flag 的完整过程如果本地想完全复原这道题可以把上面的 C 代码保存为fd.c然后执行gcc -o fd fd.c echo FLAG_TEST_123 flag ./fd 4660然后输入LETMEWIN回车你会看到good job :)和FLAG_TEST_123被打印出来。实测下来gcc 默认编译可能需要包含头文件代码里已经写清楚了没有特殊依赖。注意在远程环境里连接工具可能是 ssh也可能是网站提供的终端逻辑完全相同。远程拿 flag 时还有一个常见问题如果输入完LETMEWIN后没有反应检查一下你的输入是否真的传到了程序里。比如在某些网页终端里你需要确认光标停在程序 read 的位置再打字在管道操作里你需要确保 echo 命令带了\n例如echo LETMEWIN | ./fd 4660。我一般建议新手先用交互式输入跑一遍成功后再试管道这样容易定位问题出在输入构造还是参数计算。4. 我踩过的坑与排查技巧实录4.1 atoi 解析空参数与非法参数的坑不少新人在第一次运行时直接执行./fd程序会提示需要参数这个行为是对的。但如果你执行./fd abcatoi 解析失败会返回 0此时 fd 变成0 - 0x1234 -4660read 调用就会失败返回 -1程序走不到比较分支最后输出learn about Linux file IO。你可能会以为是字符串比较的问题实际是参数解析压根没成功。排查方法很简单在程序里加一行打印或者在本地调试时用 gdb 看 fd 的值。还有一种情况是参数带空格比如./fd 4660虽然 atoi 会自动跳过前导空白但手写时容易弄混。经验是统一用数字参数不要带引号以外的多余字符。4.2 字符串比较对换行符和回车符敏感strcmp 是对字节敏感的LETMEWIN\n这个别名必须一个字节不差。你在终端输入LETMEWIN后回车通常会产生LETMEWIN\n匹配成功。但如果你在 Windows 下用某些文本模式换行可能被转换为\r\n此时 buf 里的字节是LETMEWIN\r\nstrcmp 就会失败。如果遇到这种情况可以用xxd查看输入的实际十六进制字节printf LETMEWIN\n | xxd正确结果里应该能看到4c 45 54 4d 45 57 49 4e 0a0a就是换行符。如果看到0d 0a那说明有多余的\r需要调整输入方式。这个坑在 CTF 中非常常见因为本地复现和在远程终端里输入换行处理可能不同。4.3 read 缓冲区与多余输入的问题read 最多读 32 字节如果输入超过 32 字节buf 里会残留超出的内容而且 buf 可能没有\0终止。strcmp 会从 buf 开头一直比较到找到\0为止如果缓冲区里没有终止符函数可能越界读内存导致结果不可预测。fd 题里输入很短一般不会触发但如果测试时习惯性粘贴长字符串就容易踩到。好的习惯是只输入恰好匹配的内容或者用printf LETMEWIN\n这样明确控制字节流的输入方式。如果你怀疑 buf 不干净可以先把 buf 用memset(buf, 0, sizeof(buf))初始化再调用 read这样比较时不会受到残留数据干扰。4.4 常见问题速查表症状可能原因解决方式提示pass argv[1] a number没传命令行参数执行./fd 4660程序直接输出learn about...不等待输入参数解析后 fd 不为 0重新计算 0x1234 的十进制值输对了字符串但没有打印 flagflag 文件不存在或权限不足本地创建 flag 文件或在允许处执行 id 确认权限输对了字符串但比较失败换行符/回车符差异用 xxd 检查输入字节确保只有0a程序 read 后直接崩溃read 出错或 buf 未正常终止检查 fd 是否为非负值避免超长输入5. 从 fd 这道题延伸到更深的 pwn 世界5.1 文件描述符在后续题目中的存在感fd 题只是把文件描述符放在最显眼的位置而后续的 pwn 题目里fd 经常藏在更隐蔽的角落。例如在网络编程题目里socket 函数返回的就是一个文件描述符利用dup2可以把 socket 复制到标准输入输出上从而实现“远程 shell 直接和你交互”。这时候你对 fd 的理解直接决定了你能不能看懂这一层重定向逻辑。还有一类文件描述符泄露问题程序打开了一个关键文件但没有关闭 fd同时存在越界读写漏洞攻击者通过某种方式操作这个 fd 来读取文件内容。又比如在堆利用里某些题会先 open 一个文件再读入用户输入绕过过滤时你可能会用到 close 标准输入再重新打开的技巧。这些场景追根溯源都离不开 fd 题里那个最基础的问题fd 0 是什么程序的数据到底从哪来5.2 那些“秒过”的人到底比你多懂了什么同一个 fd 题有人 10 秒解决有人卡 30 分钟差别不在智商而在于对“程序执行流”的敏感度。秒过的人看到atoi(argv[1]) - 0x1234时脑子里会自动浮现出一个公式影响 fd 的值看到read(fd, buf, 32)时会立刻问自己fd 现在是多少它指向哪里看到 strcmp会直接联想到比较目标字节。这种敏感度不是天生的而是通过不断自问“这个函数调用的参数怎么被控制”练出来的。做 fd 题时你可以刻意训练自己每读到一个系统调用就标注它的 fd 或缓冲区来源每读到一个条件分支就思考满足什么条件才走进去。这个习惯养成后不只是 pwn包括逆向、漏洞审计都会受益。5.3 刷题之后建议做的几件小事跑通 fd 题不算结束我建议你再做几个小改动来加深理解。第一把源码里的0x1234改成0x1000重新算参数并跑通确认你没有依赖死记硬背。第二把read改成gets再编译看看比较结果有什么变化体会不同输入函数的终止符差异。第三尝试写一段代码用dup2把 fd 1 重定向到文件再调用 printf理解文件描述符重定向的标准做法。这些练习加起来可能只需半小时但它们能把 fd 题从“题”变成“工具”让你真正拥有文件描述符的直觉。后续遇到需要“往指定 fd 写入数据”的题目时你至少不会慌而是能顺着代码执行流一步步分析。我个人在实际操作中的体会是fd 题拿 flag 只算完成了一半真正有价值的是你闭上眼睛能说出“进程里 fd 0、1、2 默认对应哪三个流read 在 fd0 时会发生什么”。最后再分享一个小技巧本地调试这类题目时可以把 flag 文件里的内容写成一串容易识别的字符串比如FLAG_TEST_123这样无论远程还是本地只要看到这个串被打印出来就说明你的参数和输入整个链路都是通的。搞清楚这一步后面再遇到代码更长的 pwn 题时你就有了一个非常稳定的分析起点。