攻击 101
7.3 计算机内存
以最简单的名词来说,计算机内存是一种电子装置,能够存储和检索数据。内存能够 存储的最少的数据量是 1 bit,由内存中的 1 或 0 表示。当把 4 个 bit 放在一起时,称作“半 字节”(nibble),可以表示从 0000~-1111 的值。这刚好是 16 个二进制值,对应于十进制下 的 0~-15。当把两个半字节或 8 个 bit 放到一起时,即为一个“字节”(byte),这可以表示 十进制中的 0~255。如果把两个字节放到一起,即为一个“字”(word),可以表示十进制 下的 0~65 535。继续把数据放到一起,如果把两个“字”放到一起,则得到一个“双字 DWORD”,可以表示十进制下的 0~4 294 967 295。
计算机内存有多种类型,这里将主要讨论随机存储器(Random Access Memory,RAM)
和寄存器。寄存器是一种特殊形式的内存,嵌入到处理器内部,将在 7.4.1 节讨论。
7.3.1 RAM
在 RAM 中,任意存储的任意数据块可以在任意时间访问,这也是随机存取(Random Access)这个名字的由来。但 RAM 是易变的,这意味着如果计算机关闭,那么 RAM 中所 有的数据都将丢失。在讨论 Intel 的产品(x86)时,内存是按 32 位寻址的,这意味着处理
器用来选择特定内存地址的地址总线为 32 位宽。因此,x86 处理器最多能够寻址 4 294 967 295 字节。
7.3.2 字节序
Danny Cohen 在 1980 年曾对 Swift 的格列佛游记(Gulliver travels)做过综述:
“对格列佛游记的一些附注:
格列佛发现有一条法律,由现在的统治者的祖父颁布,要求所有小人国
(Lilliput)的公民在打蛋时,都只能将鸡蛋的小头打破。当然,那些在鸡蛋大头 打蛋的公民都被这条法律激怒了。对打破鸡蛋的方向,持有两种不同观点的人群 发生了内战,导致坚持在鸡蛋大头打蛋的人逃到附近的一个岛上,成立了新的王 国 kingdom of Blefuscu…”。
他接着描述了双方之间爆发的一场圣战。他的文章实际上描述了把数据写入内存时的 两种不同意见。有些人认为高位字节应该首先写入(称作“Little Endian”),而其他人则认
为低位字节应该首先写入(称作“Big Endian”)。这种差别,实际上依赖于所使用的硬件。
例如,在基于 Intel 的处理器上,使用 Little Endian;而在基于 Motorola 的处理器上,则使 用 Big Endian。在稍后讨论 shellcode 时,两种字节序是有区别的。
7.3.3 内存分段
内存分段的主题,可以用一整章来讲述,但基本概念比较简单。每个进程(在特别简 化的情况下,可以认为是一个执行的程序)需要访问内存中属于自身的区域,因为没有人 希望一个进程去改写另一个进程的数据。因此,可将内存划分为小的段,按需分发给进程。
寄存器用来存储和跟踪进程当前维护的段(寄存器将稍后讨论)。偏移寄存器(Offset Registers)用来跟踪关键的数据放在段中的位置。
7.3.4 内存中的程序
在进程被载入内存中时,基本上被分裂成许多小的节(section)。我们比较关注的是 6 个主要的节,将在以后几节里分别讨论。
.text节
.text 节基本上相当于二进制可执行文件的.text 部分,它包含了完成程序任务的机器指 令。该节标记为只读,如果发生写操作,会造成 segmentation fault。在进程最初被加载到 内存中开始,该节的大小就被固定。
.data节
.data 节用来存储初始化过的变量,如:
int a = 0;
该节的大小在运行时是固定的。
.bss节
栈下节(below stack section,即.bss)用来存储未初始化的变量,如:
int a;
该节的大小在运行时是固定的。
第 3 部分
堆节
堆节(heap section)用来存储动态分配的变量,位置从内存的低地址向高地址增长。
内存的分配和释放通过 malloc()和 free()函数控制。例如,在运行时声明一个整数并分配内 存,读者可能想到这样做:
int i = malloc (sizeof (int)); //动态分配一个整数(指针)变量,
//其值是分配之前该处内存的值。
栈节
栈节(stack section)用来跟踪函数调用(可能是递归的),在大多数系统上从内存的 高地址向低地址增长。读者会看到,栈增长的这种方式,导致了缓冲区溢出的可能性。
环境/参数节
环境/参数节(environment / arguments section)用来存储系统环境变量的一份复制文件,
进程在运行时可能需要。例如,运行中的进程,可以通过环境变量来访问路径、shell 名称、
主机名等信息。该节是可写的,因此在格式串(format string)和缓冲区溢出(buffer overflow)
攻击中都可以使用该节。另外,命令行参数也保存在该区域中。上述各节在内存中的顺序,
与笔者介绍的顺序相同。进程的内存空间如下所示:
低地址 高地址
堆 未使用 栈 环境
.text .data .bss
7.3.5 缓冲区
缓冲区(或缓存,buffer)是指这样的一个存储区域:该区域用来接收和保存数据,直 至进程对数据进行处理。由于各个进程都有自己的缓冲区,所以保持各进程的缓冲区彼此 无关是很重要的。通过在进程内存的.data 或.bss 节分配内存,可以做到这一点。要记住,
在内存分配之后,缓冲区的长度是固定的。缓冲区可以保存任何预定义类型的数据,但我 们目前将主要注意基于字符串的缓冲区,这种缓存区用来保存用户的输入和变量。
7.3.6 内存中的字符串
简而言之,字符串数据只不过是内存中连续的字符构成的数组。在内存中,是通过字 符串第一个字符的地址来引用一个字符串。字符串结束于空字符(C 语言中的\0)。
7.3.7 指针
指针是内存中特定的数据,用于保存其他内存区的地址。在内存中移动数据是相对较 慢的操作。如果不移动数据,只是跟踪数据项在内存中的位置(通过指针)并改变指针的 值,那要容易得多。由于内存地址是 32 位长(4 字节),因此指针保存在内存中 4 个连续 的字节里。例如,字符串是通过字符数组中第一个字符的地址进行引用,该地址值被称作 一个指针。因此 C 语言中一个字符串变量的声明如下:
char * str; //代码的意思是,要提供4个字节来保存str,而str是一个指向字符变量 //(可以是字符数组的第一个字节)的指针。
重要的是注意到,即使指针的大小是 4 个字节,但上述的声明并没有限定字符串的长 度,因此编译器将认为该数据是未初始化的,会将其放到进程内存的.bss 节。
以下是另一个例子,如果读者需要存储一个指针,指向内存中的一个整数,可以在 C 程序中使用下述声明:
int * point1; // 代码的意思是,要提供4个字节来保存point1,
// 这是个指向整型变量的指针。
为读取指针指向的内存地址中的值,可以使用*符号对指针反引用。因此,如果打算输 出上述代码中 point1 指向的整数的值,可以使用下述语句:
printf("%d", *point1);
其中*用来反引用 point1 指针,而 printf()函数则用来输出整数的值。
7.3.8 操作不同的内存区
现在读者已经了解了基础知识,我们来给出一个简单的例子,以声明如何在程序中使 用内存:
/* memory.c */ // 注释,给出程序名
int index = 5; // 存储在.data节中的整数(已经初始化)
char * str; // 存储在.bss节中的字符串(未初始化)
int nothing; // 存储在.bss节中的整数(未初始化)
void funct1(int c){ // 大括号,标志funct1的开始
int i=c; // 该变量存储在栈中
str = (char*) malloc (10 * sizeof (char)); // 在堆中分配10个字符的空间 strncpy(str, "abcde", 5); // 将5个字符"abcde"复制到str
} // funct1的结束
main (){ // main函数,必需
funct1(1); // main用参数1调用funct1
} //main函数的结束
第 3 部分
这个程序没有做什么有用的事情。首先,在进程内存不同的节中分配了几块内存。在 main 执行时,用参数 1 调用 funct1()。在调用 funct1()时,参数被传递到函数变量 c。接下 来,在堆上为字符串 str 分配了 10 字节的内存。最后,把 5 字节长度的字符串“abcde”复 制到变量 str 中。函数 funct1 结束后,main()程序结束。
警告:在阅读后文前,必须扎实地掌握了上述知识。如果需要复习本章的哪个 部分,请在继续阅读前进行。
参考文献
[1] Smashing the Stack.., Aleph One www.mindsec.com/files/p49-14.txt [2] How Memory Works http://computer.howstuffworks.com/c23.htm [3] Memory Concepts www.groar.org/expl/beginner/buffer1.txt
[4] Little-Endian vs. Big Endian www.rdrop.com/~cary/html/endian_faq.html