malloc申请空间的具体数值是一个不确定的数值,因为受到操作系统、程序本身大小、用到的动态库共享库大小、程序栈数量、大小等。甚至每次运行结果都不同,操作系统为了安全性还使用了随机地址空间技术(Address Space Layout Randomization, ASLR)^8,使得进程的堆空间变得更小。
#include <stdio.h>
#include <stdlib.h>
unsigned int max = 0;
int main()
{
unsigned int blocksize[] = {1024 * 1024, 1024, 1};
int i, count;
for (i = 0; i < 3; i++)
{
for (count = 1; ; count++)
{
void* block = malloc(max + blocksize[i] * count);
if (block) //malloc ok
{
max += blocksize[i] * count;
free(block);
}
else
{
break;
}
}
}
printf("max malloc size = %uB\n", max);
return 0;
}
04_进程虚拟地址空间
这个稍稍微微有点操作系统里面的东西了,《程序员的自我修养》一本书里面,是站在可执行程序里面看的操作系统,比如说程序是分页管理的方式,大概说明了一下分页什么原理,而从《操作系统原理》一本书里面是站在操作系统的角度去看可执行程序,就会具体的讲操作系统是如何分页的,比如hash页表,页偏移具体的公式。两部分可以说是完全不一样的视角,本文意图就是希望结合两个视角整理出一份自己理解的笔记。
1. 进程虚拟地址(VMA)
早期程序在ROM中直接加载到RAM里面执行,这种方法就十分简陋;RAM是一个十分珍贵的资源,这种方式当然不能被接受,后来人们提出了Overlay覆盖装入的方法,但这样的方法需要程序员自己对互不相关没有调用的函数进行管理,需要根据依赖关系组织成树状结构,对于开发者十分不友好;现代操作系统使用也映射(paging)的方法,按页完成数据和指令在ROM和RAM中的换入和换出(SWAP);现在操作系统还引入了MMU,让这种paging变得更为复杂。
1.1 ELF -> PVS -> PMS
最终程序的执行从编译出的ELF逻辑空间,会被映射到PVS(进程虚拟空间),最后被MMU映射到PMS(物理内存空间)。我们在编译一个文件的时候在ELF文件中readelf可以看到VMA的地址,VMA的地址是虚拟内存区域(Virtual Memory Area)。关于ELF->PVS,我们这里关注点在于ELF->PVS加载的过程,对于PVS->PMS属于MMU的知识范畴,这里暂时不涉及,相关TOPICS也会在ARM架构和Linux内核里面着重展开。
1.1.1 image load 单个段
进程在开始初始化的时候读取可执行文件头,并且建立虚拟空间和可执行文件的映射关系。(ELF -> PVS)这个过程我们可以叫做映像文件加载(image loading)。
1.1.2 页错误Page Fault
我们在开头的时候说,线代操作系统采用paging的方法,既然是节约RAM的方法,必然是从存储在ROM中的ELF部分页映射到PVS上面。这里其实有几个很简单的concern:
文献[^2]提到swap技术中的两种方式,一种是是标准交换,一种是移动系统的交换。文献^4提供了多个页面置换的算法供操作系统选择。而当进程执行的时候发现指令数据没有被映射,那么就会发生页错误(Page Fault),此时就会激发缺页中断,CPU强制抢占进程执行,在缺页中断的下半段,在不同的系统中就会采用不同的页面置换算法,当中断返回之后进程继续执行,在PVS内就有对应的PMS了。所以,页错误也是评价一个页面置换算法的好坏的指标,为了追求更好的性能,尽可能的减少页错误的发生。(文献[^2]给了一组关于系统切换上下文的数据,100MB的进程,传输速度为50MB/s,100MB进程换入和换出的时间需要4s。)
页错误的一种场景[^3]:
进程会选择性映射DP1中的指令到PVS的VP0,VP1和VP5,如果他们的指令分别对应
1+1
,1+2
,1+3
,进程按照顺序执行他们,这种映射关系被存储在两个结构体上面,左侧一个结构体存储ELF->PVS的,右侧结构体存储PVS->PMS的。当进程需要1+4
指令的时候,在结构体ELF->PVS可以查找到关系,但在PVS->PMS结构体查找不到对应关系,此时出发page fault中断,CPU开始运行页面置换算法,中断执行完毕,回到进程中,继续查找PVS->PVM的映射关系,此时如果建立了如图红色的映射关系,那么1+4
这条指令可以被查询到接着执行。这就是整个page fault的处理流程。1.1.3 swap技术
这里和生活中一些我们在应用观察的现象做一个引申。文献[^5]提到苹果的ISO内存管理一些比较初级的原理,我们在使用ISO的时候不会担心RAM被吃光,甚至库克不建议清理ISO后台。我相信一部分原因也是因为没有采用swap技术,当然也进程管理及低功耗管理肯定有着很大的作用,从文献[^5]摘录:
而Linux使用这项技术,我们在安装UBUNTU分盘的时候,常常需要预留2GB的SWAP空间,实际上就是在这里应用。那么应用SWAP的切换进程上下文,还有双缓冲都会吃掉一些性能。
1.2 链接视图和执行视图
1.2.1 section和segment区别
如果我们自己定义了很多段,甚至一个变量占了一个段,那么VMA在映射的时候就很肉痛了,因为ELF文件被映射的时候是以页为单位的(意味着映射长度是系统页单位的数倍,Linux系统页可能是4KB/8KB/16KB,利用getpagesize()或者命令行
getconf PAGESIZE
)。如果定义一个变量独占一个段,那么可能在VMA上面占据4KB的长度。在VMA角度不会关心你自己定义的这些段[^1],VMA角度只关心这些段的可读可写可执行的属性还有逻辑地址的值。因此,在装载过程中:对于权限相同的段进行合并之后再映射。这里有个约定俗成的叫法,我们把没有之前的段叫做section,把合并之后的段叫做Segment[^1],当我们写gcc程序的时候出现Segment fault这种错误的时候,就可以知道是访问内存权限出错了。1.2.2 查看section及segment
查看section就是我们老方法了,
readelf -S xxx.elf
,把debug一些section去掉了。这个叫做链接视图(Linking View)查看segment的方法:
aarch64-none-elf-readelf -l ab.elf
segment中的段,叫做执行视图(Execution View):
我们可以从视图的角度定义section和segment,section是链接视图里面的概念,而segment是执行视图里面的概念。
2. ELF角度看堆和栈
我们程序内部肯定是用到堆(Heap)和栈(Stack)了,这部分在elf上面怎么表述的,尤其是使用malloc从堆分配空间这是一个动态的过程?malloc肯定是从PVS上面分配的VMA,那么如何表示,难道编译器可以预测malloc的行为?
2.1 查看maps
我们在IMX6ULL的嵌入式Linux上面查看test_msg_1.elf进程的虚拟空间分布,
cat /proc/pid/maps
如图所示:图中第一列就是VMA的范围,第二列权限,第三列偏移,第四列主设备和次设备号,第五列映像文件的节点号,最后是文件路径。这个程序里面有很多动态链接库的地址,还有一些用[]标注起来的地址,他们被称为匿名虚拟内存区域(Anoymous Virtual Memory Area)
[heap] : 136KB大小
[stack] : 132KB大小
[vdso] : 该地址已经位于内核空间,用于和内核进行一些通信,暂时不在本文展开。
2.2 栈和堆groth方向
这里多说一个stack和heap有趣的知识,stack和heap的增长方向,stack的增长方向是向下增长的(高地址->低地址),而heap的增长方向是(低地址到高地址)^6。这个原因就是,STACK和HEAP是动态区域,很难划分出界限,所以让两个区域朝着一个方向滚动,可以充分利用各自的空间。
2.3 非头部映射
还有一个需要注意的是,Linux装载ELF的时候并直接映射,比如上图.RW区域头地址并非对应DATA区域的头地址。linux内核里面有个“hack”的方法,简言之就是上个区域没用完的段会被插入一些其他的段__libcfreeres_ptrs,这种做法在Linux内核的elf_map(), laod_elf_interp()中。
2.4 堆最大申请及ASLR
malloc申请空间的具体数值是一个不确定的数值,因为受到操作系统、程序本身大小、用到的动态库共享库大小、程序栈数量、大小等。甚至每次运行结果都不同,操作系统为了安全性还使用了随机地址空间技术(Address Space Layout Randomization, ASLR)^8,使得进程的堆空间变得更小。
3. 相邻页合并
左图是没有分段的情况,SEG0和SEG2单独使用一个页,SEG1由于比较庞大使用了三个页,因此用了5个页。而右边的图中对ELF进行页大小分割,在PMS上面,page1包含了SEG2和部分SEG1,page2被SEG1的部分独占,page3包含了SEG0,ELF头和SEG1的部分,最后依靠MMU把PMS共享的部分展开到PVS上面。相邻页合并使页的使用率提高。
4. 进程加载ELF的过程
5. Canary和ASLR
我相信计算机发展的今天,很多设计都是为了弥补一些场景的漏洞,而这些漏洞作为年轻一代的我们并没有参与过,不过读读相关论文和博客资料能从中挖掘一点乐趣,也能了解一下计算机专业的同学到底在研究什么。1988年的时候莫里斯李用unix操作系统fingerd软件中缓冲溢出安全漏洞写出了莫里斯蠕虫,基于缓冲区(堆栈)溢出的原理。基于缓冲区的攻击包括栈溢出、堆溢出、整形溢出、格式化字符串攻击、双重free^10,基于植入性的攻击包括代码植入攻击还有return-into-libc攻击。
怎么攻击?我们可以看到没有开启ASLR的动态库是固定值,黑客如果想要攻击系统可以修改这个固定值加载的动态链接:栈溢出或者return-into-libc来实现攻击。攻击者可以通过缓冲区溢出改写返回地址为一个自己实现的库函数地址,并将库函数执行的参数也重新写入栈,这样函数调用时获取的是攻击者的设定好的参数,并且结束之后返回到函数而不是main,具体操作过程可以参考[^11],里面通过栈溢出操作获取root权限(编译器关闭
–fno-stack-protector
)。5.1 SSP编译保护Canary
SSP(Stack Smashing Protect)摘了原文[^12]
程序正常的走完了流程,到函数执行完的时候,程序会把canary的值取出来,和之前放在栈上的canary进行比较,如果因为栈溢出什么的原因覆盖到了canary而导致canary发生了变化则直接终止程序。
在GCC里面开启canary保护[^13]:
但是Canary也并不安全,一方面Canary仅仅是对于stack的保护,对heap没有保护作用^14,另一方面canary也可能被fork()爆破[^12]。Canary被爆破之后程序会立刻技术,重新进入程序之后Canary的值也会变化。但是同一个进程的canary值都是一致的,当祖先进程不断fork的时候,劫持__stack_chk_fail函数就可以将canary爆破。
5.2 ASLR地址空间随机化
Canary这种方法只局限于对栈的保护。空间地址随机化是通过对操作系统内核或者C库进行修改。使得进程加载到内存地址是随机化,从而降低攻击成功的概率。在return-to-libc或者.plt/.got覆盖场景下,攻击者必须知道指定地址。地址空间随机化之后,指定地址难以确定,达到抗攻击的可能。地址空间随机化包含四个层面^14:
-fpie
选项这里就不具体解析了,参考文献^14。
在Linux Userspace中对ASLR的使用^8:
Ref
[^1]:程序员的自我修养 : 链接、装载与库 [^2]:操作系统概念(原书第9版)- 第三部分 内存管理/8.2 交换 [^3]:页错误 Page Fault /缺页异常 详解
[^5]: iOS内存管理之Swapped Memory
[^9]: 程序员的自我修养3.2 段地址对齐
[^11]:20145236《网络对抗》进阶实验——Return-to-libc攻击 [^12]:Stack Canary [^13]:stack canary绕过思路
[^15]:基于多态Canary的栈保护技术研究