# x64架构汇编
掌握汇编语言对于恶意软件开发有着很大的作用,例如可以编写**自定义 Shellcode**、在木马加载器中插入汇编代码以实现**混淆**以及**底层的指令操作**等。
### **基本概念**
汇编语言是我们可以用来为给定 CPU 编写程序的最底层的编程语言,汇编可以被翻译为 **CPU 操作码**,即 CPU 可以直接执行的机器码。 通常,汇编指令与操作码具有 **1:1** 的关系,但在 C 等高级语言中情况并非如此,它们有多种方法将书面代码编译或转换为机器代码。接下来,我们分别来讨论汇编中设计的名词与概念。
##### **字节顺序**
字节顺序指的是数据在计算机内存中的存储方式。**大端**是指数据的最高有效字节 (最左端) 存储在低的内存地址中,最低有效位存储在高的内存地址中。字节顺序只适用于**字节**,而非**位**。
[](https://raven-medicine.com/uploads/images/gallery/2023-06/vTFu5vSzahvCZmwW-image.png)
如上图所示,0x11223344 的 4 个字节 0x11,0x22,0x33,0x44 分别存储在**由低往高**的内存地址中。
**小端**则正好相反,如下图所示,0x11223344 的 4 个字节 0x11,0x22,0x33,0x44 分别存储在**由高往低**的内存地址中。
[](https://raven-medicine.com/uploads/images/gallery/2023-06/ECKVuGkCtL1PR1SF-image.png)
因为小段运用更多,请尝试理解下图:
[](https://raven-medicine.com/uploads/images/gallery/2023-06/OiImzrePCiOu0vqb-image.png)
#####
##### **有符号与无符号数字**
如果存储一个无符号的数,那么我们不需要指定其正负符号,那么该数的范围为 0 到 **2^64 -1** 。但如果要存储一个有符号的数,因为要额外留出一位存储正负符号,那么该数的范围为 **-2^63** 到 **2^63-1**。
计算一个数的负数形式,有 2 个步骤:**翻转所有位**,再**加上 1**。以 42 为例,过程如下:
```
42: 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0010 1010
翻转: 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1101 0101
加1: 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1101 0110
```
##### **CPU 寄存器**
由于访问内存 (RAM) 对于 CPU 来说通常是一个缓慢的过程,因此处理器内总是包含许多**寄存器**,这些寄存器是处理器内部的小型存储位置,可以非常快速地访问数据。在 64 位 x64 处理器上,寄存器可以保存 **64 位**或 **8 字节**。 让我们分别查看一下如下的常用寄存器:
**寄存器名称**
| **作用**
| **备注**
|
**RIP** | 指令寄存器,指向要被执行的下一条指令的地址 | 只读,不能拆分
|
**RAX**
| 累加寄存器,用于算术运算,I/O 操作,存储函数返回值等
| 通用寄存器。在 Windows syscall 中保存 SSN
|
**RBX**
| 基址寄存器,内存寻址时存放基址
| 通用寄存器 |
**RCX**
| 计数寄存器,常用于循环中的计数器
| 通用寄存器 |
**RDX**
| 数据寄存器,用于算数运算和 I/O 操作
| 通用寄存器 |
**RSI**
| 源索引,通常用作字符串操作中的输入字符串的指针
| 通用寄存器 |
**RDI**
| 目标索引,通常用作字符串操作中的输出字符串的指针
| 通用寄存器 |
**RBP**
| 栈帧指针,指向栈帧的基址,配合偏移定位变量
| 通用寄存器 |
**RSP**
| 栈指针,指向栈的顶部
| 通用寄存器 |
**R8-R15**
| 额外的通用寄存器
| 通用寄存器 |
**RFLAGS**
| FLAG 寄存器,存储着一系列标志位,揭示操作的结果,例如两数的数值大小比较。
|
|
在 x64 处理器中,每个寄存器是 8 字节,但可以被分为更小的部分以及被直接引用。例如,RAX 的后 4 字节为 EAX,EAX 的后 2 字节为 AX 等,如下图所示。AH 与 AL 中的 A 与 H 分别表示 High 和 Low。RIP 不可被拆分。
[](https://raven-medicine.com/uploads/images/gallery/2023-06/kp048CnJdlUWVQod-image.png)
寄存器 R8 也可以被类似的方式细分:
[](https://raven-medicine.com/uploads/images/gallery/2023-06/RBpYIBq50xTSYtuY-image.png)
当我们使用后 32 位的时候,例如 EAX,那么前面的 32 位被全部填充了 0。但当我们使用后 8 或者 16 位的时候,却不是这样。
对于通用寄存器,各个部分的名称如下表所示:
64 位寄存器 | 低 32 位
| 低 16 位
| 低 8 位
|
---|
rax | eax | ax | al |
rbx | ebx | bx | bl |
rcx | ecx | cx | cl |
rdx | edx | dx | dl |
rsi | esi | si | sil |
rdi | edi | di | dil |
rbp | ebp | bp | bpl |
rsp | esp | sp | spl |
r8 | r8d | r8w | r8b |
r9 | r9d | r9w | r9b |
r10 | r10d | r10w | r10b |
r11 | r11d | r11w | r11b |
r12 | r12d | r12w | r12b |
r13 | r13d | r13w | r13b |
r14 | r14d | r14w | r14b |
r15 | r15d | r15w | r15b |
以及讨论一下 **RFLAGS** 寄存器,该寄存器包含了一系列的标志位,每个标志位都有特定的含义,用于反映操作的结果,例如两数大小的比较。以下是一些最常见和重要的位:
**位**
| **标签**
| **描述**
|
0
| CF
| 进位标志
|
2
| PF
| 奇偶校验标志
|
6
| ZF
| 零标志
|
7
| SF
| 符号标志
|
11
| OF
| 溢出标志
|
##### **栈**
在计算机体系结构中,栈是一个**自动增长与收缩**的**内存区域**,允许临时存储数据,其中数据以**后进先出** (LIFO) 方式添加或删除。在大多数现代计算机系统中,每个线程都有一个保留的内存区域,即栈。栈与内存的分配、函数的调用息息相关,我们来了解一下栈的一些特性以及关联的名词概念:
**增长方向**:栈从上往下、由**高地址往低地址**增长
**栈帧**:当有函数被**调用**的时候,栈中的一片内存区域被划分为栈帧,包含了函数的**局部变量**、**函数参数**、**返回地址**等部分。每当新的函数被调用,会创建新的栈帧并且处于栈的最顶层。
**参数传递**:在 **Windows x64 (***Linux x64 下的调用约定与 Windows 的有所不同*) 下的**调用约定**中,函数调用的**前 4 个参数**通常使用寄存器 **RCX、RDX、R8、R9** 来传递,如果参数超过 4 个,则剩余参数将在栈上传递。
**参数归位**:在 x64 Windows 调用约定中,前四个参数通过寄存器 RCX、RDX、R8、R9 传递,参数归位是指在函数调用开始时将这些寄存器参数复制到栈上的操作,这样做是为了允许被调用的函数修改这些值而不影响原始参数。即使函数的参数少于 4 个,4 个参数的空间依旧会在栈帧上被预留。
**易失性寄存器**:这些寄存器不会在函数调用之间保留,在 Windows x64 调用约定中,**RAX、RCX、RDX、R8、R9、R10、R11** 被视为易失性的。
**非易失性寄存器**:这些是在函数调用期间保留的寄存器。 在 Windows x64 调用约定中,**RBX、RBP、RDI、RSI、RSP、R12、R13、R14、R15** 被视为非易失性的,如果函数使用其中任何一个,它必须保存原始值并在函数返回之前恢复它。
**函数序言**:与 x86 调用约定不同,**x64 fastcall 调用约定**限制了在**函数序言与尾声之间**使用 **PUSH/POP** 指令,因此 RSP 是不变的。函数序言是函数的初始部分,为局部变量设置栈空间,为保存的寄存器值分配空间,调整栈指针。
**函数尾声**:函数中最后的用于执行清理任务的部分,撤消了函数序言部分的所做工作,即释放局部变量的栈空间,恢复保存的寄存器值,并将栈指针重置为调用之前的值。
**函数调用**:当函数被调用时,函数的**返回地址**将在参数传递后被压入栈 (当前的**栈顶**)。当函数执行完毕后,RIP 指向调用该函数指令的下一条指令。
**局部变量**:函数中的局部变量通常存储在栈中,处在比函数参数**更高**的地址处。当函数完成执行时,这些变量将从堆栈中删除。
**栈指针** (RSP):该寄存器指向栈顶,因为栈向**低地址**方向增长,当有新的元素入栈,RSP 会指向内存更低的地址。相反,当有元素出栈后,RSP 会指向内存更高的地址。
**栈帧指针** (RBP):该寄存器指向当前栈帧的基址,用于引用局部变量以及函数参数。但是在 x64 架构下,因为 RSP 是可预测的,所以主要使用 RSP 对局部变量以及函数参数进行引用。
综上,如果在**函数 A** 中调用**函数 B**,那么栈的结构如图所示:
[](https://raven-medicine.com/uploads/images/gallery/2023-06/jqnUr56VabZHeIr8-image.png)
##### **数据尺寸**
我们会接触到的常见数据类型以及所占字节数如下所示:
**名称**
| **字节数**
|
BYTE
| 1
|
WORD
| 2
|
DWORD
| 4
|
QWORD
| 8
|
### **常见汇编指令**
接下来,我们要做的就是了解与熟悉常见的汇编指令,这对于逆向工程、漏洞利用开发、Shellcode 编写、理解高级规避技术都会很有帮助。
#####
##### **MOV**
MOV 是数据传送指令,用于将一个值传送到目的内存地址或者寄存器。我们可以将立即数赋给寄存器或内存地址,将一个寄存器中的值赋予另外一个寄存器,将寄存器中的值赋予到一个内存地址并且反之亦然。但是,我们不能直接在**两个内存地址之间**操作。一些常见用法如下:
```c++
MOV RAX, 1 // 将常数值1传递给RAX
MOV [RAX], 3 // 将3传递给RAX指向的内存地址
MOV RAX, RCX //将RCX中的值传递给RAX
MOV [RDI], RAX // 将RAX中的值传递给RDI指向的内存地址
MOV [RAX], RAX // 将RAX中的值传递给RAX指向的内存地址
MOV RBX, [RDI + 0x10] // 将RDI加上10后的内存地址中的值传递给RBX
```
##### **LEA**
LEA 指令有些类似于 MOV,但是不会解引用内存地址,而是载入内存地址自身。
```c++
LEA RBX, [RCX + 0x10] // RCX中的值加上10后,载入到RBX中
MOV RBX, [RCX + 0x10] // RCX中的值加上10后作为内存地址,取出其中的传送到RBX
LEA RAX, [RCX + 2*RAX + 0x10] // RCX + 2*EAX + 10的值载入到RAX中
```
##### **PUSH/POP**
PUSH 为入栈操作,POP 为出栈操作。但在 x64 中,因为有着更多寄存器、x64 调用约定、以及性能优化的原因,PUSH 和 POP 操作会比较少见。
```c++
PUSH RAX // 将RAX的值入栈
PUSH 1 // 将1入栈
POP RAX // 出栈栈顶的值,赋予给RAX
```
##### **INC/DEC/ADD/SUB/MUL/DIV**
这些指令都用于算数运算,其中 INC 表示自增 1,DEC 为自减 1。ADD 为加法运算,SUB 为减法运算,加减运算是比较直接的。
MUL 为乘法运算,操作数 1 保存在 RAX 中,并且因为两数相乘,数量级可能有所变化导致 RAX 不够存放结果,因此结果保存在 **RDX:RAX** 这两个寄存器中。例如 **MUL RBX** 指令,当 RAX 为 5,RBX 为 4 时,RAX 存放的数值为 20,RDX 中的值为 0。
DIV 为除法运算,**被除数**被存储于 **RDX:RAX** 中,除数为跟着 DIV 的操作数,**商**保存在 **RAX** 中,**余数**保存在 **RDX** 中。例如 DIV RBX 指令,其中 RDX 为0,RAX 为 20,那么被除数为 20。RBX 为 4,计算结果 RAX 为 5,RDX 为 0。
对于乘除运算,我们需要在运算前提前给参与运算的寄存器赋值。
```c++
INC RAX // RAX 自增1
INC BYTE [RAX] // RAX所指向的内存地址中的字节值增加1
ADD RAX, RAX // RAX = RAX + RAX
ADD RCX, 4 // RCX = RCX + 4
ADD DWORD [RSP], RAX // memory[RSP] = memory[RSP] + RAX
SUB RAX, RDX // RAX = RAX - RDX
SUB RBX, 0x10 // RBX = RBX - 0x10
MUL RCX // RDX:RAX = RAX * RCX
MUL DWORD [RDX] // RDX:RAX = RAX * memory[RDX]
DIV RCX // RDX:RAX/RCX=RAX···RDX
```
##### **NEG**
NEG 即对操作数取负值。如果操作数是正的,那么结果为负值,反之则为正值。该指令在漏洞利用开发中可以用来避免 0x00 字节。
```c++
NEG RAX // RAX = -RAX
```
##### **AND/OR/XOR/NOT**
这组指令都是位运算,在下面我们分别罗列了这些运算的所有可能性:
```c++
0 AND 0 = 0
0 AND 1 = 0
1 AND 0 = 0
1 AND 1 = 1
0 OR 0 = 0
0 OR 1 = 1
1 OR 0 = 1
1 OR 1 = 1
0 XOR 0 = 0
0 XOR 1 = 1
1 XOR 0 = 1
1 XOR 1 = 0
NOT 0 = 1
NOT 1 = 0
```
除了 NOT 只有一个操作数外,其他指令都有着 2 个操作数,把两个操作数的每个位依次按位运算,得到最终结果。我们以 **ADD RAX, RCX** 为例,计算过程如下所示:
```
RAX = 0000 0000 0000 0000 0000 0000 0000 0000 0110 0110 0110 0110 0110 0110 0110 0110
RCX = 0000 0000 0000 0000 0000 0000 0000 0000 0011 0011 0011 0011 0011 0011 0011 0011
-------------------------------------------------------------------------------------
RAX = 0000 0000 0000 0000 0000 0000 0000 0000 0010 0010 0010 0010 0010 0010 0010 0010
```
一些计算案例如下:
```c++
AND RAX, RCX ; RAX = RAX and RCX
XOR RAX, RAX ; RAX = RAX xor RAX (=0)
NOT RCX ; RCX = not RCX
AND RCX, 0x11 ; RCX = RCX and 0x11
```
##### **CALL/RET/JMP**
这组指令与控制流相关。JMP 会无条件地将 RIP 重定向到新的内存区域并让执行继续,最基本的 JMP 可以跳到**指定的内存地址**,或者进行**相对跳跃**,例如跳 4 个字节。JMP 指令不会与栈进行交互。
CALL 的语法类似于 JMP,但是会把下一条指令的地址入栈,这个地址就是返回地址。当函数执行完毕后,也就是 **RET** 指令被执行后,返回地址出栈,并且 RIP 指向该地址。通常来说,CALL 与 RET 成对使用使得函数的调用以及返回正常执行。
##### **TEST/CMP/JXX**
这组指令用于进行比较以及条件跳跃,可以实现 if-else 分支以及循环。TEST 和 CMP 都可以用于比较,TEST 指令对两个操作数进行按位 **AND** 运算,但不存储运算结果,它仅根据运算结果更改 **RFLAGS** 寄存器中的特定标志位。如果结果为 0,则设置 ZF 为 1。TEST 指令通常用于判断特定的寄存器或者内存地址是 0,并且 JZ /JNZ 经常紧随其后。例如指令 **TEST RAX, RAX** 用于判断 RAX 存储的数值是否为 0。
**CMP** 指令对两个操作数执行减法运算,但与 TEST 一样,它不存储运算结果,而是根据减法的结果设置 RFLAGS 寄存器的特定标志位,如果两数相等,ZF 设置为 1。根据比较的结果,随后可能跟随 Jxx 指令决定条件跳转。例如,**CMP RAX、RBX** 将从 RAX 中的值减去 RBX 中的值,并根据结果设置标志。
根据结果是**无符号**或**有符号**,Jxx 指令列表如下所示:
```c++
对于无符号数字的操作
JE/JZ 相等/为0时跳转
JNE/JNZ 不相等/不为0时跳转
JA/JNBE 高于/不低于/等于时跳转
JAE/JNB 高于或等于/不低于时跳转
JB/JNAE 低于/不高于或等于时跳转
JBE/JNA 低于或等于/不高于时跳转
对于有符号数字的操作
JE/JZ 相等/为0时跳转
JNE/JNZ 不相等或不为0时跳转
JG/JNLE 大于/不小于或等于时跳转
JGE/JNL 大于或等于/不小于时跳转
JL/JNGE 小于/不大于或等于时跳转
JLE/JNG 小于或等于/不大于时跳转
```
##### **SAL/SAR/SHL/SHR**
这组运算为移位运算。SAL 表示算术左移,而 SAR 为算术右移。前者将寄存器或内存位置中的位向左移动 1 位,而后者将其向右移动,此运算相当于分别乘以或除以 2^n。
SHL 表示逻辑左移,只是 SAL 的另一个名称,然而,SHR 和 SAR 还是略有不同,后者保留符号位 (最高有效位),因此这不会改变。SAR 应该用于有符号操作。
这些指令都采用 2 个操作数,分别为移位的目标以及要移位的位数。
假设 RAX 为 **0xFF**,我们执行 **SHL RAX, 3** 指令。
```c++
RAX
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1111 1111
SHL RAX, 3
0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0111 1111 1000
```
如果移动的位数过多,我们会失去部分位,例如 **SHL RAX, 56**。
```c++
1111 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000
```
而 SHL/SHR 只是 SAL/SAR 反方向版本。
##### **ROL/ROR**
ROL/ROR 与移位运算很相似,但并不会出现位丢失的情况,被“挤”出去的位会插入到另外一端。
```c++
╭───────────────╮
1 0 0 0 1 0 0 0 |
/ / / / / / / |
0 0 0 1 0 0 0 1─╯
```
##### **REP/STOS/SCAS**
这组指令经常与字符串操作相关。**STOS** 指令表示**存储字符串**,它将在内存位置存储 BYTE、WORD、DWORD 或 QWORD,指令设置的值可以是任何值,因此它不必是有效的字符串字符。目标内存地址存储在 **RDI** 中,要设置的值存储在 **RAX** 中。设置内存后,RDI 将增加存储的字节数,即如果存储 1 个字节,RDI 将增加 1,如果存储 QWORD,RDI 将增加 8。
**STOS** 通常与 **REP** 前缀一起使用,这将导致 CPU 重复 STOS 指令 **RCX 次**。每次重复时 RCX 都会减 1,直到达到 0。REP STOS 组合通常用于 memset 操作,它将内存范围设置为某个值。由于该指令使用多个寄存器,我们需要提前设置 RAX、RDI 和 RCX 中存储的值。
**SCAS** 指令用于将字符串或字节与**累加器寄存器** (AL、AX、EAX 或 RAX) 进行比较,它从源操作数中减去目标操作数并相应地设置标志,但不保存结果。如果方向标志位 DF 被设置,它会自动递减 RDI。SCAS 通常也与 REPE 前缀一起使用,用于在字符串中搜索给定的字节或字节序列。
### **总结**
以上,我们讲了x64 汇编所需的前置理论基础,以及常见汇编指令。我们在逆向工程以及漏洞利用开发的过程中还会遇到其他的指令,在掌握了前置理论的基础上,配合阅读文档以及网络搜索,很快也能知道它们的作用并灵活运用。