CTF PWN零基础入门:栈溢出、格式化字符串与堆利用实战路线
如果你翻过CTF的题解区大概率见过这种场面一道PWN题目下面有人贴出一长串十六进制拼成的payload配一句常规栈溢出ret2libc秒了底下评论区清一色的大佬牛而你连ret2libc是个什么都还没搞清楚。这不是你的问题。PWN这个方向在CTF里确实有门槛但它远远没有传说中那么高。我接触CTF时最早被劝退的就是PWN。那时候感觉它的考纲像无底洞——汇编、内存、glibc、ELF、ROP随便挑一个出来都能单独开一门课完全不知道从哪里下手。后来硬着头皮走完一个完整的入门周期再回头看才发现PWN其实有一条很清晰的主线漏洞永远发生在内存的不当使用上利用思路都是围绕控制程序流程和泄露敏感信息这两件事展开的。把这条线捋直了很多题都是同一套思路的变形。这篇文章就是顺着那条线整理的入门路线也是我陪跑过不少新手之后沉淀下来的一套学习方法。文章适合零基础、想进入CTF-PWN方向的读者也适合已经刷过一些题、但总觉得知识体系是散的、靠背题解过日子的人。你可以把它当成一份地图先搞清楚PWN在考什么再把环境装利索然后按栈溢出、格式化字符串、堆利用的顺序逐级往上刷最后配合题库和复盘方法把手感固定下来。1. PWN的真相它考的不是黑客魔法而是这三件事1.1 一句话说清PWN在CTF里的位置PWN这个词源自own可以理解成攻陷。CTF里的PWN方向本质上是给你一个编译好的二进制程序通常是一个跑在服务器上的服务让你找到它的漏洞构造输入数据劫持程序执行流程最终拿到shell或者读取flag文件。和Web方向打网站、Reverse方向分析程序的思路比PWN更接近底层——你操作的对象是内存、寄存器、函数调用栈这些东西。一个常见的误解是PWN需要你先精通黑客技术才能开始学。实际上CTF题目的目标非常单一——拿到flag。而绝大多数PWN题的漏洞类型翻来覆去就那么几种栈溢出、格式化字符串、堆漏洞UAF、double free这类、整数溢出、逻辑漏洞再加上一些竞态条件或者SROP、内核利用之类的进阶方向。入门阶段你只需要把前三个大类吃透就能覆盖大部分新手期题目。1.2 知识前置清单到底需要先会什么很多新手卡在我还没学汇编这一步。我自己也犯过这个毛病花了一个月去啃汇编教材结果回到题目里发现真正需要的15%都不到。PWN入门阶段的前置知识砍到最低限度是这样一份清单C语言的指针和数组你得知道数组越界会发生什么指针指向的是什么地址。二进制基础十六进制、字节序小端序、内存地址的概念。一点点汇编不是让你背指令集而是能看懂push、pop、call、leave、ret这几条指令在干嘛。栈溢出的利用最后几乎都是落在控制ret上。Linux进程的运行时结构ELF文件是什么、虚拟内存空间怎么布局、栈向哪个方向长、动态链接和libc是什么关系。这些知识不是要你先学完再做题而是边做题边补。我见过一个很夸张的例子有人完全不懂汇编靠观察pwndbg里的寄存器和栈变化也能把栈溢出打穿——因为利用栈溢出时真正需要你精确关心的地方只有返回地址在偏移多少字节的位置而这个用cyclic工具可以直接算。1.3 一个必须纠正的学习心态新手最容易产生的三个错觉我挨个说第一以为必须从零手写所有工具。有人觉得调用pwntools就像开挂非要自己用socket去收发数据。这纯粹是自我感动。PWN选手用pwntools就像Web选手用Burp Suite一样自然工具是放大你效率的东西不是考试的作弊器。第二以为题解能看懂就等于会做。PWN题解的代码通常非常短几十行甚至十几行就打完收工。但你看懂它和你能自己写出它中间隔着一次完整的踩坑过程。我后面会专门讲复盘方法这里先记住一个原则必须自己复现过一遍的题才算做过。第三以为PWN是堆砌工具命令。工具确实重要但漏洞利用的核心是理解程序为什么会在那个点崩溃以及如何让它按照你的意愿跳转。工具只是加快这个过程不能替你思考。2. 别急着写exp先把环境调顺比什么都重要2.1 虚拟机、系统版本和工具链我见过太多新手卡在第一步下载了题目二进制文件双击运行没反应打开终端输入./pwn显示权限不够然后就不想学了。PWN题目几乎都是Linux x86/x64的ELF文件所以你需要一个Linux环境。最省事的方式是装虚拟机跑Ubuntu我用的是Ubuntu 22.04Python 3.10配合最新的pwntools没有遇到什么兼容性问题。工具链最核心的四样pwntoolsPython库负责收发数据、构造payload、处理ELF和libc。安装一行命令pip install pwntools。gdbLinux自带的调试器配合插件才能看得舒服。pwndbg或gefgdb的增强插件能把寄存器的值、栈的内容、反汇编代码用颜色标出来是我调试栈和堆时的主要依靠。checksec检查程序开了哪些安全机制。现在pwntools自带这个命令在终端直接输入checksec --file./pwn就能用。gdb插件里我最后留下的是pwndbg因为它在堆调试上比peda舒服太多。新手如果一直在用peda遇到堆题会觉得很不方便我建议尽早换。另外记得给gdb配上~/.gdbinit把pwndbg设为默认插件省得每次手动加载。2.2 checksec读题保护机制决定了你的利用路线拿到一道PWN题不要急着拖进IDA里猛分析先跑一遍checksec。它输出的几项保护机制基本决定了你能走哪条路、不能走哪条路。保护机制作用影响NX栈不可执行栈上放的shellcode跑不了需要走ROPPIE程序地址随机化函数地址是随机的需要先泄露地址Canary栈保护值覆盖返回地址前要先绕过栈守卫RELROGOT表保护全RELRO时GOT表只读无法直接改GOTFortify编译期检查少数题会开主要影响某些函数刚入门时你可以直接背一个对应关系NX没开优先考虑往栈上写shellcodeNX开了就找程序里有没有现成的后门函数ret2text没有就想办法ret2libcCanary开了就需要先想办法泄露canary值PIE开了则要结合其他漏洞先泄露程序基址。这个读题习惯能让你在分析之前就对这题大概怎么打有一个预期。虽然最终路线要结合代码确认但checksec给了你一个思考的起点。2.3 动态容器和远程环境早点接触现在的CTF比赛里PWN题大多跑在动态容器里。听起来高级实际上就是主办方起了一个Docker容器把题目二进制丢进去监听一个端口你用nc ip 端口连上去就能交互。这个设计有两个好处一是每个选手一个独立容器互相不影响二是选手可以远程直接打真实环境不需要自己在本地折腾依赖。做题时早晚要面对本地通了、远程打不通的问题这多半是本地环境和远程libc版本不一致导致的。我建议从入门开始就养成一个好习惯本地调试和写exp最终一定用nc方式验证远程。本地用p process(./pwn)打远程时改成p remote(xxx, 端口)脚本结构完全一样只是入口换一下。这样能提前规避很多环境焦虑。3. 栈溢出不只让你看懂而是让你真正打穿3.1 从一个最小样例理解完整利用流程栈溢出为什么存在因为C语言的某些函数——gets、strcpy、sprintf、read——在拷贝数据时不做边界检查。你往一个固定大小的缓冲区里塞了太多数据多余的内容就会越过缓冲区一路覆盖到栈上保存的返回地址。看一个最经典的例子#include stdio.h #include stdlib.h void win() { system(/bin/sh); } int main() { char buf[16]; puts(input:); gets(buf); return 0; }编译时关掉保护机制方便看原理gcc -no-pie -fno-stack-protector -z execstack -o pwn1 pwn1.c。程序逻辑很简单win()函数是一个现成的后门只要能劫持流程跳进去就能拿到shell。问题来了怎么跳当main调用gets(buf)时栈上的布局从高地址到低地址大致是保存的返回地址、保存的rbp、然后才是buf数组。buf只有16字节但我输入20字节以上时超出部分就会先覆盖保存的rbp再往上覆盖返回地址。如果我把返回地址精确覆盖成win()函数的地址那么main函数执行到ret指令时CPU就会跳进win()。具体操作分四步用objdump -d pwn1或IDA找到win()的地址比如0x401156。计算偏移buf占16字节加上保存的rbp占8字节所以覆盖返回地址需要先填24字节垃圾数据。构造payloadbA*24 p64(0x401156)。注意p64是把地址按小端序打包成8字节这是x86_64的规定别写成大端序。用pwntools发出去。from pwn import * p process(./pwn1) payload bA * 24 p64(0x401156) p.sendline(payload) p.interactive()我第一次跑通这段代码时屏幕上真的弹出了shell那种感觉确实和看题解完全不一样。但值得多说一句的是这类程序里自带后门函数的题只是栈溢出里最基础的一种。现实中没人会那么好心所以还有下面这些进阶变形。3.2 保护机制逼出来的三条路线当程序开了NX栈不可执行、也没留后门函数时栈溢出就要换思路了。三条核心路线我列在这里ret2shellcode适用于NX关闭的情况。把shellcode写到栈上让返回地址指向栈地址。问题在于栈地址通常带随机性ASLR所以难度不低。新手入门了解一下即可真正比赛里很少遇到NX关着还让你随便打的情况。ret2text程序里有没有现成的危险函数比如system(/bin/sh)这种代码片段。有就直接跳过去上面那个例子就属于这一类。ret2libc没有现成后门时的通用解法。核心是找到libc里system函数和/bin/sh字符串的地址让程序去调它。由于libc是动态链接进来的system的真实地址 libc基址 libc中system的偏移。你需要先通过程序里的puts或printf把某个已解析函数的真实地址泄露出来再用libc数据库比如libc.rip查出版本算出偏移最终拿到system的地址。这里有个新手必踩的坑64位程序调用函数时前六个参数不是放在栈上而是放在rdi、rsi、rdx等寄存器里的。所以ret2libc时要先把/bin/sh的地址传给rdi这就需要一个pop rdi; ret的gadget。这也是很多人看题解发现为什么中间夹了一堆奇怪地址的原因。3.3 新手栈题最容易翻车的四个细节栈题思路不难但细节极其折磨人。以下四条都是我踩过、也看别人反复踩的坑偏移算错。buf大小、对齐、局部变量顺序都要仔细看反汇编确认。新手最容易忘记64位下还有8字节的rbp。用cyclic 200生成一串模式串看程序崩溃时$rsp或$rip的值再用cyclic -l反查偏移是最稳的方法。栈对齐。64位System V ABI规定调用函数时栈要16字节对齐。ret2libc时如果直接跳到system常常会因为栈没对齐而崩溃。解决办法是在payload里多放一个retgadget让栈蹭一下对齐。地址不稳定。本地调试时开着的ASLR会被pwndbg默认关闭导致你在gdb里看到的地址和正常运行时不一样。如果exp在gdb里能通、脱离gdb就不通先怀疑是不是ASLR的问题。把libc版本搞混。远程环境用的libc可能和本地不一样如果你按本地的libc偏移去打远程大概率打不通。这是新手从本地转远程时最常见的一道坎后面刷题部分我会展开讲怎么处理。4. 格式化字符串低门槛、高回报的信息泄露武器4.1 两句话讲透原理格式化字符串漏洞的触发条件非常简单程序员把用户输入直接当成格式化字符串传给了printf。printf(buf); // 漏洞写法 printf(%s, buf); // 正确写法如果buf里包含%d、%p、%s、%n这类格式化占位符printf就会按照占位符去读取栈上的数据甚至往指定位置写数据。这就是漏洞的核心你可以用它读任意栈上数据也可以用它写任意地址。我用档案柜来类比printf是一个按清单去档案柜拿文件的办事员。正常来说你递给它一张写好了的清单比如把第二个抽屉里的文件内容打印出来。但漏洞情况下清单内容是你输入的你说把2号位的东西打印出来它就去栈的第2个位置取。如果那个位置恰好保存了libc地址、canary、返回地址这类敏感信息你就白嫖到了。%n占位符更狠它能把当前已经输出的字符个数写到指定地址去这就是任意地址写的来源。4.2 偏移量怎么算先手动定位再谈工具利用格式化字符串第一步是找到你输入的内容在栈上的第几个参数位置。这个位置被称为偏移量。计算步骤非常机械构造输入AAAA.%p.%p.%p.%p.%p.%p.%p.%p让printf连续打印8个栈地址。运行程序观察输出里41414141就是AAAA的十六进制出现在第几个0x后面。如果出现在第6个说明你的输入缓冲区对应第6个参数偏移就是6。有了这个基础偏移后续用pwntools的fmtstr_payload构造数据就会非常顺。比如改GOT表地址、改返回地址、泄露canary几乎都是一个固定套路from pwn import * # 假设偏移是6 payload fmtstr_payload(6, {target_addr: target_value})注意fmtstr_payload不是万能的它在某些场景下生成的payload会超过缓冲区长度或者因为程序过滤了某个字节而失败。所以手动构建%n的能力还是要有至少要知道它在干什么。4.3 从泄露到劫持格式化字符串的三种典型用法在我刷过的题目里格式化字符串漏洞最常见的用法是这三种泄露地址。用%p或%s泄露栈上的canary、libc地址、PIE基址。这是它作为辅助技能出现时的主要职责——本身不一定直接打穿题目但配合栈溢出就能绕过Canary和PIE。所以很多题目会设计成先格式化字符串泄露再栈溢出getshell的连环题。改写GOT表。GOT表保存着外部函数的真实地址程序运行时通过它跳转。用格式化字符串把某个函数比如printf的GOT改成system的地址下次程序再调用printf时实际执行的就是system。这是格式化字符串最经典的getshel思路。改写返回地址或栈变量。有些情况下你需要精准地修改变量值或返回地址比如把某个判断条件改成不小于某个数。虽然用格式化字符串写栈上的变量不如直接栈溢出方便但它是唯一的通道。格式化字符串题在PWN入门阶段性价比很高原理简单、题型固定、比赛出场率却不低。许多人怕它是被复杂的payload截图吓到了实际上按部就班来比栈溢出还容易套模板。5. 堆题不神秘从UAF到double free的进阶路径5.1 先补两门课glibc的分配释放逻辑和chunk结构堆题劝退了很多人因为一上来就是house of orange、large bin attack这类让人头皮发麻的名词。但入门阶段你根本不需要碰那些。堆题的地基就两块malloc/free是怎么运作的以及chunk的结构长什么样。简单说glibc从系统申请一大块内存后自己当仓库管理员。申请内存时它从空闲链表里挑一块合适的chunk给你释放内存时它把chunk重新挂回链表。这块空闲内存链表在glibc里按大小分成fastbin、unsorted bin、small bin、large bin等。一张图看chunk结构每个chunk开头有8字节的prev_size上一个chunk的大小信息和8字节的size当前chunk的大小信息最后一位标志前一个chunk是否在使用。紧接着是用户数据区。注意chunk的大小必须是8的倍数而且malloc(24)实际分配的chunk不止24字节这些边界细节往往就是漏洞利用的切入点。这些机制用档案柜管理来理解很形象malloc是你要借文件free是归还文件bin链表是柜子里不同分区按文件大小分。漏洞利用就是想方设法让管理员把同一个文件柜的块重复借出去或者借给你一个已经还回去的、可能被改过的块。5.2 UAF最常见的堆漏洞原理和利用都像还书后继续看书UAF全称Use After Free中文是释放后使用。字面意思就是一块内存被free之后程序还在继续使用它。如果那块内存被重新分配给了别的用途而程序仍然按照原来的逻辑读写那就能造成信息泄露或者任意地址写。举一个经典场景程序有一个结构体里面存了函数指针结构体被free后程序还可以再次调用这个函数指针。如果攻击者能通过某种方式让这块被释放的内存放入了自己构造的数据那么再次调用函数指针时实际执行的就是攻击者指定的地址。利用UAF的标准三板斧找漏洞点哪块内存在free之后还在被引用重新分配同一块区域通过malloc申请大小相同的chunk让自己控制的输入覆盖那块内存。触发使用让程序执行被篡改后的数据比如函数指针、指针变量完成劫持。UAF是堆题里性价比最高的漏洞类型因为利用链条短、触发条件通常很明确。你会发现很多入门堆题漏洞标签写着UAF考的就是这样一个简单链条。5.3 double free和fastbin attack第一次接触劫持分配器double free就是同一块内存被释放两次。glibc对double free是有检测的但fastbin这个特定链表上存在一个漏洞如果连续free两个不同的chunk再free第一个管理员会误以为有两个空闲块实际上其中一个是重复的。利用这个重复块攻击者可以通过精心构造的malloc申请顺序让分配器把同一块区域分配两次——一次正常分配给程序一次分配给攻击者指定的伪造地址。这就是常常被神化的fastbin attack的基本原理。它的重要意义在于你可以让malloc返回一个你指定地址的指针。有了这个能力你就能改写GOT表、改写栈上返回地址、甚至伪造chunk结构一个特权写入就打通了。新手第一次接触double free时最容易犯的错误是以为free两次就行忽略了libc的校验和fastbin的先进后出特性。我的建议是别急着记那些攻击链模板先在pwndbg里用heap、bins命令实际观察一次连续malloc多个chunkfree掉两个再free掉第一个看bin链表里发生了什么。这一步观察比背十遍exp都管用。5.4 堆题学习的节奏建议学堆最容易犯的错是上来就刷house of系列高难文章然后在large bin attack里迷失。我的建议是按这个顺序推进先搞懂chunk布局和bin链表能用pwndbg观察堆的变化。专刷简单UAF题练free后还能用的利用手感。掌握double free的基本流程和fastbin attack理解如何让malloc返回任意地址。等这些基础都巩固了再去看unsorted bin attack、large bin attack这些进阶技术。入门阶段吃透前四步已经能覆盖绝大多数新手堆题了。剩下的那些house们是你打到一定题量之后自然会产生需求的东西到那时再看才不会一头雾水。6. 从BUUCTF起步的刷题节奏题库、复盘、远程排错6.1 题库怎么选、顺序怎么排入门刷题我强烈推荐BUUCTF。它的PWN题库很全从最基础的栈溢出到复杂堆题都有而且每个题目都自带环境可以直接远程连接不用自己折腾部署。另一个常用的是ctfshow也收录了不少经典入门题。我的推荐顺序非常朴素先刷十几道没开保护机制的栈溢出题把pwntools的基本收发、payload拼接用到肌肉记忆。再换着花样刷开了NX、PIE、Canary的栈题每换一种保护机制都算一次思路升级。中间穿插格式化字符串题重点练偏移量计算和泄露手法。堆题前面说了按UAF到double free的路径推进。不建议一上来就奔着高难度比赛真题去。比如长城杯这类比赛的PWN题虽然逃不出栈、格式化字符串、堆这几个大类但通常会组合多个知识点新手直接啃容易受挫。先把简单题刷到看到checksec输出就能预判大致打法的程度再去碰比赛真题吸收率会高很多。6.2 一套能复用到所有题的复盘模板刷题数量当然重要但真正拉开差距的是复盘质量。我每次打完一道题会按照下面五个问题重写一遍笔记这个程序是干什么的程序逻辑是什么漏洞出现在哪个函数checksec显示的机制分别要求我做什么PIE怎么绕过Canary怎么泄露我构造payload时每一步在干什么那串十六进制哪个是垃圾数据、哪个是跳转地址、哪个是gadget必须能一个字节一个字节地解释。本地和远程有什么差别libc版本架构还是路径问题这题的思路还能迁移到哪些题型抽象出这一类而不是这一道。新手的通病是笔记里只有exp代码和flag截图没有思路过程。这种复盘下次遇到变形题等于白做。我的习惯是用文字写一遍自己的理解哪怕写得很啰嗦都没关系过两周再回来看那篇笔记就是你自己的题解比官方题解还亲。6.3 远程打不通的时候按这个顺序排查本地通了远程挂了是PWN新手最崩溃的时刻。碰到这种情况掏出这个排查顺序确认连接的端口和主机对不对。有些题目是动态容器每次开题会分配不同的端口。用checksec对比远程二进制和你本地的二进制。有些比赛远程跑的是不同架构或开了不同保护机制的文件。确认libc版本。本地和远程libc不一致是最大嫌疑。用泄露的地址去libc.rip等在线数据库搜找到远程实际使用的libc版本然后下载对应的libc到本地用LD_PRELOAD加载测试。在exp里加context.log_level debug观察收发内容和预期是否一致。往往脚本发送成功但对方返回的不是你输入的提示文本说明触发逻辑有偏差。检查是否有多余的换行符或空格。sendline和send的区别、服务端是否做了过滤都会影响结果。排查的过程本身就是在积累经验。我遇到过很多次本地反复调不通结果不是因为保护机制而是因为忘记加b导致类型错误也遇到过远程一通就过的题因为本地libc版本恰好匹配。这些坑踩过一次以后就是你的条件反射。我的个人体会是PWN入门最大的敌人不是难度而是不知道怎么开始的漂浮感。只要把环境装好、把栈溢出和格式化字符串这两块地基打牢再配合一个稳定的刷题节奏80%的入门期问题都会迎刃而解。遇到卡住的时候不妨把题目放一放回头看看自己写的笔记——很多时候漏洞利用的思路不是想出来的是反复观察和试错之后自然浮出来的。