因为汇编语言是二进制文件中机器指令的标准表示形式,许多二进制分析都基于反汇编,所以读者必须熟悉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 <stdio.h>
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 "Hello, world!"
❻ .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 puts
mov eax, 0
leave
ret
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.9)"
.section .note.GNU-stack,"",@progbits
清单 A-1 通过在main函数❶中调用 printf❷输出常量字符串“Hello,World!”❸,在更底层,对应的汇编程序由4种类型的组件组成:指令(instruction)、伪指令(directive)、标号(label)及注释(comment)。
A.1.1 汇编指令、伪指令、标号及注释
表A-1列出了汇编程序的每种组件类型的说明,注意每种组件的语法因汇编或反汇编工具的变化而不同。就本书而言,读者无须非常熟悉任何汇编工具的语法特点,只需读懂和分析反汇编的代码,而无须编写汇编代码。这里推荐GCC使用-masm=intel选项生成的汇编语法。
表A-1 汇编程序的组件
类型 | 示例 | 含义 |
指令 | mov eax, 0 | 给eax赋值为0 |
伪指令 | .section .text | 将以下代码放入.text节 |
伪指令 | .string "foobar" | 定义包含“foobar”的ASCII字符串 |
伪指令 | .long 0x12345678 | 定义一个双字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❼是main函数的符号标签。
标签后面的就是main函数包含的真实指令,这些指令可以引用先前声明的数据,如.LC0❽(GCC为“Hello,world!”字符串选择的符号名称)。因为程序会输出一个常量字符串(无可变参数),所以GCC用puts❾替换printf,这是一个将指定字符串输出到屏幕的简单函数。
A.1.2 代码与数据分离
可以在清单A-2中观察到一个关键结果,即编译器通常将代码和数据分为不同的节,这在反汇编或分析二进制文件的时候非常方便,因为这样就知道程序中哪些字节被解释为代码,哪些字节被解释为数据。但是,x86架构本质上并没有阻止在同一个节中混合代码和数据,实际上某些编译器或者手写汇编程序也确实将数据和代码混合在同一个节。
A.1.3 AT&T和Intel语法
正如前面所提到的,不同的汇编器对汇编程序有不同的语法,表示x86机器指令的语法格式主要有两种:Intel语法和AT&T语法。AT&T语法显式地在每个寄存器名称的前面加上%符号,每个常量前面加上$符号,而Intel语法没有这些符号。因为Intel语法相对简洁,所以本书中使用的是Intel语法。AT&T与Intel之间最重要的区别是使用完全相反的指令操作数顺序。在AT&T中,源操作数在目的操作数前面,因此将常量移到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指令由可选前缀(prefix)、操作码(opcode)及零个或多个操作数(operand)组成。注意除了操作码外,剩余部分都是可选的。
操作码是指令类型的主要标识符,如opcode0x90表示不执行任何操作的nop指令,0x00~0x05表示各种类型的加法指令。前缀可以修改指令的行为,如让一条指令重复执行多次或访问不同的内存段。最后,操作数是指令对其进行操作的数据。
寻址模式字节,也称为MOD-R/M或MOD-REG-R/Mz字节,包含有关指令操作数类型的元数据,SIB(scale/index/base)字节和偏移(displacement)用来表示内存操作数,立即数字段(immediate)包含立即操作数(常量数值),读者可以很快了解这些字段的含义。
除了图A-1所示的显式操作数外,还有某些有隐式操作数的指令,虽然这些指令没有明确表示,但是opcode却是固定的,如opcode 0x05(add指令)的目的操作数始终是rax,只有源操作数是变量,需要显式表示,又如,push指令会隐式地更新rsp(堆栈指针寄存器)。
在x86上,指令有3种不同类型的操作数:寄存器操作数、内存操作数及立即数,接下来我们来看一下每种有效的操作数类型。
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寄存器的细分
其他寄存器有类似的命名方案,表A-2显示了x86-64上可用的通用寄存器名称,以及可用的旧式“子寄存器”,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字节 |
累加器 | rax | eax | ax | al | ah |
基址寄存器 | rbx | ebx | bx | bl | bh |
计数器 | rcx | ecx | cx | cl | ch |
数据寄存器 | rdx | edx | dx | dl | dh |
栈指针 | rsp | esp | sp | spl | |
基址指针 | rbp | ebp | bp | bpl | |
源地址索引 | rsi | esi | si | Sil | |
目标地址索引 | rdi | edi | di | Dil | |
x86-64通用寄存器 | r8~r15 | r8d~r15d | r8w~r15w | r8l~r15l |
2.其他寄存器
除表A-2中所示的寄存器外,x86 CPU还包含其他非通用寄存器,最重要的两个是rip(在32位x86上称为eip,在8086上称为ip)和rflag(在较早的ISA中称为eflag或者标志寄存器)。rip指令指针始终指向下一条指令的地址,并且由CPU自动设置,无法手动干预。在x86-64上,你可以读取指令指针的值,但在32位x86上却不行。状态标志寄存器用于比较和条件分支,并跟踪诸如上一次操作是否有除零异常、是否有溢出等事件。
x86 ISA还有cs、ds、ss、es、fs及gs段寄存器,用于将内存划分为不同的段。现在x86-64已经废止了内存分段,基本放弃了对分段的支持,因此这里不进行更多的介绍,如果想了解更多关于该内容的知识,可以阅读有关x86汇编的书籍。
还有一些控制寄存器,如cr0~cr10,内核使用这些寄存器来控制CPU 的行为,如在保护模式与实模式之间的切换。此外寄存器dr0~dr7是调试寄存器,为调试特性(如断点)提供硬件支持。在x86上,不能从用户模式访问控制寄存器和调试寄存器,只有内核可以访问它们,因此这里不会过多介绍这些寄存器的内容。
还有各种特殊模块寄存器(Model Specific Register,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计算内存操作数表达式的结果,得到最终的内存地址,base、index和scale均在指令SIB字节中得到表达,而displacement在同名数据域得到表达。scale默认为1,displacement默认为0。
这些内存操作数格式足够灵活,可以直接使用许多常见的代码范例,如可以使用mov eax,DWORD PTR [rax*4+arr]之类的指令访问数组元素,其中arr是数组起始地址的偏移,rax是访问的数组元素的索引值,每个数组元素长度为4字节,DWORD PTR告诉汇编程序要从内存中获取4字节(双字或DWORD)。同样,访问结构域的一种方法是将结构的起始地址存储在基址寄存器,并添加要访问的域的偏移。
在x86-64上,可以使用rip(指令指针)作为内存操作数的基数,但这种情况下不能使用索引寄存器。编译器经常将rip用于与位置无关的代码和数据访问,因此你会在x86-64二进制文件中看到大量的rip相对寻址。
A.2.5 立即数
立即数就是指令中硬编码的常量整数操作数,如指令add rax,42,42就是一个立即数。
在x86上,立即数以小端格式编码,多字节整数的最低有效字节排在内存中的第一位。换句话说,如果编写像mov ecx,0x10203040这样的程序集指令,相应的机器指令会将指令编码为0x40302010。
x86使用补码表示法表示有符号整数,该方法首先将该数(负数)转换为二进制正数,然后按位取反并加1,符号位0代表正数,1代表负数。如要对−1这个4字节整数进行编码,可以用整数0x00000001表示(十六进制表示1),然后将十六进制转换为二进制并按位取反生成0xfffffffe,最后加1生成补码0xffffffff。在反汇编代码的时候,可以看到立即数或内存值许多以0xff字节开头,通常都是一个负数。
现在你已经对x86指令的一般格式和工作原理有了一定了解,下面我们看一下在本书和读者自己分析的项目中会遇到的一些常见的x86指令。
A.3 常见的x86指令
表A-3描述了常见的x86指令。要了解更多未在此表列出的指令,请参考Intel手册或者相应网站。表A-3中列出的大多数指令都是不言自明的,但有些需要更详细的描述。
表A-3 常见的x86指令
指令 | 描述 |
数据传输 | |
❶ mov dst,src | 将src赋给dst |
xchg dst1,dst2 | 互换dst1和dst2 |
❷ push src | 将src压栈,并递减rsp |
pop dst | 出栈赋给dst,并递增rsp |
算术 | |
add dst, src | dst +=src |
sub dst, src | dst –= src |
inc dst | dst += 1 |
dec dst | dst –= 1 |
neg dst | dst = –dst |
❸ cmp src1, src2 | 根据src1−src2设置状态标志位 |
逻辑/按位 | |
and dst, src | dst &= src |
or dst, src | dst |= src |
xor dst, src | dst ˆ= src |
not dst | dst = ~dst |
❹ test src1, src2 | 根据src1 & src2设置状态标志位 |
无条件分支 | |
jmp addr | 跳转到地址 |
call addr | 压入返回地址到栈上,然后调用函数地址 |
ret | 从栈上弹出返回地址,然后跳转到该地址 |
❺ syscall | 进入内核执行系统调用 |
跳转分支(基于状态标志位) | |
❻ je addr / jz addr | 如果设置ZF零标志位则跳转(如当上一个cmp中的操作数相同时) |
ja addr | 上一次比较中,如果dst大于src则跳转(无符号) |
jb addr | 上一次比较中,如果dst小于src则跳转(无符号) |
jg addr | 上一次比较中,如果dst大于src则跳转(有符号) |
jl addr | 上一次比较中,如果dst小于src则跳转(有符号) |
jge addr | 上一次比较中,如果dst大于等于src则跳转(有符号) |
jle addr | 上一次比较中,如果dst小于等于src则跳转(有符号) |
js addr | 上一次比较中,如果结果为负则跳转,符号位置1 |
杂项 | |
❼ lea dst, src | 将内存地址加载到dst中,(dst=&src,其中src必须在内存) |
nop | 空指令,不执行操作(用作代码填充) |
首先,需要注意mov❶这个词并不准确,因为从技术上来讲不是将源操作数移动到目的操作数,而是对其进行复制,但是源操作数保持不变。其次,关于栈管理和函数调用,push和pop指令❷具有特殊意义,稍后会提到。
A.3.1 比较操作数和设置状态标志位
cmp指令❸对实现条件分支非常重要,它从第一个操作数中减去第二个操作数,但该指令没有将操作的结果存储在某个地方,而是根据结果在rflags寄存器中设置状态标志位,然后条件分支指令会检查这些状态标志位,决定是否跳转。其中重要的标志寄存器包括ZF标志寄存器、SF标志寄存器及OF标志寄存器,分别表示比较的结果是否为0、负数还是溢出。
test指令❹与cmp相似,但它基于操作数的按位与操作来设置状态标志位,而不是减法操作。需要注意的是,除了cmp和test之外,还有一些其他指令也可以设置状态标志,Intel手册或在线指令参考资料准确地显示了每个指令集的标志位。
A.3.2 实现系统调用
执行系统调用需要用到syscall指令❺,使用之前需要设置系统调用号。如在Linux操作系统执行read系统调用,需要将0(read的系统调用号)加载到rax,然后分别将文件描述符、缓冲区地址及要读取的字节数加载到rdi、rsi及rdx中,最后才执行syscall调用。
为了了解如何在Linux操作系统上配置系统调用,请参考man syscall。注意在32位x86上,使用sysenter或int 0x80(触发中断向量0x80软中断)替代syscall进行系统调用。另外在非Linux操作系统上,系统调用的约定也不尽相同。
A.3.3 实现条件跳转
条件跳转指令❻与前面设置状态标志位的指令是一起运行的,如cmp或test。如果指定的条件成立,则跳转到指定的地址或者标号;如果条件不成立,则跳转到下一条指令。如果rax<rbx(无符号比较)则跳转到label处,指令如下:
cmp rax, rbx
jb label
相似地,如果rax不为零,可以使用以下指令跳转到label:
test rax, rax
jnz label
A.3.4 加载内存地址
最后要介绍的是lea指令(加载有效地址)❼,该指令从内存操作数中(格式如base+index*scale+displacement)计算地址结果,并将其存储到寄存器中,但不解除地址的引用,相当于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 栈
栈是一块内存保留区域,用于存储与函数调用相关的数据,如返回地址、函数参数及局部变量。在大多数操作系统中,每个线程都有自己的栈。
栈的名称来自其访问方式,与在栈上随机写入数据不同,栈是按照后进先出(Last In First Out,LIFO)的顺序访问和读取数据的,也就是先将数据压入栈底来写入数据,然后从栈的顶部弹出数据来删除数据。这种数据结构对函数调用来说是合理的,因为它与函数调用和从函数返回的方式相匹配:最后调用的函数最先返回,图A-3说明了栈的访问模式。
在图A-3中,栈地址从0x7fffffff8000[1]开始并初始化5个值a~e,剩余部分为未初始化的内存,标记为“?”。在x86上,栈的内存地址由高往低增长,意思是新压入栈的数据的地址比之前压入栈的数据的地址要小。其中栈指针寄存器(rsp)始终指向栈的顶部,即最近压入的数据——e,其地址为0x7fffffff7fe0。
图A-3 将值f压入栈,然后将其弹入rax
当你push一个新值f时,其压入栈的顶部,而rsp递减指向该地址。x86上有一些特殊指令,称为push和pop,可在栈中插入或删除值并自动更新rsp。同样x86的call指令会自动将返回地址压入栈中,然后通过ret指令弹出返回地址,并跳到该地址。
在执行pop指令时,其将栈顶的值复制到pop操作数中,然后递增rsp以反映新的栈顶地址,如图A-3中的pop rax指令将f从栈复制到rax中,然后更新rsp以指向新的栈顶e。你可以在弹出数据之前将任意数据压入栈中,当然这取决于栈中保留的可用内存的大小。
注意从栈中弹出一个值并不是清除该值,只是复制该值并更新了rsp。在弹出之后,f仍然存在内存中,直到它被后面的push所覆盖。最重要的是要知道如果你将敏感信息放到栈上,之后仍然可能访问到它,除非你显式地清理它。
现在你已经对栈的工作方式有所了解,接下来我们来看看函数调用是如何通过栈来存储它们的参数、返回地址和本地变量的。
A.4.2 函数调用与函数栈帧
清单A-3显示了一个简单的C程序,其中包含了两个函数调用。为了简洁,省略了所有错误检查代码。首先,它调用getenv获取argv[1]中指定的环境变量的值,然后使用printf输出此值。
清单A-4显示了相应的汇编代码,该代码使用GCC编译,然后使用objdump对其进行反汇编而获得。注意在此示例中,我已使用GCC的默认选项编译了该程序,如果启用优化或使用其他编译器,则输出看起来可能会有所不同。
清单A-3 C中的函数调用
#include <stdio.h>
#include <stdlib.h>
int
main(int argc, char * argv[])
{
printf("%s=%s\n",
argv[1], getenv(argv[1]));
return 0;
}
清单A-4 汇编中的函数调用
Contents of section .rodata:
400630 01000200 ❶ 25733d25 730a00 ....%s=%s..
Contents of section .text:
0000000000400566 <main>:
❷ 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 <getenv@plt>
❼ 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 <printf@plt>
❿ 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, param1
mov rsi, param2
mov rdx, param3
mov rcx, param4
mov r8, param5
mov r9, param6
push param9
push param8
push 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 <stdio.h>
int
main(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 <main>:
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 <main+0x21>
40053b: mov edi,0x4005e4
400540: call 400400 <puts@plt>
❺ 400545: jmp 400551 <main+0x2b>
400547: mov edi,0x4005ed
40054c: call 400400 <puts@plt>
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 <stdio.h>
int
main(int argc, char *argv[])
{
while(argc > 0) {
printf("%s\n",
argv[(unsigned)--argc]);
}
return 0;
}
清单A-8 汇编中的while循环
0000000000400526 <main>:
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 <main+0x34>
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 <puts@plt>
❷ 40055a: cmp DWORD PTR [rbp-0x4],0x0
❸ 40055e: jg 400537 <main+0x11>
400560: mov eax,0x0
400565: leave
400566: ret
在这个例子中,编译器将检查loop循环的代码放在循环的结尾,所以loop循环是从地址0x40055a开始的,并在此判断循环条件❶。
这个检查是通过将argc与立即数0进行比较的cmp指令❷实现的。如果argc大于零,则跳转到循环体B开始的地方0x400537❸,循环体递减argc,从argv输出下一个字符串,然后再次进行循环体条件检查。
直到argc为零循环结束,进入main的尾声,然后清理栈帧并返回。
本文摘自《二进制分析实战》
如今,读者可以找到许多关于汇编的书籍,甚至可以找到更多有关ELF和PE二进制格式的说明。关于信息流跟踪和符号执行也有大量的文章。但是,没有哪本书可以向读者展示从理解基本汇编知识到进行高级二进制分析的全过程。也没有哪本书可以向读者展示如何插桩二进制程序、如何使用动态污点分析来跟踪程序执行过程中的数据或使用符号执行来自动生成漏洞利用程序。换句话说,直到现在,没有一本书可以教你二进制分析所需的技术、工具和思维方式。