X86架构基础

x86架构基础内容

BUFFER OVERFLOW

The Basic of x86 Architecture

寄存器名 大小 作用
AL,AH/AX/EAX 8,8/16/32 用于算术计算的主寄存器。 也称为累加器,因为它保存算术运算的结果和函数返回值。
BL,BH/BX/EBX 8,8/16/32 基址寄存器。 指向 DS 段中数据的指针。 用于存储程序的基地址
CL,DH/CX/ECX 8,8/16/32 计数器寄存器通常用于保存一个值,该值表示一个过程要重复的次数。 用于循环和字符串操作。
DL,DH/DX/EDX 8,8/16/32 通用寄存器。 也用于 I/O 操作。 帮助将 EAX 扩展到 64 位
SI/ESI 16/32 源索引寄存器。 指向 DS 寄存器指向的段中的数据的指针。 在字符串和数组操作中用作偏移地址。 它保存读取数据的地址。
DI/EDI 16/32 目标索引寄存器。 指向 ES 寄存器指向的段中的数据(或目标)的指针。 在字符串和数组操作中用作偏移地址。 它保存所有字符串操作的隐含写地址。
BP/EBP 16/32 基指针。 指向堆栈上数据的指针(在 SS 段中)。 它指向当前堆栈帧的底部。 它用于引用局部变量。
SP/ESP 16/32 堆栈指针(在 SS 段中)。 它指向当前堆栈帧的顶部。 它用于引用局部变量。

​ 表1:X86 处理器和使用

THE SEGMENT REGISTERS

六个段寄存器保存了16位的段选择器。一个段选择器是一个在内存中定义了一个段的特殊的指针。为了在内存中访问一个特别的段,这个段的段选择器必须存在于合适的段寄存器中。四个段寄存器CS,DS,ES和SS和在 intel 8086 和 intel 286 处理器中的段寄存器一样,同时 FS 和 GS 在 intel 32位架构中被引入。

段寄存器如何被使用取决于操作系统正在使用的内存管理模式。当使用 flat (unsegmented) 内存模式,段寄存器会随着指向部分重叠段的段选择器而被加载,每一个起始地址为 0 地址的线地址空间如图6所示。这些部分重叠的段之后会组成程序的线性地址空间。有两个特殊的部分重叠的段被定义:一个是 code 另一个是 data and stacks。CS 段寄存器指向了 code 段,剩下的所有段寄存器指向了 data and stack 段。

段寄存器 大小(bits) 目的
CS 16 程序段寄存器。代码段的基地址(.text section).常用来获取指令
DS 16 数据寄存器。变量的默认地址(.data section).常用来数据获取。
ES 16 额外的段寄存器。在字符串操作过程中使用
SS 16 栈段的寄存器。栈段的基地址。当隐式的使用SP或ESP或显式
FS 16 额外的段寄存器
GS 16 额外的段寄存器
表2:X86段寄存器和它们的用处

The use of segment registers for flat memory model

​ 图6:FLAT内存模式的段寄存器示例

INSTRUCTION POINTER REGISTER - EIP

EIP 寄存器包含了当前代码段和下一条将要被执行的指令之间的偏移。默认在执行一系列指令例如 JMP,JCC,CALL,RET and IRET 等在代码线性的或者前移或者后移的代码中从一条指令边界到相邻的指令。

EIP 不能直接的被软件访问。它被控制传输指令例如 JMP,JCC,CALL,RET 和 IRET隐形的控制。唯一的读取 EIP 寄存器值的方法是执行 CALL 指令之后读取指令指针从函数栈的返回值。原因是因为当 CALL 指令被执行,调用后的相邻的地址会立刻跟在 CALL 之后,作为函数返回地址被保存在站上。之后,EIP 可以通过修改在栈上的返回指令指针的值被间接的加载,并且执行返回 RET/IRET 指令。

寄存器 大小(bits) 用处
IP/EIP 16/32 指令指针保存了下一条将要被执行的指令的地址

THE ASSEMBLY LANGUAGE

为了理解缓冲区溢出的操作,一些汇编的知识非常必要。

语言 描述 示例
机器语言 是计算机真正看到的处理的。计算机看到的每一条命令是一个活着一串给定的数字。是二进制数据或者为了易读转换为16进制 83 ec 08 -> sub $0x8,%esp
汇编语言 和机器语言一样,除了命令编号已被更易读且更易于记忆的字母序列所取代 push ebp

​ 表1:语言分类

汇编直接处理处理器和内存位置的寄存器.下面列出了一些通常适用于大多数汇编语言的一般规则:

  • 资源可以是内存,寄存器和常量
  • 目标可以是内存或者非分段寄存器
  • 只有源和目标之一可以是内存
  • 源和目标必须是同样大小

操作码是程序运行时的真正指令。每一个操作码对应一行代码,其中包含了操作码和操作码所需的操作数。操作数的数量变化取决于操作码。整个可用的操作码集合对一个处理器来说被称为一个指令集。根据处理器和使用的反汇编器不同,操作码可能会有相反的顺序。例如在 windows 上:

MOV dst, src

作用和 linux 上如下代码一致

MOV %src, %dst

Windows 使用的是 intel 汇编格式而 linux 使用的是 AT&T,MAC OS 使用的是 Motorola 处理器指令集。High Level Assembly(HLA) 同样在一些程序中非常受欢迎。

Instruction Category Meaning Example
Data Transfer move from source to destination mov, lea, les, push, pop, pushf, popf
Arithmetic(计算) arithmetic on integers add, adc, sub, sbb, mul, imul, div, idiv, cmp, neg, inc, dec, xadd,cmpxchg
Floating point arithmetic on floating point fadd, fsub, fmul, div, cmp
Logical, Shift, Rotate and Bit(逻辑,移位,旋转和为) bitwise logic operations and, or, xor, not, shl/sal, shr, sar, shld and shrd, ror, rol, rcr and rcl
Control transfer conditional and unconditional jumps, procedure calls jmp, jcc, call, ret, int, into, bound.
String move, compare, input and output movs, lods, stos, scas, cmps, outs, rep, repz, repe, repnz, repne, ins
I/O For input and output in, out
Conversion Provide assembly data types conversion movzx, movsx, cbw, cwd, cwde, cdq, bswap, xlat
Miscellaneous(其他的) manipulate individual flags, provide special processor services, or handle privileged mode operations(或者处理特权模式操作) clc, stc, cmc, cld, std, cl, sti

​ 表2:汇编指令集集合

进程加载

​ 在 Linux 中,从文件系统(使用execve() 或spawn() 系统调用)加载的进程采用ELF 格式。如果文件系统位于面向块的设备上,则代码和数据将加载到主内存中。如果文件系统是内存映射的(例如 ROM/Flash 映像),则无需将代码加载到 RAM 中,而是可以就地执行。这种方法使所有 RAM 都可用于数据和堆栈,而将代码留在 ROM 或闪存中。在所有情况下,如果同一个进程被多次加载,其代码将被共享。在我们运行可执行文件之前,首先我们必须将它加载到内存中。这是由loader完成的,它通常是操作系统的一部分。加载器做以下事情:

  • 内存和访问验证:

    首先,OS 系统内核读入程序文件的头信息,并对类型、访问权限和权限、内存要求及其运行指令的能力进行验证。它确认文件是一个可执行映像并计算内存需求。

  • 流程设置,包括:

    1. 为程序的执行分配主存
    2. 将地址空间从辅存复制到主存。
    3. 将.text 和.data 部分从可执行文件复制到主存中。
    4. 将程序参数(例如,命令行参数)复制到堆栈中。
    5. 初始化寄存器:将 esp设置为指向栈顶,清除其余部分。
    6. 跳转到启动例程,它:从堆栈中复制 main()的参数,并跳转到main()

​ 地址空间是包含程序代码、堆栈和数据段的内存空间,换言之,是程序运行时使用的所有数据。内存布局通常由三段(文本、数据和堆栈)组成,简化形式如图 4 所示。动态数据段也称为,是动态分配内存的地方(例如来自malloc( ) 和new) 。动态分配的内存是在运行时而 不是编译/链接时分配的内存 . 这种组织允许在堆(显式)和栈(隐式)之间对动态分配的内存进行任何划分。这就解释了为什么栈向下增长而堆向上增长。

C 进程的内存布局

​ 图4:进程的内存布局

运行时数据结构

进程是一个正在运行的程序。这意味着操作系统已将程序的可执行文件加载到内存中,已安排它访问其命令行参数和环境变量,并已启动它运行。从概念上讲,一个进程有五个不同的内存区域分配给它,如表 1 中所列(参见图 4)

区域 描述
Code/text segment 通常称为文本段,这是可执行指令所在的区域。例如,Linux/Unix 会安排一些事情,以便同一程序的多个运行实例在可能的情况下共享它们的代码。任何时候都只有一个相同程序的指令副本驻留在内存中。包含文本段的可执行文件部分是文本段。
Initialized data – data segment 使用非零值初始化的静态分配和全局数据位于数据段中。运行相同程序的每个进程都有自己的数据段。包含数据段的可执行文件部分是数据段。
Uninitialized data – bss segment BSS 代表“由符号开始”。 默认情况下初始化为零的全局和静态分配的数据保存在进程的所谓 BSS 区域中。每个运行相同程序的进程都有自己的 BSS 区域。运行时将BSS数据放在数据段中。在可执行文件中,它们存储在 BSS 部分。对于 Linux/Unix 可执行文件的格式,只有初始化为非零值的变量才会占用可执行文件磁盘文件中的空间。
Heap 堆是动态内存(由malloc() 、 calloc() 、realloc() 和new – C++ 获得)的来源。堆上的所有内容都是匿名的,因此您只能通过指针访问其中的一部分。随着在堆上分配内存,进程的地址空间会增长。尽管可以将内存返还给系统并缩小进程的地址空间,但这几乎从未做过,因为它将再次分配给其他进程。 释放的内存( free()和 delete )回到堆,创建所谓的holes堆是典型的向上生长。这意味着添加到堆中的连续项被添加到数字大于前项的地址处。堆 在数据段的BSS区域 之后立即开始也是典型的 。 堆的末尾由一个称为break的指针标记。您不能参考休息时间。但是,您可以将中断指针(通过 brk() 和 sbrk() 系统调用)移动到新位置以增加可用的堆内存量。
Stack 堆栈段是分配局部(自动)变量的地方 。在 C 程序中,局部变量是在函数体的左大括号内声明的所有变量,包括 main() 或其他未定义为静态的左大括号。数据在后进先出后出或压入堆栈 (LIFO) 规则。堆栈保存局部变量、临时信息/数据、函数参数、返回地址等。当一个函数被调用时,一个栈帧(或一个过程激活记录)被创建并被推送到栈顶。该堆栈帧包含诸如调用函数的地址以及函数完成时跳转回的位置(返回地址)、参数、局部变量以及被调用函数所需的任何其他信息等信息。信息的顺序可能因系统和编译器而异。当一个函数返回时,堆栈帧从堆栈POPped。通常堆栈向下增长,这意味着调用链中更深的项目位于数字较低的地址并朝向堆。

​ 表1:可执行映像的区域

​ 当程序运行时,初始化的数据、BSS 和堆区通常放置在称为数据段的单个连续区域中。如图 4 所示,堆栈段和代码/文本段与数据段分开并且彼此分开。

​ 尽管理论上堆栈和堆可以相互增长,但操作系统会阻止这种情况发生。不同sections/segments之间的关系总结在表2中,可执行程序段和它们的位置。

Executable file section(disk file) Address space segment Program memory segment
.text Text Code
.data Data Initialized data
.bss Data BSS
- Data Heap
- Stack Stack

​ Table 2: Sections vs segments.

过程 PROCESS

​ 下图显示了典型 C 进程的内存布局。进程在进程的基地址加载段(对应于图中的“文本”和“数据”)。主堆栈位于正下方并向下生长。创建的任何其他线程都有自己的堆栈,位于主堆栈下方。每个堆栈帧都由一个保护页分隔,以检测堆栈帧之间的堆栈溢出。堆位于进程上方并向上生长。

​ 在进程地址空间的中间,有一个区域是为共享对象保留的。创建新进程时,进程管理器首先将可执行文件中的两个段映射到内存中。然后它解码程序的 ELF 头。如果程序头指示可执行文件链接到共享库,进程管理器将从程序头中提取动态解释器的名称。动态解释器指向一个包含运行时链接器代码的共享库。进程管理器将在内存中加载这个共享库,然后将控制传递给这个库中的运行时链接器代码。

C 在 x86 上的进程内存布局示意图

​ 图5:X86上 C 的进程内存布局示意图

THE C FUNCTIONS

​ 希望我们已经有一个大致的了解对于程序是如何编译、链接和组装,然后作为程序运行的进程映像加载到内存中。在我们进一步研究堆栈之前,如果我们能够了解函数是非常有用的,因为在调用函数时会构造堆栈。 在高级语言历史中,为构建程序(结构化或过程化编程)而引入的最重要的技术之一是过程或函数。程序员使用函数将他们的程序分解成具有特定任务的较小程序段,这些程序段可以独立开发、测试和重用。其他可以互换使用的术语是例程(如汇编中所称)、过程和方法(如面向对象编程中所称)。

​ 当函数调用发生时,它会像汇编语言中的跳转 ( JMP ) 一样改变控制流,但与跳转不同的是,当完成其任务时,函数将控制权返回给紧跟在调用 ( CALL )之后的语句或指令指令,即调用函数(caller)。当我们从内存的角度来看时,这个函数的高级抽象是在堆栈的帮助下实现的。堆栈是分配给函数运行的一部分内存。当堆栈的内容包含函数运行所需的所有数据设置好时,通常使用术语堆栈帧。堆栈帧由函数内使用的所有堆栈变量组成,包括参数、局部变量、返回地址和完成函数任务所需的其他数据。当一个函数返回到调用程序时,堆栈将被拆除(由调用者或被调用者),新的函数调用将创建一个新的堆栈。通常,函数的组件如下表所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
global_variables;

int main(int argc, char *argv[])
{
function_name(argument list);
function_return_address here
}

return_type function_name(parameter list)
{
local_variables;
static variables;
function’s code here
return something_or_nothing;
}

​ 请记住,main()也是一个函数,但具有执行点。下表列出了对上述功能组件的一些描述。

成分 描述
function name 函数名是一个符号,它实际上代表了函数代码开始的地址。在汇编语言中,符号是通过在函数代码之前键入函数名称作为标签来定义的。
function paramenters 函数的参数是显式提供给函数进行处理的数据项。有些函数有很多参数,有些没有,有些函数有可变数量的参数。
Local varuables 局部变量是函数在处理时使用的数据存储,在它返回时被丢弃。程序中的任何其他函数都无法访问函数的局部变量。
static variables 静态变量是函数在处理时使用的数据存储,之后不会被丢弃,而是在每次激活函数代码时重复使用。程序的任何其他部分都无法访问此数据。
Global variables 全局变量是函数用于处理的数据存储,在函数外部进行管理。程序中的任何其他函数都可以访问函数的全局变量。
return address 返回地址是函数必须返回的内存地址,以便继续执行下一个程序。
Return value 返回值是将数据传输回主程序(或调用程序)的主要方法。大多数编程语言只允许一个函数有一个返回值。

​ 表1:函数使用中的术语

​ 在一个进程地址空间和物理地址映射中的函数调用过程中的堆栈使用情况可以通过下图进行说明:

Stack in process address space and  physical address mapping

​ 图1:进程地址空间中的堆栈

处理器的堆栈内存

​ 希望前面的部分已经为您提供了大致的了解。现在我们将把讨论范围缩小到一个堆栈。要完全了解缓冲区溢出的环境是如何发生的,必须完全了解堆栈的布局和操作。如前所述,堆栈段包含一个堆栈、一个入口/出口、LIFO 结构。在 x86 架构上,堆栈向下增长,这意味着较新的数据将分配在地址少于之前推送到堆栈上的元素的地址处。当EBP指向的边界位于栈底,ESP指向的边界位于栈顶时,该栈通常称为栈帧(或java中的过程激活记录)。每个函数调用都会创建一个新的堆栈帧并“向下堆叠”到前一个堆栈上,每个函数都会跟踪调用链或序列,即调用它的例程以及完成后返回的位置(图 3)。使用一个非常简单的 C 程序框架,下面试图找出函数调用和堆栈帧的构造/销毁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
int a();
int b();
int c();

int a()
{
b();
c();
return 0;
}

int b()
{ return 0; }

int c()
{ return 0; }

int main()
{
a();
return 0;
}

​ 通过只分析栈区域,下面的过程展示了上面的程序在运行的时发生了什么。在程序的终点应该是栈平衡的。

Stack: Stack frame and function call, construction and destruction

​ 图3:栈帧和程序调用

​ 通过参考前面的程序示例和图 3,当程序开始在函数main() 中执行时,将创建堆栈帧,并在堆栈上为main() 中声明的所有变量分配空间。然后,当main()调用函数 a() 时,会为main()堆栈顶部的 a()中的变量创建新的堆栈帧。main()传递给 a() 的任何参数都存储在堆栈中。如果 a()要调用任何其他函数,例如b()c(),新的堆栈帧将分配在新的堆栈顶部。请注意,执行的顺序是按顺序发生的。当 c(),b()a()返回,它们的局部变量的存储被取消分配,堆栈帧被销毁,堆栈顶部返回到先前的状态。执行顺序正好相反。可以看出,栈区分配的内存在程序执行过程中被使用和重用。应该清楚的是,在该区域分配的内存将包含以前使用留下的垃圾值。