1. 栈溢出漏洞概述
栈溢出漏洞是当程序在栈内存中写入数据超出预定容量时,导致数据溢出并覆盖关键的栈内容(如函数返回地址或控制信息),进而使攻击者能够执行任意代码或导致程序崩溃。该漏洞通常发生在没有适当边界检查的情况下,尤其是在使用不安全的函数处理用户输入时。栈溢出可能导致控制流劫持,允许攻击者接管系统。为了防止这种漏洞,常用的防护措施包括启用栈保护机制、使用安全函数和进行严格的输入验证。
2. 基础知识
2.1 Intel 64架构
Intel 在 2002 年引入了 64 位架构,这是因为 x86 架构的 32 位特性在设计上达到了极限。该架构包括四个通用寄存器(RAX、RBX、RCX、RDX),它们的低 32 位分别与原来的 32 位寄存器(EAX、EBX、ECX、EDX)重叠共用。此外,还引入了指针寄存器(RIP、RBP、RSP、RSI、RDI),以及额外的八个通用寄存器(R8 到 R15),从而增强了处理能力和内存寻址范围。
数据寄存器(也称为通用寄存器):主要用于存储操作数和运算结果。根据位宽不同,常用的数据寄存器包括 64 位的 RAX、RBX、RCX、RDX,32 位的 EAX、EBX、ECX、EDX,以及 16 位的 AX、BX、CX、DX。它们在不同位宽的操作中共享同一个物理寄存器,低位寄存器作为高位寄存器的一部分使用。
变址寄存器:主要用于存放存储单元在段内的偏移量。64 位架构下常用的变址寄存器包括 RSI 和 RDI,而 32 位和 16 位架构下分别使用 ESI、EDI 和 SI、DI。这些寄存器在地址计算、数组访问等场景中起到关键作用。
指针寄存器:主要用于存储堆栈内存储单元的偏移量,能够实现多种寄存器操作数的寻址方式。常见的指针寄存器包括 64 位的 RBP 和 RSP,32 位的 EBP 和 ESP,以及 16 位的 BP 和 SP。其中,BP(基指针)用于直接存取堆栈中的数据,而 SP(堆栈指针)仅用于访问栈顶。在不同的位宽架构下,这些寄存器对应的作用相同,但位宽不同决定了其使用的灵活性和存储能力。
段寄存器:根据内存分段管理模式而设置的,用于存储段的起始地址。通过段寄存器的值与一个偏移量组合,能够形成一个较大的物理地址,从而访问较大的内存空间。常见的段寄存器包括:CS(代码段寄存器)用于存储代码段的段值,DS(数据段寄存器)用于数据段,ES(附加段寄存器)用于附加数据段,SS(堆栈段寄存器)用于堆栈段,FS 和 GS 段寄存器则用于附加数据段。这些段寄存器通过结合段值与偏移量,实现对内存的高效访问。
指令指针寄存器:用于存放下一条即将执行的指令在代码段中的偏移量,它确保处理器能够正确顺序执行指令。常见的指令指针寄存器包括 RIP(64 位)、EIP(32 位)和 IP(16 位),分别对应不同位宽的处理器架构。
2.2 函数与函数栈
栈是一种先进先出的特殊数据结构,主要用于存储程序运行时的临时数据和地址,支持函数的执行和嵌套调用。栈的分配由编译时确定,无法由程序员直接控制,栈中保存着线程或进程的局部变量。不同的线程或进程有各自独立的栈空间,且栈的位置不同,程序在运行过程中不同线程和进程之间不能互相访问对方的栈地址。
栈帧结构:在 64 位程序中,当执行 call
指令时,程序会将下一条指令的地址压入栈中,以便函数执行完后可以返回调用点。call
指令完成压栈操作后,程序会跳转到被调用的函数开始执行。
push rbp ; 保存父函数的栈底
mov rbp, rsp ; 设置当前函数的栈底
sub rsp, 0x70 ; 为当前函数分配栈空间
在函数调用结束时,程序会执行 leave
和 ret
指令。leave
指令等同于执行了两条指令:mov esp, ebp
和 pop ebp
,将 ebp
和 esp
两个指针恢复到函数被调用前的状态。ret
指令则将栈中的返回地址弹出到 rip
寄存器中,跳转回到父函数继续执行。这两个操作的组合确保了函数调用结束时栈帧和返回地址的正确恢复。
参数传递:函数通过传递参数来确定其具体的处理内容,而这些参数是通过栈来传递的。在下方的代码示例中,函数 foo
接受两个整数参数 x
和 y
,并通过栈传递给函数。在函数执行过程中,printf
打印出传递的参数值。在主函数 main
中,调用 foo(1, 2)
,这时 1
和 2
被传递到函数 foo
的参数 x
和 y
中,完成输出。
#include <stdio.h>
int foo(int x, int y) {
char a[10];
printf("%d %d\n", x, y);
}
int main() {
foo(1, 2);
}
- 在 32 位操作系统中,函数的参数是通过直接压入栈的方式传递的,即参数被写入栈上。当执行
call
指令和函数内建立新栈时,ebp
保存的是父函数的栈底地址,依次向下是程序的返回地址和传递的参数。在函数调用时,push
指令会将数据压入栈,使得栈顶向低地址偏移。函数执行完毕后,通过add esp, 0x10
指令恢复esp
,将栈顶指针恢复到调用函数前的状态,以保证程序能正确继续执行。这一过程确保了函数参数的正确传递和栈的完整性。 - 在 64 位操作系统中,函数的参数不再直接通过栈传递,而是通过寄存器传递,常用的寄存器包括 `rdi`、`rsi`、`rdx`、`rcx`、`r8`、`r9` 等。只有当超出寄存器能存放的参数数量时,才会使用栈来存储额外的参数。在 64 位程序的反汇编结果中,函数参数直接赋值给 `edi` 和 `esi`,而不是压入栈中。由于没有栈的压入操作,函数执行完毕后也不需要对应的栈恢复操作,这简化了函数调用过程中的栈操作。
数据调用:栈中的数据在函数调用时主要分为两部分:传递的参数和局部变量。在函数调用中,传递的参数会被压入栈,局部变量则通过偏移量访问。例如,`printf` 函数的两个参数由父函数传递,第一个参数和第二个参数会依次压入栈中。而代码中的 `mov` 指令操作的是局部变量(如数组 `a` 的数据)。不论是参数还是局部变量,都是通过栈底指针(如 `ebp`)加上偏移量的方式进行读取和操作,这是一种统一的访问机制。
3. 漏洞原理
栈溢出的原理:栈溢出产生的原因通常是因为在编写代码时,程序员未对数组或字符串等变量的边界进行足够的检查。栈溢出漏洞的出现伴随两件事:程序向栈上写入一组数据,并且这些数据的长度没有经过有效的验证。当写入的数据过多时,会覆盖栈上其他变量甚至关键的地址(如返回地址),从而导致攻击者能够利用这一漏洞进行控制,篡改程序的执行流或获取未授权的访问。
栈溢出的危害:修改父函数的栈底地址、篡改函数的返回地址以及修改结构化异常处理(SEH)链表指针。这些操作会导致程序的执行流被攻击者控制,从而可能执行恶意代码或破坏程序的正常运行,带来严重的安全隐患。
4. 漏洞分析
#include<stdio.h>
#include<string.h>
int hackhere(){
printf("Congratulations! You hacked me now.\n");
}
int foo(){
printf("Wrong username!\n");
}
int main(){
char tmp[10];
char username[10];
printf("Give me your username:");
scanf("%s", username);
if(strlen(username) == 4 && !strcmp(username, "admin")){
hackhere();
} else {
foo();
}
}
通过代码分析可以发现,在正常情况下,满足 if
语句条件(即用户名长度为 4 且等于 "admin")是非常困难的。通过观察程序流程可以看出,main
函数中在栈上声明了一个名为 username
的字符数组。但在读取输入时,未对输入的长度进行限制或检测,这导致可以通过 scanf
函数输入超过 username
数组长度的字符,进而引发栈溢出。
使用 IDA 工具对程序进行静态反汇编,找到 `hackhere` 函数的地址 `0x4006D6` 以及 `main` 函数执行结束前的地址 `0x4006F8`。通过反汇编工具,能够识别这些关键地址,以便进一步分析程序通过分析可以发现,s
的地址是用户输入的 username
字符串的起点。根据变量声明后的注释,s
的地址与当前栈 rsp
相同,且与 rbp
之间相隔 32 个字符。这意味着,写入 32 个字符后可以覆盖 rbp
所指向的父函数栈底地址。要进一步覆盖 rbp
所指向的父函数栈底地址相邻的函数返回地址,只需在 32 个字符后再填充 8 个字符,共 40 个字符即可完成覆盖和修改。的执行流程和调试过程。在ida中按F5反编译main函数,得到如下代码:
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[24]; // [rsp+0h] [rbp-20h] BYREF
unsigned __int64 v5; // [rsp+18h] [rbp-8h]
v5 = __readfsqword(0x28u);
printf("Give me your username:");
__isoc99_scanf("%s", s);
if ( strlen(s) == 4 && !strcmp(s, "admin") )
hackhere();
else
foo();
return 0;
}
通过分析可以发现,s
的地址是用户输入的 username
字符串的起点。根据变量声明后的注释,s
的地址与当前栈 rsp
相同,且与 rbp
之间相隔 32 个字符。这意味着,写入 32 个字符后可以覆盖 rbp
所指向的父函数栈底地址。要进一步覆盖 rbp
所指向的父函数栈底地址相邻的函数返回地址,只需在 32 个字符后再填充 8 个字符,共 40 个字符即可完成覆盖和修改。