前言
这篇文章主要是想尽量直观的介绍虚拟内存的知识, 而虚拟内存的知识不管作为在校学生的基础知识, 面试的问题以及计算机程序本身性能的优化都有着重要的意义. 而起意写这篇文章主要还是因为在 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 文件一一对应.
- /*************************************************************************
- > File Name: showVM.c
- > Author:
- > Mail:
- > Created Time: Wed 03 Jul 2019 01:24:28 PM CST
- ************************************************************************/
- #include <stdio.h>
- #include <string.h>
- #include <stdlib.h>
- #include <unistd.h>
- int add(int a, int b){
- return a+b;
- }
- int del(int a, int b){
- return a-b;
- }
- int (*fPointer)(int a, int b);
- int global = 0;
- int global_uninitialized;
- int main(int argc,char *argv[])
- {
- int var = 0;
- char *chOnHeap = "test";
- //chOnHeap = (char*)malloc(8);
- int *nOnHeap = (int*)malloc(sizeof(int)*1);
- *nOnHeap = 200;
- fPointer = add;
- while(1)
- {
- sleep(1);
- printf("-------------------------------------------------------------------------------\n");
- printf("global address = %p\n",(void*)&global);
- printf("global uninitialized address = %p\n",(void*)&global_uninitialized);
- printf("var value = %d, address = %p\n",var,(void*)&var);
- printf("chOnHeap value = %s, pointer address = %p, pointed address = %p\n",chOnHeap,(void*)&chOnHeap,chOnHeap);
- printf("nOnHeap value = %d, pointer address = %p, pointed address = %p\n",*nOnHeap,(void*)&nOnHeap,nOnHeap);
- printf("main address = %p\n",(void*)&main);
- for(int i = 0; i < argc; i++){
- printf("argument address = %p\n",(void*)&argv[i]);
- }
- printf("add address = %p\n", (void *)&add);
- printf("del address = %p\n", (void *)&del);
- printf("function pointer address = %p, pointed address = %p ,value = %d\n",(void *)&fPointer,fPointer,(*fPointer)(10,20));
- printf("--------------------------------------------------------------------------------\n");
- }
- free(nOnHeap);
- //free(chOnHeap);
- return 1;
- }
然后使用以下命令编译这个文件:
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.
- #!/usr/bin/env python3
- # coding=utf-8
- import sys
- pid = int(sys.argv[1])
- address = int(sys.argv[2],16)
- byte_arr = []
- for num in range(3,len(sys.argv)):
- byte_arr.append(int(sys.argv[num],16))
- mem_filename = "/proc/{}/mem".format(pid)
- print("[*] mem: {}".format(mem_filename))
- try:
- mem_file = open(mem_filename, 'rb+')
- except IOError as e:
- print("[ERROR] Can not open file {}:".format(mem_filename))
- print("I/O error({}): {}".format(e.errno, e.strerror))
- exit(1)
- mem_file.seek(address)
- mem_file.write(bytearray(byte_arr))
- 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 段里的数据. 一个简单的操作带来的是一个安全性不小的提升, 扰动其实是特别美妙的事情, 随机性才让我们的世界变得如此丰富多彩.
来源: https://www.cnblogs.com/ZXYloveFR/p/11150523.html