简单栈溢出原理和攻击手段
什么是栈溢出?
栈溢出的本质是:向栈上的局部变量写入了超出其分配空间的数据,导致这些多余的数据”溢出”到栈上的娶她区域,覆盖率关键信息(比如返回地址、函数参数)。 最基本的就是缓冲区溢出
**缓冲区溢出:**缓冲区溢出就是输入过长的数据到缓冲区,导致缓冲区的其他数据遭到破坏。
**栈是计算机系统的数据结构,**按照先进后出的原则存储数据,先进入的数据被压入栈底,后放入的数据在栈顶,就像叠盘子一样总是取最上面的,先放入的盘子总是被压在最下面。
_**PUSH POP **_push 和 pop 分别是进栈和出栈
_**ESP/RSP **_栈指针寄存器,其中存放这一个指针,该指针永远只想系统最上面的一个栈帧的栈顶。
_**EBP/RBP **_基址指针寄存器,其中存放着一个指针,永远指向系统栈最上面的一个栈帧的底部。
程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的其他重要数据结构被破坏,从而影响程序的正常运行,当我们写入的字符串长度可以覆盖到程序返回地址时,我们就可以控制程序的运行流程了。
栈溢出的情况
下面通过一道简单的 pwn 题目来演示基本栈溢出 BUUCTF[ ciscn_2019_n_1 ]
setvbuf 是 C 标准库中的函数,用于**设置流的缓冲区。简单来说,这行代码的作用是:让程序读取 stdin 时,输入一个字符就立即处理**,而不是等缓冲区满或按回车才处理。 我们接着进入 func 看一看:
1 | int func() |
以上是 IDA 的 F5 反汇编,通过反汇编我们明确目的就是进入到 if 语句中执行 system。如果已经具备一定知识,我们可以很敏锐地发现 [rbp-30h] 和 [rbp-4h] 这两个条件,这意味着 v1 在缓冲区覆盖到 rbp 之前是可以覆盖 v2 的值的区域的。那么我们就可以采用修改 v2 的值去执行 system(“cat /flag”)获取 flag。
作为初学者,我们也可以 gdb 去调试看一看数据的存储情况:
在 main 函数处下断点,单步执行到 func 函数后 si 进入函数,运行到 gets 函数输入过量的字符串 1

我们查看栈情况,我们发现 rbp 以及之前的区域已经完全被覆盖了,所以我们的想法行得通。
通过计算 0x30-0x04=0x2C=44 可知,覆盖到 v2 只需要 44 个字节的长度,开始构造 exp 获取 flag
1 | from pwn import * |
struct.pack(‘<f’, 11.28125) 的核心作用是把浮点数 11.28125 打包成小端字节序的 32 位单精度浮点数二进制字节串, 格式字符串 <f 是关键:< 代表小端字节序,f 代表 4 字节单精度浮点数; 最终结果是 b'\x00\x94\x28\x41',这是该浮点数在 x86 架构内存中的实际存储形式。 
1 | from pwn import * |
还有一种方法是绕过 if 语句判断。上面分析出我们溢出的字符串可以覆盖 rbp,那我们就可以控制 rbp 进行跳转
通过 IDA 知道了’’cat /flag’’的地址之后, 用 cyclic 手动测出偏移 56,就可以构造 exp 了。

1 | from pwn import * |
栈溢出的防御手段
我们可以通过checksec+程序名的指令来查看程序开启了那些保护
1. RELRO (Relocation Read-Only)
- 防御原理: 这主要用来保护程序的全局偏移表(GOT 表)。在动态链接的程序中,GOT 表用于存储外部函数(如 printf, system)的真实内存地址。
- 如果是 Partial RELRO(部分开启)或关闭,可以通过漏洞(比如你刚才问的格式化字符串 %n 或者任意地址写)将 GOT 表中某个常用函数的地址篡改成恶意代码的地址。这样程序下次调用该函数时,就会执行恶意代码(这被称为 GOT 表劫持)。
- Full RELRO 会在程序启动时,将所有外部函数的地址解析完毕,然后将 GOT 表所在的内存区域设置为只读(Read-Only)。这意味着你无法再篡改 GOT 表了,直接封死了 GOT 表劫持这条路。
2. Stack (栈保护 / Canary)
- 防御原理: Canary(金丝雀)机制用于防御栈溢出(Stack Overflow)漏洞。
- 开启后,程序在调用函数时,会在栈上的“返回地址”之前放置一个随机生成的“安全值”
- 当函数执行完毕准备返回时,会先检查这个值有没有改变。如果黑客试图通过输入超长字符串来覆盖返回地址,必定会先覆盖掉这个 Canary 值。程序一旦发现 Canary 被修改,就会立刻判定遭到攻击并强制崩溃,从而阻止黑客劫持执行流。
3. NX (No-eXecute / 数据执行不可执行)
- 防御原理: 在 Windows 下叫 DEP
- 过去,黑客喜欢把恶意的机器码(Shellcode)直接作为输入发送到程序的栈区或堆区,然后把程序的执行流跳转到那里去执行。
- NX 机制将存放数据的内存区域(如栈、堆、BSS 段)标记为不可执行(Non-Executable)。即使黑客成功把恶意代码注入到了栈上,并跳转过去,CPU 也会拒绝执行并报错。
- 开启 NX 后,黑客被迫放弃直接执行 Shellcode,转而使用更复杂的 **ROP **技术,利用程序原本自带的代码片段来拼凑攻击逻辑。
4. PIE (Position Independent Executable)
- 防御原理: 这是针对程序自身的地址随机化(ASLR 的一部分)。
- 如果关闭 PIE,程序每次运行的代码段、数据段加载的内存基础地址都是固定不变的。可以轻易地在本地算好各种代码的地址(如后门函数的地址),直接写死在攻击脚本里。
- PIE enabled 表示程序在每次启动时,其自身的代码段、数据段的基地址都是完全随机的。如果不知道本次运行的基地址,就无法准确跳转到程序的任何函数。
- 要攻破 PIE,通常必须先找到一个“信息泄露”漏洞(比如用 %p 打印指针),算出基地址,才能进行后续攻击。
5. SHSTK (Shadow Stack / 影子栈) & IBT (Indirect Branch Tracking)
- 防御原理: 这两个是 Intel 较新的硬件级控制流完整性保护机制(CET - Control-Flow Enforcement Technology),防御力度非常强:
- SHSTK(影子栈): 专门用来对抗刚才提到的 ROP 攻击。它在内存中开辟了一个额外的、受到硬件严格保护的隐藏栈,专门只保存函数的返回地址。当函数返回时,CPU 会对比常规栈和影子栈里的返回地址。如果黑客篡改了常规栈上的返回地址,比对就会失败,程序直接终止。
- IBT: 专门用来对抗 JOP/COP(面向跳转/调用编程)。它要求所有的非直接跳转(如 call rax、jmp rdx)目标位置,必须是一条特殊的机器指令(ENDBR64)。否则 CPU 就会抛出异常。
6. Stripped (符号表剥离)
- 这不是防御机制,而是给逆向工程师的“福利”。
- 未剥离意味着程序里保留了开发者写的函数名、变量名等调试符号(比如 main, input_function 等名字都还在)。这会让反编译和逆向分析的过程变得非常轻松。如果显示 Yes,所有的名字都会变成无意义的地址或代号。
更新: 2026-04-10 16:57:24
原文: https://www.yuque.com/idcm/wnemg9/fzoe3y2khng76r20