PIE和ASLR:
参考链接:对PIE和ASLR的理解
首先,ASLR的是操作系统的功能选项,作用于executable(ELF)装入内存运行时,因而只能随机化stack、heap、libraries的基址;而PIE(Position Independent Executables)是编译器(gcc,…)功能选项(-fPIE),作用于excutable编译过程,可将其理解为特殊的PIC(so专用,Position Independent Code),加了PIE选项编译出来的ELF用file命令查看会显示其为so,其随机化了ELF装载内存的基址(代码段、plt、got、data等共同的基址)。
PIE只是在编译的过程中赋予了ELF加载到内存时其加载基址随机化的功能,也就是说PIE编译出来的ELF如果在ASLR=0的情况下,ELF的加载基址也是不会变的。
所以这是ASLR 的三个级别变成了 :0, 不开启任何随机化;1, 开启stack、libraries [、executable base(special libraries -^-) if PIE is enabled while compiling] 的随机化;2,开启heap随机化。
因而,我们会发现PIE编译出来的executable如果ASLR=0的话,基址也是不会变的(有能力但没使用),如果ASLR=1的话,即使按照ASLR定义这个级别似乎不会对heap基址随机化,但是由于executable的基址已经随机化了,所以heap的基址自然也就被随机化了:)
ELF文件的结构:
参考链接:参考
elf文件分三种类型: 1、目标文件(通常是.o); 2、可执行文件(我们的运行文件) 3、动态库(.so)
我们先讲一下可执行文件。
可执行文件一般分成4个部分,能扩展,我们理解这4部分就够了。
1. elf文件头 ,这个文件是对elf文件整体信息的描述,在32位系统下是56的字节,在64位系统下是64个字节。
对于可执行文件来说,文件头包含的一下信息与进程启动相关
e_entry 程序入口地址
e_phoff segment偏移
e_phnum segment数量
2. segment表, 这个表是加载指示器,操作系统(确切的说是加载器,有些elf文件,比如操作系统内核,是由其他程序加载的),该表的结构非常重要。
typedef struct
{
Elf64_Word p_type; /* Segment type */
Elf64_Word p_flags; /* Segment flags */ /*segment权限,6表示可读写,5表示可读可执行
Elf64_Off p_offset; /* Segment file offset */ /段在文件中的偏移/
Elf64_Addr p_vaddr; /* Segment virtual address */ /*虚拟内存地址,这个表示内存中的
Elf64_Addr p_paddr; /* Segment physical address /物理内存地址,对应用程序来说,这个字段无用/
Elf64_Xword p_filesz; /* Segment size in file */ /段在文件中的长度/
Elf64_Xword p_memsz; /* Segment size in memory / /在内存中的长度,一般和p_filesz的值一样/
Elf64_Xword p_align; /* Segment alignment / / 段对齐*/
} Elf64_Phdr;
3. elf的主题,对于可执行文件来说,最主要的就是数据段和代码段
4. section表,对可执行文件来说,没有用,在链接的时候有用,是对代码段数据段在链接是的一种描述。
整个elf文件的组成可以使用下图来描述:
ELF文件的载入和运行及动态链接:
参考链接:重读《深入理解计算机系统》
运行地址也就是链接地址。实际上指的是,程序在运行过程中,该指令对应的内存地址。
我们再回到本系列的主题:程序的加载和运行。可执行程序生成之后,是保存在硬盘中的,当用户执行该程序的时候,该程序会被加载器按照program header table的描述将程序的代码段和数据段从硬盘加载到内存中。在使用MMU的机器上,CPU处理的地址是虚拟地址。同样的,加载到内存中的地址指的也是虚拟地址。由于虚拟内存的抽象,每个进程都认为其独占内存,因此,每个可执行程序总是可以被加载到相同的内存地址(虚拟地址),其实,这些内存地址都是位于各个可执行程序独自的内存空间的地址。但是,对于MMU来讲,这些相同的虚拟地址其实对应了不同的物理地址。而对于CPU来讲,指令是按照其虚拟地址一条条的被加载到CPU中运行的。
如上所述,加载器是按照program header table的描述来给程序代码段分配指令地址的。具体的过程如下:
还记得我们在可执行文件详解中segment和section的对应关系吧?每个section按照这个mapping表顺序排列构成了不同的segment。其中第2个segment就是可执行文件的代码段。代码段中第一个section是.interp,其起始地址是0x400238,然后,加上.interp section的大小,就是下一个section .note.ABI-tag的起始地址。依次类推,对于可执行文件详解中的可执行文件add来讲,其.text的起始地址就是0x400430。
为什么对于X86_64架构来讲,所有可执行文件的
text segment的起始地址都是0x400000?实际上是链接脚本规定的,在链接过程中,链接器会根据链接脚本的描述来构建可执行文件。对于X86_64来讲,其默认的链接脚本位于
/usr/lib/ldscripts/elf_x86_64.x。在其中我们发现这句话:__executable_start = SEGMENT_START("text-segment", 0x400000))
它指定了可执行的text segment应起始于0x400000。
根据上述objdump -d的输出,.text的第一个函数是_start,因此,_start的第一条指令地址就是0x400430。后面每个函数的地址等于它的上一个函数的地址加上该函数自身的字节数。这样,完成了给每个函数重定位(分配运行地址)的过程。
函数中的每条指令的地址的重定位类似于函数重定位。函数的首地址即是第一条指令的首地址,后面每条指令的地址依次等于上一条指令的地址加上该指令的字节数。回忆编译过程分析中,在编译完成后,指令引用外部符号时,生成了对应的操作数和符号的占位符,此时,对于除动态链接库的符号外,其他的符号都已经有了确定的地址。因此,结合符号表我们就可以将类似的指令完成重定位。
比如在本例中,对于main函数来讲,调用了两个外部函数add和printf,根据上面信息,add函数相关的代码已经确定在0x400526处。因此,该地址就是call指令的调用add的操作数。而上述代码显示的400400处似乎并非是printf函数真正的实现。没错,这是因为printf函数是属于libc的库函数,但是,我们知道对于动态链接来讲,在生成可执行文件时,并未将它所依赖的动态库的代码复制过来,而只是复制了相关的重定位信息和符号表,所以,此时依然不能确定printf函数的地址。而400400处的内存值只是一个跳板,等程序运行时,动态链接器会将相关动态链接库的代码链接进来,修改这个跳板处对应的值,就可以让跳转指令正确的跳转到printf函数真正的内存地址处执行了。稍后我们将会对该过程做详细的分析。(涉及到PLT和GOT表的知识)
上篇文章我们提到,为了保证代码复用和节省计算机资源,在链接时,动态链接库的代码段和数据段等是不会被复制到最终生成的可执行文件中的,这些部分会在程序加载的时候复制到内存,并做动态链接,使原来可执行文件能够对其中定义的符号正常引用。也就是说在这个时候,可执行文件代码段中对动态链接库包含的符号引用的地址才真正确定下来。但是我们查看各个segment的属性可以知道,.text segment是只读的,也就是说在编译成可以执行文件之后,就不能被修改了,那么如何确保它能够正确的引用在加载时才能确定下来的动态链接库里的符号呢?这就需要我们这篇文章里的GOT和PLT作为跳板来实现了。
GOT全称Global Offset Table,即全局偏移量表。它在可执行文件中是一个单独的section,位于.data section的前面。每个被目标模块引用的全局符号(函数或者变量)都对应于GOT中一个8字节的条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含正确的目标地址。
PLT全称Procedure Linkage Table,即过程链接表。它在可执行文件中也是一个单独的section,位于.text
section的前面。每个被可执行程序调用的库函数都有它自己的PLT条目。每个条目实际上都是一小段可执行的代码。