the art of software security assessment Chap5. translate

7.第二部分 软件漏洞

7.1 第五章 内存损坏 (memory corruption)

“ 几乎所有的人都能忍受逆境,但如果你想测试一个人的人格,那就给他力量。” ——亚伯拉罕 林肯

7.1.1 概述

在本书中,你应该接受这样的假设,即任何内存损坏漏洞都是可以被利用来实施攻击的,除非你能证明它不成立。这个假设看起来很极端,但这对代码审计者来说非常有用。攻击者经常可以利用对越界的内存进行写操作来任意地改变一个进程的运行状态, 从而违反应用程序应该执行的任何安全策略。 然而,除非你对如利用内存损坏有一定了解,否则你很难接受内存损坏漏洞的严重性,甚至无法理解它们。

漏洞利用和软件审计是两种高度互补的技巧。一个审计者拥有对漏洞利用的理解能提高效率,并且发现那些无伤大雅的bug与真实漏洞的区别。关于内存损坏漏洞的知识有很多,本章会简要介绍在Intel x86架构下机器的一些基本方法(当然这些概念同样适用于所有架构)。顺着漏洞利用的方法,你可以学到更多关于反漏洞利用技术以及发现可利用之处的方法。本教程并不打算作为开发内存损坏漏洞的权威指南,但它确实提供了你需要了解和了解贯穿本书的许多漏洞的背景知识。

读者如果有兴趣了解更多关于利用内存损坏漏洞的知识,可以参考The Shellcoder’s Handbook (Wiley, 2004))或 Exploiting Software(Addison-Wesley, 2004))。你还可以找到许多关于开发技术的在线资源,如 phrack magazine(www.phrack.org)和Uninformed magazine(www.uninformed.org)。

7.1.2 缓冲区溢出

你可能很熟悉“缓冲区溢出”(buffer overflow)这个术语,如果不是的话,下面是它的定义:缓冲区溢出是一种软件bug,它是当数据被复制到内存的某个位置但这段内存不足以容纳这些数据时产生的。当缓冲区溢出发生时,溢出的数据会破坏与目标缓冲区相邻的信息,并通常造成灾难性的后果。

缓冲区溢出是最常见的一种内存损坏漏洞。如果不你熟悉如何利用这种bug,它们似乎违背逻辑,以某种方式允许攻击者完全访问脆弱的系统。但的机制是如何的呢?为真么它是对系统一致性(system integrity)的一种威胁呢? 为什么操作系统会保护一段内存不被损坏呢?为了回答这些问题,你需要对程序内部以及CPU和操作系统怎样管理进程有所熟悉。

一些在本书种提到的漏洞严格来说并不是缓冲区溢出而是更加复杂的内存损坏漏洞,但它们都有很多共同特征。关于可利用性的讨论主要适用于这类问题,特别是在本章后面的小节“评估内存损坏影响”中。

进程的内存布局

一个进程可以被操作系统选择任意的方式布局在内存中,但几乎所有当下的系统都遵循了一些常见的约定。通常的,一个进程被组织在以下几个主要区域:

  • 程序代码区。这个区域包含了能被处理器解释并运行的可执行程序指令。程序代码包括了编译后的运行程序代码以及额外的被程序所使用的位于共享库的代码。共享库通常不和主程序的代码放在一起。
  • 程序数据区。这个区域储存了非函数本地的变量。包括了全局和静态变量。数据区通常包含了一个动态内存区域称为“程序堆”(program heap)用来储存动态分配内存的变量。
  • 程序栈区。栈(stack)区用来存储当前正在执行的函数,并且它在函数执行时跟踪函数的调用链。

尽管这是个关于进程内存如何组织的高级视角,但它也显示了缓冲区溢出漏洞的影响如何根据缓冲区的位置而变化。下面几节讨论与每个位置相关的常见和惟一的攻击模式。

栈溢出

栈溢出(stack overflow)是一种目标缓冲区位于运行中程序栈的缓冲区漏洞。它们是最容易理解,并且在历史上是最直接的可以利用的缓冲区溢出。本节涵盖了关于运行中程序栈的基础知识,然后显示攻击者是怎样利用基于栈的缓冲区溢出的。

抽象数据类型:栈

从一般的计算机科学视角来看,栈是一种抽象数据类型(abstract data type,ADT),它用来有序存储和检索一系列数据元素。 栈数据结构通常会给用户提供两种用来控制的操作:

  • push()push操作添加一个元素到栈的顶端
  • pop() pop操作将栈顶元素移除,并将其作为返回值;

栈是一个后进先出(last-in, first-out LIFO)的数据结构,你可以将它看成一个物理的盘子的堆栈。你可以将一个盘子放在栈的顶端,相当于push()操作,你也可以将顶端的盘子拿走,对应pop()操作。你不能在移出上面的盘子之前直接从栈的中间拿走一个盘子。

运行时栈

每个进程都有运行时栈(runtime stack),也叫做程序栈(program stack),调用栈(call stack),或者直接称它为“栈”。运行时栈为每种结构化编程语言中使用的函数提供了必要的基础。 函数可以用任意的顺序调用,它们可以是递归的,也可以是相互递归的。运行中的栈通过激活记录(activation record)支持这个功能,激活记录也就是记录了从函数到函数的调用链以便在函数返回时跟踪它们。激活记录当然也包含了每次函数调用时需要分配的数据,例如局部变量,存储的机器状态以及函数参数。

因为运行时栈是程序运行不可分割的一部分 ,因此它们在CPU的帮助下实现而不是通过纯净的软件抽象。处理器通常有专门用来指向栈顶的寄存器,这些寄存器用机器指令push()pop()来对栈进行修改。在因特尔x86 CPU中,这个指针叫ESP(ESP表示扩展堆栈指针,extended stack pointer)。

在几乎所有现代CPU中,栈都是向下生长的,也就是说栈通常在虚拟内存的一个高地址开始,然后顺着低地址生长。一个push操作让栈指针减去固定值,然后栈指针就移动到了更低的进程内存。相应的,pop操作让栈指针加一个固定值将它移动到更高位置的内存。

每当函数被调用时,程序都会创建一个新的栈区, 它只是一个保留的连续内存块,用于存储本地变量和内部状态信息。 函数在它返回之前就使用这一段内存,在返回之后,它会被移出栈。为了了解这个过程,考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int function_B(int a, int b){
int x,y;
x = a * a;
y = b * b;
return (x+y);
}

int function_A(int p, int q){
int c;
c = p * q * function_B(p, p);
return c;
}

int main(int argc, char **argv, char **envp){
int ret;
ret = function_A(1, 2);
return ret;
}

function_A()调用时,一个新的栈会被分配出来然后放置在堆栈的顶部,如图5-1所示。

这张图只是程序栈的一个简化版本,但你可以看到main()函数栈的布局中调用了function_A().

5-1

图5-1和图5-2第一眼看起来可能会让你感到困惑,因为栈似乎是从上往下生长的,然而这并不错。如果你想象一个从地址0到0xFFFFFFFF的内存地址空间,低地址接近于0,因此在图中就是比较高的地方。

WeChat Screenshot_20200512175525

图5-2显示了当function_A()调用function_B()时程序栈会变成的样子。

function_B()运行完毕后会返回到function_A(),于是function_B()对应的这一栈段就会出栈,然后程序栈会重新像图5-1那样,简单来说就是寄存器ESP储存的值会被重置回当function_B()调用时。

5-1和5-2的栈图是简化版的。事实上,main()函数不是调用栈的第一个函数。通常,函数会在main()函数设置进程环境时就被调用。例如,gibic Linux系统通常以名为_start()的函数开始,它会调用_libc_start_main(),然后这个函数最后再调用main()

每个函数都管理着属于自己的栈段,这些栈段取决于有多少局部变量在函数中以及这些局部变量的大小。局部变量需要当函数需要时被直接访问,如果只使用push和pop指令则效率会不高。因此,很多程序会利用其他的寄存器来完成这些事,它们叫帧指针(frame pointer)或者基址指针(base pointer)。在Intel x86 CPU中,这个寄存器名字叫EBP(extended base pointer)。这个寄存器指向函数栈段的起始点。每个在给定栈段中的变量都可以通过引用基址指针开始的固定偏移量的内存位置被访问。对基址指针的使用并不是必要的,有时候甚至是多余的,当然为了讨论它,我们可以假设它是存在的。

前面忽略的一个重要细节是记录在每个栈段中的内部状态信息。状态信息的存储随着处理器架构的不同而不同。但它通常包含了先前函数段的指针以及返回地址。这个返回地址的值被保存过了,所以当当前运行函数运行完毕返回时,CPU就会知道当前运行程序应该从哪里继续。当然,帧指针的值必须恢复,以便在函数调用分配其自身栈段的子函数之后,局部变量的访问能保持一致。

函数调用约定

调用约定(calling convention)描述了函数参数时怎样传入以及调用和被调用的函数必须执行哪些栈的操作。 在本章前面一节“运行时栈”中说明了最流行的一种调用约定,然而调用约定会随着处理器架构,操作系统,编译器的不同而不同。

编译器可以通过优化来改变调用约定。例如,一种流行的 x86 调用约定叫快速调用(fast call)。如果可能,快速调用会将函数参数传入寄存器,这样可以提高变量访问的速度以及减少栈上的操作。每个编译器都有不同版本的快速调用。

语言特性也可以造成不同的调用约定。一个典型的例子就是要求访问类实例的this指针的C++类成员函数。在windows x86系统中,this指针会被传入ECX寄存器中传递给具有固定数量参数的函数。相反,在GCC C++编译器中会将this指针当作函数最后一个变量将它push到栈中。

栈指针也必须被恢复到它以前的状态,但这个过程不是隐式的,被调用的函数必须在返回前重置栈指针到正确的位置。这个过程时必须的,因为储存的帧指针以及返回的地址会从栈顶被恢复。通过使用隐式使用栈指针的pop指令,帧指针能够被恢复。用于从函数返回的ret指令也隐式地使用ESP寄存器获取返回地址。

每个函数会分配自己的栈段,因此,它们需储存自己的段指针。下面这段代码显示了在Intel机器中函数在开头是怎样储存自己的栈段指针的。

1
2
3
text:5B891A50		mov edi, edi
text:5B891A52 push ebp
text:5B891A53 mov ebp, esp

这段函数开头的代码并不要求调用者具体地将返回地址push到栈中,这个过程会被call指令完成。因此当function_B()被调用时栈地布局会如图5-3所示。

5-3

你可能注意到了在上面那段代码中一个看起来没有用的指令(mov edi, edi)。这个指令只是一个占位符,添加它时为了简化系统监视和debug。

利用栈溢出

如你所见,局部变量非常接近彼此。事实上,它们被安排在了连续的内存中。因此如果一个程序有这样一个漏洞,它允许数据将数据写入到本地栈缓冲区末尾之后的地方,那么与数据相邻的变量就会被重写。这些相邻的变量可以包括其他的局部变量,程序状态信息,甚至函数参数。取决于有多少个字节能够被写入,攻击者能够在前面的栈段中损坏变量以及状态信息。

编译器有时会在一个变量和下一个变量之间添加填充,这取决于优化级别异界变量大小等多个因素。出于讨论目的,可以将变量视为连续的。

我们来考虑一个简单的写入覆盖局部变量的情况。写入一个局部变量的危险性就是你可以任意更改一个应用不想让你更改的变量的值。状态的变化通常也可以造成你不想要的后果。考虑以下代码:

1
2
3
4
5
6
7
8
9
int authenticate(char *username, char *password){
int authenticated;char buffer[1024];
authenticated = verify_password(username, password);
if(authenticated == 0){
sprintf(buffer,"password is incorrect for user %s\n",username);
log("%s", buffer);
}
return authenticated;
}

假设用来验证的变量位于栈段的顶部,将其放置在比buffer变量更高的内存位置。函数栈就像图5-4那样

5-4

图5-4展现了代码可能的布局。然而你不能从源代码中推断出变量在栈段中是怎样分布的。为了优化性能,编译器可以(通常也会这样做)重新布局变量。

authenticate()函数有缓冲区溢出的漏洞。具体来说,printf()函数没有规定它写入输出缓冲区数据的多少。因此,如果一个username字符串大约在1024字节大小的话,数据将写入缓冲区变量的末尾之后,并写入变量authenticate。(注意authenticated()在栈的顶部)。图5-5显示了当溢出触发时会发生什么

5-5

authenticated是一个简单的状态变量,用来表示用户是否能够成功登陆。0值表示验证失败,非0值表示成功。通过溢出缓冲区的变量,攻击者可以重写authenticated变量,然后让它变成非0值,于是调用函数不正确地将攻击者当作了验证成功。

重写相邻地局部变量是一个很有用的技巧,但一般来说它很难应用。这种技巧取决于什么样的变量能够被重写,编译器如何在内存中安排变量,以及程序在溢出发生后会做什么。一个更一般性的技巧是定位到每个栈帧保存的状态信息,即保存的帧指针以及返回地址。对于这两个变量来说,返回地址对于攻击者来说是最重要的。如果缓冲区溢出能够重写保存的返回地址,应用程序就可以在当前运行函数返回时重定位到任意的位置,这个过程见图5-6:

5-6

本质上说,攻击者会在程序中保存一些对他们有用的代码的地址,并用这个新地址覆盖返回地址。确切的位置取决于攻击者,但有两种比较基本的选择:

  • 运行程序可以被重定位到应用程序运行的代码段或者一些在可以利用的分享库中的代码。例如,在UNIX libc里的system()函数,这个函数通过shell来运行命令。
  • 运行程序可以被重定位到一个包括了攻击者控制数据的内存区域,例如全局变量,一个栈的位置,或者一个静态的缓冲区。在这种情况下, 攻击者用一小段与位置无关的代码填充目标返回位置,以完成一些有用的工作,例如连接回攻击者并在连接的套接字上生成shell。 这些小段的代码通常被称为shellcode.
SEH 攻击

Windows系统很容易受到与传统堆栈溢出攻击稍有不同的攻击,这种变体称为 “销毁结构化异常处理程序”。 Windows提供了结构化异常处理( structured exception handling, SEH),因此成语可以注册一个处理程序,以一致的方式处理错误。当一个线程导致了一个异常抛出,这个线程就有机会捕获异常并进行恢复。每当一个函数注册异常处理程序时, 它都被放在当前注册的异常处理程序链的顶部。 当异常抛出时,这个程序链就会从顶部遍历,直到为抛出的异常找到正确的处理程序类型为止。如果没有找到合适的异常处理程序,异常就会被送到一个“未处理的异常过滤器”(unhandled exception filter),这个过滤器一般会终止该进程。

异常处理时许多程序拥有的特性并且是由C++推广而来的。尽管C++的异常处理比基础的Windows SEH机制更复杂,但C++异常在Windows中是通过SEH来实现的。如果想了解更多关于C++异常处理的内容,请查看该链接: www.openrce.org/articles/full_view/21.

SEH提供了一个便利的方法来利用Windows系统的栈溢出实施攻击,因为异常处理程序注册结构就位于栈中。每个结构都由例行注册程序(handler routine)的地址以及一个指针指向它的父注册程序。这种结构见图5-7(其实就是一个链表 — By 译者):

5-7

当异常发生时,这些记录将从最近注册的处理程序遍历到第一个处理程序。在每一层中,处理程序都运行决定当前抛出的异常是否合适。(这个说明有点过于简化了,但有一篇很好的文章描述了这一过程,见: www.microsoft.com/msj/0197/exception/exception.aspx.)因此,如果攻击者能够通过任何方法触发溢出,然后触发任何类型的异常,检查这些异常注册结构时,异常处理程序会定位到每个调用的结构直到找到其中合适的哪一个。因为它们是被攻击者损坏过的栈,程序就会跳转到攻击者选择的地址。当能够造成大量数据溢出缓冲区时,攻击者就可以复制整个栈区,在栈基指针被修改时导致异常抛出。然后应用程序在栈上使用被损坏的SEH信息然后跳跃到任意地址,这个过程见图5-8:

5-8
单字节溢出(Off-by-One Errors)

内存损坏经常会由算错了数组的长度造成。其中最常见的错误就是单字节溢出一错误,即算错了数组1个单位的长度。这种错误最典型的原因就是没用考虑终止符号或者错误地理解了数组索引的工作方式。考虑下面的例子:

1
2
3
4
5
6
...
void process_string(char *src){
char dest[32];
for (i = 0; src[i] && (i <= sizeof(dest)); i++)
dest[i] = src[i];
...

process_string()函数一开始从变量src读入一定数量的字符然后将它们存入栈中的缓冲区dest。这份代码想要防止在src大于32个字符时产生缓冲区溢出。然而这里面有一个简单的问题:这个过程可以从dest写入超过边界1个单位的元素。数组索引从0开始,到sizeof(array)-1结束,所以一个长度为32的数组应该从0到31进行索引。上面代码的过程会越过dest的末尾,因为控制循环的语句是(i<= sizeof(dest)),但正确的情况应该是(i< sizeof(dest))。在这份有漏洞的代码中如果i增加到32时,就会越过长度的检查,程序就会将dest[32]设置为src[32]

这种类型的问题重复地出现在C语言的字符串中。C字符串在要求为每个字符提供存储空间时还会额外加上一个空字节用来存储终止符。这个空字节经常没有被正确地考虑进来,因此就造成了难以被察觉的单字节溢出一错误,就像下面这份代码一样:

1
2
3
4
5
6
int get_user(char *user){
char buf[1024];
if(strlen(user) > sizeof(buf))
die("error: user string too long\n");strcpy(buf, user);
...
}

这份代码使用strlen()函数来检查是否有足够的空间来将用户名复制到缓冲区中。strlen()函数返回C字符串的长度,但没有考虑终止符所占的空间。所以一个strlen()返回长度1024的字符串实际上占用了1025个字节的内存。在get_user()函数中,如果user字符串正好是1024个字符,strlen()返回1024,sizeof()返回1024,长度检查通过,于是strcpy()函数写入1024个字节的数据再加上一个字节的空字符,导致buf中被多写入了一个字节。

你可能觉得单字节溢出一错误很罕见,就算发生,也很难被利用。然而,再Intel x86架构机器上运行的操作系统上,这种错误往往是可以被利用的,因为你至少可以向存储在栈上的帧指针写入至少一个字节的数据。在程序执行的课程上你就知道,每个函数都会分配一个栈帧指针用于局部变量的存储。这个被叫做帧指针或者基址指针的地址由寄存器 EBP 来存储。在函数开始运行前,程序会将旧的基址指针存入返回地址相邻的栈中。如果在一个正好位于基址指针存储位置之下的缓冲区引发了单字节溢出一错误,空字节被写入了缓冲区末尾之后,就会导致至少一个字节写入了保存后的基址指针。这就意味着当函数返回时基址指针至少会出现255个字节的跳转错误,就像图5-9显示的这样。

5-9

如果新的基址指针指向了一些被用户控制的数据(比如一段字符缓冲区),用户就可以指定来自前一个栈帧的局部变量值以及保存的基址指针的返回地址。因此,当调用函数返回时,返回地址就会变成指定的位置,以夺取对程序的完全控制。

单字节溢出一错误还可以在一个元素越界写入另一个被当前函数使用的变量时被利用。单字节溢出一错误的安全影响取决于相邻的变量在溢出之后是如何使用的。如果这个变量是一个记录大小的整数,在被截短后程序就无法在这个值的基础上做出正确的计算。相邻变量也有可能直接影响到安全模型。比如,如果它是一个用户ID,就可能允许用户得到它没有被授予的权限。尽管这些类型的漏洞利用都是特定实现的,但它们的严重性不亚于广义的攻击(generalized attacks)。

堆溢出

堆溢出(heap overflow)的漏洞利用技巧要更高级一些。尽管在当前已经非常普遍了。常见的堆溢出攻击技术直到2000年7月才浮出水面。这些技术一开始被一个成就非凡的被称为Solar Designer的安全研究员发布,见 www.openwall.com/advisories/OW-002-netscape-jpeg/ 。为了理解堆溢出攻击是如何工作的,你需要熟悉堆是如何维护的。下面的小节将介绍堆管理的基础,然后阐述堆溢出攻击如何实现。

堆管理

堆的实现各不相同,但在几乎所有的算法中都有一些共通性。本质上,当调用malloc()或者相似的分配函数时,一些内存就会从堆中取出返回给用户。当这些内存被free()函数取消分配时,系统就必须将这些内存标记为未被使用以便在以后被用户继续使用。结果就是, 必须为返回给调用者的内存区域保留状态,以便能够有效地分配和回收内存。 在很多情况下,这些状态信息被存在了里面。特别地,大多数堆实现中会在向用户返回的一块内存前面留一个头,描述这个内存块的基本特征,以及相邻地址内存块的一些附加信息。在这块头部中的信息通常包括:

  • 当前内存块的大小
  • 前面内存块的大小
  • 这个内存块是否正在被使用
  • 一些附加的标记

BSD系统和其他操作系统对堆的内存管理不一样,它们会将内存块的信息存在内存块之外。

未被使用的内存块通常使用一些标准数据结构链接在一起,比如单向或者双向链表。大多数堆的实现中会定义一个最小大小,这个最小大小通常是大到能够存下指向前后元素的链表指针,在这个内存块未被使用时存储这些变量。图5-10时一个简单的由glbic malloc()实现的内存块结构:

5-10
堆溢出漏洞利用

就像你所猜测的那样,能够任意地对头部数据和链表指针进行写入的操作(通常是在堆溢出发生时)给了攻击者破坏堆块管理的机会。这些破坏可以用来修改块头部信息,通过堆管理算法来执行任意代码,特别是未被使用的链表块的管理算法。这个过程第一次被Solar Designer发现, 在Phrack 57中有深入的描述 (www.phrack.org/phrack/57/p57-0x09). 下面总结一下标准的流程:

  • 内存块被标记为未被使用时会有链表指针指向下一个和前一个在链表中的内存块
  • 当一个内存块被标记为未被使用时,通常会和相邻未被标记的内存块合并
  • 因为两个内存块被合并为一个,堆算法会移除在链表中的下一个块,调整当前块的大小,将新的大内存块加入链表中
  • 溢出的缓冲区能够修改位于损坏地区的链表指针,将它们指向对于攻击者有利的位置
  • 当unlink操作执行后,一个由攻击者提供的,一定长度的值就被攻击者写入他们决定的内存位置上

为了理解为什么将两个块unlink能够导致任意地址被改写,考虑下面的在双向链表中用来unlink一个元素的代码:

1
2
3
4
5
6
7
int unlink(ListElement *element){
ListElement *next = element->next;
ListElement *prev = element->prev;
next->prev = prev;
prev->next = next;
return 0;
}

这个代码通过更新相邻两个链表元素的指针,移出对当前元素的引用来移出元素。如果你可以修改 element->nextelement->prev的值,你就能看到这份代码不经意地将任意地址的值修改为了你可以控制的值。这个过程在图5-11 unlink前和图5-12 unlink后可以显示:

5-11_12

能够向任意内存位置写入可控制的值通常就是攻击者能够得到进程控制权所有需要的前提。许多有用的值都可以让攻击者利用。一些比较常见的目标在下面列出:

  • 全局偏移表(Global offset table,GOT)/过程连接表( process linkage table /PLT),UNIX ELF二进制文件使用几种加载结构来将库中调用的函数解析为地址。 这些结构使共享库能够位于内存中的任何位置,以便应用程序在编译时不需要API函数的静态地址。 通过针对这些结构,攻击者可以在调用某个API函数时将执行重定向到任意位置(例如,free())。
  • 退出处理程序(exit handlers)。退出处理程序是一个函数指针表,当进程在UNIX操作系统中退出时调用。通过重写这些值中的一个,就可以在main()函数返回时调用exit()函数时让任意代码执行。
  • 锁指针(lock pointers)。Windows使用一系列在进程环境空间( process environment block ,PEB)的函数指针用来防止竞争线程对进程信息的非同步修改。这些锁指针可以被改写然后由特定类型的异常条件触发 。
  • 异常处理程序例程 (exception handler routine)。Windows PEB 为未处理的异常过滤器例程维护一个地址。 这个例程在当异常未被成功被其他异常处理程序成功处理时被调用。一个常见的方法就是当更新链表的一部分(比如前一个元素)时利用链表维护代码覆盖未处理的异常例程然后导致当更新其他部分的链表(下一个元素)时违反内存访问权限。这个技术保证了异常处理程序例程在假设了其他异常处理程序没有成功 捕获产生的访问冲突异常时立即被调用。
  • 函数指针。应用程序会由于很多原因使用函数指针,比如调用函数来自动态加载的库中,用于C++虚成员函数, 或者用于在不透明结构中抽象低级工作函数。 覆盖应用程序特地给的函数指针能够提供针对应用程序的可靠利用。
全局和静态数据溢出

全局和静态变量用于存储在不同函数调用之间持久存在的数据,因此他们通常存储在不同于栈和堆的内存段中。通常,这些位置不含有广义的程序运行时的数据结构,例如栈激活记录或者堆块数据,因此在这个段中的漏洞利用要求和单字节溢出一错误差不多,应用于特定程序。可利用程度取决于什么样的变量能在缓冲区溢出发生时够被损坏以及这些变量如何被使用。例如,如果一个指针变量能够被损坏,可利用的几率就增加了,因为这个被损坏的变量能够有概率向任意位置进行写入。

7.1.3 Shellcode

缓冲区溢出通常是通过将执行定向到内存中存储攻击者控制数据的已知位置来利用的。为了成功利用,该位置必须包含允许攻击者执行恶意活动的可执行机器代码。这是通过构造用于启动shell、连接回原始用户或执行攻击者选择的任何操作的一小段机器码来实现的。在撰写本文时,shellcode构造中最常见的趋势是使用能够根据需要在连接的套接字上加载额外组件的存根,这是另一端的攻击者所需要的。

写code

在最基本的层次上,shellcode是一小块位置无关的代码,它使用系统api来实现你您的目标。要了解这是如何做到的,请考虑在UNIX中生成shell的简单情况。在本例中,要运行的代码大致如下 :

1
2
char *args[] = { "/bin/sh", NULL };
execve("/bin/sh", args, NULL);

这个简单的代码在运行时生成一个command shell。如果此代码在网络服务中运行,则需要在stdin、stdout和可选的stderr上复制用户连接的套接字描述符。

要构造生成shell所需的机器码,你需要在较低的层次上理解这些代码是如何工作的。execve()函数由标准C库导出,因此普通程序首先在加载器的帮助下找到libc execve()实现,然后调用它。由于这种功能很难在适当大小的shellcode中复制,所以通常需要寻找一种更简单的解决方案。事实证明,execve()也是UNIX系统上的系统调用,libc函数所做的只是执行系统调用。

在基于intel的操作系统上调用system call通常涉及构建参数列表(在寄存器或堆栈中,这取决于操作系统),然后请求内核代表进程执行系统调用。这可以通过多种方法来实现。对于Intel系统,系统调用功能可以依赖于一个软件中断,由int指令发起;呼叫门用lcall调用;或特殊用途的机器支持,如sysenter。对于Linux和许多BSD变体,int 128中断是为系统调用保留的。当这个中断生成时,内核会处理它,确定进程需要执行一些系统功能,并执行请求的任务。Linux系统的程序如下:

  1. 将system call参数放在从EBX开始的通用寄存器中。如果一个系统调用需要五个以上的参数,则会在堆栈上放置其他参数。
  2. 将所需的system call number放入EAX
  3. 使用int 128指令执行system call

汇编代码一开始会像这样:

1
2
3
4
5
6
xorl %eax, %eax ; zero out EAX
movl %eax, %edx ; EDX = envp = NULL
movl $address_of_shell_string, %ebx; EBX = path parameter
movl $address_of_argv, %ecx; ECX = argv
movb $0x0b ; syscall number for execve()
int $0x80 ; invoke the system call

当你创建shellcode时,你需要的几乎所有功能都由一系列system call组成,并遵循这里给出的基本原则。在Windows中,system call number在操作系统版本中是不一致的,因此大多数Windows shellcode加载系统库并调用这些库中的函数。一个名为“谵语的最后阶段”(Last Stage of Delirium,LSD)的黑客组织在www.lsd-pl.net/projects/winasm.zip上记录了编写大多数现代Windows shellcode的基础。

在内存中找到你的代码

所构造的机器码段必须是位置独立的,也就是说,无论它们在内存中的位置如何,它们都必须能够成功运行。要理解为什么这很重要,请考虑上一节中的示例;你需要提供参数数组向量的地址和pathname参数的字符串"/bin/sh"的地址。通过使用绝对地址,你在很大程度上限制了shell代码的可靠性,并且需要针对你编写的每个漏洞修改它。因此,你应该有一种动态确定这些地址的方法,而与代码运行的进程环境无关。

通常,在Intel x86 cpu上,shellcode所需要的字符串或数据是与代码一起提供的,它们的地址是独立计算的。要理解这是如何工作的,请考虑调用指令的语义。这个函数隐式地在堆栈上保存一个返回地址;它是调用指令后的第一个字节的地址。因此,通常使用以下格式构造shellcode:

1
2
3
4
5
6
jmp end
code:
... shellcode ...
end:
call code
.string "/bin/sh"

这个示例跳到代码的末尾,然后使用call运行直接位于jmp指令之后的代码。这种间接的意义是什么?基本上,你将字符串"/bin/sh"的相对地址定位在堆栈上,因为调用指令隐式地将返回地址推入堆栈。因此,无论shellcode位于目标应用程序中的何处,都可以自动计算“/bin/sh”的地址。结合上面的信息,execve() shellcode看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
jmp end
code:
popl %ebx ; EBX = pathname argument
xorl %eax, %eax ; zero out EAX
movl %eax, %edx ; EDX = envp
pushl %eax ; put NULL in argv array
pushl %ebx ; put "/bin/sh" in argv array
movl %esp, %ecx ; ECX = argv
movb $0x0b, %al ; 0x0b = execve() system call
int $0x80 ; system call
call code
.string "/bin/sh"

如你所见,启动shell的代码相当简单;你只需分别用pathnameargvenvp填充EBXECXEDX,然后调用一个system call。这个示例是一个简单的shellcode片段,但是更复杂的shellcode基于相同的原则。

7.1.4 保护机制

到目前为止所涵盖的基础知识代表了一些当代系统的可行的开发技术,但是安全前景正在迅速变化。现代操作系统通常包括预防技术,使其难以利用缓冲区溢出。这些技术通常会减少攻击者利用漏洞的机会,或者至少减少构建程序可靠地利用目标主机上的漏洞的机会。

第三章“操作审查”,从高级操作的角度讨论了这些技术中。本节以第三章的内容,侧重于常见的反内存损坏保护的技术细节,并解决这些机制中潜在的和真正的弱点。本讨论并不是对保护机制的全面研究,但确实涉及了最常用的保护机制 。(第三章我没有翻译 — by译者)

Stack Cookies

Stack Cookies(通常也叫金丝雀值 (canary values))是一种目的在于检测和预防应用于栈的缓冲区溢出攻击的方法。Stack Cookies是Windows XP SP2及以后版本附带的大多数默认应用程序和库中提供的编译时解决方案。还有一些堆栈信息记录程序保护的UNIX实现,最著名的是ProPolice和Stackguard。

Stack Cookies通过插入随机的32位值,通常在运行时栈上当在局部变量之前的返回地址和保存的栈帧指针之后立即生成,就像图5-13显示那样。

5-13
不足

这个技术很有用但并不完善。尽管它防止了保存的栈帧指针和返回地址被修改,但它并没有保护相邻局部变量被修改,图5-5显示了覆盖局部变量会对系统安全造成什么样的影响,特别是当你损坏了函数用来修改数据的指针的值。通过修改这些指针的值通常导致了攻击者通过覆盖指针为有用的值来获得应用程序的控制权。不过, 许多栈保护系统对局部变量重新排序,这可以最小化相邻变量覆盖的风险。

另一种攻击是写入Stack Cookies并覆盖当前函数的参数。攻击者通过覆盖函数参数来破坏Stack Cookies,但攻击的目标是不让函数返回。在某些情况下,覆盖函数参数允许攻击者在函数返回之前获得对应用程序的控制,从而使Stack Cookies保护失效。

尽管这种技术似乎对攻击者很有用,但优化有时会无意中消除漏洞被利用的机会。当经常使用一个变量值时,编译器通常会生成代码,将它从栈中读取一次,然后在函数运行期间或函数中重复使用该值的部分中将其保存在寄存器中。因此,即使在触发溢出后可能会频繁访问参数或局部变量,攻击者也可能无法使用该参数来执行任意重写。

Windows上另一个类似的技术是不用担心保存的返回地址,而是尝试SEH覆盖。这样,攻击者可以破坏SEH记录并在当前运行的函数返回之前触发访问冲突;因此,攻击者控制的代码会运行,而不会检测到溢出。

最后,请注意,Stack Cookies是一种编译时解决方案,如果开发人员不能重新编译整个应用程序,那么它可能不是一个现实的选择。开发人员可能无法访问所有源代码,比如商业库中的代码。对大型应用程序的构建环境进行更改也可能会出现问题,特别是手工优化的组件。

堆实现强化(Heap Implementation Hardening )

堆溢出经常通过系统的内存分配和取消分配的unlinking操作实施攻击。内存管理中的链表操作能够被利用来向任意内存中的位置进行写入操作以得到整个应用程序的控制权。为了处理这种威胁,很多系统都将它们的堆实现进行了强化,让它们变得更难进行漏洞利用。

Windows XP SP2以及后续的版本实现了很多种类的保护措施以确保堆操作不会在不经意见允许攻击者使用有害的方法去操控进程。这些机制具体如下;

  • 一个8位大小的Cookie被保存在了堆头部结构中,然后一个XOR操作将这个Cookie和全局堆Cookie连接起来, 并将堆块的地址除以8。 如果结果值不是0,则表示发生了堆损坏。因为在这个操作中使用了堆块的地址,cookie不应该容易受到蛮力攻击。

  • 每当发生unlink操作时,都会进行检查,以确保上一个和下一个元素确实是有效的。具体来说,下一个和上一个元素都必须指向即将断开链接的当前元素。如果没有,则假定堆已损坏,操作将中止。

    UNIX glibc堆实现也经过了强化,以防止堆被进行漏洞利用。glibc开发人员在他们的堆管理代码中添加了unlink检查,类似于Windows XP SP2防御措施。

不足

堆保护技术并不完美。大多数都有弱点,仍然允许攻击者利用堆数据结构进行可靠(或相对可靠)的利用。以下给出一些已发表的关于破解Windows堆保护措施的工作:

  • 破解微软Windows XP SP2堆保护和DEP绕过(Defeating Microsoft Windows XP SP2 Heap Protection and DEP Bypass),作者为 Alexander Anisimov
  • (www.maxpatrol.com/defeating-xpsp2-heap-protection.htm) 一种绕过Windows堆保护措施的新方法( A New Way to Bypass Windows Heap Protections )作者为 Nicolas Falliere
  • (www.cybertech.net/~sh0ksh0k/heap/XPSP2%20Heap%20Exploitation.ppt) Windows堆漏洞利用( Windows Heap Exploitation )作者为 Oded Horovitz和 Matt Connover

UNIX glibc的实现上也有类似的安全问题,一个有用的资源链接如下:The Malloc Maleficarum (www.securityfocus.com/archive/1/413007/30/0/threaded) 作者为 Phantasmal Phantasmagoria 。

这些堆保护措施机制中最重要的不足之处就是它们只保护了内部的堆管理结构。他们没有对在堆中对应用程序数据的改写进行保护。如果你能够更改其他有用的数据,那么这个漏洞利用只会是时间和精力问题。然而更改程序变量比较困难,因为这需要一些特定的变量布局。 攻击者可以在许多应用程序中创建这些布局,但它并不总是一种可靠的利用形式,特别是在多线程应用程序中。

另外一个需要记住的点是在系统分配历程的顶端实现应用程序自己的内存分配策略通常是很常见的。在这种情况下, 有问题的应用程序通常一次向系统请求一个或一系列页面,然后用自己的算法在内部管理它们。 这对于攻击者来说就很开心,因为自己设计的内存管理算法经常是没有受到保护的,使用经典的堆溢出攻击方法通常都能奏效。

不可执行堆栈保护(Nonexecutable Stack and Heap Protection)

许多CPU在内存方面提供了细粒度的保护措施,允许CPU将内存块标记为可读,可写,或者可执行。如果应用程序将代码和数据完全分割,那么就可能通过将数据页面设置为不可执行以防止shellcode的运行。通过强制执行不可执行保护,CPU防止了大多数常见漏洞利用方法,这些方法通过将控制流转移到攻击者预先设定好数据的内存位置中。

Intel CPU直到最近(2004)年才推行了不可执行页面。一些有趣的变通方法也被开发出来克服这一限制,最明显的是由PaX开发团队(现在是GR-Security团队的一部分)开发的。 文档见 http://pax.grsecurity.net/.

不足

因为不可执行内存是由CPU来强制执行的, 一般来说,直接绕过这种保护是不可行的 。 攻击者完全不能定向到栈或堆上的某个位置去执行代码。 然而,这并不能阻止攻击者返回可执行代码部分中的有用代码。 不管它是在被利用的应用程序中还是在共享库中。 规避这些保护的一种流行技术是在栈上构造一系列返回地址,以便攻击者可以对有用的API函数进行多次调用。通常,攻击者可以通过API函数来不保护他们控制的数据区域内存。这将目标页标记为可执行文件并禁用保护,从而允许漏洞运行自己的shellcode。 一般来说,这种保护机制使得利用受保护的系统更加困难,但是老练的攻击者通常可以找到绕过它的方法。只需一点创造性,就可以对现有代码进行拼接、切割和强迫,以满足攻击者的目的。

地址空间布局随机化

地址空间布局随机化(Address space layout randomization, ASLR)是一种通过将应用程序在运行时映射数据和代码时进行随机化来试图缓和缓冲区溢出威胁的方法。本质上,就是数据和代码段在加载时映射到随机的内存位置。因为缓冲区溢出攻击的关键部分就是将关键数据结构覆盖或者返回到特定的内存位置。在理论上,地址空间布局随机化能够防止这种漏洞利用因为攻击者将不再能够依赖静态的地址。尽管地址空间布局随机化是一种晦涩的安全形式,但它对于防止漏洞利用是一种有效的方法,特别是和前面讨论过的技术一起使用时。

不足

击败地址空间布局随机化本质上依赖于找到地址空间布局随机化实现中的弱点。攻击者通常尝试采用以下方法之一:

  • 在内存中找到一些尽管有空间布局随机化但仍然处于静态位置的东西。不管静态元素是什么,它都可能以某种方式有用。静态定位元素的示例可能包括不包含重定位信息的基本可执行文件(因此加载程序可能无法对其进行重定位)、出现在所有映射进程中的专用数据结构(例如Windows PEB和Linux vsyscall页面)、加载程序本身以及不可重定位的共享库。如果空间布局随机化未能随机化过程中的任何特定部分,就可以依赖并潜在地破坏空间布局随机化的保护
  • 尽可能使用蛮力攻击。在很多情况下,数据元素会在内存中移动,但移动的幅度并不大。例如,当前的Linux exec-shield ASLR将堆栈映射到一个随机位置;但是,仔细检查代码就会发现这些映射只包含256个可能的位置。这一小组可能的位置不能提供大的随机因素,而且大多数ASLR实现不会随机化子进程的内存布局。当脆弱的服务为服务请求创建子进程时,这种随机性的缺乏可能导致蛮力攻击。攻击者可以针对每个可能的偏移量发送请求,并最终在找到正确的偏移量时成功利用。
SafeSEH

现代Windows系统(XP SP2+, Windows 2003, Vista)为栈中的SEH结构实现了保护机制。当异常发生时,异常处理程序会追踪到异常发生前的地址以确保每个异常处理程序例程都是合法的。 以下过程确定异常处理程序的有效性:

  1. 获取异常处理程序地址,确定哪个模块(DLL或者可执行二进制文件)是异常处理程序指向的。
  2. 检查模块是否有注册异常表。异常表是一个记录了可以合理进入 EXCEPTION_REGISTRATION结构的的异常处理程序表。这个表是可选的,模块可能会忽略它。在这种情况下,就假定处理程序合法并且可以调用。
  3. 如果异常表存在并且处理程序在EXCEPTION_REGISTRATION结构中没有匹配到合法的处理程序入口,那么这个结构就会被认为已经损坏,处理程序就不会被调用
不足

SafeSEH保护是对最近Windows版本中使用的stack cookie的一个很好的补充,因为它可以防止攻击者使用SEH覆盖作为绕过stack cookie保护的方法。但是,同其他保护机制一样,它在过去也有弱点。 Next Generation Security Software (NGSSoftware)的大卫·利奇菲尔德(David Litchfield)写了一篇论文,详细描述了SafeSEH早期实现中出现的一些问题,这些问题已经得到了解决(可以在www.ngssoftware.com/papers/defeating-w2k3-stack-protection.pdf找到)。绕过SafeSEH的主要方法包括返回到内存中不属于任何模块的位置(比如PEB),返回到没有注册异常表的模块,或者滥用可能允许间接运行任意代码的已定义异常处理程序。

函数指针混淆(Function Pointer Obfuscation)

长期存在的函数指针通常是内存损坏利用的目标,因为它们提供了一种获取程序执行控制的直接方法。防止这种攻击的一种方法是混淆存储在全局可见数据结构中的任何敏感指针。这种保护机制不能防止内存损坏,但它确实降低了成功利用任何攻击(denial of service除外)的概率。例如,你在前面看到,攻击者可能能够利用正在运行的Windows进程的PEB中的函数指针。为了帮助减轻这种攻击,微软现在使用EncodePointer()DecodePointer()EncodeSystemPointer()DecodeSystemPointer()函数来混淆这些值。这些函数通过使用异或操作将指针的指针值与秘密cookie值组合在一起来混淆指针。Windows的最新版本也在堆实现的某些部分中使用了这种反漏洞利用技术。

不足

这项技术无疑提高了开发人员利用的门槛,特别是与其他技术(如ASLR和非可执行内存页)结合使用时。然而,它本身并不是一个完整的解决方案,只有有限的用途。攻击者仍然可以覆盖特定于应用程序的函数指针,因为编译器目前没有对应用程序使用的函数指针进行编码。攻击者还可能覆盖普通的未编码变量,这些变量最终通过不那么直接的向量提供执行控制。最后,攻击者可能会识别以有限但有用的方式重定向执行控制的环境。 例如,当用户控制的数据接近函数指针时,只要破坏已编码函数指针的低字节就可能给攻击者提供运行任意代码的合理机会,特别是当他们可以重复利用尝试,直到成功识别出一个值时。

7.1.5 审计内存损坏漏洞的影响

既然你已经熟悉了内存损坏,那么你就需要知道如何正确地评估这些漏洞的代表的风险。很多参数都影响着一个漏洞是如何被利用的。通过认识到这些参数,代码审计者能够预测一个漏洞会有多么严重以及它能被利用到什么程度。它能只被用来摧毁整个应用程序?或者能够让任意代码被运行?唯一去具体知道它的途径就算写出漏洞存在证明(proof-of-concept exploit),但这个过程会消耗很多时间,即使是一个合适大小的应用程序审计。但是, 你可以通过回答一些关于结果内存损坏的问题来合理地估计可利用性。这种方法不像poc那样明确,但它花费的时间少得多,因此适合大多数审计。

修复漏洞的实际成本

你可能会惊讶地发现,在向供应商披露漏洞时可能遇到的阻力,即使是专门雇佣你来执行审计的供应商。供应商经常说,潜在的内存损坏bug是不可利用的,或者出于某种原因不是问题。但是,内存损坏会在最基本的层次上影响应用程序,因此需要认真考虑所有实例。事实上,历史已经表明,攻击者和安全研究人员都已经想出了巧妙的方法来利用看起来不可利用的东西。我想到一句老话“有志者事竟成”,当涉及到违背电脑系统原则的时候,肯定会有很多的人愿意去做漏洞利用。

因此,大多数审计员认为软件供应商应该将所有问题视为高优先级;毕竟,为什么供应商不希望他们的代码尽可能安全,而不尽快修复问题呢?事实上,修复软件缺陷总是要付出代价的,包括开发人员时间、补丁部署成本以及可能的产品召回或重新发布。例如,考虑一下向广泛部署的嵌入式系统(如智能卡或手机)发布漏洞更新的成本。更新这些嵌入式系统通常需要硬件修改或由合格技术人员进行其他干预。如果一个公司没有合理地预期漏洞会被利用,那么它就会不负责任地承担与更新相关的成本。

缓冲区位于内存的哪个地方?

缓冲区在内存的位置非常重要,它影响了攻击者做出什么样的选择去获得进程的控制权。变量通常存储在三个内存的位置:栈,堆和不变数据(persistent data),包括静态和全局变量。然而,不同的操作系统常常将这些地区再进行分段或者添加新的地区。初始化的和未初始化的全局数据会有所不同(未初始化的全局变量在数据区中只有一个placeholder,初始化的全局变量会占一定内存空间—by译者),或者操作系统会将线程本地存储(thread local storage, TLS)放置在一个特定的位置。并且,共享库还会有它专门的初始化和未初始化的数据在程序代码运行后迅速映射到进程内存中。当在确定可利用性时,你需要跟踪发生内存损坏的位置以及应用特定的注意事项。这项任务可能包括进行一些额外的研究,以了解特定操作系统的进程内存布局

哪些其他的数据会被覆盖重写?

内存损坏可能不能仅与攻击者所针对的变量有关。它还可以覆盖可能使开发过程复杂化的其他变量。当试图利用进程栈上的损坏时,通常会发生这种情况。你已经知道,通过覆盖保存的程序计数器,栈段中的漏洞经常被利用。然而,它并不总是那么直接;攻击者通常会在重写保存的程序计数器之前先重写本地变量,这可能会使利用程序变得复杂,例如下面的代码:

1
2
3
4
5
6
7
8
9
int dostuff(char *login){
char *ptr = (char *)malloc(1024);
char buf[1024];
...
strcpy(buf, login);
...
free(ptr);
return 0;
}

这个例子有一个很小的问题:尽管攻击者可以覆盖保存的程序计数器,但它们也会覆盖ptr变量,让它在函数返回前就被释放。这意味着攻击者必须将ptr覆盖成一个内存中合法位置的值并且不会在free()调用前导致程序崩溃。尽管这个方法让攻击者可能利用对free()的调用,但这就让漏洞利用的方法比简单的计数器覆盖要更加复杂,特别是当在内存静态区域中没有用户控制的数据时。

在评估缓冲区溢出漏洞的风险时,要特别注意溢出路径中可以减少利用尝试的任何变量。另外,请记住编译器可能会在编译期间重新排序变量的布局,因此你可能需要检查二进制文件以确认可利用性。

有时需要多个函数返回才能利用错误。例如,由于Sun SPARC cpu注册窗口的工作方式,运行在Sun SPARC cpu上的操作系统经常需要两个函数返回。

有多少字节能够被覆盖?

你需要考虑缓冲区溢出了多少字节,以及用户能控制多少大小的溢出。字节过少或过多的溢出会使利用变得更加困难。显然,攻击者的理想情况是选择任意长度的数据进行溢出。

有时,攻击者只可以溢出一个固定数量的缓冲区,这提供的选项较少,但仍然有可能对缓冲区漏洞成功利用。如果只有少量的字节可以溢出,可利用性就取决于什么数据被破坏了。如果攻击者只能破坏内存中再也不会使用的相邻变量,那么这个错误可能是不可利用的。显然,攻击者破坏的内存越少,漏洞被利用的可能性就越小。

相反,如果攻击者可以溢出一个固定的数量,而这个数量恰好是非常大的,那么这个错误总是会导致程序内存的很大一部分被破坏,并且几乎肯定会使进程崩溃。在某些情况下,当信号处理程序或异常处理程序可能被破坏时,攻击者可以利用这种情况,并在异常发生后获得对流程的控制。最常见的例子是Windows中基于栈的大量溢出,因为攻击者可以覆盖包含在发生异常时被访问的函数指针的SEH结构。

此外,一些错误可能导致对内存中任意位置的多次写操作。尽管通常只能执行一次覆盖,但如果可以执行多次覆盖,攻击者在选择如何利用脆弱的程序时就有更大的优势。例如,对于格式化字符串漏洞,攻击者通常可以任意写入任意位置,这增加了成功利用的可能性。

有时,1到2字节的覆盖比4字节的覆盖更容易被利用。例如,假设你覆盖了一个指向一个对象的指针,该对象由几个指针和一个带有你控制的数据的缓冲区组成。在这种情况下,可以覆盖指针值的最低有效字节,以便指向对象中的数据缓冲区而不是对象本身。你可以任意更改任何对象属性的状态,并可能相当可靠地利用该缺陷。

什么样的数据能够用来损坏内存?

一些内存损坏漏洞不允许直接控制用于覆盖内存的数据。可能会根据使用方式对数据进行限制,如字符限制、单字节覆盖或对攻击者能延展的(attaker-malleable)memset()的调用。下面是一个漏洞示例,其中内存被攻击者无法控制的数据覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int process_string(char *string){
char **tokens, *ptr;
int tokencount;
tokens = (char **)calloc(64, sizeof(char *));
if(!tokens)
return -1;
for(ptr = string; *ptr;){
int c;
for(end = ptr; *end && !isspace(end); end++);
c = *end;
*end = '\0';
tokens[tokencount++] = ptr; //这一行是粗体 --by译者
ptr = (c == 0 ? end : end + 1);
}
...

这份代码在操作tokens数组的粗体行中有缓冲区溢出。用于覆盖内存的数据不能被攻击者直接控制,但被覆盖的内存中包含指向攻击者可控制数据的指针。这使得漏洞利用比使用标准技术更容易。例如,如果函数指针被覆盖,攻击者不需要内存布局信息,因为可以用指向攻击者控制的数据的指针替换函数指针。但是,如果覆盖了堆的头部或其他复杂结构,漏洞利用可能会更加复杂。

单字节溢出漏洞是最常见的漏洞之一,涉及到攻击者无法控制的数据覆盖。下面是一个单字节溢出的漏洞示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct session {
int sequence;
int mac[MAX_MAC];
char *key;
};

int delete_session(struct session *session){
memset(session->key, 0, KEY_SIZE);
free(session->key);
free(session);

}

int get_mac(int fd, struct session *session){
unsigned int i, n;
n = read_network_integer(fd);
if(n > MAX_MAC) // 这一行是粗体 --by译者
return 1;
for(i = 0; i <= n; i++)
session->mac[i] = read_network_integer(fd);
return 0;
}

如果攻击者将mac的长度设定为正好等于MAX_MACget_mac()函数就会在分配的空间中读取一个多余元素(在粗体的一行中显示)。在这种情况下,最后一个整数被读入后会覆盖变量key。变量key在被删除前会被传入menset,这样就允许攻击者能够向任意内存的位置进行写入操作。此外,攻击者提供的内存位置随后被释放,这意味着攻击很可能指向内存管理例程。成功地执行这种攻击可能非常困难,特别是在多线程应用程序中。

这两段代码显示了当覆盖的数据无法控制时,攻击者可能难以利用漏洞。在检查类似问题时,你需要确定覆盖的数据中包含什么,以及攻击者是否可以控制它。通常,攻击者对写入的数据有相当直接的控制,或者可以损坏结果来访问攻击者控制的数据。

内存块是否是共享的?

偶尔,应用程序中会出现一些bug,其中内存管理器错误地多次分配相同的内存块,即使它仍在使用中。当这种情况发生时,应用程序的两个或多个独立部分使用内存块,期望它们能够独占地访问它,而实际上它们并没有。这些漏洞通常是由以下两个错误之一引起的 :

  • 内存管理代码中的bug
  • 内存管理的API没有被正确使用

这些类型的漏洞还可能导致远程执行;然而,确定内存块共享漏洞是否可以利用通常是复杂的,并且是特定于应用程序的。一个原因是攻击者可能无法准确地预测应用程序的其他部分获得相同的内存块,并且不知道提供什么数据来执行攻击。此外,在他们试图连接时应用程序可能存在时间问题,特别是服务于大量客户机的多线程软件。在接受这些困难的情况下,有一些程序可以利用这些漏洞,所以它们不应该被认为是没有理由的低优先级。

类似的内存崩溃也可能发生,即一个内存块只分配一次(正确的行为),但随后该内存块被分配给两个并发运行的线程,并假定其访问是互斥的。 这种类型的漏洞经常由于同步问题导致,这会再13章“同步与状态”中详细提及。

什么样的保护机制在运行?

在了解了潜在可利用的内存损坏漏洞的细节之后,你需要考虑可能阻止利用的任何缓解因素。例如,如果一个软件只在Windows XP SP2+上运行,你知道存在stack cookie和SafeSEH,因此典型的栈溢出可能无法利用。当然,你不能仅仅因为有了保护措施就对内存损坏视而不见。攻击者很有可能通过使用不安全的加载模块或覆盖函数参数来破坏stack cookie,从而破坏SafeSEH。但是,您需要考虑这些保护措施,并尝试评估攻击者绕过它们并可靠地利用系统的可能性。

7.1.6 总结

本章解释了内存损坏是如何发生的,以及它如何影响应用程序的状态。特别是,你已经看到了攻击者如何利用内存损坏bug来控制应用程序并执行恶意活动。在评估应用程序安全漏洞时,这些知识非常重要,因为它允许你准确地确定攻击者利用特定内存损坏问题的可能性。然而,内存损坏利用本身就是一个完整的研究领域,而且技术的发展状况也在不断变化,以找到新的方法来利用以前无法利用的漏洞。作为审计者,你应该将所有内存损坏问题视为潜在的严重漏洞,直到你能够证明并非如此。

 wechat
scan and following my wechat official account.