carloscn / blog

My blog
Apache License 2.0
125 stars 35 forks source link

04_ELF文件_加载进程虚拟地址空间 #18

Open carloscn opened 2 years ago

carloscn commented 2 years ago

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)。

image-20220403130558621

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]:

image-20220403140728712

进程会选择性映射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]摘录:

iOS中,内存分为两种,一种为Clean memory,另一种为Dirty memoryClean memorypage可以换出,既磁盘中有其对应内容,系统可以在内存紧张时将Clean memorypage换出,当再次访问时,可以重新从磁盘中读取,我们使用的图片、mapped filesFramework的数据段常量以及代码段等,这些都是Clean memoryDirty memory是无法换出的,我们所有的堆上的分配等都是属于Dirty memory,所以我们一定要尽可能的减少Dirty memory的使用。

而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)

There are 36 section headers, starting at offset 0x2f50:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000400200  00000200
       000000000000001b  0000000000000000   A       0     0     1
  [ 2] .note.ABI-tag     NOTE             000000000040021c  0000021c
       0000000000000020  0000000000000000   A       0     0     4
  [ 3] .note.gnu.bu[...] NOTE             000000000040023c  0000023c
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .hash             HASH             0000000000400260  00000260
       0000000000000024  0000000000000004   A       5     0     8
  [ 5] .dynsym           DYNSYM           0000000000400288  00000288
       0000000000000060  0000000000000018   A       6     1     8
  [ 6] .dynstr           STRTAB           00000000004002e8  000002e8
       000000000000003d  0000000000000000   A       0     0     1
  [ 7] .gnu.version      VERSYM           0000000000400326  00000326
       0000000000000008  0000000000000002   A       5     0     2
  [ 8] .gnu.version_r    VERNEED          0000000000400330  00000330
       0000000000000020  0000000000000000   A       6     1     8
  [ 9] .rela.dyn         RELA             0000000000400350  00000350
       0000000000000018  0000000000000018   A       5     0     8
  [10] .rela.plt         RELA             0000000000400368  00000368
       0000000000000048  0000000000000018  AI       5    21     8
  [11] .init             PROGBITS         00000000004003b0  000003b0
       0000000000000014  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         00000000004003d0  000003d0
       0000000000000050  0000000000000010  AX       0     0     16
  [13] .text             PROGBITS         0000000000400420  00000420
       0000000000000294  0000000000000000  AX       0     0     8
  [14] .fini             PROGBITS         00000000004006b4  000006b4
       0000000000000010  0000000000000000  AX       0     0     4
  [15] .rodata           PROGBITS         00000000004006c8  000006c8
       0000000000000034  0000000000000000   A       0     0     8
  [16] .eh_frame         PROGBITS         00000000004006fc  000006fc
       0000000000000004  0000000000000000   A       0     0     4
  [17] .init_array       INIT_ARRAY       0000000000410df8  00000df8
       0000000000000008  0000000000000008  WA       0     0     8
  [18] .fini_array       FINI_ARRAY       0000000000410e00  00000e00
       0000000000000008  0000000000000008  WA       0     0     8
  [19] .dynamic          DYNAMIC          0000000000410e08  00000e08
       00000000000001d0  0000000000000010  WA       6     0     8
  [20] .got              PROGBITS         0000000000410fd8  00000fd8
       0000000000000010  0000000000000008  WA       0     0     8
  [21] .got.plt          PROGBITS         0000000000410fe8  00000fe8
       0000000000000030  0000000000000008  WA       0     0     8
  [22] .data             PROGBITS         0000000000411018  00001018
       0000000000000034  0000000000000000  WA       0     0     8
  [23] .bss              NOBITS           000000000041104c  0000104c
       0000000000000014  0000000000000000  WA       0     0     4
  [24] .comment          PROGBITS         0000000000000000  0000104c
       0000000000000024  0000000000000001  MS       0     0     1
  [34] .strtab           STRTAB           0000000000000000  000029c8
       0000000000000431  0000000000000000           0     0     1
  [35] .shstrtab         STRTAB           0000000000000000  00002df9
       0000000000000157  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), p (processor specific)

查看segment的方法:aarch64-none-elf-readelf -l ab.elf

Elf file type is EXEC (Executable file)
Entry point 0x400420
There are 8 program headers, starting at offset 64

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000400040 0x0000000000400040
                 0x00000000000001c0 0x00000000000001c0  R E    0x8
  INTERP         0x0000000000000200 0x0000000000400200 0x0000000000400200
                 0x000000000000001b 0x000000000000001b  R      0x1
      [Requesting program interpreter: /lib/ld-linux-aarch64.so.1]
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x0000000000000700 0x0000000000000700  R E    0x10000
  LOAD           0x0000000000000df8 0x0000000000410df8 0x0000000000410df8
                 0x0000000000000254 0x0000000000000268  RW     0x10000
  DYNAMIC        0x0000000000000e08 0x0000000000410e08 0x0000000000410e08
                 0x00000000000001d0 0x00000000000001d0  RW     0x8
  NOTE           0x000000000000021c 0x000000000040021c 0x000000000040021c
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000000df8 0x0000000000410df8 0x0000000000410df8
                 0x0000000000000208 0x0000000000000208  R      0x1

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.ABI-tag .note.gnu.build-id .hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame
   03     .init_array .fini_array .dynamic .got .got.plt .data .bss
   04     .dynamic
   05     .note.ABI-tag .note.gnu.build-id
   06
   07     .init_array .fini_array .dynamic .got

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如图所示:

image-20220403145309995

图中第一列就是VMA的范围,第二列权限,第三列偏移,第四列主设备和次设备号,第五列映像文件的节点号,最后是文件路径。这个程序里面有很多动态链接库的地址,还有一些用[]标注起来的地址,他们被称为匿名虚拟内存区域(Anoymous Virtual Memory Area)

VMA 说明
代码VMA RW,有映像文件
数据VMA RWX,有映像文件
堆VMA RWX,无影响文件,匿名,可向上扩展
栈VMA RW,无影响文件,匿名,可向下扩展

2.2 栈和堆groth方向

image-20220403152021482

这里多说一个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,使得进程的堆空间变得更小。

#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;
}

3. 相邻页合并

我们装载以段为基本单位划分,使得内存的利用率提升了,但是我们总是要一直追求内存的利用率不断提升的,比我们继续分析,假如有三个段,大小分别为127,9899,1988,那么他们分别需要1,3,1个页,一共需要5个页,但是每个页都存在页内碎片,尤其是第一个段,仅仅127的大小,却要给其分配一整个页,三个段大小一共是12014字节,但是却需要20480字节的空间,空间使用率只有58.6%,所以我们继续思考如何提高内存利用率。

一般大部分unix系统采用的都是相邻页合并的方法,注意 : 我们采用相邻页合并,改变的是物理内存的分页方式,虚拟内存中我们依然是按照正常方式分配页,比如上例中,我们采用相邻页合并,会使得分配的物理页数目减少,但是虚拟内存还是按照正常分配方式分配5个,只不过会存在虚拟内存中两个页映射到同一个物理内存页上,比如,让第一段的唯一一个页和相邻段(该段有3个页)的相邻页合并成为一个页,如下图

image-20220403160317500

左图是没有分段的情况,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保护机制的原理,是在一个函数入口处从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保护[^13]:

-fstack-protector 启用保护,不过只为局部变量中含有数组的函数插入保护
-fstack-protector-all 启用保护,为所有函数插入保护
-fstack-protector-strong
-fstack-protector-explicit 只对有明确stack_protect attribute的函数开启保护
-fno-stack-protector 禁用保护.

# 使用checksec查看保护
adef@ubuntu:~$ checksec microwave
[*] '/microwave'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    FORTIFY:  Enabled

但是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

这里就不具体解析了,参考文献^14

在Linux Userspace中对ASLR的使用^8

地址空间随机化 ASLR(Address Space Layout Randomization) 查看当前系统的ASLR配置情况

image-20220403154048815

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

image-20220403154100094

使用ldd命令可以观察程序所依赖动态加载模块的地址空间,当ASLR开启时,地址就会发生变化

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的栈保护技术研究

carloscn commented 2 years ago

https://github.com/carloscn/clab/tree/master/macos/test_malloc