当我上第一节课的时候,大学里教我x86汇编的方式已经完全过时了很多年。那是在2008或2009年左右,64位处理器已经开始成为一种东西,甚至在我所处的环境中也是如此。与此同时,我们正在做DOS、实模式、内存分段和所有其他过去糟糕的事情。
尽管如此,我在课程期间(以及随后的几年)学到了足够多的代码,以便能够理解来自编译器另一端的内容,这对我有几次帮助。然而,我从来没有为一些重要的事情手动编写过大量的x86程序集。由于被关在里面(因为全球大流行),我决定改变这种情况,打发时间。
我想特别关注x86-64,并且完全忘记/跳过所有不再与此架构相关的遗留问题。在深入了解了一下之后,我也决定在这个博客上以教程的形式发布我的笔记,因为似乎有人想要这种类型的内容。
我在这些帖子中写的所有东西都将是一个普通的64位Windows程序。我们将使用Windows,因为这是我在所有非工作机器上运行的操作系统,当你下降到编写汇编语言的级别时,开始变得越来越不可能忽略你正在运行的操作系统。我也会尽可能从头开始-没有库,我们只允许调用操作系统,仅此而已。
在第一个介绍性部分(是的,我正在计划一个系列,我知道我稍后会后悔的),我将谈论我们将需要的工具,展示如何使用它们,解释我对汇编语言编程的一般想法,并展示如何编写可能是最小的可行的Windows程序。
CPU执行机器代码-这是处理器指令的一种有效表示,几乎完全无法被人类理解。汇编语言是它的人类可读表示。将这种符号表示转换成可由CPU执行的机器码的程序称为汇编器。
X86-64汇编语言没有单一的公认标准。市面上有很多汇编器,尽管它们中的一些有很多相似之处,但每个都有自己的一组功能和怪癖。因此,选择哪种汇编器很重要。在本系列中,我们将使用平面汇编程序(简称FASM)。我喜欢它,因为它很小,容易获取和使用,有一个很好的宏系统,并配有一个方便的小编辑器。
另一个重要的工具是调试器。我们将使用它来检查我们程序的状态。虽然我非常确定可以使用Visual Studio的集成调试器来实现这一点,但我认为当您只想查看反汇编、内存和寄存器时,独立调试器会更好。我一直使用OllyDbg来处理这样的事情,但不幸的是它没有64位版本。因此,我们将使用WinDbg。这里链接的版本是这个历史悠久的工具的改版,具有稍微好一点的界面。或者,您也可以在这里获得非Windows商店版本,作为Windows10SDK的一部分。只需确保在安装过程中取消选择除WinDbg之外的所有其他内容。出于我们的目的,这两个版本基本上是可以互换的。
既然我们已经有了工具,我想花点时间来讨论一些基础知识。出于这些教程的目的,我假设您对C或C++等语言有一定的了解,但以前很少或根本没有接触过汇编语言,因此许多读者会觉得这些东西很熟悉。
只有CPU知道如何做固定数量的某些事情。当您听到有人谈论指令集时,他们指的是特定CPU被设计用来做的一组事情,而术语“指令”只是指CPU可以做的事情之一。大多数指令都以这样或那样的方式参数化,而且它们通常非常简单。通常,一条指令是将给定的8位值写入内存中的给定位置,或将寄存器A和B中的值解释为16位有符号整数,将它们相乘并将结果记录到寄存器A&34;中。
这跳过了很多事情(可以有多个内核执行指令和读/写内存,有不同级别的高速缓存,等等),但是应该可以作为一个很好的起点。
要有效地进行低级编程或调试,您需要了解每个高级概念最终都会映射到这个低级模型,了解映射的工作原理将会对您有所帮助。
您可以将寄存器视为直接内置到CPU中的一种特殊类型的内存,它非常小,但访问速度极快。X86-64中有许多不同类型的寄存器,现在我们只关注所谓的通用寄存器,其中有16个。它们中的每一个都是64位宽,并且它们的低位字节、字和双字可以单独寻址(顺便说一句,1";字=2字节,1";双字=4字节,如果您以前没有听说过这个术语的话)。
另外,rax、rbx、rcx和rdx的较高8位可以称为ah、bh、ch和dh。
请注意,尽管我说的是通用寄存器,但有些指令只能与某些寄存器一起使用,而有些寄存器对某些指令有特殊的含义。具体地说,rsp保存堆栈指针(它由PUSH、POP、CALL和ret等指令使用),而RSI和RDI充当";字符串操作指令的源和目标索引。某些寄存器得到特殊处理的另一个例子是乘法指令,其要求乘数值之一在寄存器rax中,并将结果写入寄存器对rax和rdx。
除了这些寄存器之外,我们还将考虑特殊寄存器rip和rflag。RIP保存要执行的下一条指令的地址。它由控制流指令(如CALL或JMP)修改。Rflag保存一组二进制标志,指示程序状态的各个方面,例如最后一次算术运算的结果是小于、等于还是大于零。许多指令的行为取决于这些标志,并且许多指令在执行过程中更新某些标志。也可以使用特殊指令读取和写入标志寄存器。
X86-64上的寄存器要多得多。它们中的大多数用于SIMD或浮点指令,我们不会在本系列中考虑它们。
您可以将内存看作字节大小的单元格";的大型数组,从0开始编号。我们将这些数字称为内存地址。很简单,对吧?
嗯..。在过去,寻址记忆是一件相当烦人的事情。您知道,旧x86处理器中的寄存器过去只有16位宽。16位足以寻址64千字节的内存,但不能更多。硬件实际上能够使用20位宽的地址,但是您已经将一个基址地址放入了一个特殊的段寄存器,读或写内存的指令将使用该段中的16位偏移量来获得最终的20位线性地址。代码、数据和堆栈部分有单独的段寄存器(还有几个额外的段寄存器),段可以重叠。
在x86-64中,这些问题是不存在的。代码、数据和堆栈的段寄存器仍然存在,并且它们加载了一些特殊的值,但是作为一个用户空间程序员,您不需要关心它们。无论出于何种目的,您都可以假设所有段都从0开始,并延伸到整个可寻址的内存长度。因此,就我们而言,在x86-64上,我们的程序将内存视为一个连续的字节数组,具有连续的地址,从0开始,就像我们在本节开头所说的那样。
好吧,我可能有点歪曲了事实。事情并不是那么简单。虽然在64位Windows上,您的程序确实将内存视为地址从0开始的扁平连续字节数组,但它实际上是操作系统和CPU共同维护的精心设计的错觉。
事实是,如果你真的能够随意读写内存中的任何字节,你就会践踏所有其他程序的代码和数据(这在过去确实可能发生)。为了防止这种情况,存在特殊的保护机制。我不会在这里太深入地了解他们的内部工作,因为这些东西对操作系统开发人员来说最重要。不过,下面是一个非常简短的概述:
如上所述,每个进程都获得一个平面地址空间(我们将其称为虚拟地址空间)。对于每个进程,操作系统在其虚拟地址和内存中的实际物理地址之间建立映射。硬件遵守此映射:虚拟地址在运行时动态转换为物理地址。因此,对于两个不同的进程,相同的地址(例如0x410F119C)可以映射到物理存储器中的两个不同位置。简而言之,这就是进程之间的分离是如何实施的。
我想请大家注意的最后一件事是,它们操作的指令和数据是如何保存在同一存储器中的。虽然这看起来似乎是一个显而易见的选择,但它并不一定是计算机必须如何工作的。这是冯·诺依曼模型的一个特性--与哈佛模型相反,哈佛模型将指令和数据分别保存在不同的存储器中。哈佛电脑的一个真实例子是Arduino上的AVR微控制器。
希望此时您已经下载了FASM,并准备好编写一些代码。我们的第一个程序将非常简单:它将加载,然后立即退出。我们最想要的就是熟悉一下这些工具。
格式化PE64 NX GUI 6.0条目开始部分';.text';代码可读可执行文件开始:int3 ret。
我们将一行行地看一遍这篇文章。Format PE64 NX GUI 6.0-这是一个指令,告诉FASM我们希望它生成的二进制文件的格式-在我们的例子中,是可移植的可执行格式(这是大多数Windows程序使用的格式)。我们稍后会更详细地讨论这件事。
Entry Start-这定义了进入我们程序的入口点。Entry指令需要一个标签,在本例中为";start";。标签可以被认为是程序中地址的名称,因此在本例中,我们说的是程序的入口点位于开始标签所在的任何地址。请注意,您可以引用标签,即使它们稍后在程序代码中进行了定义(就像这里的情况一样)。
段.text';代码可读可执行文件-此指令指示可移植可执行文件中新节的开始,在本例中是包含可执行代码的节。稍后会详细介绍这一点。
开始:-这是表示我们程序的入口点的标签。我们在早些时候的条目指令中提到了它。请注意,标签本身不会生成任何可执行机器代码:它们只是程序员标记可执行文件地址空间中位置的一种方式。
Int3-这是一条特殊指令,使程序调用调试异常处理程序-当在调试器下运行时,这将暂停程序,并允许我们检查其状态或逐步继续执行。这就是断点的实际实现方式-调试器用对应于int3的操作码替换可执行文件中的单个字节,当程序命中它时,调试器接管(显然,在继续执行或单步执行之前,必须记住并恢复断点地址处内存的原始内容)。在我们的例子中,为了方便起见,我们立即在入口点对断点进行了硬编码,这样我们就不必每次都通过调试器手动设置断点。
Ret-此指令从堆栈顶部弹出一个地址,并将执行转移到该地址。在我们的例子中,我们将返回到最初调用我们的入口点的操作系统代码。
启动FASMW.EXE,将上面的代码粘贴到编辑器中,保存文件并按Ctrl+F9。您的第一个汇编程序现在已经完成!现在让我们将其加载到调试器中,并单步执行以查看其实际工作情况。
打开WinDbg。转到视图选项卡,并确保以下窗口可见:反汇编、寄存器、堆栈、内存和命令。转到File>;Launch Executable并选择您刚刚使用FASM构建的可执行文件。此时,您的工作区应该类似如下所示:
在“反汇编”窗口中,您可以看到当前正在执行的代码。现在它不是我们程序的代码,而是一些操作系统加载程序代码-这些东西会将我们的程序加载到内存中,并最终将执行转移到我们的入口点。WinDbg确保在任何情况发生之前触发断点。
在寄存器窗口中,您可以看到我们前面讨论的x86-64寄存器的内容。
内存窗口显示给定虚拟地址附近程序内存的原始内容。我们稍后会用到它。
堆栈窗口显示当前的调用堆栈(如您所见,它现在都在ntdll.dll中)。
如果此时按F5,将导致程序继续运行,直到到达另一个断点。它将命中的下一个断点是我们硬编码的断点。试着按F5,您会看到类似以下内容:
您应该能够认出我们写的两个指令-int3和ret。要前进到下一个指令,请按F8。当您这样做时,请注意寄存器窗口-您应该看到RIP寄存器随着您的前进而更新(WinDbg突出显示更改为红色的寄存器)。
在执行ret指令之后,您将立即返回到调用我们程序的入口点的代码。
正如您从上图中看到的,接下来将发生的事情是调用RtlExitUserThread(一个非常不言自明的名称)。如果现在按F5,程序的主线程将被清除并结束,程序也将结束。还是会呢?..。
事实是,通过使用ret,我走了一条捷径。在Windows上,如果满足以下任一条件,进程将终止:
但是,我们离开了这里的主线,所以我们应该很好,对吗?算是吧。不能保证Windows没有在我们的进程中启动任何其他后台线程(例如,加载DLL或类似的东西)。看起来至少在这个例子中,主线程是唯一的线程(我已经检查过了,进程没有停留),但是这种情况可能会改变。行为良好的Windows程序应该始终在适当的时间调用ExitProcess。
为了能够调用WinAPI函数,我们需要了解一些关于可移植可执行文件格式、如何加载DLL以及调用约定的知识。
ExitProcess函数位于KERNEL32.DLL中(是的,这不是打字错误,KERNEL32是64位库的名称。为Back-compat puepores提供的这些库的32位版本位于名为SysWOW64的文件夹中。我不是在开玩笑。)。为了能够调用它,我们首先需要导入它。
我们不会在这里全面介绍可移植可执行文件格式。它在Microsoft Docs网站上有广泛的文档记录。以下是我们需要了解的几个基本事实: PE文件由部分组成。我们已经在程序中看到了包含可执行代码的节,但是节可能包含其他类型的数据。
有关从哪些DLL导入哪些符号的信息存储在名为';.idata';的特殊部分中。
让我们来看看.idata部分。
根据文档,.idata部分以导入目录表(IDT)开头。IDT中的每个条目对应一个DLL,长度为20字节,由以下字段组成: 导入查找表(ILT)的4字节相对虚拟地址(RVA),其中包含要导入的函数的名称。稍后会详细说明这一点。
导入地址表(IAT)的4字节RVA。IAT的结构与ILT相同,唯一的区别是IAT的内容在运行时由加载器修改-它用相应导入函数的地址覆盖每个条目。因此,从理论上讲,您可以让ILT和IAT字段指向完全相同的内存段。此外,我发现将ILT指针设置为零也是有效的,尽管我不确定此行为是否得到官方支持。
“导入目录表”由所有字段均为零的条目终止。
ILT/IAT是以空值结尾的64位值的数组。每个条目的底部31位包含提示/名称表中条目的RVA(包含导入函数的名称)。在运行时,IAT的条目将替换为导入函数的实际地址。
上面提到的提示/名称表由条目组成,每个条目都需要在偶数边界上对齐。每个条目都以一个2字节的提示(我们现在将忽略它)、一个包含导入的函数名称的以NULL结尾的字符串和一个空字节(如果需要)开始,以便在偶数边界上对齐下一个条目。
有了这些,让我们看看我们将如何在FASM中定义可执行文件的.idata部分。
第#39;.idata';节导入可读写文件 Idt:;导入目录表从此处开始 ;KERNEL32.DLL的条目 DD RVA内核32_iat DD 0 DD 0 DD RVA内核32_名称 DD RVA内核32_iat ;NULL Entry-IDT结束 DD 5 DUP(0) NAME_TABLE:;提示/名称表 _ExitProcess_Name dw%0 数据库";退出进程";,0,0 Kernel32_name:DB";KERNEL32.DLL";,0 Kernel32_iat:;导入KERNEL32.DLL地址表 退出进程dq rva_退出进程名称 DQ0;KERNEL32';的IAT结束
关于一个新的体育部分的指示我们已经很熟悉了。在这种情况下,我们再次告知您,我们即将引入的部分包含导入数据,需要在加载到内存时设置为可写(因为导入函数的地址将写入内存)。
指令db、dw、dd和dq都使FASM分别发出原始字节/字/双字/四字的值。不出所料,RVA运算符生成其参数的相对虚拟地址。因此,ddrva kernel32iat将导致FASM发出一个等于kernel32iat标签的RVA的4字节二进制值。
在这里,我们刚刚使用了Fasm的db/dw/etc指令来精确描述我们的.idata部分的内容。
我们现在几乎已经准备好最终调用ExitProcess。不过,我们必须回答的一件事是-函数调用是如何工作的?想想看。有一个CALL指令,它将RIP的当前值推送到堆栈上,并将执行转移到其参数指定的地址。还有ret指令,它从堆栈中弹出一个地址并将执行转移到那里。没有指定应该如何将参数传递给函数,或者如何处理返回值。硬件根本不在乎这一点。呼叫者和被呼叫者的工作是在他们之间建立合同。这些规则可能看起来像是沿着这条线走的。
.