1. 项目概述:为什么我们要亲手“制造”一次缓冲区溢出?
如果你在Windows系统上见过那个弹窗——“系统在此应用程序中检测到基于堆栈的缓冲区溢出。溢出可能允许恶意用户获得此应用的控制权”——心里除了点击“关闭程序”的烦躁,是否也闪过一丝好奇:这到底是怎么发生的?攻击者是如何通过一段看似无害的数据,就能让一个程序乖乖听话,甚至执行任意代码的?AttackLab,或者更广为人知的CMU CS:APP课程的配套实验,就是带你亲手揭开这层神秘面纱的绝佳沙盒。这不是教你成为黑客,而是让你以“攻击者”的视角,深入理解计算机系统最底层的运行机制——栈帧、函数调用、指令执行,以及当这些机制被恶意数据破坏时,会发生什么。
我们常说的“缓冲区溢出”,尤其是“基于堆栈的缓冲区溢出”,是安全领域最经典、最古老的漏洞之一。它的原理并不复杂:程序在栈上为局部变量(比如一个字符数组)分配了一块固定大小的内存(缓冲区),但接收数据时没有检查长度,导致写入的数据超出了缓冲区边界,覆盖了栈上其他关键数据。这些被覆盖的数据,很可能就是函数返回地址。想象一下,栈就像一摞精心摆放的盘子(栈帧),每个盘子代表一个函数调用,盘子边缘贴着一个小纸条,写着“吃完饭后请放回原处”(返回地址)。缓冲区溢出就像有人往最上面的盘子里倒了过多的汤,汤溢出来,不仅弄脏了下面的盘子,还把那张“放回原处”的纸条泡烂、改写了。当函数执行完毕,试图按照被改写的地址“回家”时,它就会去到一个完全错误甚至危险的地方。
AttackLab实验的精妙之处在于,它提供了一个完全可控、无害的环境。你面对的是一个特意被编译成存在漏洞的、去除了各种现代防护机制(如栈不可执行NX、地址空间布局随机化ASLR)的二进制程序。你的任务就是精心构造输入数据(我们称之为“攻击字符串”或“Exploit”),利用其漏洞,实现特定的目标,比如跳转到某个秘密函数,或者执行你注入的机器指令。这个过程,就是一次对栈帧艺术的极致解构与重构。通过这个项目,你不仅能深刻理解漏洞的成因,更能建立起坚实的安全编程意识,明白为什么简单的
strcpy
、
gets
函数是如此的“声名狼藉”。对于开发者、安全研究员乃至任何对系统底层感兴趣的人来说,这都是一次不可多得的实战之旅。
2. 实验环境搭建与目标程序分析
动手之前,我们需要一个战场。AttackLab通常以一系列预编译的二进制文件(如
ctarget
和
rtarget
)和其C源代码的形式提供。为了完全复现,我们需要一个Linux环境,因为实验的设计和工具链都围绕它展开。Windows 10用户可以通过WSL2(Windows Subsystem for Linux)获得近乎原生的体验,这是目前最推荐的方式。
2.1 基础环境准备
首先,确保你的WSL2已经安装并启用。打开PowerShell(管理员身份),运行
wsl --install -d Ubuntu
来安装Ubuntu发行版。安装完成后,启动Ubuntu终端。
实验需要基本的编译和调试工具。在Ubuntu终端中,更新软件包列表并安装必备工具:
sudo apt update
sudo apt install -y gcc gdb make git python3
gcc
用于编译我们可能需要的辅助代码或查看汇编;
gdb
(GNU调试器)是我们本次探险的“显微镜”和“手术刀”,至关重要;
make
用于管理构建;
git
用于获取实验材料;
python3
则用于编写生成攻击字符串的脚本,这比手动计算字节方便得多。
接下来,获取实验材料。AttackLab是CS:APP官网的配套实验,其文件通常包含:
-
ctarget:一个存在栈缓冲区溢出漏洞的程序,用于前期的代码注入攻击。 -
rtarget:一个相对高级的程序,引入了栈随机化等防护,需要用到面向返回编程(ROP)技术。 -
farm.c:一个包含许多小工具函数(gadget)的源代码,用于构建ROP链。 -
hex2raw:一个工具程序,用于将我们编写的十六进制攻击字符串转换成原始字节数据,作为程序的输入。
你可以从课程官网下载,或者在一些开源课程仓库中找到。假设我们将实验文件放在
~/attacklab
目录下。
2.2 目标程序逆向初探
在发动攻击前,我们必须像侦探一样仔细勘察“犯罪现场”——分析目标程序。使用
file
命令查看程序信息:
cd ~/attacklab
file ctarget
输出可能会显示
ctarget: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
。这告诉我们它是64位程序,并且是静态链接的(所有库代码都包含在内部),符号表没有被剥离(方便调试)。
使用
objdump
进行反汇编是理解程序逻辑的关键:
objdump -d ctarget > ctarget.asm
这会将
ctarget
的汇编代码输出到
ctarget.asm
文件中。打开这个文件,我们可以找到
main
函数以及实验说明中提到的关键函数,如
test
、
getbuf
等。
getbuf
函数通常就是存在漏洞的函数,它可能使用不安全的
gets
或
strcpy
来向栈上的缓冲区读入数据。
注意 :在实际的AttackLab中,
getbuf的汇编代码至关重要。你需要仔细分析它分配了多少字节的栈空间(通过sub $0x28, %rsp这样的指令),缓冲区从哪里开始,以及返回地址存储在哪个位置。这决定了你的攻击字符串需要多长,以及从哪个偏移位置开始覆盖返回地址。
2.3 调试器GDB的配置与使用技巧
GDB将是我们的主战场。为了更高效地工作,建议在用户主目录下创建一个
.gdbinit
文件,添加一些常用配置:
set disassembly-flavor intel
set pagination off
第一行将汇编语法设置为更直观的Intel格式(默认为AT&T格式)。第二行关闭分页,避免输出长信息时频繁暂停。
启动GDB调试
ctarget
:
gdb ctarget
在GDB中,几个关键命令必须熟练掌握:
-
break *0x400000:在指定地址(例如0x400000)设置断点。通常我们会在getbuf函数入口和返回前设置断点。 -
run -q < exploit.txt:运行程序,-q是程序自带的参数(可能表示安静模式),并从exploit.txt文件重定向输入。这是我们测试攻击字符串的方式。 -
stepi (si)/nexti (ni):单步执行一条机器指令。si会进入函数调用内部,ni则越过函数调用。 -
info frame/info registers:查看当前栈帧信息和所有寄存器值。 -
x/20gx $rsp:以十六进制格式检查从栈指针$rsp开始的20个8字节(64位)内存内容。这是查看栈上数据布局最直接的方法。 -
print (char*)0x7fffffffe000:以字符串形式打印指定地址的内存。
实操心得 :在攻击的早期阶段,我强烈建议你先构造一个“探路”字符串。例如,生成一串有规律且易于辨认的字节序列(比如从
0x00到0xff的循环),作为输入。当程序在getbuf返回前崩溃时,用GDB检查栈顶和指令指针$rip的值。如果$rip的值是你字符串中的某几个字节,恭喜你,你已经成功控制了程序流!接下来就是精确计算偏移,将$rip指向你希望它去的地方。
3. 栈帧结构与漏洞原理深度解析
要实施精准攻击,必须对栈帧在内存中的布局了如指掌。在x86-64架构下,每个函数调用都会在栈上创建一个新的帧(Frame)。
3.1 函数调用约定与栈帧布局
当一个函数(调用者)调用另一个函数(被调用者)时,按照System V AMD64 ABI调用约定(Linux标准),过程如下:
-
传递参数
:前6个整数或指针参数通过寄存器
%rdi,%rsi,%rdx,%rcx,%r8,%r9传递。多余参数通过栈传递。 -
执行call指令
:
call指令会做两件事:首先将下一条指令的地址(返回地址)压入栈中,然后跳转到目标函数。 -
被调用者序言
:被调用函数开始执行,通常以
push %rbp; mov %rsp, %rbp开头,保存旧的帧指针并建立新的帧。接着,通过sub $X, %rsp在栈上为局部变量和临时空间分配内存。 - 函数体执行 。
-
被调用者尾声
:函数返回前,执行
leave指令(等价于mov %rbp, %rsp; pop %rbp)恢复栈指针和帧指针,然后执行ret指令。ret指令从栈顶弹出返回地址,并跳转到那里。
一个典型的栈帧在
getbuf
函数内部时,从高地址到低地址(栈向下增长)可能如下所示:
| 地址(高->低) | 内容 | 说明 |
|---|---|---|
| ... | 调用者的栈帧 | |
%rbp+8
| 返回地址 (Return Address) |
call getbuf
时压入,是
test
函数中
call
之后指令的地址
|
%rbp
|
保存的
%rbp
(Saved %rbp)
|
getbuf
序言中
push %rbp
保存的调用者帧指针
|
%rbp-8
| 局部变量/缓冲区开始 |
例如
char buf[40];
,假设分配了40字节
|
%rbp-48
| 缓冲区结束 / 栈指针附近 | |
%rsp
->
| ... | 当前栈顶 |
假设
getbuf
通过
sub $0x28, %rsp
分配了40字节(0x28)的栈空间。那么缓冲区
buf
很可能就从
%rsp
开始。而返回地址存储在
%rbp+8
的位置。由于
%rbp
是旧栈指针,在分配栈空间后,
%rbp
和
%rsp
的关系是固定的。通过计算可以知道,从缓冲区起始位置到返回地址的偏移量是:
sizeof(saved %rbp) + 缓冲区大小
。在64位系统中,
saved %rbp
占8字节。所以如果缓冲区是40字节,那么偏移量就是
8 + 40 = 48
字节。也就是说,我们输入的前48个字节会填满缓冲区和
saved %rbp
的位置,从第49到56个字节(共8字节)就会覆盖掉至关重要的
返回地址
。
3.2 漏洞利用的核心:控制流劫持
这就是缓冲区溢出攻击的核心。
gets(buf)
或
strcpy(buf, input)
等函数不检查边界,如果我们输入超过40字节的数据,多出的部分就会继续向高地址写入,依次覆盖
saved %rbp
和
返回地址
。
当
getbuf
函数执行到
ret
指令时,它会从栈顶(此时
%rsp
指向的位置,即原本存放返回地址的地方)弹出一个8字节的值,并把它当作下一条指令的地址跳转过去。如果我们用精心设计的地址覆盖了这个位置,我们就完全控制了程序的执行流。
AttackLab的第一部分(
ctarget
)实验,就是基于这个原理,要求你通过覆盖返回地址,让程序跳转到:
-
一个已有的特定函数(如
touch1,touch2,touch3),这些函数在成功调用时会输出“过关”信息。 - 或者,跳转到你自己注入到栈上的机器代码(Shellcode)的起始地址。这些代码是你用汇编编写、并转换成字节序列嵌入到攻击字符串中的。
注意事项 :在构造注入代码时,必须考虑栈地址的确定性。因为实验关闭了ASLR,并且程序是静态链接的,每次运行栈地址是固定的。我们可以通过GDB在运行时直接打印出缓冲区起始地址(例如
print $rsp在getbuf开头时),这个地址就是我们的Shellcode需要放置的位置,也是我们用来覆盖返回地址的目标地址。注意字节序,x86-64是小端序,所以地址0x5561dc78在内存中要以字节序列78 dc 61 55 00 00 00 00(64位补零)的形式写入。
4. 实战:代码注入攻击(Phase 1-3)
让我们进入实战,以AttackLab的
ctarget
为例,分解前三个关卡。
4.1 Phase 1: 直接跳转
这是最简单的热身。目标:让
getbuf
函数返回时,不返回到
test
,而是跳转到已有的
touch1
函数。
步骤:
-
找到
touch1的地址 :使用objdump -d ctarget | grep,找到touch1函数的起始地址,例如0x4017c0。 -
确定偏移量
:如前所述,通过分析
getbuf的汇编代码确定缓冲区到返回地址的偏移。假设为48字节。 -
构造攻击字符串
:前48个字节可以是任意数据(通常用
0x90NOP指令填充,或0x00至0xff的测试模式)。从第49字节开始,用小端序写入touch1的地址。-
例如,偏移48字节,地址
0x4017c0的小端序字节为c0 17 40 00 00 00 00 00。 -
因此,字符串结构为:
[48个任意字节] + [c0 17 40 00 00 00 00 00]。
-
例如,偏移48字节,地址
-
生成和测试
:将上述十六进制序列保存到文件
exploit1.txt中,每两个十六进制数字之间可以用空格或换行分隔。使用实验提供的hex2raw工具转换:./hex2raw < exploit1.txt > raw1.txt。然后用GDB或直接运行:./ctarget -q < raw1.txt。如果看到Touch1!: You called touch1()的输出,则攻击成功。
4.2 Phase 2: 跳转并传递参数
难度升级。目标:跳转到
touch2
函数,并且需要传入一个特定的整数参数(例如
0xdeadbeef
),使得
touch2
中的校验通过。
分析:
touch2
的C代码大概像这样:
void touch2(unsigned val)
,我们需要让
val
等于某个特定值。在x86-64调用约定中,第一个整数参数通过
%rdi
寄存器传递。所以,我们不能直接跳转到
touch2
,因为那时
%rdi
的值是不可控的。我们需要先执行一段代码,将目标值(
0xdeadbeef
)移动到
%rdi
,然后再跳转到
touch2
。
步骤:
-
编写注入代码(Shellcode)
:我们需要用汇编写一段小程序。
注意,我们不能直接movq $0xdeadbeef, %rdi # 将参数放入 %rdi pushq $0x4017ec # 假设 touch2 的地址是 0x4017ec,压入栈 ret # 弹出地址并跳转到 touch2call touch2或jmp touch2,因为那样需要计算相对偏移,而我们的代码在栈上,地址是绝对的。更通用的方法是push地址然后ret。 -
汇编并获取字节码
:将上述汇编保存为
phase2.s,编译并反汇编获取字节序列:
你会得到类似gcc -c phase2.s objdump -d phase2.o48 c7 c7 ef be ad de 68 ec 17 40 00 c3的机器码。 -
确定缓冲区地址
:在GDB中,于
getbuf开头设置断点,运行并查看%rsp的值,这就是我们缓冲区(Shellcode)的起始地址,例如0x5561dc78。 -
构造攻击字符串
:
- 第一部分:注入的机器码。
-
第二部分:填充,直到覆盖返回地址的位置。填充内容可以用
0x90(NOP)指令,形成一个“NOP雪橇”,增加命中的容错率。 -
第三部分:覆盖返回地址。这个地址应该指向我们Shellcode的开始,即第一步得到的缓冲区地址
0x5561dc78(小端序:78 dc 61 55 00 00 00 00)。 -
所以整体布局:
[机器码] + [NOP填充至偏移量-8] + [缓冲区地址]。
-
测试
:同样用
hex2raw转换后喂给程序。
4.3 Phase 3: 传递字符串参数
这是
ctarget
的终极挑战。目标:跳转到
touch3
函数,并传递一个字符串指针作为参数,该字符串需要匹配一个特定的值(例如
"59b997fa"
,一个哈希值)。
分析:
touch3
的签名可能是
void touch3(char *sval)
。我们需要在内存中某个位置构造出这个字符串,然后将该字符串的地址作为参数传给
touch3
。这里的关键挑战是:字符串放在哪里?由于
getbuf
返回后,其栈帧可能被后续函数调用覆盖,我们不能把字符串放在
getbuf
的缓冲区里(因为
touch3
也会使用栈)。一个可靠的策略是:将字符串放在我们注入代码的
后面
,也就是在覆盖的返回地址之后的高地址区域。因为
getbuf
返回后,栈指针
%rsp
会指向返回地址之后的位置,那片区域在本次调用链中暂时不会被重用。
步骤:
-
确定字符串内容与地址
:字符串
"59b997fa"的十六进制ASCII码是35 39 62 39 39 37 66 61 00(注意末尾的终止符\0)。我们需要决定它的存放地址。假设我们计划将字符串放在返回地址之后。那么,如果缓冲区起始地址是A,返回地址存放在A+48处,那么字符串就可以从A+56开始存放。所以字符串地址是A+56。 -
编写注入代码
:代码需要将字符串地址
A+56放入%rdi,然后跳转到touch3。
重要 :这里的movq $0x5561dcb8, %rdi # 假设 A=0x5561dc78, A+56=0x5561dcb0? 需要精确计算 pushq $0x4018fa # touch3 地址 ret0x5561dcb8需要根据实际的缓冲区地址和布局精确计算。如果Shellcode从A开始,Shellcode代码占S字节,填充占P字节,返回地址占8字节,那么字符串起始地址 =A + S + P + 8。 -
构造攻击字符串
:这是最复杂的一环,需要精确计算每个部分的长度和地址。
-
布局:
[Shellcode机器码] + [NOP填充,使总长度达到48字节] + [返回地址(指向Shellcode起始A)] + [字符串“59b997fa\0”]。 -
在Shellcode中,需要计算字符串的绝对地址。这个地址等于
A + 48 + 8 = A + 56(因为前48字节是代码和填充,接着8字节是返回地址,之后就是字符串)。
-
布局:
-
调试与修正
:这个阶段极易出错。务必使用GDB单步执行,在
getbuf返回前检查栈内存布局,确保Shellcode、返回地址、字符串都位于你预期的地方,并且Shellcode中的地址计算是正确的。查看%rdi寄存器的值是否确实指向了字符串开头。
常见问题与排查 :
- 程序崩溃,报错Segmentation fault :最可能的原因是指令指针
%rip跳转到了一个非法地址。用GDB在崩溃时查看%rip的值,检查你覆盖的返回地址是否正确,以及地址的字节序是否正确。另外,确保你的攻击字符串通过hex2raw正确转换,没有多余的换行符或空格。- 跳转到了正确函数但参数错误 :检查注入的代码是否成功修改了
%rdi寄存器。在GDB中,可以在touch2或touch3入口设置断点,然后info registers rdi查看其值。- 字符串比较失败 :对于
touch3,确保字符串末尾有终止符\0。并且确认字符串的地址计算无误,没有因为栈对齐等原因出现偏差。在GDB中用x/s命令查看你认为的字符串地址处的内存内容。- 注入的代码没有执行 :可能是返回地址没有精确指向代码开始。由于栈地址可能因环境略有差异,使用“NOP雪橇”可以增加容错。在Shellcode前填充大量
0x90,只要返回地址落入这片NOP区域,处理器就会一直执行NOP直到遇到你的有效代码。
5. 进阶:面向返回编程(ROP)攻击(Phase 4-5)
通过了
ctarget
的考验,你将面对
rtarget
。这个程序引入了现代防护机制:栈随机化(ASLR的模拟)和栈不可执行(NX)。这意味着:
- 栈地址不再固定 :每次运行,栈的基地址都会变化,我们无法硬编码Shellcode的栈地址。
- 栈内存不可执行 :即使我们成功将代码注入到栈上,CPU也不会执行那里的指令,尝试执行会引发异常。
这时,就需要更精巧的攻击技术—— 面向返回编程 。
5.1 ROP攻击原理
ROP的核心思想是:利用程序中已有的、以
ret
指令结尾的短指令序列(称为“gadget”),将它们像拼图一样串联起来,实现我们想要的逻辑。这些gadget存在于程序的代码段(如
rtarget
本身或它链接的库),其地址是固定的(因为代码段不随机化)。
一个gadget通常长这样:
400f15: 58 popq %rax
400f16: 90 nop
400f17: c3 retq
这段代码的起始地址是
0x400f15
,它执行的操作是:从栈顶弹出一个值到
%rax
寄存器,然后执行一个空操作,最后返回。
retq
指令又会从栈顶弹出下一个地址并跳转。
如果我们能控制栈的内容,我们就可以布置一系列的gadget地址和它们所需的数据。执行流程是这样的:
- 初始的缓冲区溢出覆盖返回地址,将其改为第一个gadget的地址(G1)。
- 函数返回时,跳转到G1。
-
G1执行其指令(如
popq %rax),从栈顶(即我们布置的下一个位置)弹出数据到寄存器。 -
G1执行
ret,从栈顶弹出下一个地址,跳转到第二个gadget(G2)。 - 如此循环,形成一条“ROP链”。
5.2 构建ROP链:以Phase 4为例
假设
rtarget
的Phase 4要求与
ctarget
的Phase 2相同:调用
touch2
并传递参数
0xdeadbeef
。但我们不能注入代码了。
思路
:我们需要找到一个gadget,能把一个常数(
0xdeadbeef
)送入
%rdi
。通常,我们分两步:
-
将常数放到某个寄存器(比如
%rax)。 -
再从那个寄存器移动到
%rdi。
寻找gadget
:使用
objdump -d rtarget > rtarget.asm
,然后搜索有用的指令序列。实验通常会提供一个
farm.c
,编译后里面有很多预设的gadget。我们需要找到类似以下的序列:
-
popq %rax; ret(地址:0x4019ab) -
movq %rax, %rdi; ret(地址:0x4019c5)
构造ROP链栈布局
:
从
getbuf
的返回地址位置开始,我们布置以下内容:
地址(栈顶向下) | 内容
----------------|-------------------
... | ...
return address | -> Gadget 1 地址 (popq %rax; ret)
| 常数 0xdeadbeef (这是给popq %rax的数据)
| -> Gadget 2 地址 (movq %rax, %rdi; ret)
| touch2 函数地址
当
getbuf
的
ret
执行后:
-
弹出
Gadget 1地址,跳转过去。 -
Gadget 1执行popq %rax,将栈顶的0xdeadbeef弹入%rax。 -
Gadget 1执行ret,弹出Gadget 2地址,跳转。 -
Gadget 2执行movq %rax, %rdi,将0xdeadbeef移动到%rdi。 -
Gadget 2执行ret,弹出touch2地址,跳转并成功调用。
5.3 Phase 5:ROP传递字符串地址
这是ROP的终极挑战,对应
touch3
。难点在于:字符串本身需要存放在内存中,我们需要计算其地址并放入
%rdi
。由于栈随机化,我们无法直接硬编码字符串的栈地址。
解决方案
:利用栈指针
%rsp
本身。虽然栈的绝对地址随机,但栈上数据的
相对偏移
在攻击字符串中是固定的。我们可以找到一个gadget,将
%rsp
的值复制到另一个寄存器,然后通过一系列算术运算(加一个固定偏移量),计算出字符串的地址。
典型ROP链思路 :
-
捕获栈地址
:找到一个
movq %rsp, %rax; ret的gadget,将当前栈指针值存入%rax。 -
计算字符串偏移
:字符串被我们放置在ROP链的后面。假设从
movq %rsp, %rax执行时算起,字符串位于栈顶向下第offset个字节之后。我们需要将这个偏移量加到%rax上。这可能需要多个gadget:-
先将偏移量常数(比如
0x30)弹出到某个寄存器%rbx。 -
然后找到
addq %rbx, %rax; ret这样的gadget进行加法。 -
注意,实验提供的gadget farm可能没有直接的
add,但可能有add指令的编码,需要仔细搜索。
-
先将偏移量常数(比如
-
传递参数
:将计算后的地址(现在在
%rax中)移动到%rdi。 -
调用touch3
:跳转到
touch3。
布局示例 :
栈布局 (从溢出点开始):
地址: 内容 (解释)
A+0 : Gadget A 地址 (movq %rsp, %rax; ret)
A+8 : Gadget B 地址 (popq %rbx; ret) // 用于弹出偏移量
A+16: 偏移量常数 (例如 0x30) // 这是给popq %rbx的数据
A+24: Gadget C 地址 (addq %rbx, %rax; ret)
A+32: Gadget D 地址 (movq %rax, %rdi; ret)
A+40: touch3 地址
A+48: 字符串 "59b997fa\0" // 其地址 = (Gadget A执行时的%rsp) + 0x30
这个链的执行流程是线性的,每个gadget的
ret
都引导至下一个。计算偏移量
0x30
需要非常精确,必须考虑从
Gadget A
执行
movq %rsp, %rax
时
%rsp
所指的位置,到字符串起始位置之间的字节数。这需要通过反复调试和计算来确认。
实操心得:ROP链的调试技巧 调试ROP链比代码注入更繁琐。关键是在GDB中,在
getbuf的ret指令处设置断点,然后单步执行(si)。每执行一个ret,观察跳转的地址是否是你预期的gadget地址,并观察栈指针%rsp的变化。使用x/10gx $rsp随时查看栈顶接下来的内容,确保它符合你设计的ROP链布局。一个常见的错误是偏移计算错误,导致pop指令弹错了数据,或者加法结果不对。耐心和细致的计算是成功的关键。
6. 从实验到现实:安全启示与防护
完成AttackLab的五个阶段,你不仅掌握了两种经典的漏洞利用技术,更应深刻理解其背后的安全寓意。
根本原因
:缓冲区溢出的根源在于
程序员信任了不可信的输入
。像C语言中的
gets
、
strcpy
、
strcat
、
sprintf
等函数,如果不对目标缓冲区大小做检查,就是一颗定时炸弹。
现代防护技术 :
-
栈不可执行(NX/DEP)
:这是防御代码注入攻击的第一道防线。它通过硬件和操作系统支持,将栈内存标记为不可执行。
rtarget实验模拟了这种环境,迫使攻击者转向ROP。 - 地址空间布局随机化(ASLR) :随机化栈、堆、库的加载地址,使攻击者难以预测关键数据的地址。ROP攻击依赖于固定的代码地址,但ASLR对代码段(PIE)的随机化可以增加ROP的难度。现代系统普遍启用全ASLR。
- 栈金丝雀(Stack Canary) :编译器在函数栈帧的返回地址前插入一个随机值(金丝雀)。函数返回前检查该值是否被改变,若改变则立即终止程序。这可以有效防御覆盖返回地址的攻击。
- 控制流完整性(CFI) :更先进的防护,限制程序跳转的目标必须为事先分析过的合法地址,极大增加了ROP等跳转导向编程的攻击难度。
对开发者的启示 :
-
永远不要使用不安全的函数
:用
fgets代替gets,用strncpy代替strcpy(并注意strncpy不保证结尾\0的问题),或用更安全的snprintf。 - 进行边界检查 :对所有来自外部的输入(网络、文件、用户)进行严格的长度和格式校验。
- 使用安全编程语言 :考虑使用内存安全的语言,如Rust、Go、Java、Python等,它们通过编译器或运行时管理内存,从根本上消除了缓冲区溢出的可能性。
-
启用安全编译选项
:即使使用C/C++,也要开启编译器的安全选项,如
-fstack-protector-all(栈保护)、-D_FORTIFY_SOURCE=2(强化安全函数)等。
AttackLab就像一次在受控环境下的“消防演习”。它让你亲身体验了攻击是如何发生的,从而让你在构建软件时,能本能地想到“这里会不会有缓冲区溢出?”、“这个输入我检查长度了吗?”。这种深入骨髓的安全意识,是任何理论教学都无法替代的。当你再看到那个“基于堆栈的缓冲区溢出”的错误对话框时,你看到的将不再是一个冰冷的报错,而是一段内存的悲鸣,以及一个等待被填补的安全漏洞。

120

被折叠的 条评论
为什么被折叠?



