要说pwn中最基础的一种攻击手段,必须是栈溢出,但是什么是栈,为什么要溢出?在正式了解栈溢出之前,需要简单了解一些前置知识:
ELF文件结构#
我们要了解一个程序是如何从磁盘到内存,然后跑起来的,但是要了解这个问题,我们也要先简单了解一下ELF。
ELF(Executable and Linkable Format)是Linux中的一种二进制文件格式,根据文件的不同功能,ELF可被分类为可执行文件、目标文件、共享库。什么是目标文件和共享库?这里就不细讲了,以后迟早会说到(挖坑ing)
我们重点要讨论的是可执行文件的结构,需要注意的是ELF文件有两种视图 — 链接视图和执行视图,区分这两种视图的目的是为了让链接器和装载器都能快速的了解程序布局,提高链接和执行这两个不同的操作的效率。在不同的视图下结构会有一些微妙的差别,我们来简单看一下:
文件头 — 两个视图共有#
文件头是ELF文件的标签,用来描述这个文件的一些信息,具体包括:
魔数:用来说明这是一个ELF文件
文件类型:是哪种功能的ELF文件
架构:这个文件在什么样的架构下运行
入口地址:这个文件从哪里开始运行
程序头表和节头表的地址:字面意思
节头表与节 — 链接视图#
在链接视图中,我们主要看节,这个结构是给链接器用的,它需要将来自不同文件的具有相同功能的代码或数据整合到一起,在这里功能最重要!
节头表会列出每个节的名称、功能、大小、以及在文件中的位置。
节是储存数据和代码的最小逻辑单位,当我们链接多个目标文件的时候,都是以节为对象进行操作的。
不同的节有不同的功能,常见的节与其功能如下:
.text:存储代码。
.data:存储已经初始化的全局变量或静态变量。
.bss:存储未初始化的全局变量和静态变量,注意这个节在文件中只占用了描述该节具体信息的空间,不存储实际数据,是用来在内存中占用指定大小空间的。
.rodata:存储只读数据。
.symtab:存储函数,全局变量等的名字和地址,这里的名字指的是其字符串存储的地址,不存储字符串。
.shstrtab:存储节的名字,同上。
.strtab:存储前两个节中的名字的字符串。
.dynamic:存储动态链接需要的信息,其中用到的符号和前面类似,只存储地址。
.dynsym .dynstr:存储前面这个节的符号的字符串。
程序头表与段 — 执行视图#
在执行视图中,我们主要看段,这个结构是给加载器看的,他需要将具有不同权限的代码或数据区域分开,方便管理,在这里权限最重要!
程序头表会列出每个段的类型、权限、大小以及如何被装载到内存中。
段是装载程序内容的最小逻辑单位,将程序装载到内存中的时候,以段为对象进行操作。
一个段包含了多个节,这些节有着相同的权限,常见的段如下:
代码段(LOAD段):可读可执行 包含了
.text、.rodata、.plt等节数据段(也叫LOAD段):可读可写 包含了
.data、.bss、.got等节,和上面的不是同一个LOAD段,通过权限区分 (什么是plt和got?以后会说到(继续挖坑ing))动态链接路径段(PT_INTERP段):可读 包含了
.interp节,存储了动态链接器的路径动态链接段(PT_DYNAMIC段):可读可写 包含了
.dynamic节,存储了动态链接需要的数据
内存装载#
当我们运行一个程序时,会先进行一些准备工作,也就是内存装载,将程序映射到内存空间中变为进程,装载过程分三步:
读取文件头、划分虚拟内存空间#
首先,系统需要读取文件头,了解一些相关信息,比如是否为合法ELF文件,入口地址在哪之类的信息。
接下来硬件会给程序分配一个虚拟内存空间,在32位中,每个程序都是最大空间,也就是4G,但是在64位中,最大空间太大了,不太好管理,所以分配的空间大小比较灵活。
你可能有疑问:一台计算机的内存好像没有那么大,怎么做到给每个程序都分配那么大内存空间的?我们要知道这个内存空间只是一个虚拟的概念,是一种逻辑上的固定大小的连续的空间,实际上这个空间对应的物理内存空间既不连续,也不会那么大,具体受硬件限制,而且装载的所有内容也不是都始终在物理内存中(这个叫交换技术,大概原理是只有活跃的部分才会在内存中,不活跃的都扔进磁盘中的交换空间),甚至划分完虚拟内存后还不会全部立刻关联到物理内存区域,一般在物理内存找到空间写入数据之后才会关联到虚拟内存。也就是说虚拟内存空间的一些规格和物理内存关系不大。像不像给你画大饼的hxd?把这篇文章转发给ta,让ta好好学习一下什么叫内存装载!
不过这么大的虚拟内存空间也不是只给程序用的,我们把给程序用的空间叫做用户空间,除此之外还有内核空间,内核空间一般在虚拟内存空间的最高处。
我们知道硬件资源是有限的,为了更好的使用硬件资源,操作系统规定了一个唯一的访问硬件资源的方法:通过内核访问,当程序需要执行文件读写等涉及硬件的操作时,就要通过系统调用,让内核帮你访问硬件资源。
到了这步,分配的虚拟内存空间是这样的:

装载#
接下来正式把程序按照段复制到物理内存中,分别设置不同权限,接下来关联到虚拟内存。
在现在,这里的复制也不是一次性将整段完全复制与关联,有些段会分成多部份,只有在使用时才会搞到物理内存中设立关联。
虚拟内存空间和文件都是线性的,我们可以把他们想象成一条长的线段,按单位长度分成许多份,每一份为一字节,这个装载过程就是从虚拟内存空间这条线段的某一点开始,参照文件中的结构,一直向下与物理内存中存储的有关进程的信息相对应,最终这篇空间对应的段与段之间的相对结构应该是与实际文件结构相同的。
在虚拟内存空间选择的这个起始点叫基址,一般来讲是不变的,但是现在在PIE(地址无关化可执行)保护(这个保护需要同时开启ASLR才管用,下面会说到)的作用下,保持文件内部的相对结构不变,基址可以是“随机”的,不过这个随机也不是完全在用户空间中为所欲为:在虚拟内存空间的最低处,划分了一个保留区域,主要目的是保护一些非法访问,比如空指针访问;而在其他地址处,还要预先留下足够的空间,之后要放置堆栈等东西。
也就是说,我们除了将程序装载到物理内存,与虚拟内存空间关联以外,还要划分一个保留区,为后续初始化划分一些区域,这些都完成之后,虚拟内存空间大概是这样的:

这里要注意不同预留区域之间可能会有一些空白区域,现在很多系统都开启了ASLR(地址空间随机化)保护,这个保护会在规定的合法区域内随机选择一个基址,并从这个地址开始向下划分一片预留区域,专门用来给将要初始化的其中一个部分用,比如堆,栈等,不同的部分会划分不同的区域,这就会导致不同区域之间空出一些空白区域。当关闭这个保护时,基址固定,空白区域会少很多。
初始化、动态链接#
这时到了准备的最后一步,首先为栈分配物理内存,将其映射到虚拟内存空间中,栈空间内部会经历一个初始化过程,准备完成后会设置寄存器,调整一些初始参数。
然后会映射动态链接器,接下来就开始了最重要的一步:动态链接。
早期的程序导入一些外部内容都是静态链接,特点是在编译时就会把外部的内容导入到程序中,好处是只需要一个可执行文件就能跑,但是也有坏处:假设有一千个程序都要用同一个内容,都静态链接的话,磁盘中不仅会存入一千个程序,还会有一千个相同的内容!所以就出现了动态链接:在编译时只需要记载需要链接什么内容,在运行时将程序和这些内容一起搞到内存中,映射到同一个虚拟内存空间,这就是动态链接。动态链接的内容会被映射到虚拟内存空间中的堆与栈之间的一个区域,这里管他叫库预留区。
接下来就剩堆区,堆区会怎么样呢?堆区比较特殊:这个区域动态分配内存。一开始划分预留区的时候,堆区的大小就是0,直到程序申请内存并实际访问时,堆区才会开始映射数据。如果没有申请内存的操作,堆区大小可以始终为0。
至此,整个内存装载就结束了!此时的内存空间是这样的,程序可以开始运行了。

栈与寄存器#
在初始化的过程中我们提到了三个东西:栈、堆、寄存器,这三个负责存储一些程序计算时的中间状态,其中堆比较特殊,可以暂时不讲,现在来看看栈与寄存器到底是什么:
栈#
栈是在虚拟内存空间中一片连续的区域,由栈底和栈顶划分范围,其中栈底在高地址,栈顶在低地址。栈中存储了函数调用时的一些局部信息,每一个正在调用的函数都有一个独立的用于存储信息的部分,叫做栈帧。
栈的特点是在存储和取出时只能操作栈顶的数据,是一种典型的后进先出结构,由于新存入的数据在栈顶,也就是低地址处,所以说栈向低处生长。
寄存器#
寄存器是一种独立于内存,在CPU上的存储单元,与内存相比速度极快,但是容量极小,数量极少,适合做一些临时的存储。
在32位处理器中,寄存器都以E开头,比如EAX,大小是32bits(4字节),而在64位处理器中,以R开头,比如RAX,大小是64bit(8字节)。
我们举一些常用的32位寄存器:
- 通用寄存器
具有通用的存储数据的功能,但是有的有一些专用功能。
ESP:存放指向栈顶的指针,也就是存放栈顶地址。
EBP:存放指向栈底的指针,也就是存放栈底地址。当指定栈上某一处的一个数据时,通常用EBP加上其相对于栈底的位置来表达这个数据的位置。
EAX:执行加减乘除计算,存储累加值,存放函数返回值,存放系统调用号。
EBX:存储内存地址的基址。
ECX:作为循环计数器。
EDX:存放I/O端口号。
ESI:当对字符串或数组操作时,这个寄存器可以存放来源的地址。
EDI:当对字符串或数组操作时,这个寄存器可以存放目的地址。
这些寄存器在64位中都能找到对应的R开头的寄存器,在64位中也可以访问E开头的寄存器去只操作这些寄存器的低32位,清空高32位。
除此之外,64位还加了R8到R15共8个额外的通用寄存器。
- 指令寄存器
- EIP:存放一个指针,始终指向下一条指令,也就是存储了下一条指令的地址,这个寄存器不能被直接修改,只能在执行完当前指令后自动更新,或者被一些相关的指令间接修改,比如CALL、RET、JMP。
64位中对应RIP,也可以访问EIP,操作RIP低32位,清空高32位。
除此之外还有标志寄存器与段寄存器,和基础知识不太搭边(躺)。
总结#
本篇文章讲述了一些pwn中的基础知识,包括ELF文件结构,内存装载,栈与寄存器,有些知识可能讲了不会立刻用到,所以不需要全都记住,用到了再现查也没问题。
所以我们讲了什么是栈,但是为什么要栈溢出?他会给我们什么好处?这里涉及到栈帧的基本结构,在下一篇文章中会说到。
通过邮件回复
