前言
这篇文章主要是想尽量直观的介绍虚拟内存的知识,而虚拟内存的知识不管作为在校学生的基础知识,面试的问题以及计算机程序本身性能的优化都有着重要的意义。而起意写这篇文章主要还是因为在python,人工智能的大浪潮下,我发现好多人对这方面真的无限趋近于不知道。我不是说懂这些基础知识比懂人工智能水平就是高,但是作为一个软件工程师,我觉得相对于调库调参,我们更应该有更牢靠的基础知识。不然很容易陷入,高深的数学不会,基础的知识也不知道的尴尬境地。毕竟从事算法核心的,没有多少人,而作为工程师,我始终觉得我们的使命是如何把这些天赋异禀,脑袋发达的人的想法,构思,算法变成真正可用的东西。而在我从业不算长的年限中遇过的人来看,这绝对不是一种很简单的能力。
阅读本文,需要有基本的c语言和python语言知识,如果提到虚拟内存,脑海中就有虚拟内存分布图的大概样子,那就完美适配这篇文章了。我希望通过这篇文章可以帮助你可以通过推理的方法回答出虚拟内存的各种问题,可以知道这个东西是如何真正和程序结合起来的。
文章大体分为三个部分,
第一部分,介绍虚拟内存的基本知识
第二部分,会直观的展示虚拟内存和我们的程序代码到底是怎么联系起来的
第三部分,我会演示如何改掉虚拟内存的内容,和修改这些内容到底意味着什么,吹的大一点,如何hack一个程序
本文所有的代码都很简单,只有c语言代码和python代码,并且我都跑过,如果你使用以下的环境,应该代码都能跑起来看到结果:
- 一台Linux发行版的机器,我用的,一个树莓pi
- Python 3+
- gcc 5.4.0+
什么是虚拟内存
如果你是一个程序员,至少你肯定听过内存这个词,虽然你可能真的不知道内存是什么,但是确实在现代程序语言的包装下,你依然可以写出各种程序。如果你真的不知道,那么我觉得还是应该去学习下内存的知识的以及计算机程序是如何被执行起来的。而什么叫虚拟,我至今记得我大学操作系统老师上虚拟内存这一节的时候引用的解释,我拙劣的翻译成中文大概就是:
真实就是这个东西存在并且感受到,虚拟就是这个东西存在但是你感觉不到。
虚拟内存就是这么一类东西,它确实存在,而你却不能在程序中感受到他。为什么要有虚拟内存,原因有很多,比如操作系统分配内存的时候,很难保证一个程序用的内存地址一定是连续的。比如内存是一个全局的东西而且只有一个,而程序有无数个,直接操作内存出问题的概率大,管理也不方便等等。于是虚拟内存的概念就给计算机程序的编写者,编译器等等都提供了一段独立,连续的“内存”空间。而实际上,这段内存不是真是存在的,其地址空间可以比真实的地址空间还要大,通过各种换出换入技术,让程序以为自己运行在一段连续的地址空间上。虚拟内存的概念的伟大之处在于给计算机科学的各种概念设计提供了一种思路,隔离,虚拟,直到现在,docker,各种虚拟化技术不能不说和虚拟内存的概念没有关系。
而提到虚拟内存那么无论在什么样关于操作系统的教科书里一定有这么一张图:
我当时在学习的时候老师会跟我们说这个虚拟内存由哪些部分组成,为了文章看起来比较整体,让我再简单的说明下,对于一个运行的程序,到底有哪些部分组成:
首先虚拟内存的寻址地址是由机器和操作系统决定,比如你是一个32bit的操作系统,那么寻址空间就是4GB,换句话说你的程序可以跑在一个0到0xffff ffff的“盒子”里,而如果你是64位的操作系统,那么这个寻址空间就会更大,意味着,你有更大的“盒子”,可以有更多的可能。
而图中的低地址就是0x0,假设是32位操作系统,那么高地址就是0xffff ffff。那么,就让我们按照人类的认知习惯,从低往高看看每一层都“住”着些什么。
最下面是text段,这里放着程序的执行的代码等等,如果你用objdump这样的程序打开一个程序,最前面你能看到应该是你的代码转化而成的汇编语言。
往上就是已初始化数据段和未初始化数据段,这里存放着全局变量,而这些都会被exec去执行,他们不仅有不同的名称,还有不同的权限,在后面的展示中,你可以直观的看到这些。
而再往上是堆段,也就是面试中经常会被问的,malloc,new出来的内存是存放在哪里的,没错,就是这里。而他的上面是另一个面试问题的来源,局部变量,参数都存在哪里。
住在顶楼的是命令行参数,环境变量等等。
而这些都是理论书本上写的,类似于告诉你两点之间有且只有一条直线一样。到底两点之间是不是真的只能画一条直线,最好的办法应该是自己画一画,以真实去验证理论。所以,到底一个程序在内存中真的是这样吗,或者说我们的程序代码到底和这样一个概念有什么关系,下面的章节就让你看看“虚拟”是如何可以被真实的展示的。
/proc/{pid}/maps
在这一节的最开始,我不得不特别简单的介绍linux下的proc文件夹,其实正确的应该叫他文件系统。而这也是为什么要使用Linux作为代码运行环境的原因,Windows上要看到一个程序的虚拟内存不是不可以,但是要去使用一些第三方工具,唯有Linux,在不需要任何工具的情况就能直观的给你展示所有的内容。而Proc文件系统就是这样一个入口。
如果你在Linux的命令行中输入ls /proc/,你会发现好多内容,其中有很多以数字为名字的文件夹。这些数字对应的就是一个一个的进程,而这些数字就是进程的pid,此时你可以更进一步,随便选一个数字大一点的文件夹,看看里面到底有什么。在我的电脑上,我选了7199这个数字,使用ls /proc/7199。你会看到更多的文件和文件夹,而且这些文件的名字都很有意思,比如cpuset,比如mem,比如cmdline等等。没错,这些文件里存储的就是该进程相关的信息,比如命令行,比如环境变量等等。而LINUX中一切都是文件的思想也在这里得到了体现。proc是一种伪文件系统(也即虚拟文件系统),存储的是当前内核运行状态的一系列特殊文件,用户可以通过这些文件查看有关系统硬件及当前正在运行进程的信息。而和我们这个主题相关的文件就是/proc/pid/maps和/proc/pid/mem。一个显示了改进程虚拟内存的分布,一个就是真正的虚拟内存的文件表现了。作为好奇的人类,你可以随便找一个pid文件夹看看maps文件里的内容,而mem由于特殊设置是无法被直接读取查看的。或者,你可以跟着这篇文章后面的代码,查看自己的程序的maps文件。
我编写了一个很简单小程序叫做showVM,这个程序会是下一章的主角。在我运行showVM文件后,使用下面的命令找到这个程序的id:
ps aux | grep showVM
在我的机器上,这一次运行分配的ID是20772,接下来就是让人充满啊!哈!感的时刻了。既然找到了id,根据最前面介绍的proc文件系统知识,首先使用 cat /proc/20855/maps查看下这个进程的虚拟内存分布图:
maps文件是一个非常值得细细研究的文件,这就是一个虚拟内存最好的示意图。和上面的有一些些不同,貌似这个虚拟内存地址似乎不是从0x0开始到0xffff ffff结束,和我上面说的32位操作系统寻址空间有点差别。而这个由于和本文所想介绍的主题不是那么的联系紧密,而太多的细节容易让人偏离主题,所以这个有兴趣的话可以就是那句俗话,自己去搜索搜索。
废话不再多扯了,就从一眼最熟悉的两个词开始,stack和heap。maps文件的第一列是地址,所以从这个文件中可以最直接的验证的就是heap是存在于低地址段,而stack位于高地址段。还有一个就是这两个段的权限都是可读可写,这样保证了这两段是可以被程序读写的。
这个时候再回到上面的示意图中,可以看到图中所绘,stack的更高地址存储的是命令行参数,而heap更低地址是代码段和数据段。而这里,我想从更低的地址开始说起,因为即使你从来没接触过aps文件,你会发现最后一列是文件的名称,最低地址放着的是我们自己的程序代码文件。这不足为奇,一个程序总要把自己的可执行部分放在虚拟内存中,这样CPU才能找到并且执行,这里比较有意思的是这里貌似有三个重复的,但是仔细看,你会发现这三个部分的权限是不同的,而示意图中heap之下也正好有三个部分,看起来正好是对应了示意图的三个部分。但是这个想法是不准确的,可以看到这三个部分:
第一个部分是可读可执行权限,这里存放的是代码。
第二个部分只有读权限,这个部分涉及另外一类称之为RELRO的技术,简答来说这个技术在gcc,linux中采用可以减少非法篡改着修改可写区域的机会,不是简单的一节两节可以说清楚的。考虑到这个和了解熟悉虚拟内存分布的关系不大,如果没有兴趣,完全可以暂时忽略这个部分。
第三个部分是可读可写的部分,这里存放的呢就是各种数据,和上面的示意图可能有点不一样,这里包括已经初始化的和未被初始化的数据。
说完heap更低的地址,下面再看看另一个部分,stack更高的地址。这里有很多缩写名词,而这些名词又涉及到更多的细节,主要是内核态和用户态的相关知识,这个部分就很深入而且不是很少的篇幅就能叙述清除的,在这里只需要知道,在Linux虚拟地址空间映射中,最高的1GB是kernel space的映射,具体有什么作用呢?可以完成比如用户态,内核态数据交换,在这里映射一些内核态的函数,加快调用内核态函数时的速度等等。这1GB的地址的内容,用户态的程序是不可以读不可以写的。
对应着示意图,似乎maps文件多了一个部分,就是中间的一串.so文件。当然,只要你稍微有点Linux的知识,你会知道这些都是Linux的库文件,也就是可执行程序。那么虚拟内存里面为什么要放这么多库文件呢?很明显的一点,就是这些库文件肯定是我们的程序需要调用的文件,这一部分叫做内存映射文件,最大的好处就是可以提高程序的运行速度。
说了这么多,对应着示意图,Linux虚拟内存地址更准确的示意图应该是这样的:
回归代码
作为程序员,我们的世界里最直接面对的就是代码了。如果书上描写的一切不能用代码证明,感觉总是缺少点什么,而这一节主要就是用真实的代码证明maps文件里面的各个区域。而和内存交互,最直接想到的应该就是使用c语言,而证明maps文件的各个部分最简单的方法就是打印出各个部分的地址然后和maps文件一一对应。
1 /************************************************************************* 2 > File Name: showVM.c 3 > Author: 4 > Mail: 5 > Created Time: Wed 03 Jul 2019 01:24:28 PM CST 6 ************************************************************************/ 7 8 #include <stdio.h> 9 #include <string.h> 10 #include <stdlib.h> 11 #include <unistd.h> 12 13 14 int add(int a, int b){ 15 return a+b; 16 } 17 18 int del(int a, int b){ 19 return a-b; 20 } 21 22 int (*fPointer)(int a, int b); 23 int global = 0; 24 int global_uninitialized; 25 26 int main(int argc,char *argv[]) 27 { 28 int var = 0; 29 char *chOnHeap = "test"; 30 //chOnHeap = (char*)malloc(8); 31 int *nOnHeap = (int*)malloc(sizeof(int)*1); 32 *nOnHeap = 200; 33 34 fPointer = add; 35 while(1) 36 { 37 sleep(1); 38 printf("------------------------------------------------------------------------------- "); 39 printf("global address = %p ",(void*)&global); 40 printf("global uninitialized address = %p ",(void*)&global_uninitialized); 41 printf("var value = %d, address = %p ",var,(void*)&var); 42 printf("chOnHeap value = %s, pointer address = %p, pointed address = %p ",chOnHeap,(void*)&chOnHeap,chOnHeap); 43 printf("nOnHeap value = %d, pointer address = %p, pointed address = %p ",*nOnHeap,(void*)&nOnHeap,nOnHeap); 44 45 printf("main address = %p ",(void*)&main); 46 for(int i = 0; i < argc; i++){ 47 printf("argument address = %p ",(void*)&argv[i]); 48 } 49 printf("add address = %p ", (void *)&add); 50 printf("del address = %p ", (void *)&del); 51 printf("function pointer address = %p, pointed address = %p ,value = %d ",(void *)&fPointer,fPointer,(*fPointer)(10,20)); 52 53 printf("-------------------------------------------------------------------------------- "); 54 } 55 56 free(nOnHeap); 57 //free(chOnHeap); 58 return 1; 59 }
然后使用以下命令编译这个文件:
gcc -Wall -Wextra -Werror showVM.c -o showVM
下面就是运行showVM,得到输出如下,准确的说应该是一次输出如下:
对应着上一节的maps文件,我们就可以开始我们的代码验证之旅了。
首先,对于global变量,不管是已初始化的或者是未初始化的,都是位于0x21000-0x22000这个段中的,对应上面的maps文件,可以看到无论是初始化的数据或者未初始化数据都是放在上面所说的heap之下的第三部分,可写可读区域的。
接下来就是最常见的局部变量的位置,在无数的关于c语言的书中,都会类似这样的描写: c语言中,一个变量是在栈上分配(存储)的。这里可以看到这个变量var的地址是0x7e8441d8,位于0x7e824000-0x7e845000之间,并且可以看到是更接近于7e845000,似乎可以印证栈都是从高地址向低地址增长的。不过,只有一个变量的话,有可能正好这个变量就坐落于这个区域。没有关系,我们可以用声明更多的变量看看栈到底是怎样生长的。
在接下里的两行,打印的是两个指针的地址,而指针本身是一个变量,所以可以看到他们的地址都是在栈上。如果结合上面一个变量的地址来看,正好每一个都是前一个的地址减去4,而这和32位机器上指针的大小一致。可以看到,在虚拟内存中,栈是由高地址往低地址生长的。
还是这两行,根据c语言书里面关于变量分配的另外一句话,“指针数据都是存储(分配)在堆上的”,似乎从这个输出中看有点出入。对于这两个指针,指向整数的那个指针,所指向的整数确实是分配在堆上的,因为地址0x1fce018确实坐落于0x1fce000-0x1fef000之间,而且从这个位置来看,堆似乎是从低地址往高地址分配的。而指向字符串的那个指针所指的地址明显不是在栈上,而是在0x10000-0x11000这个区域之间。这不是堆的区域,而是可执行文件存放的区域,从下一行main函数的地址更加可以证明这一点。为什么会这样呢?因为c语言把这种字面量(string literal)都放在所谓的“文字常量区”,这里的数据会在程序结束后由程序自己释放,所以即使对于这个指针不进行free也不会造成内存泄露。所以,对于这道常见的面试题,“指针指向的值都分配在哪里?”,如果你的回答可以提及文字常量区,那么一定是更有加分的。
那么,如果再多想一步,如何让指向字符串的指针所指的值也分配在堆上呢?办法有很多,比如malloc之后用strncpy,有兴趣可以试试,你会发现,这个时候指向的地址就是在堆上了。不过,千万别忘了这样的之后指针需要被free,不然就会有内存泄漏。另外,其实还有一个很有意思的行为,这个行为凸显出了编译器的机智。如果在这个文件中再定义一个指针,指向的值还是“test”,那么这两个指针指向的地址会是一样的,有兴趣只要稍微在上面的代码中加一点内容就可以验证。这种聪明的行为最直接的好处就是可以节省空间,很多这种细小的行为,至少我觉得真的是很有意思的。
讲完了指针以及main函数的地址,在示意图中说还有一部分位置是留给命令行参数的。于是,我也做了小小的验证,可以看到,虽然我这个程序执行只有一个命令行参数,也就是程序名,但是不妨碍看看这个参数到底是在哪个区域中。可以看到其地址是在前面分配的栈空间的更高地址,344明显大于1d4,所以说,和示意图中说的一样,命令行参数是位于栈空间之上的。
剩下来我想展示的是函数的地址,所谓调用函数,其实就是执行某一个地址的代码。所以,可以看到,函数地址是位于可执行区域的,和main的地址在一个区域,maps文件里也表明了这个区域具有的是可读可执行权限。
另外一个,既然函数是地址,那么按照c语言的规范,就可以使用一个指针指向这个地址,而体现在代码之中,就是函数指针。最后一行,打印了指向add函数的函数指针的地址,因为这个指针是全局定义的,所以指针本身的地址是位于全局的数据去,和globa数据一样。而指向的地址,就是add函数的地址,当然,执行的也就是add函数。
好了,现在我们使用程序本身打印出程序中不同变量的地址,并且我们知道了,maps 文件可以显示整个虚拟内存地址的分布。而正如上面提到的,还有一个和虚拟内存相关的文件,mem,这个文件就是一个程序虚拟内存的映射。而作为一个文件,就有可能有读写的权限,而下一节,就是让你看看如何hack掉一个正在运行的程序的行为(虚拟内存数据)。
修改一个运行的程序的小把戏
这一节,我想做的是,改掉一个正在运行的程序的函数指针指向的地址,这样会让一个函数的结果改变,或者说执行自己想要的函数。在一些用心良苦,技术高超的侵入者里,就这一个行为就完全有可能控制你整个电脑。当然,在我这里,我程序本身就知道函数的地址,所以,只要你理解上面所说的,看起来有点太过于玩具。而真正的黑客,会用精心构造好的代码修改掉虚拟内存中任何一个可以有写权限的地方,从而达到为所欲为的目的。
就像前面所说的,既然我知道一个指针的地址,而且又知道修改后函数应该指向的地址,那么就很简单了,读出这个文件,在这里就是mem文件了,将文件写指针指向这个位置,修改之,大功告成。而完成这个操作,可以选择任一语言,只要有文件操作的接口,而我,选择的是python。
1 #!/usr/bin/env python3 2 # coding=utf-8 3 import sys 4 pid = int(sys.argv[1]) 5 address = int(sys.argv[2],16) 6 byte_arr = [] 7 for num in range(3,len(sys.argv)): 8 byte_arr.append(int(sys.argv[num],16)) 9 10 mem_filename = "/proc/{}/mem".format(pid) 11 print("[*] mem: {}".format(mem_filename)) 12 13 try: 14 mem_file = open(mem_filename, 'rb+') 15 except IOError as e: 16 print("[ERROR] Can not open file {}:".format(mem_filename)) 17 print(" I/O error({}): {}".format(e.errno, e.strerror)) 18 exit(1) 19 20 mem_file.seek(address) 21 mem_file.write(bytearray(byte_arr)) 22 23 mem_file.close()
在执行这个程序时,可能需要使用sudo来提升权限执行。这个python程序很简单,也没啥错误提示,处理的,因为我只是想展示下基本的原理。这个脚本接受的参数依次为pid,你想改变的地址的16进制字符串,比如我想改变的那个函数指针在文件内的偏移就是他的地址 21040,想替换的终极数据,一个byte数组。这里有一点讲究,就是你需要知道一些大端,小端机器的知识,这个并不难,搜索引擎2分钟就可以告诉你答案。我想把这个函数指针指向的地址改成减法函数的地址,看起来应该改成0x10504,也就是传入01,05,04。但是如果你传入这个数据,会发现运行着的showVM程序立刻就崩溃了。而如果你认真学习了关于大端小端的知识,你会发现这里应该传入的其实是04 05 01 00。这个原因,就留给热爱探索的人吧。
好了,要想看到神奇的事情发生,只需要做两步,第一步,运行showVM,第二步,根据你的输出向这个python文件传入对应的参数,因为我又重新运行了下showVM,所以,下面执行的截图和上面会略有不同:
准备好,奇迹发生的时刻:
你可以看到,正在运行的程序,得到的结果变了,本来是10+20=30,现在变成了10-20=-10了。函数指针的地址也变了,确实指向了del。就这一套小把戏,理论上你可以改这个输出中的任意地址,但是实际上,有些你是改不了的,因为权限问题。
是不是很神奇?你还可以想想到其他有意思的实验,比如修改掉一个运行程序的字符串。方法也并不复杂,从maps文件里找到heap段的范围,在这个范围里搜索需要的字符串。有可能搜不到,因为按照上面说的,字面量字符串可能不是存储在heap区域的,而他所存储的区域你是无法修改的。这里假设在heap中搜到你所需要的字符串,那么剩下的就是找到这个位置,修改其中的内容,你会发现和上面一摸一样的效果。
最后我想说的是,如果观察maps文件更仔细一点,你会发现当你执行同一个程序,开头的三个段地址是不会改变的,但是heap开始的地址貌似并不是固定的,为什么要这么做?这里涉及到虚拟内存实现中的一个常见技术,这里会有一个随机gap,目的是增加安全性。因为前三段是固定的,而heap又是如此重要,因为你完全可以改变heap中的内容来改变一个指针指向的内容。所以一段随机的偏移可以让侵入者不那么容易的找到heap段里的数据。一个简单的操作带来的是一个安全性不小的提升,扰动其实是特别美妙的事情,随机性才让我们的世界变得如此丰富多彩。
这篇文章也在我的公众号同步发表,我的这个公众号嘛,佛系更新,当然,本质上是想到一个话题不容易(懒的好借口),欢迎关注哦: