CTF竞赛权威指南(Pwn篇)
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

3.2 x86/x64汇编基础

本节将介绍PC端最常见的架构——x86架构以及扩展的x64架构。汇编语言是人类与计算机交互过程中的底层,和汇编语言关系最密切的,莫过于计算机的中央处理器。

x86架构是最广为人知的处理器架构,主要包括Intel的IA-32、Intel 64处理器以及AMD的AMD与AMD64处理器。x86-64处理器架构包括了Intel的x86-64架构和AMD的amd64架构,我们可以将其看为x86指令集的64位扩展。此外,广泛用于服务器端的Intel IA-64架构虽然和x86-64架构有所不同,但它依然是一个64位架构。

3.2.1 CPU操作模式

对于x86处理器而言,有三个最主要的操作模式:保护模式、实地址模式和系统管理模式,此外还有一个保护模式的子模式,称为虚拟8086模式。

保护模式是处理器的原生状态,此时所有的指令和特性都是可用的,分配给程序的独立内存区域称为内存段,处理器将阻止程序使用自身段以外的内存区域。为了模拟8086处理器,在虚拟8086模式下,操作系统可以在实体CPU中划分多个8086 CPU,这也是早期虚拟机的来源。

实地址模式是早期Intel处理器的编程环境,该模式下程序可以直接访问硬件及其实际内存地址,而没有经过虚拟内存地址的映射,方便了驱动程序的开发。

系统管理模式为操作系统提供了诸如电源管理或安全保护等特性机制。

对于x86-64处理器而言,除上述模式外,还引入了一种名为IA-32e的操作模式。该模式包含两个子模式,分别为兼容模式和64位模式,在兼容模式下现有的32位和16位程序无须重新编译;在64位模式下,处理器将在64位的地址空间下运行程序。

3.2.2 语法风格

x86汇编语言主要的语法风格有两种:AT&T风格和Intel风格。

Intel公司设计了x86架构,Intel 8086即第一个x86架构的处理器。由于直接使用机器码对于人类来说可读性极差,也不便于开发,于是他们设计了一类汇编语言便于程序员开发程序,这就是Intel风格的由来。

AT&T公司的前身是贝尔实验室,这是C语言和GNU Linux的诞生地。实验室的开发者希望汇编语言的语法有更好的可移植性,于是他们抛弃Intel的汇编语法规范,创立了AT&T语法风格。这类语法风格在Linux下有着广泛的支持,GCC、GDB和objdump等工具都默认使用AT&T风格。表3-1比较了AT&T风格和Intel风格的不同,本书也将统一使用Intel风格。

表3-1 AT&T风格和Intel风格对比

3.2.3 寄存器与数据类型

寄存器

从8位处理器到16位处理器,再到32位以及64位处理器,寄存器的名称也有一些变化。表3-2列出了不同位数处理器的通用寄存器名称。

表3-2 不同位数处理器的通用寄存器名称

需要注意的是,在64位模式下,操作数的默认大小仍然为32位,且有8个通用寄存器;当给每条汇编指令增加REX(寄存器扩展)的前缀后,操作数变为64位,且增加了8个带有标号的通用寄存器(R8~R15)。

此外,64位处理器还有两个不容忽视的特点:第一,64位与32位有着相同的标志位状态;第二,64位模式下不能访问通用寄存器的高位字节(如AH、BH、CH及DH)。

整数常量

对于整数常量,如果仅给出1234这类数字而不加任何说明,那么它既可以是一个十进制整数,也可以是八进制或者十六进制整数,因此需要使用后缀进行区分。此外,由于十六进制包含一些字母(ABCDEF),为了避免汇编器将该数字解释为汇编指令或标识符,需要在以字母开头的十六进制数前加0表示,如0ABCDh。

浮点数常量

浮点数常量,也称实数常量。x86架构中有单独的浮点数寄存器和浮点数指令来处理相关浮点数常量。我们通常以十进制表示浮点数,而以十六进制编码浮点数。浮点数中至少包含一个整数和一个十进制的小数点,以下均为合法的浮点数:“1.”、“+2.3”、“-3.14159”、“26.E5”。

字符串常量

字符串常量是用单引号或双引号括起来的字符序列(含空格符)。需要注意的是,汇编语言中允许字符串常量的嵌套。以下均为合法的字符串常量:"hello, world"、'he says "hello"'、"he's a funny man"。

字符串常量在内存中是以整数字节序列保存的,字符串“ABCDEFGH”在gdb中显示的样子如下所示。关于字节序可以查看4.1 Linux基础一节,其表示字节在内存中的排列顺序,Intel处理器默认使用小端序。

3.2.4 数据传送与访问

MOV指令是最基本的数据传送指令,几乎在所有的程序中都有使用,甚至有研究者证明了MOV指令是图灵完备的,即在一个程序中可以只使用MOV指令完成所有的程序功能,详情可查看参考mov is Turing-complete

MOV指令的基本格式中,第一个参数为目的操作数,第二个参数为源操作数。如语句MOV EAX,ECX表示将ECX寄存器的值拷贝到EAX中。MOV指令支持从寄存器到寄存器、从内存到寄存器、从寄存器到内存、从立即数到内存和从立即数到寄存器的数据传送,但不支持从内存到内存的直接传输,想要完成从内存到内存的数据传送,必须使用一个寄存器作为中转。

对不同位数寄存器的数据传送如下所示。

在编写汇编语言时,可能会出现将较小的操作数扩展为较大操作数的情况,这时就需要对操作数进行全零扩展或符号扩展。

此外,数据访问指令还有XCHG,该指令允许我们交换两个操作数的值,可以是从寄存器到寄存器的交换、内存到寄存器的交换,或者寄存器到内存的交换。

x86汇编语言使用变量名+偏移量表示一个直接偏移量操作数,如下表示一个数组。

需要注意的是,由于某些汇编器(如masm)未实现数组的边界检查,如果偏移量超出了数组的实际定义范围,将导致数组越界错误。对于双字数组的汇编代码段,需要使用符合数组元素的偏移量才能正确标识数组元素位置。

3.2.5 算术运算与逻辑运算

最简单的算术运算指令是INC和DEC,分别用于操作数加1和操作数减1。这两条指令的操作数既可以是寄存器,也可以是内存。

在介绍算术运算指令前,需要了解补码的知识。计算机底层的数据表示均是以补码表示的。两个机器数相加的补码可以先通过分别对两个机器数求补码,然后再相加得到。在采用补码形式表示时,进行加法运算可以把符号位和数值位一起进行运算(若符号位有进位则直接舍弃),结果为两数之和的补码形式。对于机器数的补码减法可以利用与其相反数的加法实现。

ADD指令将长度相同的操作数进行相加操作。

SUB指令为减法操作,将从目的操作数中减去源操作数。

在汇编语言中存在标志位寄存器,使用SUB、ADD等指令都可能会造成整数溢出、符号位等标志位发生变化,因此进位标志位、零标志位、符号标志位、溢出标志位、辅助标志位和奇偶标志位都将根据存入的输入发生变化。

NEG指令是把操作数转换为二进制补码,并将操作数的符号位取反。

3.2.6 跳转指令与循环指令

一般情况下,CPU是顺序加载并执行程序的。但是,指令集中会存在一些条件型指令,将根据CPU的标志位寄存器决定程序控制流的走向。在x86汇编语言中,每一个条件指令都隐含着一个跳转指令。跳转指令有两种最基本的类型:条件跳转和无条件跳转。无条件跳转就是无论标志位寄存器为何值,都会跳转;条件跳转则是当满足某些条件时,程序出现分支,各类分支结构可以组合成不同的程序逻辑。

JMP指令是无条件跳转指令,在编写汇编语言时需要使用一个标号来标识,汇编器在编译时就会将该标号转换为相应的偏移量。一般情况下,该标号必须和JMP指令位于同一函数中,但使用全局标号则不受限制。

JMP指令也可以创建一个循环,也就是在循环结束时用JMP指令再跳回循环开始的位置。由于JMP是无条件跳转,所以除非使用其他方式退出,该循环将一直运算下去。

LOOP指令也可以创建一个循环代码块,ECX寄存器为循环的计数器(实地址模式中略有不同,CX寄存器是LOOP指令与LOOPW指令的默认循环计数器,ECX寄存器为LOOPD指令的循环计数器,64位的x86汇编语言LOOP指令使用RCX为默认循环计数器),每经过一次循环,ECX的值将减去1。

LOOP指令执行分为两步,第一步是ECX值减1;第二步将ECX与0进行比较,如果ECX不为0,则跳转到标号地址处;如果ECX为0,则不发生跳转,执行LOOP指令的下一条指令。在使用LOOP指令前,如果将ECX的值设为0,那么在执行LOOP指令时,ECX的值减去1后实际上为FFFFFFFFh,这将是一个非常大的循环,因此我们在编写x86汇编语言的过程中一般情况不需要显式地改变ECX寄存器的值,特别是存在循环嵌套的情况时。

3.2.7 栈与函数调用

栈是计算机中最重要、最基础的数据结构之一,它是一个先入后出的数据结构,我们可以把它想象成一个薯片桶,先放入薯片桶的薯片总是最后一个被拿出。在一个编译完成的二进制程序中,栈的空间总是有限的。通常来说,编译器会默认分配足够程序自身使用的栈空间,即便递归函数使栈不受控制地增长,也会有编译器做一些优化处理。在Linux上,可以使用命令“ulimit -a”查看或更改当前系统默认的栈大小。

栈空间是计算机内存中一段确定的内存区域,也有着一些指针指向相应的内存地址,在x86架构中这个指针位于ESP寄存器,而在x86-64平台上为RSP寄存器。在计算机底层,栈主要的几个用途是:(1)存储局部变量;(2)执行CALL指令调用函数时,保存函数地址以便函数结束时正确返回;(3)传递函数参数。

操作栈的常用指令是PUSH和POP,即入栈和出栈。PUSH指令会对ESP/RSP/SP寄存器的值进行减法运算,并使其减去4(32位)或8(64位),将操作数写入上述寄存器中指针指向的内存中。POP指令是PUSH指令的逆操作,先从ESP/RSP/SP寄存器(即栈指针)指向的内存中读取数据写入其他内存地址或寄存器,再依据系统架构的不同将栈指针的数值增加4(32位)或增加8(64位)。

下面的汇编代码通过栈来实现EAX和EBX值的交换。入栈操作的结果如图3-1所示。

图3-1 入栈操作

POP指令则是PUSH指令的反操作,如下汇编代码片段的结果如图3-2所示。

图3-2 出栈操作

使用栈保存函数返回地址

CALL指令调用某个子函数时,下一条指令的地址作为返回地址被保存到栈中,等价于PUSH返回地址与JMP函数地址的指令序列。被调用函数结束时,程序将执行RET指令跳转到这个返回地址,将控制权交还给调用函数,等价于POP返回地址与JMP返回地址的指令序列。因此无论调用了多少层子函数,由于栈后入先出的特性,程序控制权最终会回到main函数。

调用子函数这一行为使用PROC与ENDP伪指令来定义,且需要分配一个有效标识符,所有的x86汇编程序中都包含标识符为main的函数,这是程序的入口点,main函数不需要使用RET指令,但其他的被调用函数结束时都需要通过RET指令将控制权交还调用函数。

通过上面的代码片段,可以看到栈是如何保存函数返回地址的。当第5行的CALL指令执行时,下一条指令的地址0x00008025将被压入栈中,被调用函数testFunc的地址0x00008A00则被加载至EIP寄存器,如图3-3所示。

图3-3 执行CALL指令

当执行第13行的RET指令时,将分为两个过程。第一步,ESP指向的数据将被弹出至EIP寄存器;第二步,ESP的数值增加,将指向栈中的上一个值。如图3-4所示。

图3-4 执行RET指令

使用栈传递函数参数

在x86平台程序中,最常见的参数传递调用约定是cdecl,其他的还有stdcall、fastcall和thiscall等。需要注意的是,我们可以使用栈传递参数,但并不代表栈是唯一传递参数的方式,在x86-64上,我们还可以通过寄存器传递参数。

假设函数func有三个参数arg1、agr2和arg3,那么在cdecl约定下通常如下所示。

此外,被调用函数并不知道调用函数向它传递了多少参数,因此对于参数数量可变的函数来说,就需要说明符标示格式化说明,明确参数信息。常见的printf函数就是参数数量可变的函数之一。如果我们在C语言中这样使用printf函数:

那么得到的结果不仅会显示整数9998,还将显示出数据栈内9998之后两个地址的随机数(通常这种数据是被调用函数内部的局部变量)。

使用栈存储变量

由于MOV指令不允许将标志位寄存器的值复制到一个变量,因此使用PUSHFD指令就是保存标志位寄存器中标志位的最佳途径。PUSHFD指令把32位EFLAGS寄存器的内容压入栈中,POPFD指令则把栈顶部数据弹出至EFLAGS寄存器中。因此当我们需要保存标志位寄存器的值又将其恢复为之前值的时候,可以使用如下的指令序列: