栈溢出攻击初探

7erry

前置知识

段 ( Segment )

一个程序的本质就是 bss 段,data 段和 text 段。其中 text 和 data 段在可执行文件中,程序执行时由系统从可执行文件中加载,bss 段不在可执行文件中,但会被记录数据所需空间大小,在程序链接时链接器得到这个大小的内存块,紧跟在 data 段后面。包含 data 段和 bss 段的整个区段成为数据区。

bss 段(Block Started by Symbol segment)

bss 段是用于存放程序中未初始化的全局变量,静态变量,和被初始化为0的全局或静态变量的内存区域,但 bss 段不占据实际空间,它只是一个占位符。目标文件区分变量初始化与否以提高空间利用率————未被初始化的变量不会占据实际的磁盘空间,而是运行时在内存中分配这些变量并初始化为 0

为什么未初始化的数据叫做 bss
它起源于 IBM 704 汇编语言中“块存储开始 ( Block Storage Start ) ”指令的首字母缩写,并沿用至今,一种记住 .data 和 .bss 段之间区别的简单方法是把 bss 看成是“更好地节省空间 ( Better Save Space) ”的缩写。

data 段 (data segment)

数据段是用于存放程序中已初始化的全局变量的内存区域,属于静态内存分配

text 段 (code segment/text segment)

代码段是用于存放程序执行代码的内存区域,这部分区域的大小在程序运行前就已确定,并且内存区域通常属于只读(NX 保护)

代码段中也可能包含一些只读的常值变量,例如字符串常量等

堆 ( Heap )

堆是用于存放进程运行中被动分配的内存区域,它的大小不固定,可动态扩张和缩减。当进程调用 malloc 等函数分配内存时候,新分配的内存会被动态添加到堆上 (堆被扩张);当进程调用 free 等函数释放内存时候,被释放的内存会被从堆上剔除 (堆被缩减)

栈 ( Stack )

栈是一种经典的数据结构,遵循 LIFO (Last In First Out) ,即后进先出的存取方式。栈的这一特性与函数调用不谋而合,主调 (caller)函数调用被调 (callee)函数时,后被调用的被调函数先返回函数调用结果,先被调用的主调函数后返回函数调用结果。计算机系统通过内存空间中的栈保存函数的参数,局部变量,返回值,返回地址等数据,函数调用的本质就是将不同的数据与地址 push 到栈中或从栈中 pop 到指定寄存器或内存中,更概括一些地来说,计算机通过使用栈来提供对过程的机器级支持。

值得注意的是,由于处理器的约定,栈是从高地址往低地址方向增长的。除此以外,和计算机中的其他存储结构一样,栈是惰性的。当用户执行了”清除”/pop 操作后,作为操作对象的数据并没有被清空,而是变得无法直接访问了 (被释放/free了)

栈帧

栈帧 (Stack Frame)用于保存函数调用中的各种信息,包括但不限于函数创建的局部变量,参数,返回值,返回地址。栈帧由栈顶指针 (栈指针) sp 和栈底指针 (帧指针,基址指针) bp 表示,其中栈顶指针位于栈帧地址最低处,栈底指针位于栈帧地址最高处。

函数调用的底层原理

函数 (function),方法 (method),子例程 (subroutine),回调函数 (handler)等等,本质上都是同一抽象的不同形式,这一抽象叫做过程。为便于称呼,我们把这些过程都叫做函数。它提供了一种封装代码的方式,用一组指定参数和返回值实现了某种功能,尽管在不同语言不同平台不同应用场景下,它们的形式多种多样,但它们都存在着一些最基本的共有的特性。例如,它们必然实现了以下机制,包括但不限于,转移控制,分配和释放内存和数据传送。当讨论函数是如何调用的时候,我们完全可以从这三个角度来看待它们

运行时栈

当一个函数或过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间,这些空间主要用来做两件事。首先,按照遵守的函数调用约定,例如 __cdecl 约定方式,主调函数会先将被调函数的参数从右到左压入栈中,再将返回地址压入到栈中。由于这一行为是由主调函数负责的,返回地址实际上也是与主调函数相关的信息,所以此时分配的空间仍属于主调函数的栈帧。其次,被调函数获取处理器控制权后,会先将主调函数的栈底指针入栈进行保存,即 push ebp ,此时 esp 会指向主调函数的栈底指针,接下来,被调函数会将 esp 的值赋给 ebp,此时的ebp 即为被调函数的栈底指针,它指向的内存空间保存了主调函数的栈底地址,在多层函数调用时,ebp 指针实际上就形成了一个链表,每一个ebp 指针指向的值都是自己的主调函数的栈底地址。再接下来,被调函数会根据自己的需要创建用于存储局部变量的内存空间,这一步通常是通过 sub esp,value 实现的,这样,被调函数就创建出了自己的栈帧。

当被调函数返回时,它只需要把自己被调用时做的事倒着做一遍,就能恢复到函数调用前的状态。具体来说,它会 mov esp,ebp,释放掉局部变量,然后 pop ebp ,将栈底指针恢复到指向主调函数栈底的状态。同时,主调函数也会根据遵守的函数调用约定,例如 __cdecl 约定方式,由主调函数清理调用时使用的栈。

函数调用约定因编译器与平台而异,最主要的三种是 __cdecl ,__stdcall 和 __thiscall
__cdecl 是 C 语言默认的调用约定,同时也是可变函数参数的函数调用约定,因为只有主调函数知道被调函数具体有几个参数,它的栈只能由主调函数清理
__stdcall 是 Windows API 中广泛使用的调用约定,与 __cdecl 调用约定一样,它也将参数从右到左压入栈,但由被调用者清理栈
__thiscall 是 C++ 中成员函数默认的调用约定 , this 指针隐式的作为第一个参数通过 ecx 寄存器传递给函数,剩余参数也是从右向左传递,大多数情况下遵循与编译器默认的函数调用约定一致的原则

转移控制

处理器的控制权从函数 P 转移到函数 Q 实质上就是把程序计数器寄存器 CS:IP 的值设置为函数 Q 的代码的起始位置。显然,当被调用函数交还处理器的控制权时,处理器需要指导它要从函数Q的哪个位置开始恢复执行,因此函数调用指令 call 在转移到被调用函数前会先将紧跟在 call 指令后的下一条指令的地址视作返回后要执行的指令的地址,即返回地址压入到栈中,再将 IP 寄存器的值设置为函数 Q 的起始地址。由于 call指令一般都是 5 个字节的长度,因此

1
call function

往往可以等价为

1
2
push ip + 5
jmp function

与 call 指令相对应的 ret 指令会在函数的末尾执行,被调函数的栈帧被释放后,栈顶指针会指向主调函数的返回地址, ret 指令会从栈中弹出主调函数的返回地址,并将 IP 寄存器的值设置为返回地址。尽管 pop ip 这一指令并不存在 (ip 寄存器的值不能直接修改) , 但 ret 指令实际上等价于它

数据传送

将数据作为参数传递给被调函数与获得函数的返回值等调用函数时的数据传输大多是通过寄存器实现的,被调函数通过访问寄存器取得参数,主调函数通过访问寄存器取得返回值。当一个函数的参数数量大于 6 个的时候,超出 6 个的部分需要通过栈传递 ,主调函数会在自己的栈帧中为超出 6 个部分的参数分配空间。通过栈传递的参数的数据大小需要向 8 的倍数对齐。所有参数到位后,主调函数才会执行 call 指令转移处理器控制权

局部存储

栈上的局部存储

在寄存器不足以存储局部数据时,局部数据会被放在内存中,常见的情况包括但不限于:

  • 寄存器不足够存放所有的局部数据
  • 对一个局部变量取了地址,因此该变量必须要有一个地址
  • 存在某些局部变量是数组或结构体,因此必须能够通过数组或结构引用被访问

一般来说,函数通过减小栈指针在栈上分配空间,分配的结果作为栈帧的一部分。当函数执行结束后,栈顶指针指向栈底位置,局部变量被释放

寄存器中的局部存储

寄存器组是唯一被所有函数共享的资源。

虽然在给定时刻只有一个函数是活动的,我们仍必须确保主调函数调用被调函数时,被调函数不会覆盖调用者稍后会使用的寄存器值。为此,x86-64等处理器架构采用了一组统一的寄存器使用惯例,所有的过程都必须遵守。

根据惯例,寄存器 rbx 、 rbp 和 r12 ~ r15 被划分为被调用者保存寄存器 , 当主调函数调用被调函数时,被调函数必须保存这些寄存器的值,保证它们的值在主调函数调用被调函数时和被调函数返回时是一样的。被调函数保存一个寄存器的值不变,要么是根本不去改变它,要么就是把原始的值压入栈中,改变寄存器的值,然后在返回前弹出寄存器的旧值。而所有其他寄存器,除栈指针 rsp 以外,都分类为调用者保存寄存器。者意味着任何函数都能修改它们。可以这样理解“调用者保存”这个名字,主调函数在某个此类寄存器中有局部数据,然后调用被调函数,被调函数可以随意修改这个寄存器,因为在调用前保存好这些寄存器中的值是主调函数的责任。

栈溢出攻击原理

栈溢出,即向栈中某一变量写入的字节数超过了这一变量被分配的内存大小,导致与其相邻的栈中的内存空间中的内容被覆写。栈溢出是特定情况下的缓冲区溢出,与其类似的还有堆溢出,bss 段溢出等溢出方式,它无疑是二进制攻击的”Hello World”,是最具有代表性的二进制漏洞之一。

栈是从高地址向低地址生长的,程序向内存中写入数据则是从低地址向高地址写入的。不难想到,如果一个程序在执行过程中在栈上写入了大小没有得到充分控制的数据,那么这些数据会溢出用于存储局部变量的内存空间,溢出部分的值将会被程序写入到栈底甚至是前一个栈帧。栈溢出攻击最主要的覆写目标是前一个函数栈帧的返回地址,如果覆写后的这个地址是非法的,例如指向了内核保护的内存区域,程序在执行 ret 指令时就会崩溃,攻击者可能会通过这种方式瘫痪受害者的防御软件。更常见的情况是,覆写后的返回地址是攻击者精心设计的,指向了程序此时本不应该执行的其他指令,攻击者便能够控制程序的执行流程,为 RCE 在内的进一步的攻击打开攻击面,取得更显著的入侵成果。

栈溢出攻击示例

如果存在这样一个 C 语言程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// pwn.c
#include <stdio.h>
#include <string.h>

void backdoor() {
system("/bin/sh");
}

void vulnerable() {
char s[12];
gets(s);
puts(s);
return;
}

int main(int argc, char **argv) {
vulnerable();
return 0;
}

它会调用 vulnerable 函数,该函数会接收用户输入并原样输出,除了一个看起来很危险但不会被调用的 backdoor 函数以外似乎没什么问题。但如果对这一程序进行编译,你会得到来自编译器的警告

text
1
2
3
4
5
6
7
pwn.c: In function ‘vulnerable’:
pwn.c:10:3: warning: implicit declaration of function ‘get’; did you mean ‘fgets’? [-Wimplicit-function-declaration]
10 | gets(s);
| ^~~~
| fgets
/usr/sbin/ld: /tmp/ccSmo4Ao.o: in function `vulnerable':
pwn.c:(.text+0x3f): warning: the `gets' function is dangerous and should not be used.

gets 是一个危险函数,它不会检查输入的字符串的长度,只通过回车判断输入是否结束,是最容易存在栈溢出漏洞的危险函数之一。历史上的第一种蠕虫程序,莫里斯蠕虫就是利用了 gets 函数实现的栈溢出攻击。反编译这一程序的可执行文件,会发现字符串 s 位于 [sp+4h] [bp-14h]的位置,该函数栈帧附近的栈结构是这样的

text
1
2
3
4
5
6
7
8
9
10
11
12
             +-----------------+
| ret addr |
+-----------------+
| caller ebp |
ebp--->+-----------------+
| |
| |
| |
| |
| |
| |
s,ebp-0x14-->+-----------------+

如果攻击者能够通过某种方式,例如在该程序未开启 ASLR 保护的情况下逆向分析了这一程序,得到了 backdoor 函数的地址,并通过缓冲区溢出,用 backdoor 函数的地址覆写了 ret addr ,攻击者便可通过该程序 get shell ,进行任意命令 RCE 攻击,导致特大安全事故。

栈溢出漏洞危险函数

常见的危险函数如下

  • 输入
    • gets
    • scanf
    • vscanf
  • 输出
    • sprintf
  • 字符串操作
    • strcpy
    • strcat
    • bcopy

栈溢出攻击的对抗措施

PIE/ASLR 栈随机

为了在控制攻击目标,攻击者在栈溢出攻击时必然要插入指向能够实现自己攻击目的的代码的指针。在过去,对于所有运行同样程序和操作系统的不同机器之间,栈的位置是相当固定因此也非常容易预测的。因此,如果攻击者可以确定一个常见的服务器所使用的栈空间,就可以实施一个在许多机器上都会有效的栈溢出攻击,这一现象被称作***安全单一化 (security monoculture)***。为了破坏栈地址的可预测性,操作系统会进行栈随机化,使得栈的位置在程序每次运行时都会发生变化。一个简单的验证程序如下

1
2
3
4
5
6
7
#include<stdio.h>

int main() {
int i;
printf("i is at %p\n",&i);
return 0;
}

在同一机器上每次运行这个程序都会得到不同的输出。在 32 位 Linux 上运行这段代码,地址变化范围为 0xff7fc59c 到 0xffffd09c ,范围大小约为 2^23 。 在 64 位 Linux 上时,地址变化范围为 0x7fff0001b698 到 0x7ffffffaa4a8 ,范围大小约为 2^32 。栈随机化已经成为了操作系统的标准行为,它是更大类的技术中的一种,这类技术叫做 ASLR ( Address-Space Layout Randomization ) ,中文名是地址空间布局随机化。

然而,随机化的防御手段并不总是有效。一个执著的攻击者总是能够用蛮力克服随机化,他可以反复地用不同的地址进行攻击。一种常见攻击技巧就是在实际插入的攻击代码前插入很长一段的 nop ( 读作“ no op”,no operation 的缩写 ) 指令。 执行该指令除了对程序计数器加一,使之指向下一条指令之外,没有任何的效果。只要攻击者能够猜中这段序列中的某个地址,程序就会经过这个序列,到达攻击代码。这个序列常用的术语是 “空操作雪橇 ( nop sled )” ,即程序会”滑过”这个序列。如果攻击者建立一个 256 字节的 nop sled ,那么枚举 2^15 = 32768 个起始地址,就能破解地址变化范围大小为 2^23 的随机化,这对于一个顽固的攻击者来说,是完全可行的。如果攻击目标的操作系统是 64 位,枚举 2^24 = 16777216 次,才会让人有一些畏惧。栈随机化和其他 ASLR 技术能够增加成功攻击一个系统的难度,因而大大降低了病毒或者蠕虫的传播速度,但是也不能提供完全的安全保障

Stack Canary 栈破坏检测

计算机的第二道防线是检测程序执行时栈是否被破坏,之前的示例中可以看到栈溢出攻击需要首先实现栈溢出。在 C 语言和一些其它语言中,没有可靠的办法阻止写入数据的越界,但系统有办法在栈溢出发生并造成有害效果前尽可能地去检测它。GCC 在编译时在栈帧中的任何局部缓冲区与帧指针间(不一定与 ebp 相邻)存储一个特殊的金丝雀 ( Canary ) 值,也叫做哨兵值 ( guard value )。Canary 即金丝雀,在技术上表示最先的测试。这一说法来自以前挖煤时,矿工都会先把金丝雀放进矿洞,或者挖煤的时候一直带着金丝雀。金丝雀对甲烷和一氧化碳浓度比较敏感,会先报警。所以开发者会用Canary来指代最先的测试。该值会在程序每次运行时随机生成,当程序执行到 Canary 处时,会检测 Cannary 值是否与程序刚开始所生成的值一样,如果不一样则说明必然发生了栈溢出,此时程序会抛出异常并终止,从而达到保护自身的目的。一个启用了栈破坏检测机制的函数的汇编指令一般会是

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
; void function()
function:
push rbp
mov rbp,rsp
sub rsp,100
mov rax,fs:40
mov [rbp - 8],rax
xor rax,rax
...
...
mov rax,[rbp - 8]
xor fs:40,rax
je .L9
call __stack_chk_fail
.L9:
mov rsp,rbp
pop rbp
ret

这段程序会从内存中读出一个值(读 Canary 的内存段往往是只读的),再将它存放到栈上适合存放 Canary 的位置,在释放栈帧前,程序将栈上的 Canary 和 内存中的 Canary 进行异或运算,如果两个值相等,即异或运算的结果为 0 ,函数会按照正常流程完成,否则程序会调用一个错误处理历程。

栈破坏检测机制很好地防止了缓冲区溢出攻击对栈上内存的破坏,同时 GCC 只在函数中育局部 char 型缓冲区的时候才会插入 Canary ,它带来的性能损失也非常小。当然,它也不能完全防御栈溢出攻击。Canary 的末位为 ‘\0’ ,因此 Canary 可能会被故意覆盖最后一字节被输出导致泄露,攻击者便可以在栈溢出时保持 Canary 值不变,仿佛栈溢出没有发生地实现栈溢出攻击。

NX 限制可执行代码区域

各大厂商为自己的处理器内存引入了 “NX” (No eXcute 不执行) 位,将读和执行访问模式分隔开(在此之前,由于区分可读与可执行会带来严重的性能损失,因此可读与可执行共用一个标志位,彼此的权限是互通的)。有了这个特性后,NX 位会标记数据所在内存页为不可执行,避免程序执行被写入的 shellcode ,而检查页是否可执行则由硬件完成,避免了效率上的损失。

和前面的保护机制一样,NX 也并非总是有效,例如一些解释性语言会采取 JIT (Just-In-Time) 技术动态地生成执行的代码以提高程序性能,此时 NX 能否正确地限制执行代码的范围仅在编译器创建原始程序的部分中,取决于语言和操作系统

其他

其他的保护程序的方法还有

  • RELRO
    • 部分 RELRO
    • 完全 RELRO
  • FORTIFY

Reference

CTF Wiki
C函数调用介绍
CS APP

  • Title: 栈溢出攻击初探
  • Author: 7erry
  • Created at : 2024-02-04 19:32:18
  • Updated at : 2024-02-04 19:32:18
  • Link: http://7erry.com/2024/02/04/栈溢出攻击初探/
  • License: This work is licensed under CC BY-NC 4.0.