04_ELF文件_加载进程虚拟地址空间
https://github.com/carloscn/blog/issues/18
最后更新于
https://github.com/carloscn/blog/issues/18
最后更新于
这个稍稍微微有点操作系统里面的东西了,《程序员的自我修养》一本书里面,是站在可执行程序里面看的操作系统,比如说程序是分页管理的方式,大概说明了一下分页什么原理,而从《操作系统原理》一本书里面是站在操作系统的角度去看可执行程序,就会具体的讲操作系统是如何分页的,比如hash页表,页偏移具体的公式。两部分可以说是完全不一样的视角,本文意图就是希望结合两个视角整理出一份自己理解的笔记。
进程虚拟地址
装载方式
进程虚拟空间分布
ELF角度看堆和栈
早期程序在ROM中直接加载到RAM里面执行,这种方法就十分简陋;RAM是一个十分珍贵的资源,这种方式当然不能被接受,后来人们提出了Overlay覆盖装入的方法,但这样的方法需要程序员自己对互不相关没有调用的函数进行管理,需要根据依赖关系组织成树状结构,对于开发者十分不友好;现代操作系统使用也映射(paging)的方法,按页完成数据和指令在ROM和RAM中的换入和换出(SWAP);现在操作系统还引入了MMU,让这种paging变得更为复杂。
最终程序的执行从编译出的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:
什么时候映射,映射规则如何?
如果进程执行的时候发现下一条指令或者需要的数据没有被映射怎么办?
文献提到swap技术中的两种方式,一种是是标准交换,一种是移动系统的交换。文献提供了多个页面置换的算法供操作系统选择。而当进程执行的时候发现指令数据没有被映射,那么就会发生页错误(Page Fault),此时就会激发缺页中断,CPU强制抢占进程执行,在缺页中断的下半段,在不同的系统中就会采用不同的页面置换算法,当中断返回之后进程继续执行,在PVS内就有对应的PMS了。所以,页错误也是评价一个页面置换算法的好坏的指标,为了追求更好的性能,尽可能的减少页错误的发生。(文献给了一组关于系统切换上下文的数据,100MB的进程,传输速度为50MB/s,100MB进程换入和换出的时间需要4s。)
页错误的一种场景:
进程会选择性映射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技术
这里和生活中一些我们在应用观察的现象做一个引申。文献提到苹果的ISO内存管理一些比较初级的原理,我们在使用ISO的时候不会担心RAM被吃光,甚至库克不建议清理ISO后台。我相信一部分原因也是因为没有采用swap技术,当然也进程管理及低功耗管理肯定有着很大的作用,从文献摘录:
iOS
中,内存分为两种,一种为Clean memory
,另一种为Dirty memory
;Clean memory
的page
可以换出,既磁盘中有其对应内容,系统可以在内存紧张时将Clean memory
的page
换出,当再次访问时,可以重新从磁盘中读取,我们使用的图片、mapped files
、Framework
的数据段常量以及代码段等,这些都是Clean memory
。Dirty memory
是无法换出的,我们所有的堆上的分配等都是属于Dirty memory
,所以我们一定要尽可能的减少Dirty memory
的使用。
而Linux使用这项技术,我们在安装UBUNTU分盘的时候,常常需要预留2GB的SWAP空间,实际上就是在这里应用。那么应用SWAP的切换进程上下文,还有双缓冲都会吃掉一些性能。
1.2.1 section和segment区别
如果我们自己定义了很多段,甚至一个变量占了一个段,那么VMA在映射的时候就很肉痛了,因为ELF文件被映射的时候是以页为单位的(意味着映射长度是系统页单位的数倍,Linux系统页可能是4KB/8KB/16KB,利用getpagesize()或者命令行getconf PAGESIZE
)。如果定义一个变量独占一个段,那么可能在VMA上面占据4KB的长度。在VMA角度不会关心你自己定义的这些段,VMA角度只关心这些段的可读可写可执行的属性还有逻辑地址的值。因此,在装载过程中:对于权限相同的段进行合并之后再映射。这里有个约定俗成的叫法,我们把没有之前的段叫做section,把合并之后的段叫做Segment,当我们写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):
LOAD:属于我们应该关心的地址2个,第一个地址是VMA0,属于可读可执行的区域;第二个地址是VMA1,属于可读可写的区域。
DYNAMIC:动态链接的时候再说
NOTE, GNU_STACK, GNU_RELRO:起到辅助作用,不说了。
我们可以从视图的角度定义section和segment,section是链接视图里面的概念,而segment是执行视图里面的概念。
我们程序内部肯定是用到堆(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] : 该地址已经位于内核空间,用于和内核进行一些通信,暂时不在本文展开。
代码VMA
RW,有映像文件
数据VMA
RWX,有映像文件
堆VMA
RWX,无影响文件,匿名,可向上扩展
栈VMA
RW,无影响文件,匿名,可向下扩展
2.2 栈和堆groth方向
这里多说一个stack和heap有趣的知识,stack和heap的增长方向,stack的增长方向是向下增长的(高地址->低地址),而heap的增长方向是(低地址到高地址)。这个原因就是,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),使得进程的堆空间变得更小。
我们装载以段为基本单位划分,使得内存的利用率提升了,但是我们总是要一直追求内存的利用率不断提升的,比我们继续分析,假如有三个段,大小分别为127,9899,1988,那么他们分别需要1,3,1个页,一共需要5个页,但是每个页都存在页内碎片,尤其是第一个段,仅仅127的大小,却要给其分配一整个页,三个段大小一共是12014字节,但是却需要20480字节的空间,空间使用率只有58.6%,所以我们继续思考如何提高内存利用率。
一般大部分unix系统采用的都是相邻页合并的方法,注意 : 我们采用相邻页合并,改变的是物理内存的分页方式,虚拟内存中我们依然是按照正常方式分配页,比如上例中,我们采用相邻页合并,会使得分配的物理页数目减少,但是虚拟内存还是按照正常分配方式分配5个,只不过会存在虚拟内存中两个页映射到同一个物理内存页上,比如,让第一段的唯一一个页和相邻段(该段有3个页)的相邻页合并成为一个页,如下图
左图是没有分段的情况,SEG0和SEG2单独使用一个页,SEG1由于比较庞大使用了三个页,因此用了5个页。而右边的图中对ELF进行页大小分割,在PMS上面,page1包含了SEG2和部分SEG1,page2被SEG1的部分独占,page3包含了SEG0,ELF头和SEG1的部分,最后依靠MMU把PMS共享的部分展开到PVS上面。相邻页合并使页的使用率提高。
检查ELF格式的有效性,包含magic,程序头表中段(segment)的数量。
寻找动态链接.interp段,设定动态链接的路径。
根据ELF表头的描述,对ELF文件进行映射,code,data,和只读区域。
初始化ELF进程环境。(参照动态链接)
将系统调用的返回地址修改成ELF的入口地址。(load_elf_binary -> do_execve -> sys_execve),此时系统调用从内核态返回到用户态的时候,直接跳转到ELF的地址了。
ELF装载完成。
我相信计算机发展的今天,很多设计都是为了弥补一些场景的漏洞,而这些漏洞作为年轻一代的我们并没有参与过,不过读读相关论文和博客资料能从中挖掘一点乐趣,也能了解一下计算机专业的同学到底在研究什么。1988年的时候莫里斯李用unix操作系统fingerd软件中缓冲溢出安全漏洞写出了莫里斯蠕虫,基于缓冲区(堆栈)溢出的原理。基于缓冲区的攻击包括栈溢出、堆溢出、整形溢出、格式化字符串攻击、双重free,基于植入性的攻击包括代码植入攻击还有return-into-libc攻击。
怎么攻击?我们可以看到没有开启ASLR的动态库是固定值,黑客如果想要攻击系统可以修改这个固定值加载的动态链接:栈溢出或者return-into-libc来实现攻击。攻击者可以通过缓冲区溢出改写返回地址为一个自己实现的库函数地址,并将库函数执行的参数也重新写入栈,这样函数调用时获取的是攻击者的设定好的参数,并且结束之后返回到函数而不是main,具体操作过程可以参考,里面通过栈溢出操作获取root权限(编译器关闭–fno-stack-protector
)。
SSP(Stack Smashing Protect)摘了原文
Canary保护机制的原理,是在一个函数入口处从gs(32位)或fs(64位)段内获取一个随机值,一般存到eax - 0x4(32位)或rax -0x8(64位)的位置。如果攻击者利用栈溢出修改到了这个值,导致该值与存入的值不一致,__stack_chk_fail函数将抛出异常并退出程序。Canary最高字节一般是\x00,防止由于其他漏洞产生的Canary泄露
需要注意的是:canary一般最高位是\x00,64位程序的canary大小是8个字节,32位的是4个字节,canary的位置不一定就是与ebp存储的位置相邻,具体得看程序的汇编操作
首先,canary被检测到修改,函数不会经过正常的流程结束栈帧并继续执行接下来的代码,而是跳转到call __stack_chk_fail处,然后对于我们来说,执行完这个函数,程序退出,屏幕上留下一行*** stack smashing detected ***:[XXX] terminated。这里的[XXX]是程序的名字。很显然,这行字不可能凭空产生,肯定是__stack_chk_fail打印出来的。而且,程序的名字一定是个来自外部的变量(毕竟ELF格式里面可没有保存程序名)。既然是个来自外部的变量,就有修改的余地。
程序正常的走完了流程,到函数执行完的时候,程序会把canary的值取出来,和之前放在栈上的canary进行比较,如果因为栈溢出什么的原因覆盖到了canary而导致canary发生了变化则直接终止程序。
在GCC里面开启canary保护:
但是Canary也并不安全,一方面Canary仅仅是对于stack的保护,对heap没有保护作用,另一方面canary也可能被fork()爆破。Canary被爆破之后程序会立刻技术,重新进入程序之后Canary的值也会变化。但是同一个进程的canary值都是一致的,当祖先进程不断fork的时候,劫持__stack_chk_fail函数就可以将canary爆破。
Canary这种方法只局限于对栈的保护。空间地址随机化是通过对操作系统内核或者C库进行修改。使得进程加载到内存地址是随机化,从而降低攻击成功的概率。在return-to-libc或者.plt/.got覆盖场景下,攻击者必须知道指定地址。地址空间随机化之后,指定地址难以确定,达到抗攻击的可能。地址空间随机化包含四个层面:
栈的随机化:/usr/src/sys/kem/kem_exec.c里面。
堆的随机化:malloc初始化上面实现
动态库映射随机化:内核mmap函数实现
可执行映像随机化:gcc需要支持-fpie
选项
这里就不具体解析了,参考文献。
在Linux Userspace中对ASLR的使用:
地址空间随机化 ASLR(Address Space Layout Randomization) 查看当前系统的ASLR配置情况
cat /proc/sys/kernel/randomize_va_space sysctl -a --pattern randomize
配置选项
0 关闭 1 半随机 共享库 栈 mmap()以及VDSO将被随机化 2 全随机 还有heap
开启后基址每次加载都会发生变化
echo 0 > /proc/sys/kernel/randomize_va_space sysctl -w kernel.randomize_va_space=0
使用ldd命令可以观察程序所依赖动态加载模块的地址空间,当ASLR开启时,地址就会发生变化