
如果你在CTF比赛里见过pwn这个分类又恰好被它劝退过好几次这篇内容就是写给你的。pwn在CTF里基本等价于“通过分析并利用二进制程序漏洞拿到目标机器的权限或者读取flag”听起来很硬核但并不是只有那些从小学编程的天才才能玩的东西。这篇文章我打算用尽量不绕弯的讲法把pwn是什么、为什么叫pwn、入门到底需要哪些前置知识说清楚顺便整理一套可以照着做的环境、工具和学习路线。后面还会拿一个最经典的栈溢出例子拆开揉碎讲一遍让零基础的朋友也能明白第一道pwn题到底是怎么做出来的。1. pwn到底是什么从CTF里的一个分类说起1.1 一个容易让人困惑的名字很多新人第一次看到“pwn”这个词会懵因为它看起来不像一个正经英文单词。它最早来自游戏圈里“own”的拼写变体意思是你“完全支配”了对手后来在黑客社区里被用来表示“我已经拿下这个程序/系统了”。CTF里用pwn作为一类题目的名字就是延续了这个含义——题目会给你一个编译好的二进制程序里面肯定藏着某种漏洞你需要想办法构造输入让程序执行你想要的逻辑比如直接输出flag或者反弹一个shell。那pwn和常说的“二进制漏洞利用”是不是一回事可以这么说在CTF的语境下基本等同。只不过pwn更偏向竞赛题型目标明确环境可控flag就在某个地方等着你。它不像现实中的漏洞研究要考虑各种复杂条件但核心技术栈是一样的包括逆向分析、调试、内存布局理解、指令集掌握、防护机制绕过等等。因此练好pwn题对理解真实系统安全非常有帮助但也要记住这些技术只应该在CTF比赛、自建实验环境、漏洞众测授权范围内使用。1.2 pwn题和web、reverse、crypto这些方向到底有什么区别CTF里分类很多很多新手分不清pwn和reverse、web的边界在哪。我用一张表直接说明方向目标主要能力典型题目Web攻击服务端应用了解HTTP协议、注入、上传、逻辑漏洞SQL注入、命令执行Reverse逆向分析程序读懂汇编、反编译算法、破解验证逻辑注册机、算法逆向Crypto破解密码学算法数学推导、编码、协议分析RSA、AES、ECCPwn利用二进制漏洞汇编、内存布局、调试、构造payload栈溢出、堆溢出、格式化字符串最需要说明的是pwn和reverse的关系。两者都要会读汇编都要会用反编译工具但reverse的核心是“理解程序”你要搞清楚一个函数做了什么、算法是什么而pwn的核心是“利用程序”你不仅要看懂它有漏洞还要设计输入数据让程序沿着你期望的路径执行。也就是说reverse是pwn的前置技能但pwn比reverse多出了“攻击性”的一步要考虑防护机制、内存布局、代码执行流控制。这也是为什么pwn入门比reverse更让新人痛苦不只要读懂还得亲手构造一个能打通的payload。不过反过来讲一旦你搞定第一个栈溢出exp你会发现读汇编的动力会大大增加因为每个细节都可能变成利用点。2. 入门pwn之前需要搞清楚的几个前置知识2.1 二进制程序是怎么跑起来的要玩pwn得先明白程序在操作系统里是怎么运行的。我们平时写的C代码经过预处理、编译、汇编、链接最终变成ELF文件在Linux/x86-64下。这个文件不是一堆乱码而是一个有结构的容器里面分成了代码段、数据段、BSS段、只读数据段等等。程序被加载进内存后系统会为它安排一块完整的虚拟地址空间。我用一个生活类比帮助理解你把程序想象成一本菜谱CPU就是一个严格按照菜谱操作的厨师。代码段是“先切菜、再热油、最后翻炒”这样的步骤数据段是已经写好的食材清单栈是厨师临时放调料和工具的台面而堆则像一个可以随时借还的储物间。漏洞利用的核心就是想办法让厨师在台面或储物间里做一些原本没写进菜谱的操作最终按你的意思做菜。对于pwn来说你不需要先精通操作系统底层的每一个细节但至少要知道程序入口在哪、代码段和数据段大概在什么位置、函数调用时栈空间如何变化。这些概念后面都会反复遇到。还有一个重要点绝大多数CTF pwn题目都是Linux下的x86-64程序所以学习重心应该放在ELF格式、System V AMD64 ABI调用约定上建议把Windows PE格式先放一边。2.2 内存布局栈、堆、代码段、数据段一个典型的x86-64 Linux进程虚拟内存布局从高地址到低地址大致长这样内核空间用户态无法直接访问栈区地址向下增长共享库映射区例如libc.so堆区地址向上增长BSS段未初始化全局变量数据段已初始化全局变量代码段/文本段指令保留区域这里最需要牢记的是栈和堆的增长方向。栈是从高地址向低地址增长的也就是说你每调用一个函数rsp会减小栈帧会向下“压”而堆是从低地址向高地址增长的malloc分配的内存地址会越来越大。这个方向和数组写入方向不要搞混数组是连续写向高地址方向的比如char buf[64]从buf[0]开始写地址是递增的但如果buf是栈上的变量那么越过buf[64]之后你会写到更高地址的相邻栈空间也就是可能覆盖到保存的rbp和返回地址。这一点困惑了很多初学者。很多人画程序流程图时把栈画成从上往下“生长”但读写地址时又要从左往右“增加”两套方向叠在一起就乱了。我的建议是不要管上下左右只记“地址值大小”。函数调用压栈是地址值减小数组越界写是地址值增大越界方向是朝高地址覆盖。只要记住这个再看gdb的地址输出就不会晕。2.3 函数调用与栈帧从call到retpwn中所有关于栈溢出的利用最终都绕不开函数调用机制。一个函数调用发生时大致经历这几步调用方按调用约定传参x86-64下优先用rdi、rsi、rdx、rcx、r8、r9六个寄存器传参多余参数压栈。执行call指令call会把call指令下一条指令的地址也就是返回地址压入栈中然后跳转到目标函数开头。被调函数通常先push rbp把调用方的栈底地址保存下来然后mov rbp, rsp建立新的栈帧再sub rsp, 0xXX分配局部变量空间。函数末尾执行leave和ret。leave相当于mov rsp, rbp; pop rbp恢复调用方栈帧ret相当于pop rip把之前压栈的返回地址弹给指令指针。关键点在第二步和第四步返回地址存放在栈上并且在局部变量缓冲区的高地址方向。如果程序把一个危险输入直接读入栈上的缓冲区并且没有限制长度那么输入越界后就可能覆盖保存的rbp和返回地址。当函数结束执行ret时CPU会从栈上取被覆盖过的地址作为下一条指令地址程序就被劫持了。这也是几乎所有入门pwn题的核心。先把这个过程在纸上画一遍再用gdb单步跟踪一次比背十篇理论都管用。2.4 常见防护机制NX、ASLR、PIE、Canary、RELRO现代系统为了防这种劫持给程序加了好几层防护。pwn入门必须知道它们是什么意思否则连题目给的二进制都看不懂。防护名作用带来的麻烦常见绕过思路NX栈和堆等数据页不可执行不能直接在栈上执行shellcodeROP跳转已有指令片段ASLR共享库、栈、堆基址随机化libc地址、栈地址不固定泄漏地址后计算偏移PIE程序自身代码段地址随机化后门函数、gadget地址不固定泄漏程序基址再用相对偏移Canary函数开始随机生成一个值在ret前校验直接覆盖返回地址会触发检测泄漏canary或通过溢出先修改它RELRO将GOT表设为只读防止改写GOT不能轻松改GOT表项用其他写原语或劫持别的控制流看一个程序开启哪些防护最常用的命令是checksec。例如护全开的程序和一个裸奔程序利用难度完全是两个世界。刚入门时建议优先选NX开启但Canary关闭、PIE关闭的题目这类题逻辑简单能让你把注意力集中在栈溢出的基本流程上。另外要说清楚防护机制不是独立存在的。现代题目经常同时开启NX和PIE这时候你可能要组合使用先泄漏一个地址再计算基址再构造ROP链。所以学习时不要怕“叠加”每个防护拆开单独搞清楚再组合起来就不那么吓人。3. 工具链准备一个pwn手的工作台3.1 系统与虚拟机选择pwn题目绝大多数是Linux x86-64程序所以准备一个Linux环境是必须的。你不一定非得把主力操作系统换成Linux用虚拟机就行。我建议用VMware或VirtualBox装一个Ubuntu版本选16.04、18.04或20.04都可以但不要一味追求最新版因为有些老题用的libc版本较老在高版本glibc环境下可能出现即使漏洞利用正确也无法通配的情况。有个比较省心的做法装好Ubuntu后把常用题目运行环境做成Docker镜像需要哪个glibc版本就启动哪个容器。这也是现在很多CTF题目采用“动态容器”方式的原因之一——平台为每支队伍临时拉起一个包含题目的容器双方通过网络端口交互。这种机制让题目的libc、文件权限、flag位置都变得标准化也防止选手互相干扰。如果你的电脑性能一般不要开太多虚拟机一个Ubuntu虚拟机装好pwntools和gdb插件就够了。等到后面做堆题、需要对比不同glibc行为时再考虑补环境。3.2 必备工具从pwntools到gdb插件入门pwn至少要准备下面这些东西我按使用频率排个序Python 3写exp的主力语言建议用3.8以上版本。pwntoolsPython库用来构造payload、接收数据、和本地/远程程序交互。可以说没有一个pwn手不用它。gdb pwndbg调试程序查看内存和寄存器。pwndbg是gdb的一个插件能高亮显示栈、堆、反汇编信息比裸gdb友好太多。也有gef和peda看个人习惯我推荐新手先用pwndbg。checksec检测程序防护机制。它通常随pwntools一起安装也可以单独使用。file/readelf/objdump/strings/nmLinux自带的二进制分析工具用来查文件类型、节信息、反汇编、字符串和符号表。ROPgadget搜索ROP链需要的gadget。one_gadget查找execve(/bin/sh, NULL, NULL)的一发地址对做ret2libc类题目很有用。LibcSearcher或libc数据库网站根据泄漏的libc函数地址反查远程libc版本。这些工具不需要一次性全部精通刚开始只要会用file、checksec、pwntools、gdb基本操作就够了。其余可以边做题边补齐。3.3 一个最小可用的环境搭建流程写一个可以直接照做的最小安装流程假设你有一个干净的Ubuntu 20.04环境# 更新软件源 sudo apt update sudo apt upgrade -y # 安装基本依赖 sudo apt install -y python3 python3-pip git gdb binutils file netcat-traditional # 安装pwntools pip3 install --upgrade pwntools # 安装pwndbg git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh # 验证 checksec --version装完后新建一个测试用的C文件比如test.c#include stdio.h int main() { puts(hello pwn); return 0; }编译后随便调试一下gcc -o test test.c gdb ./test在gdb里输入start然后运行b main、c、x/10i $rip等命令看到pwndbg彩色输出就说明环境没问题。这里有一个容易踩的小坑有些系统默认把python3的pip权限限制住pip3 install会报externally-managed-environment。这种情况建议用虚拟环境或者加上--break-system-packages参数。不过我更推荐用venv管理避免把系统Python搞乱。4. 第一个pwn题实战从读题到拿flag的思路4.1 题目类型与运行方式本地文件还是nc连接每次拿到一道pwn题先看它给什么文件。最常见的给法是一个压缩包里面有二进制文件比如vuln、libc.so.6、Dockerfile可能还有源码或说明文档。有的题目还会给你一个nc地址和端口比如nc 127.0.0.1 10001这说明远程环境已经起好了。本地做题时我们直接用二进制文件启动程序./vuln远程做题时用pwntools的remote代替processp remote(127.0.0.1, 10001)很多新人上来就对着nc地址用命令行交互虽然也能看交互逻辑但根本没办法发送复杂payload。正确做法是写一个Python脚本创建remote连接或本地process然后用pwntools的send、recv系列函数收发数据。这里也解释一下“动态容器”这个词。比赛平台通常不会把题目直接挂在同一个服务器上而是当你点击“打开题目/获取环境”时系统用Docker或k8s临时创建一个容器里面运行这个题目。你通过nc访问容器的端口拿到flag后提交到平台。这保证了每个队伍的环境独立也方便平台回收资源。所以做题时看到“动态容器”提示并不代表题目本身变难了只是运行方式变成了远程连接。4.2 静态分析流程先看文件、防护、字符串和反汇编打开一个题目后先别急着运行。我固定会执行几条命令file vuln checksec vuln strings vuln | head -50 readelf -h vuln假设这个程序叫vuln用file你会发现类似“ELF 64-bit LSB executable, x86-64”的输出。说明是64位小端程序通常表示exploit里要用p64()来打包地址。checksec输出如果显示No canary found、PIE disabled说明栈溢出是很有希望的攻击点如果NX enabled说明不能简单在栈上执行shellcode但可以用ROP或其他方式。接着用strings看有没有可疑字符串比如“cat flag”、win、“/bin/sh”等等。很多入门题会在程序里留一个后门函数专门为了让你提交一下就能拿flag。你发现这样的函数名后再用objdump或反编译器定位它的地址。如果你会用IDA或Ghidra强烈建议拖进去看伪代码。pwn并不是只用命令行学会用反编译器能大大加速理解。但也要知道伪代码只是还原后的高级语言近似真正做漏洞利用时还是要回到汇编级别确认偏移量、栈布局和调用约定。4.3 动态调试用gdb和pwndbg确定偏移量静态分析只能告诉你哪里有洞但要精确控制程序还得动态调试。我们用一个常见的简单示例来说明假设源码是#include stdio.h void win() { system(cat flag); } void vuln() { char buf[64]; gets(buf); puts(done); } int main() { vuln(); return 0; }这个程序完全没有防护win函数可以直接读flag。你拿到了vuln二进制用checksec确认没有canary、没有PIE目标很明确覆盖vuln函数里的返回地址让它跳到win。问题是返回地址相对于buf起始位置的偏移是多少如果buf是64字节是不是覆盖到64字节的地方就行了不对因为事实上函数的栈帧结构是局部变量buf占用64字节往上高地址紧接着是保存的rbp占8字节再往上才是返回地址。所以需要填充的偏移通常是64 8 72字节。为什么要加上那8个字节因为大部分函数开头会push rbp来保存调用方的栈底vuln也不例外。你写的buf从低地址开始覆盖cnt_sequence是先填满buf再把保存在栈上的rbp覆盖掉然后才能碰到返回地址。有些编译器有栈对齐优化但这里不讨论特殊情况入门题通常就是这种结构。为了不自己心算更稳妥的方法是借助gdb的cyclic来测偏移。pwndbg下可以先用pwntools生成一串有规律的字符from pwn import * cyclic(200)然后把输出作为程序的输入程序崩溃后用cyclic_find(寄存器值)或者pwndbg的pattern search来定位偏移。这个技巧能省去很多手工猜测的麻烦。下面是一个典型的gdb调试过程gdb ./vuln cyclic 200 r /tmp/input # 程序崩溃后观察RIP # RIP: 0x6161616c (laaa - offset?) cyclic_find 0x6161616c如果手边没有pwndbg也可以直接在Python里from pwn import * payload cyclic(200)再在崩溃后用cyclic_find(p.recv())不过更常用的是看RIP的值再用cyclic_find。4.4 构造第一个expret2text的完整过程知道偏移是72之后payload就很简单了72字节填充物加win函数的地址。先用objdump、nm或readelf拿到win地址objdump -d vuln | grep win # 或者 nm vuln | grep win假设win地址是0x4011b6那么完整exp如下from pwn import * # 本地调试时用process打远程时换成remote p process(./vuln) # 64位小端程序用p64打包地址 payload bA * 72 p64(0x4011b6) p.sendline(payload) print(p.recvall().decode())这段代码的流程是先启动目标程序构造payload发送给程序程序调用gets时会把payload读入栈上的buf越界覆盖返回地址。vuln函数执行完后retCPU跳到0x4011b6进入win函数执行system(cat flag)把flag打印出来。第一次打通这个exp你会有一种“原来如此”的感觉。这里的要点是理解“为什么填72”和“为什么覆盖返回地址要注意大小端”。x86-64是小端存储内存中表示地址0x4011b6时按字节从低到高是b6 11 40 00 00 00 00 00而p64()正是按这个顺序打包。如果你手动构造字节序写反了程序肯定会崩。很多入门题比如各种平台上的“pwn 074”、名字带“twice”的题目万变不离其宗有的只是把读入方式从gets换成read有的需要你利用两次输入机会第一次泄漏地址第二次再控制返回地址。看到这类题目先回忆这个基本流程再根据题目变化调整payload就行。5. 常见问题与排查技巧实录5.1 本地能打通远程不通是怎么回事这是新手最容易崩溃的问题。本地exp明明能拿到flag一打远程就卡住或者直接报错。原因最常见的就这几种远程libc版本和本地不一样。如果你需要泄漏libc地址后再计算system或one_gadget本地库和远程库的偏移对不上就会段错误。题目存在setvbuf/stdout缓冲差异。本地和远程的交互时机不同导致recvuntil等不到该出现的字符串。远程容器运行时不带ASLR关闭。本地调试时可能顺手关掉了ASLR或没开PIE远程却开了导致地址不固定。你用了本机绝对路径或本地flag文件名比如程序里写的是./flag远程容器里文件名叫flag.txt都会让后门函数执行失败。解决方法是先确认远程环境给不给libc。给的话用patchelf或LD_PRELOAD把本地程序link到远程的libc上再调试不给的话想办法通过泄漏地址判断远程内核和libc。更简单的方法是看题目docker目录里的Dockerfile理解它运行程序的准确环境。我在实战中另一个习惯是每次打远程都先输出接收到的前几行内容确认交互正常。这样能判断是不是网络延迟、端口连接失败还是payload本身有问题。5.2 pwntools里的send、sendline和recv到底怎么用新手经常把send和sendline混用。简单结论是服务端用的函数如果是gets它会在读到换行符之前截断所以你用sendline发送payload加一个\n让gets返回如果服务端用read指定长度你可能只想精确发送多少字节千万别多带换行。判断依据是看反汇编里调用的是gets还是readgets对应sendlineread(n)对应send或者把\n算进长度里。再看接收端。recvuntil可以传一个期望的字符串让程序等收到那个字符串后再继续非常适合处理交互式题目。recvall会一直读到程序关闭适合最后拿flag时使用。recvline是读一行如果不确定服务端输出的内容可以先recvrepeat然后print出来看。还有个小细节pwntools默认上下文context.log_level可能是info调试时建议设置context.log_leveldebug这样它会把所有发送和接收的数据都打印出来。你会发现很多“怎么没反应”的问题其实只是自己做错了判断。5.3 新人最容易踩的五个坑根据我带过的很多新人经验下面这些坑基本人人都会踩一次不看checksec直接开始写exp。结果题目开了canary或PIEpayload一发入魂失败还找不到原因。不区分32位和64位传参。32位程序函数参数直接压栈64位程序前六个参数用寄存器很多人用32位的思路做64位题然后怎么堆栈都不对。把偏移算错。特别容易忘记那8字节的rbp导致覆盖位置正好差8个字节。迷信“本地调试成功等于远程也行”。本地和远程的地址、环境、编译器甚至机器的pad都可能不同必须用远程题目给的环境对齐。不会看报错。程序段错误后直接把gdb崩出来的RIP截图问人其实用cyclic_find算一下偏移就能自己解决。提示当你看到一个程序在某个输入上崩溃时第一件事是观察RIP或PC寄存器的值而不是盲目改payload。只有知道了程序当前到底跳到了哪里你才能精准计算下一步。5.4 接下来怎么学从栈溢出到其他利用方式等你完整打通第一个ret2text的栈溢出题下一步建议按这个顺序走ret2shellcode学习如何在NX关闭时把shellcode放进可写可执行的内存并跳转过去。ret2libc学习泄漏libc地址用system(/bin/sh)执行命令。这需要学会用PLT和GOT以及处理ASLR。ROP链如果NX开启且没有现成后门怎么在已有指令里拼出exploit。先做简单的ret2csu再接触复用libc中的gadget。格式化字符串漏洞学会用%s、%n任意读和写这类题目既能泄漏地址也能修改内存。堆入门从堆溢出、UAF开始再到经典的Fastbin/Tcache攻击。这个路线够你走半年到一年。不要贪多每做完一类题就写一篇笔记记录payload结构、偏移计算方法、调试命令和踩坑点。pwn是一个非常吃实操的领域光看不练是绝对不行的。说到具体题目像ctfshow pwn 074这类平台题名字往往看不出难度但入门阶段大多还是栈溢出或格式化字符串的变体。你可以把它当成一个“检测自己是否真的理解原理”的小测验先不看题解自己在本地把偏移量算清楚再尝试连远程。如果两道题都能独立做出再进入下一阶段也不迟。我个人在实际操作中最深的一点体会是pwn入门最大的门槛不是技术而是那种“不知道从哪下手”的迷茫。但只要完整做下来第一道栈溢出题后面所有概念都会被串起来。建议你把文中的示例自己敲一遍不要复制运行、崩溃、调试、修复反复来几次。等你亲手把那个跳转到win函数的payload打通时你就已经正式踏入pwn的门了。