内存持久战之内存安全性

作者:Diting0x


CSysSec注: 本系列文章主要介绍内存的安全性问题,以及相应的攻击模型及防御措施,对整个系统安全问题的概览有很大的帮助。
转载本文请务必注明,文章出处:《内存持久战之内存安全性》与作者信息:Diting0x


  • IEEE Security&Privacy’13
  • 不访问未定义的内存
  • 无限间距
  • Pointers as capabilities

C语言中的buffer overflows, format string attacks等其它的一些vulnerabilities都有一个共同的问题:违背内存安全(Memory Safety)。本文主要讲述如何准确定义内存安全,为什么这些vulnerabilities违背了内存安全。 也为后续两篇文章内存持久战-攻击模型内存持久战-防御措施做好铺垫。

IEEE Security&Privacy’13

发表在IEEE Security&Privacy’13的一篇SoK(Systematization of Knowledge)文章,Eternal War in Memory ,阐述了一种定义内存安全的通用方法。
Definition 1: 文中提到,一个程序的执行,只要不会出现以下内存访问错误,就是内存安全的:

  • 1.缓冲区溢出
  • 2.引用空指针
  • 3.释放后使用(use after free)
  • 4.使用未初始化内存
  • 5.非法释放已经释放过的指针或未分配的指针

维基百科 wikipedia page on memory safety 也有类似的定义。从定义来看,排除这些错误是内存安全本身的定义所导向的,而并非内存安全性的本质。那么,如何将这些错误统一起来?

不访问未定义的内存

只有当程序访问未定义的内存时才会产生内存错误,这块内存是在程序中没有具体分配的,例如,heap 的一部分(通过malloc),stack(作为局部变量或者函数参数),又或者是静态数据区域(作为全局变量). George Necula 在他的CCured项目中(旨在为C程序实施内存安全性)提到,一个内存安全的程序从来不会去访问未定义的内存。我们可以假设,内存可以无限的大,大到内存地址从不会复用(reused).如此一来,被释放的内存(可以调用free 或者从函数返回的时候pop)从不会被重新分配,并且会永久的保持未定义状态。

Definition 2: 不访问未定义的内存就是内存安全的。
这种定义明显排除了error 2error 3. 如果将allocated 的定义包括initialized,又可以排除error 4. 如果假设free只能在定义过的内存指针中调用,那又可以排除error 5.

不幸的是,Definition 2 并未排除缓冲区溢出错误,也就是error 1。 来看一个例子,假定一个标准stack 布局, 在此定义下,program 1 的执行会被认为是内存安全的:

1
2
3
4
/* Program 1 */
int x;
int buf[4];
buf[5] =3; /*overwrite*/

Definition 2 允许 Program 1 通过是因为此程序是在合法分配的内存中写数据,甚至写的数据类型也是正确的。但实际上问题在于,数组buf 的溢出将数据写进了变量x 中,显然这是内存不安全的。

无限间距

Definition 2 延伸, Program 1 被看作是内存不安全的。只要加上这个假设: 内存区域分配的间距是无限大的。

Bufx 的分配间距无限的大,buf[5] 将会访问 buf 区域的边界外部。边界外部是个未定义的内存区域,按照上述定义,就会产生错误。heap ,静态数据区域对溢出的处理方式类似。

尽管 Definition 2 是个很接近让人满意的定义,但事实并未如此。来看 Program 1 的变形 Program 2,也是一种缓冲区溢出, Definition 2 仍然会允许 Program 2 执行。

1
2
3
4
5
6
7
/*Program 2 */
struct foo {
int buf[4];
int x;
};
struct foo *pf -malloc(sizeof(struct foo));
pf->buf[5] =3;/*overwrite pf->x*/

这里,缓冲区溢出发生在 object 的内部。我们仍然可以类似的在域间引入无限间距的概念来排除缓冲区溢出的错误。这并未太背离现实,C标准允许编译器决定不同域的间距。另一方面,程序语言把结构体当做一个单独的object (从 malloc 返回的单独指针). 许多程序会把一个结构体映射到另一个结构体,或者会确定好一种间距方案。许多编译器都支持这些操作,但是否可以有一种更好的定义不依赖于这些?

Pointers as capabilities

Definition 2 中,了解到许多概念,比如,定义的(分配的),未定义的(从没有分配的或者分配后回收的),我们假设分配后回收的内存不会再复用。如此一来,只要访问未定义的内存,就会违背内存安全性。

Definition 3: 我们引入这么一个概念, Pointers as capabilities. 也就是说,允许指针的持有者访问一定区域中的内存。一个指针由三个元素组成(p,b,e): b 定义有效的区域,e 定义边界,p 代表指针本身。 程序只能操作pbe ,这样做只是为了定义一次执行是否是内存安全的。

举个例子,看下面的Program 3以及对应的内存效果图:

1
2
3
4
5
6
7
8
9
10
11
12
/* Program 3 */
struct foo {
int x;
int y;
char *pc;
};
struct foo *pf = malloc(...);
pf->x = 5;
pf->y = 256;
pf->pc = "before";
pf->pc += 3;
int *px = &pf->x;

memory-safety3

重点关注代码的最后两行。Program 3 允许指针运算来新建一个新的指针,但只能当新指针落在b到e之间才能被解引用。从代码中看到,增加 \pcp ,新指针仍然落在be 之间,所以执行*(pf->pc) 是合法有效的。但如果执行 pf->pc+=10 , *(pf->pc) 将会违背内存安全性,尽管pf->pc 有可能碰巧就落在定义的内存区域中(这块内存区域可能分配给了其它object*).

最后一行代码创建一个新的指针px 指向pf 指针的第一个域,将边界缩小到其中的一个域中。这就排除了 Program 2 带来的内存溢出问题。加入我们保留pf整个的边界,此程序可能会利用px溢出到结构体中的其它域中。

Capability是无法伪造的,就像我们并不能伪造一个指针映射到整形数据中。非法映射可以是直接的(e.g. p=(int \)5 ) 也可以是间接的,比如将含有整形数据的结构体映射到含有指针的结构体中(e.g. p=(int **)pf ), 将Program 3 中结构体中的第一个整形数据域映射成指针。我们的定义简单的将映射看作是空操作。只有有效的指针才能被解引用,一个指针的capabilities在它创建的时候就已经确定了。 我们的定义中允许 Program 4* 的执行:

1
2
3
4
5
6
/* Program 4 */
int x;
int *p = &x;
int y = (int)p;
int *q = (int *)y
*q = 5;

p 指针初始化得be 一直会保持不变,尽管之后p 被转化成整形y, 因此当y 被转回为q 并被解引用的时候,指针依然存在。从另一方面来看,如果在Program 3 的最后加上 p=(int \*)pf , 紧接着 *p=malloc(sizeof(int)), 之后的操作 **p以及printf(“%d\n”,pf->x)* 都是合法的。也就是说,一块内存区域一开始存储了整形数据,之后也可将整形数据修改为指向整形数据的指针,然后解引用指针,这样操作是安全的,但反过来却不行。

在某种意义上来说,基于capability定义的内存安全性是一种类型安全形式(type safety)。这里只有两种类型:指针类型和非指针类型。这种定义保证了 1) 指针只在定义了合法内存区域的安全模式下被创建. 2) 指针只有在它们是指向分配给它们的内存区域的情况下被解引用. 3) 那块内存区域仍然是定义过的。这种定义排除了上述所有的五种错误。

注:本文主要意译 PL Enthusiast 上的一篇文章: What is memory safety

参考

What is memory safety
S&P’13 Eternal War in Memory

另,感谢S&P’13 Eternal War in Memory
的作者 Mathis Payer教授 的某些答疑,感谢感谢好友 叶邦宇 指出的一些勘误。


转载本文请务必注明,文章出处:《内存持久战之内存安全性》与作者信息:Diting0x