二进制和汇编,汇编语言二进制代码表示指令
由于汇编语言是二进制文件中机器语言指令的标准表示,并且大部分二进制分析都是基于反汇编,因此读者必须熟悉x86 汇编语言的基础知识,才能充分利用本书。本附录介绍了汇编语言的基础知识。本附录的目的不是教你如何用汇编程序编写(虽然有专门针对汇编程序的书籍),而是为读者提供理解汇编程序所需的基础知识。在本附录中,您将了解汇编程序和x86 指令的结构及其运行时行为,以及C/C++ 程序中的常见代码如何在汇编级别表示。这里只介绍基本的64位用户态x86指令,不包括浮点指令集和SSE、MMX等扩展指令集。为了清楚起见,我们将x86 的64 位版本(x86-64 或x64)统称为x86,因为x86 是本书的重点。
A.1 汇编程序布局清单A-1 显示了一个简单的C 程序。清单A-2 显示了GCC v5.4.0 生成的相应汇编程序。第1 章描述编译器如何将C 程序转换为汇编列表并最终转换为二进制。文件。反汇编二进制文件时,反汇编工具会尝试将二进制文件转换为尽可能接近编译器生成的汇编代码。不讨论汇编指令的细节,让我们看看汇编器的布局。
清单A-1 | C 写入“Hello, world!”
#include int main(int argc, char* argv[]) { printf('Hello, world!\n'); return 0; }清单A-2 GCC 生成的汇编程序
.file 'hello.c'.intel_syntax noprefix .section .rodata .LC0:string '你好,世界!' .text .globl main .type main, @function main: Push rbp mov rbp, rsp sub rsp, 16 mov DWORD PTR [ rbp-4], edi mov QWORD PTR [rbp-16], rsi mov edi, OFFSET FLAT:LC0 call put mov eax, 0 保留ret .size main,-main .ident 'GCC: (Ubuntu 5.4.0-6ubuntu1~ 16.04.9)' .section .note.GNU-stack,'',@progbits 清单A-1 通过在主函数中调用printf 来打印常量字符串“Hello, World!”。在较低级别,相应的汇编器由四种类型的组件组成:指令、伪指令、标签和注释。
A.1.1 汇编指令、指令、标签和注释表A-1 提供了每种汇编器组件类型的描述。请注意,由于汇编或反汇编工具的变化,每个组件的语法会有所不同。就本书而言,读者不需要熟悉汇编工具的语法特征;无需编写汇编代码,阅读和分析反汇编代码就足够了。 GCC 建议使用-masm=intel 选项生成的汇编语法。
表A-1:汇编程序组件
类型
例子
意义
命令
移动eax, 0
为每个值分配0
命令
.节.文本
将以下代码放入.text 部分。
命令
.string 'foobar'
定义包含“foobar”的ASCII 字符串
命令
.long0x12345678
定义双字0x12345678
标签
foo:string 'foobar'
使用符号定义“foobar”字符串
评论
#这是一条评论
人类可读的评论
指令是CPU执行的实际操作。伪指令旨在指示汇编工具生成特定数据并将指令或数据放置在指定部分中。标签是指指令或数据的符号名称。组装工具中的注释是易于阅读的注释。当程序被汇编并链接到二进制文件时,所有符号名称都将替换为地址。清单A-2 中的汇编器告诉汇编工具将字符串“Hello, world!”放在.rodata 节中,该节用于存储常量数据。伪指令.section告诉汇编工具下一个内容应该放在哪个节中,string表示定义ASCII字符串的伪指令。当然,还有几种伪指令用于定义其他数据类型,例如.byte(1字节)、word(2字节)、long(4字节)和.quad(8字节)。 main函数被放置在存储代码的.text的代码部分中。text 伪指令是.section .text 的缩写,其中main 是主函数的符号标签。标签后面是主函数中包含的实际指令,它可以引用先前声明的数据,例如.LC0(GCC 为“Hello, world!”字符串选择的符号名称)。由于程序打印常量字符串(无可变参数),因此GCC 将printf 替换为put。这是一个简单的函数,它将指定的字符串打印到屏幕上。
A.1.2 分离代码和数据主要结果可以在清单A-2 中看到,但是编译器通常将代码和数据分离到不同的部分。这在反汇编或分析二进制文件时非常有用。程序被解释为代码,而字节被解释为数据。然而,x86 架构本身并不禁止在同一段内混合代码和数据,事实上,一些编译器和手写汇编器不允许在同一段内混合数据和代码。
A.1.3 ATT 和Intel 语法如前所述,不同的汇编器对于汇编程序有不同的语法,但是表示x86 机器指令的语法形式主要有两种:Intel 语法和ATT 语法。 ATT 语法在每个寄存器名称前显式添加一个% 符号,并在每个常量前显式添加一个$ 符号,但Intel 语法没有这些符号。本文档使用Intel 语法,因为它相对简单。 ATT 和Intel 最重要的区别是它们使用完全相反的指令操作数顺序。在ATT 中,源操作数位于目标操作数之前,因此将常量移动到edi 寄存器的语法是:
mov $0x6,%edi 相反,以Intel 语法表示的指令前面是目标操作数:
mov edi,0x6 在深入研究二进制分析时,操作数的顺序很重要,因为您可能会经常遇到这两种语法样式。
A.2 x86 指令结构现在我们已经了解了汇编程序的结构,接下来我们看一下汇编指令的格式。您还可以看到程序集表示的机器级指令的结构。
A.2.1 x86 指令的汇编层表示x86 指令在汇编中通常的助记形式是目标地址、源地址。助记符是机器指令的人类可读表示,源地址和目标地址是指令的操作数。例如,汇编指令mov rbx和rax将寄存器rax的值赋给rbx。请注意,并非所有指令都有两个操作数,有些指令没有操作数。如前所述,助记符是CPU 可以理解的机器语言指令的高级表示。让我们快速了解一下x86 指令在机器级别的结构。这对于某些二进制分析很有用,例如修改二进制文件。
A.2.2 x86 指令的机器级结构x86 ISA 使用可变长度指令。有些指令只有1 个字节,而有些指令是多个字节。最大指令长度为15字节,指令可以从任意内存地址开始。这意味着即使编译器经常对齐代码以优化指令性能,CPU 也不会强制执行代码对齐。图A-1显示了x86指令的机器级结构。
图A-1:x86指令的机器级结构x86指令由可选前缀、操作码和零个或多个操作数组成。请注意,除了操作码之外,其余都是可选的。操作码是指令类型的主要标识符,例如opcode0x90表示不执行任何操作的nop指令,0x00到0x05表示各种类型的add指令。前缀可以改变指令的行为,例如多次重复指令或访问不同的内存段。最后,操作数是指令操作的数据。寻址模式字节,也称为MOD-R/M 或MOD-REG-R/Mz 字节,包含有关指令操作数类型、SIB(标度/索引/基址)字节和偏移量(位移)的元数据。来表示内存操作数。立即字段(immediate)包含立即操作数(常量值),以便读者可以立即理解这些字段的含义。除了图A-1 中所示的显式操作数之外,还有某些指令具有隐式操作数。虽然这些指令没有明确列出,但它们的操作码是固定的,例如操作码0x05(加法指令)所需的操作。始终为rax,只有源操作数是变量并且必须显式表达。例如,push 指令隐式更新rsp(堆栈指针寄存器)。在x86 上,指令具有三种不同类型的操作数:寄存器操作数、内存操作数和立即值。接下来,让我们看看每个有效的操作数类型。
A.2.3 寄存器操作数寄存器非常小,允许快速访问CPU 上的内存。有些寄存器有特殊用途,例如跟踪当前执行地址的指令指针(EIP/RIP)和跟踪堆栈顶部的堆栈指针(ESP/RSP)。其他寄存器是通用存储单元,主要用于存储CPU执行程序时使用的变量。
1.通用寄存器x86是基于原始8086指令集,寄存器为16位宽,但32位x86 ISA将这些寄存器扩展为32位,x86-64将它们进一步扩展为64位。是。为了确保向后兼容性,新指令集中使用的寄存器是旧寄存器的超集。要在汇编中指定寄存器操作数,必须使用寄存器名称(例如mov rax,64)将64 分配给rax 寄存器。图A-2展示了x86-64 rax寄存器如何分为传统的32位寄存器和16位寄存器。 rax 的低32 位形成一个名为eax 的寄存器,其低16 位形成原始8086 寄存器ax。读者可以通过寄存器名al 访问ax 的低字节,通过ah 访问ax 的高字节。
图A-2:x86-64 rax 寄存器细分其他寄存器也有类似的命名方案,显示为“register”。 r8 到r15 是x86-64 中的新功能,以前的x86 变体中不存在。给32 位子寄存器(例如eax)赋值会自动清除父寄存器(本例中为rax)中的其他位,并清除子寄存器的低位(例如ax、al 和ah)其他位被保留。不要太关注这些源自8086指令集的寄存器的用途描述。然而,表A-2 中列出的大多数寄存器现在可以互换使用,并且如A.4.1 节所示,堆栈指针(rsp)和基址指针(rbp)被认为是特殊寄存器。虽然取决于堆栈布局,但原则上它也可以用作通用寄存器。
表A-2 x86 通用寄存器
解释
64位
低32位
低16位
低字节
2字节
累加器
勒克斯
前任
斧头
铝
啊
基地址寄存器
RBX
埃布克斯
乙x
乙肝病毒
唔
柜台
接收方
埃克斯
CX
氯
渠道
数据寄存器
RDX
埃多克斯
DX
下载
啊
堆栈指针
回复
尤其
sp
样条线
基指针
RBP
电子BP
血压
BPL
源地址索引
相对强弱指数
埃希
S
窗台
目标地址索引
rdi
埃迪
迪
莳萝
x86-64 通用寄存器
r8r15
r8dr15d
r8wr15w
r8lr15l
2. 其他寄存器除了表A-2 中列出的寄存器外,x86 CPU 还包括其他非通用寄存器。最重要的两个是rip(在32 位x86 上称为eip,在8086 上称为ip)和rflag(称为eflag)。或旧的ISA 标志寄存器)。 rip指令指针始终指向下一条指令的地址,由CPU自动设置,无需人工干预。您可以在x86-64 上读取指令指针值,但不能在32 位x86 上读取指令指针值。状态标志寄存器用于比较和条件分支,并跟踪事件,例如最后一个操作是否有被零除异常或溢出。 x86 ISA 还具有cs、ds、ss、es、fs 和gs 段寄存器,用于将内存划分为不同的段。由于内存分段现在在x86-64 上已经过时,并且对分段的支持基本上已经被放弃,所以这里没有进一步的介绍,但是如果你想了解更多关于这个主题的信息,可以查看任何关于x86 汇编的书,你可以阅读它。还有cr0~cr10等控制寄存器,内核用来控制CPU的操作,比如在保护模式和实模式之间切换。此外,寄存器dr0 到dr7 是调试寄存器,为调试功能(例如断点)提供硬件支持。在x86 上,控制和调试寄存器无法从用户模式访问,只能从内核访问,因此这里不讨论这些寄存器的内容。还有各种特定于模型的寄存器(MSR) 和扩展指令集(例如SSE 和MMX)使用的寄存器,这些寄存器并不存在于所有x86 CPU 上。您可以使用cpuid 指令查看您的CPU 支持哪些功能,并使用rdmsr 和wrmsr 指令读取和写入特殊模块寄存器。许多特殊寄存器只能从内核中获得,本书没有介绍。
A.2.4 内存操作数内存操作数是指CPU 从中检索一个或多个字节的内存地址。 x86 ISA 每条指令仅支持一个显式内存操作数。这意味着您不能在一条指令内直接将值从一个内存地址移动到另一个内存地址;您必须使用寄存器作为中间存储。在x86 上,您可以使用[base+index*scale+displacement] 指定内存操作数。其中base和Index是64位寄存器,scale是1、2、4或8的整数值,displacement(偏移量)是32位常数或符号,所有这些组成部分都是可选的。 CPU计算内存操作数表达式的结果,得到最终的内存地址。基址、索引和标度均以指令SIB 字节表示,位移以相同名称的数据字段表示。比例默认为1,位移默认为0。这些内存操作数格式足够灵活,可以直接使用许多常见的代码示例。例如,可以使用mov eax、DWORD PTR [rax*4+arr] 等指令访问数组元素。其中arr 是距起始位置的偏移量。数组的地址。 rax 是正在访问的数组元素的索引值,每个数组元素长4 个字节,DWORD PTR 告诉汇编器从内存中检索4 个字节(双字或DWORD)。同样,访问结构体字段的一种方法是将结构体的起始地址存储在基址寄存器中,并添加要访问的字段的偏移量。在x86-64 上,您可以使用rip(指令指针)作为内存操作数的基础,但在这种情况下不能使用索引寄存器。编译器经常使用rip 进行与位置无关的代码和数据访问,因此rip 相对寻址在x86-64 二进制文件中很常见。
A.2.5 立即数数据立即数是硬编码到指令中的常量整数操作数;例如,add rax, 42, 42 指令是立即数。在x86 上,立即值以小端格式编码,多字节整数的最低有效字节在内存中首先排序。也就是说,如果您创建类似mov ecx,0x10203040 的汇编指令,则相应的机器语言指令将其编码为0x40302010。 x86 使用二进制补码表示法来表示有符号整数。它首先将一个数字(负数)转换为正二进制数,然后将该数字逐位反转并加1。符号位0代表正数,1代表负数。如果要对4字节整数-1进行编码,则用整数0x00000001来表示(十六进制表示1),然后将十六进制转换为二进制并按位取反得到0xfffffffe,最后加1生成补码。0xffffffff。如果反汇编代码,你会看到很多立即数或内存值以0xff字节开头,并且通常是负数。现在您已经了解了一般格式以及x86 指令的工作原理,接下来让我们看一下您将在本书中或您自己分析的项目中遇到的一些常见x86 指令。
A.3 常用x86 指令表A-3 介绍了常用x86 指令。有关此表中未列出的步骤的更多信息,请参阅Intel 文档或相应网站。表A-3 中列出的大多数步骤都是不言自明的,但有些步骤需要更详细的解释。
表A-3 常用x86指令
命令
解释
数据传输
移动目标、源
将src 分配给dst
xchg dst1, dst2
交换dst1 和dst2
推送源
将src 压入堆栈并递减rsp
流行时间
弹出堆栈并将其分配给dst 并递增rsp
算术
添加目标、源
dst +=源
子夏令时,来源
dst =源
夏日时光有限公司
目标值+=1
十二月夏令时
dst =1
没有夏令时
夏令时=夏令时
CMP src1、src2
根据src1-src2设置状态标志位。
逻辑/按位
和dst、src
dst=源
或dst、src
dst |=源
异或dst, src
dst^=src
现在不是夏天
夏令时=~ 夏令时
测试src1、src2
根据src1 src2 设置状态标志位。
无条件分支
跳转地址
跳转到地址
呼叫地址
将返回地址压入堆栈并调用函数地址。
让
将返回地址从堆栈中弹出并跳转到该地址
系统调用
进入内核并执行系统调用
跳转分支(基于状态标志位) jcc addr 仅当条件cc 为真时跳转到该地址。否则,进入jncc的相反条件,如果条件cc不为真则跳转。
je地址/jz地址
如果设置了ZF 零标志则跳转(例如,如果先前的cmp 具有相同的操作数)
贾地址
如果上次比较时dst 大于src,则跳转(无符号)
新山地址
如果上次比较时dst 小于src,则跳转(无符号)
jg地址
如果上次比较时dst 大于src,则跳转(有符号)
jl地址
如果上次比较时dst 小于src,则跳转(有符号)
jge地址
如果上次比较时dst 大于或等于src,则跳转(有符号)
jle地址
如果上次比较时dst 小于或等于src,则跳转(有符号)
js地址
在最后一次比较中,如果结果为负,则跳转并且代码点为1。
其他的
读取夏令时、来源
将内存地址加载到dst中(dst=src,src必须在内存中)。
不
空指令,不执行任何操作(用作代码的填充)
首先,需要注意的是,mov 一词并不准确。从技术上讲,源操作数不会移动到目标操作数,而是会被复制,但源操作数不会被修改。其次,push 和pop 指令在堆栈管理和函数调用方面具有特殊含义,我们将在稍后讨论。
A.3.1 比较操作数和设置状态标志cmp 指令对于实现条件分支非常重要。尽管它从第一个操作数中减去第二个操作数,但该指令不会将运算结果保存在任何地方。相反,根据结果在rflags 寄存器中设置状态标志位,条件分支指令检查这些状态标志位以确定是否跳转。重要的标志寄存器包括ZF标志寄存器、SF标志寄存器和OF标志寄存器,每个标志寄存器指示比较结果是否为0、负数或溢出。 test 指令与cmp 类似,但基于操作数的按位AND 运算而不是减法运算来设置状态标志。请注意,除了cmp 和test 之外,还有几个指令可以设置状态标志。英特尔手册或在线指令参考精确指出了每个指令集的标志位。
A.3.2 系统调用的实现需要一条syscall指令,使用前必须设置系统调用号。例如,要在Linux 操作系统上执行读取系统调用,必须向rax 加载0(读取的系统调用号),然后是文件描述符、缓冲区地址和要读取的字节数。分别转换为rdi、rsi、rdx,最后执行syscall调用。有关在Linux 操作系统上配置系统调用的信息,请参阅man syscall。请注意,在32 位x86 上,使用sysenter 或int0x80(中断向量0x80 触发软中断)而不是syscall 进行系统调用。此外,Linux 以外的操作系统对于系统调用有不同的规则。
A.3.3 实现条件跳转条件跳转指令与之前设置状态标志位的指令(例如cmp 或test)结合执行。如果指定的条件为真,则跳转到指定的地址或标签,否则跳转到下一条指令。对于raxrbx(无符号比较),跳转到label,指令为:
cmp rax, rbxjb label 同样,如果rax 非零,则可以使用以下指令跳转到label:
test rax, raxjnz labelA.3.4 加载内存地址我们要介绍的最后一条指令是lea 指令(加载有效地址)。它从内存操作数(形式为基址+索引*比例+位移等)计算地址结果并将其存储。尽管它位于寄存器中,但它不会取消引用该地址。这相当于C/C++中的地址运算符。例如,lea r12, [rip+0x2000] 表示将表达式rip+0x2000 的结果加载到r12 寄存器中。现在您已经了解了最重要的x86 指令,让我们看看如何组合这些指令来实现常见的C/C++ 代码结构。
A.4 程序集中的通用代码构造GCC、Clang 和Visual Studio 等编译器为函数调用、if/else 分支和循环等构造创建通用代码模式,甚至在一些手写的汇编代码中也可以生成。一旦熟悉了这些,您将很快了解汇编或反汇编代码的作用。现在让我们看一下GCC 5.4.0 生成的代码模式。其他编译器使用类似的模式。您将看到的第一个代码构造是函数调用,但在您了解如何在汇编中实现函数调用之前,您需要了解堆栈在x86 上的工作原理。
A.4.1 堆栈堆栈是内存的保留区域,用于存储与函数调用相关的数据,例如返回地址、函数参数和局部变量。在大多数操作系统中,每个线程都有自己的堆栈。堆栈的名称来自于它的访问方式;与随机向堆栈写入数据不同,堆栈按后进先出(LIFO) 顺序访问和读取数据。即数据被压入栈底。首先将其入栈并写入数据,然后从栈顶弹出数据并取出数据。此数据结构对于函数调用很有意义,因为它与函数调用和返回发生的方式匹配(最后调用的函数首先返回)。图A-3 显示了堆栈访问模式。图A-3中,堆栈地址从0x7fffffff8000[1]开始,a到e这五个值被初始化,其余的是未初始化的内存,标记为“”。在x86 上,堆栈内存地址从高到低递增。这意味着新推送的数据的地址小于之前推送的数据的地址。栈指针寄存器(rsp)始终指向栈顶,即最近压入的数据——e,其地址为0x7fffffff7fe0。
图A-3 将值f 压入堆栈并将其弹出到rax 压入新值f 将其压入堆栈顶部并递减rsp 以指向该地址。 x86 有称为push 和pop 的特殊指令,可以在堆栈中插入或删除值并自动更新rsp。类似地,x86 call指令自动将返回地址压入堆栈,然后通过ret指令弹出返回地址并跳转到该地址。当执行Pop指令时,栈顶的值被复制到Pop操作数,并且rsp递增以反映新的栈顶地址。图A-3 中的Pop rax 指令从堆栈中复制f。 rax 并更新rsp 以指向新的堆栈顶部e。任何数据都可以在弹出之前压入堆栈,具体取决于堆栈上剩余的可用内存量。请注意,从堆栈中弹出值不会清除该值,它只会复制该值并更新RSP。弹出后,f 保留在内存中,直到被后续推送覆盖。最重要的是要知道,如果您将敏感信息放入堆栈中,则以后可以访问它,除非您明确清理它。现在我们了解了堆栈的工作原理,我们来看看函数调用如何使用堆栈来存储参数、返回地址和局部变量。
A.4.2 函数调用和函数堆栈帧清单A-3 显示了一个带有两个函数调用的简单C 程序。为了简洁起见,所有错误检查代码均被省略。首先,我们调用getenv 来获取argv[1] 指定的环境变量的值,并使用printf 打印该值。清单A-4 显示了使用GCC 编译并使用objdump 反汇编的相应汇编代码。注意在本例中,我们使用GCC 的默认选项编译程序。如果启用优化或使用不同的编译器,输出可能会有所不同。
清单A-3 C 中的函数调用
#include #include int main(int argc, char * argv[]){ printf('%s=%s\n', argv[1], getenv(argv[1])); return 0;}列表A- 4 汇编中的函数调用
节内容.rodata: 400630 01000200 25733d25 730a
00 ....%s=%s.. Contents of section .text: 0000000000400566 :❷ 400566: push rbp 400567: mov rbp,rsp❸ 40056a: sub rsp,0x10❹ 40056e: mov DWORD PTR [rbp-0x4],edi 400571: mov QWORD PTR [rbp-0x10],rsi 400575: mov rax,QWORD PTR [rbp-0x10] 400579: add rax,0x8 40057d: mov rax,QWORD PTR [rax]❺ 400580: mov rdi,rax❻ 400583: call 400430 ❼ 400588: mov rdx,rax 40058b: mov rax,QWORD PTR [rbp-0x10] 40058f: add rax,0x8 400593: mov rax,QWORD PTR [rax]❽ 400596: mov rsi,rax 400599: mov edi,0x400634 40059e: mov eax,0x0❾ 4005a3: call 400440 ❿ 4005a8: mov eax,0x0 4005ad: leave 4005ae: ret编译器将printf中的字符串常量%s=%s与代码分开存储,字符串常量存储在0x400634的.rodata节❶(只读数据)中,后面会在代码中看到该地址被当作printf的参数。原则上,x86 Linux程序中的每个函数都有自己的函数帧,也叫栈帧,由指向该函数的栈底rbp(基址指针)和指向栈顶的rsp所组成。栈帧用于存储函数的栈数据。注意某些编译器优化可能会忽略基址指针rbp(如VC的release版本),所有的栈访问都使用相对rsp地址,而rbp则作为通用寄存器,后文示例中所有的函数都使用完整的栈帧。图A-4显示了清单A-4中main和getenv创建的函数栈帧,为了了解它是如何工作的,我们先重温一下汇编列表,看看它是如何生成图中所示的函数栈帧的。正如第2章所述,main不是Linux程序运行的第一个函数,你只要知道main是由call指令调用的,该指令将返回地址放置在栈上,当main执行完后返回该地址继续执行(图A-4左上方所示)。 1.函数序言、局部变量及参数main要做的第一件事是运行创建函数栈帧的序言。这个序言首先将rbp寄存器的内容保存在栈中,然后将rsp复制到rbp❷中(见清单A-4)。这样做可以将前一个函数栈帧的起始地址保存起来,并在栈顶创建一个新的栈帧。因为指令序列push rbp; mov rbp,rsp很常用,所以x86有一个enter的缩写指令,其功能相同。 图A-4 清单A-4中main和getenv创建的函数栈帧在x86-64 Linux操作系统中,需要保证寄存器rbx和r12~r15不会被调用的任何函数“污染”,也就是说如果函数确实污染了这些寄存器,则在返回之前必须将其恢复为原始值。通常情况下,函数在保存基址指针之后需要将所有寄存器压入栈,然后在返回之前将其弹出以达到此目的。清单A-4中main不会执行此操作,因为它不使用任何有问题的寄存器。设置完函数栈帧后,main将rsp递减0x10字节,以便为栈上的两个8字节局部变量❸分配空间,虽然该C程序没有明确申请任何局部变量,GCC也会自动生成它们用作argc与argv的临时存储。在x86-64 Linux操作系统上,函数的前6个参数分别保存在rdi、rsi、rdx、rcx、r8及r9中,[2] 如果有超过6个以上的参数,或者某些参数不能保存到64位寄存器,则其余参数以相反的顺序(与它们在参数列表中出现的顺序相比)压入栈,如下所示: mov rdi, param1mov rsi, param2mov rdx, param3mov rcx, param4mov r8, param5mov r9, param6push param9push param8push param7一些流行的32位x86调用约定,如cdecl会以相反的顺序(不使用寄存器)在栈上传递参数,而其他调用约定(如fastcall)会在寄存器上传递参数。在栈上预留空间后,main将argc(存储在rdi中)复制到局部变量中,将(存储在rsi中的)argv复制到另一个变量❹中。图A-4的左侧显示了main序言(prologue)完成后的堆栈布局。 2.红色区域你可能会留意到在图A-4的栈顶中的128字节的“红色区域”。在x86-64上,函数将红色区域作为临时空间,确保操作系统不使用该区域[如当信号处理(signal handler)程序需要创建新的函数栈帧时]。随后调用的函数会覆盖红色区域作为自身函数栈帧的一部分,因此红色区域对不调用其他函数的所谓叶子函数最有用。只要叶子函数不使用超过128字节的栈空间,红色区域就会在设置栈帧的地方释放这些函数,从而减少执行时间。在32位x86上,没有红色区域的概念。 3.准备参数并调用函数在序言之后,main首先加载argv[0]的地址,然后添加8字节(指针大小),并将指针解引用到argv[1],将argv[1]加载到rax,其将指针复制到rdi用作getenv❺的参数,再调用getenv❻(见清单A-4),call会自动将返回地址(call的下一条指令)压入栈,getenv就可以在返回时找到该地址。getenv是库函数,这里不做过多介绍,我们假设它通过保存rbp创建函数栈帧,以起到节省寄存器和为局部变量保留空间的作用。假设函数没有push任何寄存器,图A-4的中间显示了getenv调用并完成序言后的栈布局。getenv执行完后,将返回值弹出(返回值一般保存到rax寄存器),然后通过递增rsp从栈中清除局部变量,将保存的基址指针从栈弹出到rbp,恢复main的函数栈帧,此时栈顶是保存的返回地址,main的地址为0x400588。最后getenv执行ret指令,该指令从栈中弹出返回地址,将控制权返回给main,图A-4的右侧显示了getenv返回后的栈布局。 4.读取返回值main函数将返回值(指向请求的环境字符串的指针)复制到rdx,用作printf❼的第三个参数,然后main用相同的方式再次加载argv[1]并将其保存到rsi,作为printf❽的第二个参数,第一个参数保存在rdi,是前面.rodata节中格式化字符串%s=%s的地址0x400634。与调用getenv不同,main在调用printf之前将rax设置为0。这是因为printf是一个可变参数函数,它假设rax通过向量寄存器指定传入的浮点参数的数量(在本例中不存在),参数准备好后,main调用printf❾,为printf压入返回地址。 5.从函数返回printf执行完后,main将rax寄存器❿清零以准备自己的返回值,然后执行leave指令,这是x86的mov rsp,rbp; pop rbp的缩写,是函数的尾声(epilogue)。与函数的序言相反,尾声指令将rsp指向栈基址并通过恢复rbp来还原现场,然后main执行ret指令,从栈顶弹出保存的返回地址并跳转过去,最后main结束执行。 A.4.3 条件分支接下来,我们来看另一个重要的构造:条件分支。清单A-5显示了一个包含条件分支的C程序,如果argc大于5,则显示argc>5,否则显示argc<=5。清单A-6显示了GCC使用默认选项生成相应的汇编实现,这些代码是使用objdump从二进制文件恢复的。 清单A-5 C程序中的条件分支 #include intmain(int argc, char *argv[]){ if(argc > 5) { printf("argc > 5\n");} else { printf("argc <= 5\n");} return 0;}清单A-6 汇编中的条件分支 Contents of section .rodata: 4005e0 01000200 ❶61726763 ....argc 4005e8 203e2035 00❷617267 > 5.arg 4005f0 63203c3d 203500 c <= 5. Contents of section .text: 0000000000400526 : 400526: push rbp 400527: mov rbp,rsp 40052a: sub rsp,0x10 40052e: mov DWORD PTR [rbp-0x4],edi 400531: mov QWORD PTR [rbp-0x10],rsi❸ 400535: cmp DWORD PTR [rbp-0x4],0x5❹ 400539: jle 400547 40053b: mov edi,0x4005e4 400540: call 400400 ❺ 400545: jmp 400551 400547: mov edi,0x4005ed 40054c: call 400400 400551: mov eax,0x0 400556: leave 400557: ret就像在A.4.2小节看到的那样,编译器将printf格式化字符串保存在.rodata节❶❷中,而非代码节.text中。main函数从序言开始,将argc和argv复制到局部变量中。条件分支通过cmp指令❸实现,该指令将包含argc的本地变量与立即数0x5进行比较,后面跟着一条jle指令❹。如果argc小于或等于0x5(else分支),则跳转到地址0x400547(该地址有一个对puts的调用)输出字符串argc <=5。最后是main的尾声和ret指令。如果argc大于0x5,则不会跳转到jle,而是来到下一条指令序列,地址为0x40053b(if分支),其调用puts输出字符串argc >5,然后跳转至main的尾声地址0x400551❺。注意,最后的jmp指令是为了跳过0x400547处的else分支。 A.4.4 循环在汇编语言中,你可以将循环看成条件分支的特殊情况。与常规分支一样,循环使用cmp/test和条件跳转指令实现。清单A-7显示了C中的while循环,该循环以相反的顺序输出所有指定的命令行参数。清单A-8显示了对应的汇编程序。 清单A-7 C中的while循环 #include int main(int argc, char *argv[]){ while(argc > 0) { printf("%s\n", argv[(unsigned)--argc]);} return 0;}清单A-8 汇编中的while循环 0000000000400526 : 400526: push rbp 400527: mov rbp,rsp 40052a: sub rsp,0x10 40052e: mov DWORD PTR [rbp-0x4],edi 400531: mov QWORD PTR [rbp-0x10],rsi❶ 400535: jmp 40055a 400537: sub DWORD PTR [rbp-0x4],0x1 40053b: mov eax,DWORD PTR [rbp-0x4] 40053e: mov eax,eax 400540: lea rdx,[rax*8+0x0] 400548: mov rax,QWORD PTR [rbp-0x10] 40054c: add rax,rdx 40054f: mov rax,QWORD PTR [rax] 400552: mov rdi,rax 400555: call 400400 ❷ 40055a: cmp DWORD PTR [rbp-0x4],0x0❸ 40055e: jg 400537 400560: mov eax,0x0 400565: leave 400566: ret在这个例子中,编译器将检查loop循环的代码放在循环的结尾,所以loop循环是从地址0x40055a开始的,并在此判断循环条件❶。这个检查是通过将argc与立即数0进行比较的cmp指令❷实现的。如果argc大于零,则跳转到循环体B开始的地方0x400537❸,循环体递减argc,从argv输出下一个字符串,然后再次进行循环体条件检查。直到argc为零循环结束,进入main的尾声,然后清理栈帧并返回。本文摘自《二进制分析实战》 如今,读者可以找到许多关于汇编的书籍,甚至可以找到更多有关ELF和PE二进制格式的说明。关于信息流跟踪和符号执行也有大量的文章。但是,没有哪本书可以向读者展示从理解基本汇编知识到进行高级二进制分析的全过程。也没有哪本书可以向读者展示如何插桩二进制程序、如何使用动态污点分析来跟踪程序执行过程中的数据或使用符号执行来自动生成漏洞利用程序。换句话说,直到现在,没有一本书可以教你二进制分析所需的技术、工具和思维方式。阴阳师4月22日更新内容:帝释天上线技能调整,红莲华冕活动来袭[多图],阴阳师4月22日更新的内容有哪些?版本更新
2024-04-06四川电视台经济频道如何培养孩子的学习习惯与方法直播在哪看?直播视频回放地址[多图],2021四川电视台经济频
2024-04-06湖北电视台生活频道如何培养孩子的学习兴趣直播回放在哪看?直播视频回放地址入口[多图],湖北电视台生活频道
2024-04-06