二进制-栈溢出漏洞

基础知识

寄存器基础

数据寄存器(通用寄存器)

主要用来保存操作数和运算结果;

  • 64位:RAX、RBX、RCX、RDX
  • 32位:EAX、EBX、ECX、EDX
  • 16位:AX、BX、CX、DX

变址寄存器

主要用于存放存储单元在段内的偏移量;

  • 64位:RSI、RDI
  • 32位:ESI、EDI
  • 16位:SI、DI

指针寄存器

主要用于存放堆栈内存储单元的偏移量,用它们可实现多种存储器操作数的寻址方式;

  • 64位:RBP、RSP
  • 32位:EBP、ESP
  • 16位:BP、SP

标注:

  • BP为基指针(BasePointer)寄存器,用它可直接存取堆栈中的数据;
  • SP为堆栈指针(StackPointer)寄存器,用它只可访问栈顶;

段寄存器

段寄存器是根据内存分段的管理模式而设置的;

内存单元的物理地址由段寄存器的值和一个偏移量值组合而成的,这样可用两个较少位数的值组合成一个可访问较大物理空间的内存地址;

  • CS代码段寄存器(CodeSegmentRegister),其值为代码段的段值;
  • DS数据段寄存器(DataSegmentRegister),其值为数据段的段值;
  • ES附加段寄存器(ExtraSegmentRegister),其值为附加数据段段值;
  • SS堆栈段寄存器(StackSegmentRegister),其值为堆栈段的段值;
  • FS附加段寄存器(ExtraSegmentRegister),其值为附加数据段的段值;
  • GS附加段寄存器(ExtraSegmentRegister),其值为附加数据段的段值;

指令指针寄存器

存放下次将要执行的指令所在代码段的偏移量;

  • 64位:RIP
  • 32位:EIP
  • 16位:IP

栈基础

函数与函数栈

  • 栈是一种先进后出的特殊数据结构,用于存储程序在运行时的临时数据和地址,用于支撑函数的运行和嵌套调用;
  • 栈的分配是由程序编译时确定下来的,无法由程序员控制;
  • 栈中存储着线程或者进程的局部变量;
  • 不同的进程或线程的栈处于不同的位置,在程序正常运行时不同线程和进程之间不能互相访问彼此的栈地址;

栈帧结构

以64位程序执行call指令为例,在执行call指令的时候,会向栈中压入call指令完成后下一条指令的地址,之后跳转到被调用的函数开始执行;

1
2
3
push rbp	;将父函数栈底压入栈中
mov rbp, rsp ;将父函数栈顶变为子函数栈底
sub rsp, 0x70 ;抬高栈顶为子函数开辟栈帧

在函数调用结束时,会执行如下两条指令;

  • 其中leave指令相当于执行了如下指令,相当于将ebp和esp两个指针恢复到函数被调用前;

    1
    2
    mov esp, ebp
    pop ebp
  • ret指令相当于将栈中的返回地址pop给rip的操作,从而回到父函数继续执行;

函数传递参数

函数都要通过传递进去的参数来确定其具体的工作内容,而函数间的参数也是通过栈来传递的;

以如下程序为例,学习32位程序和64位程序的参数传递过程的区别;

1
2
3
4
5
6
7
8
#include<stdio.h>
int foo(int x,int y){
char a[10];
printf(“%d %d\n”,x,y);
}
int main(){
foo(1,2);
}
  • 32位程序

    • 函数需要传递的参数是直接被压入栈中的的方式,即直接写在栈上;

      • ebp的下方依此是父函数的栈底地址,程序返回地址,传递的参数1,传递的参数2,…;
      • 由于在函数调用前通过push指令向栈中压入了数据,使得栈顶被抬高了;
      • 在函数调用结束以后,将通过add esp 0x10这条指令,即增加esp来恢复函数调用前的esp;
  • 64位程序

    • 传递的参数不再直接写在栈上,而是通过寄存器传递;

    • 这些寄存器分别是rdi、rsi、rdx、rcx、r8、r9,超出寄 存器存放数量的数据才继续在栈上存储;

    • 64位程序反汇编结果如下;

    • 函数传递参数不再向栈中压入,而是将参数赋值给edi和esi,由于没有压栈的操作,所以函数执行结束以后也就没有相应的恢复栈顶的过程;

栈中数据调用分析如下:

  • 函数可以调用的栈上的参数主要分为两部分:传递的参数和程序里的局部变量;
    • 这里的printf函数的两个参数分别是父函数传递过去的第一个参数和第二个参数;
    • 最上边的mov指令的参数就是局部变量:代码中的数组a;
  • 不论是函数的参数还是局部变量,都是通过统一的方式:栈底指针+偏移量来读取;

栈溢出漏洞原理

栈溢出产生的原因

  • 编写代码时,对于数组和字符串等变量的边界没有进行足够的检测;
  • 栈溢出漏洞的产生总会伴随着以下两个事件:
    • 程序向栈上写入一组数据;
    • 写入的数据总长度没有进行有效的检测;
  • 栈溢出导致攻击者可以通过向其中写入过多的数据,从而覆盖掉栈上的其它变量和原本不能访问到的地址,如返回地址等;

栈溢出的危害

  • 修改父函数栈底地址
  • 修改返回地址
  • 修改SEH链表地址

栈溢出漏洞分析

以如下程序为例进行漏洞分析;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/* stack_overflow */
#include<stdio.h>
#include<string.h>
int hackhere(){
printf("Congratulations! You hacked me now.\n");
}
int foo(){
print("Wrong!!!\n");
}
int main(){
char username[10];
printf("Give me your username:");
scanf("%s",username);
if(strlen(username)==4 && !strcmp(username,"admin")){
hackhere();
}
else{
foo();
}
}

程序分析

  • 程序第12行,在正常的流程下,无法或很难找到一个满足if语句的条件:长度为4而且与 admin相同的字符串;
  • 通过观察程序流程发现,main函数中在栈上申请了一个名为username的字符串;
  • 在读取该字符串的时候对于读入的长度没有进行检测,导致可以通过scanf函数向栈上写入超过username本身长度的字符串从而造成栈溢出

实验步骤

  1. 扔进IDA,进行静态反汇编,找到hackhere函数的地址0x4005F7main函数的执行结束前的地址0x400692

  2. 反汇编main函数;

  3. 分析

    1. username后面的变量声明注释表明username与rbp之间相差了0x0A个字符;
    2. 写入0x0A个字符之后就能覆盖掉rbp所指向的父函数栈底地址;
    3. 想要覆盖rbp所指向的父函数栈底地址下方的函数返回地址,只需要填充0x0A+0x08=0x12个字符后修改即可;
  4. 构造payload如下:

    1
    2
    3
    4
    5
    6
    7
    from pwn import *
    p = process('./stack_overflow')
    hackhere = p64(0x4005F7)
    payload = 'a'*(0x0A + 0x8) + hackhere
    p.sendline(payload)
    p.recv()
    p.interactive()

调试分析

  • gdb调试攻击代码;

  • 继续运行,直到代码停在之前设置的断点0x400692处,可以发现rbp和rsp的地址所指的内容已经被覆盖了;

  • 接下来使用x /20x 0x7ffeb89a0128指令查看栈底的返回地址,此时栈底的返回地址已经被修改成0x400626,即hackhere函数的地址了;

  • 继续运行程序,发现程序结束,打印出Congratulations!You hacked me now,证明栈溢出漏洞利用成功;

备注:实验程序下载