2026/10/21 10:18:08

从warmup_csaw_2016入门栈溢出:pwn新手的第一道利用题

从warmup_csaw_2016入门栈溢出:pwn新手的第一道利用题 把题目拖进终端的那一刻我心里其实没什么底。栈溢出这个名词我已经听过无数遍但真正让我开窍的不是任何一本教程而是某个晚上从某CTF平台扒下来的这道热身题——warmup_csaw_2016。它没有复杂的堆利用没有格式化字符串甚至连canary都没有只给了你一个明晃晃的后门函数再搭配一个最经典的gets翻转栈帧。四十分钟完成逆向分析一个小时内写出能拿shell的exp整个过程像打通了一条之前一直堵着的路。如果你想切入pwn领域又不想啃枯燥的理论直接找几道这种级别的题顺着“逆向思路→内存布局→控制流劫持”这条线反复练比看十篇入门文章都管用。这道题适合谁刚学完C语言、懂一点汇编、知道函数调用栈大概是什么东西的初学者。不需要懂ROP链不需要了解glibc细节只要你能跟着我一步步把栈上的字节摆弄明白第一个pwn题的flag基本就是囊中之物。下面我会把完整思路和实操过程全部拆开包括我自己踩过的坑和最后总结出来的排查套路。1. 为什么“直接刷题逆推”是pwn入门的最快路径1.1 传统学习顺序的问题很多朋友入门pwn第一反应是找一本系统安全的教材从计算机组成原理看到操作系统再从汇编看到C语言最后觉得自己“知识不够”不敢动手。这个思路本身没错但效率很低。pwn的核心不是背知识点而是建立“漏洞点在内存里长什么样”的感觉。这种感觉就像学游泳你在岸上把换气口诀背得滚瓜烂熟真下水还是得呛几口但如果你直接下水扑腾呛几次以后很快就知道手怎么划、腿怎么蹬。刷题的逻辑就是这样题目帮你圈定了一个具体漏洞类型你要做的就是在限定的空间里完成逆向、布局、利用三个动作。这个过程逼迫你同时使用正向思维写代码攻击目标和逆向思维分析程序意图两个方向在同一个漏洞点上交汇理解速度远超线性阅读。1.2 warmup_csaw_2016这道题为什么适合当“第一块跳板”这道题的难度设置非常友好。它没有开启地址随机化PIE后门函数地址固定写死在二进制里没有canary不会因为覆盖返回地址触发崩溃检测也没有开启栈不可执行NX由题目决定但这道题根本用不到注入shellcode因为系统的后门函数已经给了最重要的漏洞入口是gets这个函数不限制输入长度你要多少字节它就给多少字节。用一句人话总结程序把一个“不设防的入口”和一个“已经摆好的胜利按钮”同时放在你面前你要做的只是让流程跳过去按下按钮。这种直给的训练模式让新手把注意力全部集中在“栈帧布局”和“返回地址覆盖”这两件事上不会被其他安全机制干扰。等你把这道题的每一个字节都弄懂了再去看canary绕过、ROP等进阶话题就有了踏实的基础。1.3 一次完整的刷题闭环是什么我个人的刷题习惯是先跑再分析、先报错再调整、先本地再远程。拿到题目文件后第一件事不是急着看反汇编而是用file命令确认文件的位数和平台用checksec查看程序开了哪些保护直接运行几次程序手工输入不同长度的字符串观察崩溃行为再打开反汇编工具逆向关键函数。这个顺序的好处是动态体验先帮你建立了“程序确实会崩溃”的直觉之后再在静态代码里找“为什么会崩溃”的原因会有一种恍然大悟的感觉。下面我从环境准备开始完整走一遍。2. 环境准备与二进制体检先把工具链配齐2.1 我需要哪些工具一道入门级栈溢出题工具要求并不高。我个人用的是三件套虚拟机或Linux物理机系统是Ubuntu 22.04内核版本无所谓Python环境加pwntools库这是编写exp的主力工具IDA或Ghidra做静态逆向GDB配合peda/pwndbg插件做动态调试。如果你手头只有Windows也没关系虚拟机开一台就行pwn题基本都在Linux环境下考查很多题目提供的libc、动态链接库都是Linux版本的脱离Linux你会平白多出很多麻烦。安装pwntools很简单直接用pip。我建议把它装在系统Python或者虚拟环境里不要装在Anaconda默认环境否则有时候会碰到ctypes类型不兼容的问题sudo apt update sudo apt install python3 python3-pip gdb pip3 install pwntoolsGDB插件的选择上pwndbg比peda更适合初学者因为它能直接高亮栈上对应的返回地址和参数省去很多“咦这个值在哪”的困惑。安装pwndbg最省事的方式是克隆仓库后执行setup脚本如果网络下载不方便也可以只用原版GDB配合几个常用命令不影响这道题的分析。2.2 先给二进制做个“体检”把题目文件下载下来我习惯先查三样东西文件类型、保护机制、运行行为。file warmup_csaw_2016 checksec --filewarmup_csaw_2016file的输出会显示这是一个64位的ELF可执行文件动态链接符号没被strip这很重要意味着你能在导出表里直接看到函数名。checksec的结果大致长这样Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)逐项解释一下Stack: No canary found说明栈上没放金丝雀值覆盖返回地址不会触发检测这是本题能直接利用的前提PIE: No PIE说明程序加载基址固定后门函数地址在每次运行时都不变可以直接写死进payloadNX enabled说明栈不可执行但这道题用不到shellcode因为程序本身已经有现成的后门流程RELRO是Partial对这道题没有影响暂时不用管。体检之后顺手运行一次。输入一长串字符程序大概率直接段错误退出这就是栈被破坏的迹象。看到这种崩溃你心里应该有个方向重点检查哪里把数据写到了栈上以及这个数据离返回地址有多远。3. 完整逆向分析先看后打把栈布局摸清楚3.1 静态分析IDA反汇编与函数定位用IDA打开二进制后习惯性先看一眼左侧的函数窗口。这道题的符号没有被剔除你能看到main、vuln还有一个看起来有点突兀的函数——它通常是个“隐藏后门”名字不一定直接叫backdoor但函数体里一定有system(/bin/sh)或者类似调用。打开后能确认这一点。main函数的反汇编大概长这样push rbp mov rbp, rsp mov edi, 0x4006... ; 字符串输入点什么 call puts mov eax, 0 call vuln核心在vuln函数里按下F5伪代码能看到类似这样的逻辑void vuln() { char buf[64]; gets(buf); }就是这一行gets整个漏洞就在这里。gets读入字符串时完全不检查目标缓冲区大小你输入多少数据它就往栈上堆多少数据。buf只有64字节但你可以输入几百字节超出的部分就会直接覆盖寄存器保存区和返回地址。接下来要确认两个关键信息。第一个是栈帧布局buf离当前栈帧的返回地址有多远。第二个是后门函数地址虚拟地址是多少反汇编里能不能直接看到。buf缓冲区大小从栈帧分配指令很容易看出来通常是sub rsp, 0x40也就是64字节。64位调用约定下函数入口先push rbp再把rsp赋给rbp然后sub rsp, 0x40给局部变量留空间。所以从buf起始到返回地址的偏移是0x40加上一个保存的rbp宽度8字节等于72字节。这个数字就是覆盖返回地址前需要填满的字节数。后门函数在二进制里的位置可以在IDA里直接单击函数名查看地址也可以终端里用nm -n warmup_csaw_2016 | grep -i backdoor\|flag\|system\|win我本地拿到的后门函数地址是0x40060d左右但这个值在不同版本的题目文件里可能不同你务必以自己反汇编或者nm命令看到的实际地址为准。后面的exp里我只用占位符表示避免你直接抄错。提示即使这个地址是固定的也不能想当然把main函数地址当成后门地址。后门函数是一段独立的控制流你跳转的目标必须是它真正开始执行的位置通常是函数入口第一条指令的地址而不是plt表里的某个跳转项。3.2 动态验证用GDB确认偏移量静态分析得出的72字节只是理论值实际调试里我习惯用两种方式交叉验证。第一种最粗暴用cyclic生成一串有规律的字符输进去让程序崩溃再看崩溃时返回地址里的字符片段反查偏移cyclic 200 # 得到一串形如 aaaabaaacaaa... 的字符串把这段字符作为输入发给程序崩溃后GDB会停在ret指令处并告诉你将要跳转的地址是0x616161...之类然后把地址末尾的字节用cyclic -l反查cyclic -l 0x6161616161616161这种方式适合确认偏移也适合快速排查“我是不是把偏移算错了”。第二种方式更基础在vuln函数返回的ret指令处下断点打印栈顶b *vuln0x... r # 输入任意字符串后停在ret处 x/8gx $rspx/8gx会把栈顶上8个8字节的值都列出来。正常情况下$rsp指向的值就是返回地址而你输入的字符串从某个位置开始会一路覆盖到这里。把输入字符串的排列和栈上内容一一对应起来你会非常直观地看到哪个字节段落进了返回地址的位置。我建议你在做题初期至少手工做一次这种对应而不是完全靠工具算偏移。因为“看着输入变成栈上的字节再变成指令地址”这个过程就是建立内存直觉的关键一步。后面做更复杂的题时你也会反复用到这个技能。4. 编写exp从本地验证到远程拿flag4.1 三个必须确认的数值写exp之前把三件事写在纸上漏洞函数vuln中的gets偏移量从buf起始到返回地址的距离本题目为64 8 72字节跳转目标后门函数入口地址例如0x40060d。这三个数值都有了之后整个利用逻辑就是一句话输入72个填充字节再接上后门地址让vuln返回时CPU直接跳进后门函数执行system(/bin/sh)。这里需要稍微解释一下64位和32位的区别。32位栈溢出中函数返回地址保存在ebp之上缓冲区到返回地址的偏移通常是“缓冲区长度4”。64位下保存的基址寄存器从ebp换成了rbp但它仍然是8字节所以偏移是“缓冲区长度8”。很多新手一上来用32位的习惯算64位题目结果差出4个字节正好把返回地址覆盖歪了。4.2 完整exp逐行解析exp代码量很少但每一行都有讲究。先看完整版本from pwn import * # 选择本地模式还是远程模式 LOCAL True if LOCAL: p process(./warmup_csaw_2016) exe ELF(./warmup_csaw_2016, checksecFalse) # 动态从二进制里提取后门函数地址避免手写抄错 win_addr exe.symbols[backdoor] # 以实际函数符号名为准 else: p remote(目标域名或IP, 端口) win_addr 0x40060d # 远程题目地址需以实际反汇编为准 # 构造payload72字节填充 返回地址 payload bA * 72 payload p64(win_addr) # 发送并切换交互模式 p.sendline(payload) p.interactive()逐行看几个细节。process和remote都是pwntools的管道封装前者启动本地进程后者建立TCP连接两者的接口一致所以切换攻击目标时只需要改这一处。payload里的bA*72是补齐偏移量的部分用什么字符填充并无所谓只要不包含换行符即可。p64是把整数地址按小端序编码成8字节这道题是64位小端架构地址低位字节在前必须用p64而不是p32否则地址会被截断。最后用p.sendline(payload)发送。注意这里用的是sendline而不是send因为gets函数是以换行符作为输入结束标志的sendline会在payload末尾自动追加一个\n正好起到“提交本次输入”的作用。如果用的是send程序会一直阻塞在gets里等待换行出现exp就卡死了。p.interactive()让脚本进入交互模式把当前终端的输入输出都转交给远程shell。一旦跳转成功你会在这里看到一个可用的shell提示符输入flag命令就能读取题目文件。4.3 本地验证与远程打通的完整路径本地验证时我建议先用一个简单payload做“冒烟测试”不急着找真实flag。先执行python3 exp.py如果看到shell提示符出现再手动执行ls、whoami、cat flag等命令。如果程序崩溃大概率是偏移算错或地址写错。这时回到GDB里用前文的方法重新确认一遍。本地打通后在本地完成系统函数调用即libc中的system时实际执行shell的进程是当前用户权限这和远程题目环境的交互方式没有太大区别。从本地切换到远程时只需要把LOCAL改成False并填上题目平台提供的连接信息。远程环境里程序的标准输入输出通过socket转发你用interactive可以看到远程机器的shell响应。整个流程走完你会得到类似下面这样的结果[] Opening connection to x.x.x.x on port 12345 [*] Switching to interactive mode $ id uid1000(ctf) gid1000(ctf) groups1000(ctf) $ cat flag.txt flag{...}看到flag的这一刻你就算正式跨过了pwn的一道门槛。注意拿到shell之后不要在靶机上执行破坏性命令也不要去读取题目环境之外的敏感文件。CTF练习环境的价值在于学习漏洞原理拿到flag验证利用成功就足够了。5. 我踩过的坑栈溢出实战的五个易错细节5.1 sendline和send的区别一个换行引发的卡死第一次写exp时我用的是p.send(payload)结果脚本发完payload就停在那不动了。原因就是gets函数必须遇到换行符才认为输入结束而send只发送了你给出的字节不会自动补\n。改用sendline后问题立刻消失。这个坑在打远程题时特别隐蔽。因为本地调试时你可能用的是终端手动输入按下回车本身就会发一个换行写到脚本里后换行却不会自动出现行为就变了。排查这类问题最直接的方法是在send后面加一个p.recv()看看程序是否回了新提示符如果完全没反应八成是被输入截断卡住了。5.2 地址里的坏字节0x0a会直接截断gets输入64位地址本身是一串8字节二进制但其中某个字节可能恰好是0x0a换行符、0x00空字符或0x20空格。gets函数遇到换行符会终止输入遇到空格不会截断但某些其他API会。如果后门函数地址里恰好含0x0a你用p64打包后发送gets会在读到这个字节时提前结束payload后半部分全部失效。warmup_csaw_2016的后门地址通常是0x40060d字节序列是0d 06 40 00 00 00 00 00里面没有0x0a也没有空字符问题所以这道题很顺利。但你在做其他题目时一定要检查打包后的字节序列可以用print(p64(win_addr).hex())如果发现地址中含0x0a就需要换一种输入方式比如把payload写入文件再重定向输入或者寻找地址中不含坏字节的其他跳转目标。5.3 为什么有时候地址要偏移一点ret指令与栈对齐64位System V ABI规定在执行call指令时栈顶需要16字节对齐。如果跳转到后门函数后函数内部马上执行movaps这类要求对齐的指令而栈恰好不对齐程序会直接崩溃。这种现象在某些版本环境里会出现明明地址写对了偏移也对了但跳过去就坏。解法一种是先执行一条ret指令把栈指针再弹8字节让栈回到对齐状态再进入真正的目标函数。具体到payload上就是在返回地址位置先填一个ret gadget地址再填后门地址payload bA * 72 payload p64(ret_gadget) # 只需要一条ret指令的地址 payload p64(win_addr)ret gadget的地址可以通过在二进制里搜索ret指令得到或者在pwntools里用ROP类去自动找。这道题不一定需要这一步但理解这个机制能帮你避免在其他机器上本地打通、远程打不通的诡异问题。5.4 GDB调试环境与直接运行的差异有时候本地直接运行exp能打通但放进GDB就崩或者GDB里能打通直接运行就崩。原因通常是环境变量差异导致栈地址偏移或者地址ASLR开启状态不同。GDB默认会禁用ASLR所以你在里面看到的地址和实际运行时的地址可能不完全一致。对这道题来说因为没有PIE后门地址是固定的所以影响不大。但如果用GDB调试时发现RSP或返回地址位置和理论值差一点先去确认GDB是不是改了栈环境。可以用set disable-randomization off命令在GDB里恢复ASLR模拟真实环境。5.5 python3和python2的payload差异早期很多pwn题exp教程是用python2写的字符串类型和编码行为和python3完全不一样。python2里bA*72和A*72没区别python3里前者是bytes后者是strstr不能直接和p64返回的bytes拼接。如果你看到的教程报错说can only concatenate str (not bytes) to str那就是把str和bytes混着用了。统一方案是payload从开头到结尾全部用bytes构造填充部分写bA地址用p64这样在python3下没有任何问题。6. 问题速查从调试报错到定位原因的排查表刷题过程里最常见的几个异常现象我整理成一个对照表方便你卡住时直接查现象可能原因排查与解决思路程序段错误崩溃偏移量错误覆盖位置不对用cyclic重新算偏移或GDB停在ret处观察rsp指向发送payload后无响应没用sendlinegets等待换行改用sendline或手动补b\n跳转后崩溃但地址看起来对栈对齐问题加一条ret gadget再跳目标函数本地能打通远程打不通远程地址不同或libc版本不同重新反汇编远程环境对应文件检查地址报错can only concatenate strpython3下str与bytes混用payload全用bytes构造地址看起来被截断忘记p64或用了p32确认架构是amd64还是i386输入被提前截断地址中存在0x0a等坏字节检查p64后的字节序列换输入方式排查的时候我一般遵循“从漏洞点到利用点”的顺序先确认程序确实在目标输入处崩溃再确认崩溃点离返回地址的距离最后确认跳转目标地址没写错。不要一上来就怀疑保护机制大多数入门题不会同时叠加多个机制刁难你。7. 从这道题延伸出去的pwn知识树7.1 入门后的下一个台阶warmup_csaw_2016代表的是最基础的ret2text跳转到程序内部的现成代码。理解它之后自然产生三个方向的分叉。第一个方向是ret2libc。程序内部没有后门函数时你需要跳转到libc里的system函数并给它传入/bin/sh参数。这比单纯跳转复杂一些因为你不仅要控制返回地址还要控制函数参数在64位下参数通过寄存器传递就要在栈上布置pop rdi等gadget。学到这里你对“栈上布置”的理解会从“覆盖一个地址”升级到“伪造一个调用链”。第二个方向是ROP链。当你想执行的代码片段在程序里没有完整函数时可以把若干短小的指令片段拼接起来每个片段以ret结尾从而在栈上串联成一段连续的指令流。这类利用方式非常依赖gadget搜索工具比如pwntools的ROP类或者ROPgadget。第三个方向是格式化字符串。栈溢出之外格式化字符串漏洞也是入门重点它和栈溢出不同是通过scanf/printf的格式参数可控来读写内存。学会了它你可以在泄露地址和任意内存写两个能力之间来回切换体系上会逐渐成型。7.2 进阶路线上的训练方法刷题顺序上我个人推荐的路线是在主流CTF练习平台找“pwn入门”标签下的题每道题先不看题解自己逆向30分钟规划利用思路再动手写exp。打不通再看别人的exp但看的时候不要只看最终payload要看它的逆向过程偏移怎么算出来的、地址哪来的、为什么用ret而不是call。每一道简单题打完之后我都会把涉及的保护机制和利用技术记录在一个单独的笔记里。比如这道题记录核心是“no canary no PIE gets → ret2text”再附上栈布局图和exp。后面做类似题目时回顾这个笔记会比重新找教程快得多。练习频率上保持每天一道题比周末猛刷十道更有效。栈溢出的手感很脆弱隔两天不碰再回来写exp总要花几分钟重新定位偏移。保持手感的方式很简单每天抽半小时要么做新题要么把旧题的exp重写一遍不加注释逼自己回忆每个步骤的用意。7.3 围绕这道题还能做的两个变体练习如果觉得一道题不够尽兴可以尝试两个改造方向。一个方向是把后门函数删掉自己手动构造ROP链调用system另一个方向是把PIE开关打开再尝试泄露地址绕过随机化。这两个变体练习都基于同一个栈帧理解但会迫使你接触新的机制学到的知识层次完全不同。我在做这两个扩展练习时最大的感受是基础题不是拿来背答案的而是拿来拆解机制的。同一道warmup题当你能够自己回答“如果没有后门函数我怎么办”、“如果地址每次运行都变我怎么办”这两个问题时这个栈溢出的知识点才算真正消化成你自己的。最后再分享一个小技巧写exp时不要把所有地址都写死在代码里。pwntools的ELF类可以直接读取符号地址exe ELF(./warmup_csaw_2016) win_addr exe.symbols[backdoor]这样就算你从网上下载的题目文件版本和教程不完全一致只要符号名没变脚本就能自动适配地址。远程环境的地址如果和本地不同就用脚本里提供的远程二进制做一次解析。我刚开始刷题时老是手动复制IDA里的地址遇到一次远程环境地址略有差异的题目卡了整整一个下午之后就养成了“代码内部读地址”的习惯。另一个小技巧是写payload之前先用gdb开一个独立的终端观察栈露出payload的前几行字节。你看着那些字节一个接一个排进栈里再对照返回地址在GDB里被改写成后门函数入口的那一瞬间整个漏洞利用的“手感”就出来了。这个画面比任何文字描述都直观。栈溢出的入门没有想象中那么难难的是迈出第一步之前总觉得自己还没准备好。warmup_csaw_2016这道题就像一道恰到好处的引子把它彻底吃透后面的路会顺很多。