在我进入逆向工程之前,可执行文件对我来说总是像是黑魔法。我一直想知道这些东西是如何在幕后工作的,二进制代码如何在 .exe 文件中表示,以及在不访问原始源代码的情况下修改这个“编译代码”是多么困难。但主要的令人生畏的障碍之一似乎总是汇编语言,它使大多数人不敢尝试学习这个领域。这就是为什么我想写这篇直截了当的文章的主要原因,它只包含您在倒车时遇到的最重要的东西,尽管为了简洁而遗漏了关键细节,并假设读者有反射在线寻找答案,查找定义,更重要的是,提出要练习的示例/想法/项目。目标是希望引导有抱负的逆向工程师,并激发更多地了解这种看似难以捉摸的激情的动力。注意:本文假设读者具备有关十六进制数字系统和 C 编程语言的基本知识,并且基于 32 位 Windows 可执行案例研究 - 结果可能因不同的操作系统/体系结构而异。使用编译语言编写代码后,会进行编译 (duh),以生成输出二进制文件(例如 .exe 文件)。编译器是完成此任务的复杂程序。在编译和优化生成的机器代码之前,它们会通过最小化其大小和提高其性能(只要适用)来确保您丑陋代码的语法正确。
正如我们所说,生成的输出文件包含二进制代码,只能由 CPU “理解”,它本质上是一系列按顺序执行的变长指令 - 以下是其中一些指令的样子:这些指令是主要是算术,它们在执行时操作 CPU 寄存器/标志以及易失性存储器。 CPU 寄存器几乎就像一个临时整数变量——它们的数量是固定的,它们存在是因为它们可以快速访问,不像基于内存的变量,它们帮助 CPU 跟踪其数据(结果、操作数) 、计数等)在执行期间。重要的是要注意一个称为 FLAGS 寄存器(32 位上的 EFLAGS)的特殊寄存器的存在,它包含一堆标志(布尔指标),它们保存有关 CPU 状态的信息,其中包括有关最后一次算术的详细信息操作(零:ZF,溢出:OF,奇偶校验:PF,符号:SF 等)。其中一些寄存器也可以在前面提到的汇编摘录中找到,即:EAX、ESP(堆栈指针)和 EBP(基指针)。当 CPU 执行东西时,它需要访问内存并与内存交互,这就是堆栈和堆的作用出现的时候。这些是(不详细介绍)在程序执行期间“跟踪变量数据”的两种主要方法:
两者中更简单和更快 - 它是一个线性连续 LIFO(后进先出)数据结构,具有推送/弹出机制,它用于记住函数范围的变量、参数并跟踪调用(曾经听说过堆栈跟踪?)然而,堆是非常无序的,并且适用于更复杂的数据结构,它通常用于动态分配,其中缓冲区的大小最初未知,和/或如果它太大,和/或者以后需要修改。正如我之前提到的,汇编指令具有不同的“字节大小”和不同数量的参数。参数也可以是立即数(“硬编码”),也可以是寄存器,具体取决于指令: 55 push ebp ;大小:1 字节,参数: register6A 01 push 1 ;大小:2 个字节,参数:立即 让我们快速浏览一下我们将要看到的一些常见的非常小的集合 - 请随意进行自己的研究以获取更多详细信息: push value ;将一个值压入堆栈(将 ESP 减 4,即一个堆栈“单位”的大小)。
mov 目的地, [表达式] ;将通过“寄存器表达式”(单个寄存器或涉及一个或多个寄存器的算术表达式)解析的内存地址中的值复制到寄存器中。 jz/je 目的地;如果设置了 ZF(零标志),则跳转到代码位置。 cmp操作数1,操作数2;比较 2 个操作数,如果相等则设置 ZF。注意:您可能会注意到 x86 术语中“相等”和“零”这两个词可以互换使用——这是因为比较指令在内部执行减法,这意味着如果 2 个操作数相等,则设置 ZF。现在我们对程序执行过程中使用的主要元素有了一个大致的了解,让我们熟悉一下在逆向工程普通日常 32 位 PE 二进制文件时可能遇到的指令模式。函数序言是嵌入在大多数函数开头的一些初始代码,它用于为该函数建立一个新的堆栈框架。 55 推 ebp ;在 stack8B EC mov ebp, esp 中保留调用者函数的基指针;调用函数的堆栈指针成为基指针(新堆栈帧)83 EC XX sub esp, X ;将堆栈指针调整 X 字节,为局部变量保留空间
尾声与序言完全相反——它在返回调用者函数之前撤消其步骤以恢复调用者函数的堆栈帧: 8B E5 mov esp, ebp ;恢复调用函数的堆栈指针(当前基指针) 5D pop ebp ;从 stackC3 retn 恢复基指针;返回调用者函数 现在,您可能想知道 - 函数如何相互通信?调用函数时如何发送/访问参数,以及如何接收返回值?这正是我们有调用约定的原因。调用约定基本上是用于与函数通信的协议,它们有一些变体,但它们共享相同的原理。我们将查看 __cdecl(C 声明)约定,这是编译 C 代码时的标准约定。在 __cdecl(32 位)中,函数参数在堆栈上传递(以相反的顺序推送),而返回值在 EAX 寄存器中返回(假设它不是浮点数)。 6A 03 push 36A 02 push 26A 01 push 1E8 XX XX XX XX 调用函数
假设 func() 只是对参数进行加法并返回结果,它可能看起来像这样: int __cdecl func(int, int, int): prologue:55 push ebp ;保存基指针8B EC mov ebp, esp ;新的栈帧体:8B 45 08 mov eax, [ebp+8] ;将第一个参数加载到 EAX(返回值)03 45 0C add eax, [ebp+0Ch] ;添加第二个参数 03 45 10 添加 eax, [ebp+10h] ;添加第三个参数结语:5D pop ebp ;恢复基指针C3 retn ;返回给来电者 如果您一直在关注但仍然感到困惑,您可能会问自己以下两个问题中的一个: 1) 为什么我们必须将 EBP 调整 8 才能得到第一个参数?如果你检查我们之前提到的调用指令的定义,你会意识到,在内部,它实际上是将 EIP 压入堆栈。如果您还检查 push 的定义,您会发现它将 ESP(在序言之后复制到 EBP)减少了 4 个字节。另外,prologue 的第一条指令也是 push,所以我们最终得到了 4 的 2 次减量,因此需要加上 8。 2)prologue 和epilogue 发生了什么,为什么它们看起来是“截断”的?这仅仅是因为我们在函数执行期间没有使用过堆栈——如果你注意到了,我们根本没有修改 ESP,这意味着我们也不需要恢复它。
为了演示流程控制汇编指令,我想再添加一个示例来展示如何将 if 条件编译为汇编。 void print_equal ( int a , int b ) { if ( a == b ) { printf ( "equal"); } else { printf ( "nah" );编译后,这是我在 IDA 的帮助下得到的反汇编: void __cdecl print_equal(int, int): 10000000 55 push ebp 10000001 8B EC mov ebp, esp 10000003 8B 45 08 mov eax, ;加载第一个参数 10000006 3B 45 0C cmp eax, [ebp+0Ch] ;将其与第二个 ┌┅ 10000009 75 0F jnz short loc_1000001A 进行比较;如果不相等则跳转 ┊ 1000000B 68 94 67 00 10 推偏移 aEqual ; “相等” ┊ 10000010 E8 DB F8 FF FF call _printf ┊ 10000015 83 C4 04 add esp, 4┌─┊─ 10000018 EB 0D jmp short loc_10000027│10┊10┊027│0┊10┊027│0┊10┊10┊10┊10┊10┊10┊10┊10┊10┊10┊10┊7 "nah"│ 1000001F E8 CC F8 FF FF call _printf│ 10000024 83 C4 04 add esp, 4│└── loc_10000027: 10000027 5D pop ebp 10000028 试试这个简单的C3输出为了,我已经更改了真实地址并将函数改为从 10000000 开始)。如果您想知道 add esp, 4 部分,它只是将 ESP 调整回其初始值(与 pop 效果相同,但不修改任何寄存器),因为我们必须推送 printf 字符串参数。现在让我们继续讨论数据是如何存储的(尤其是整数和字符串)。
字节序是表示计算机内存中值的字节序列的顺序。作为参考,x86 系列处理器(您可以找到的几乎所有计算机上的处理器)总是使用小端。为了给你一个关于这个概念的真实例子,我编译了一个 Visual Studio C++ 控制台应用程序,在那里我声明了一个 int 变量,赋值为 1337,然后我在主函数上使用 printf() 打印了变量的地址.然后我运行附加到调试器的程序,以检查内存十六进制视图上打印变量的地址,这是我获得的结果:详细说明这一点 - int 变量长度为 4 个字节(32 位)(以防万一不知道),所以这意味着如果变量从地址 D2FCB8 开始,它将在 D2FCBC (+4) 之前结束。十进制:1337 -> 十六进制:539 -> 字节:00 00 05 39 -> little-endian:39 05 00 00 这部分很有趣,但相对简单。您在这里应该知道的是,整数签名(正/负)通常是在称为二进制补码的概念的帮助下在计算机上完成的。
它的要点是整数的最低/前半部分是为正数保留的,而最高/后半部分是为负数保留的,这是十六进制中的样子,对于 32 位有符号整数(突出显示 = 十六进制, 括号内 = 十进制):如果您已经注意到,我们的价值总是在上升。无论我们以十六进制还是十进制上升。这就是这个概念的关键点——算术运算不需要做任何特殊的事情来处理签名,他们可以简单地将所有值视为无符号/正数,结果仍然会被正确解释(只要我们不去INT_MAX 或 INT_MIN 之外),这是因为整数也会在设计上溢出/下溢时“翻转”,有点像模拟里程表。提示:Windows 计算器是一个非常有用的工具 - 您可以将其设置为程序员模式并将大小设置为 DWORD(4 字节),然后输入负十进制值并以十六进制和二进制形式显示它们,并享受对它们执行操作的乐趣。在 C 中,字符串存储为字符数组,因此,这里没有什么特别需要注意的,除了称为空终止的东西。如果您想知道 strlen() 如何能够知道字符串的大小,这非常简单 - 字符串有一个指示其结束的字符,即空字节/字符 - 00 或 '\0'。例如,如果你在 C 代码中声明一个字符串常量,然后在 Visual Studio 中将鼠标悬停在它上面,它会告诉你生成的数组的大小,正如你所看到的,由于这个原因,它比“可见”多一个元素' 字符串大小。注意:字节序概念不适用于数组,仅适用于单个变量。因此,内存中的字符顺序在这里是正常的 - 从低到高。
既然您知道了所有这些,那么您可能就可以开始理解一些机器代码,并在某种程度上用您的大脑模拟 CPU。让我们以 print_equal() 为例,但这次我们只关注 printf() 调用指令。 void print_equal(int, int):... 10000010 E8 DB F8 FF FF call _printf... 1000001F E8 CC F8 FF FF call _printf 你可能想知道 - 等一下,如果这些是相同的指令,那为什么他们的字节不同吗?那是因为,调用(和 jmp)指令(通常)将偏移量(相对地址)作为参数,而不是绝对地址。偏移量基本上是当前位置和目的地之间的差异,这也意味着它可以是负数或正数。如您所见,采用 32 位偏移量的调用指令的操作码是 E8,后跟所述偏移量 - 这构成了完整的指令:E8 XX XX XX XX。
拿出你的计算器,你怎么这么早就关了?!并计算两条指令的偏移量之间的差异(不要忘记字节序)。你会注意到这个差异(的绝对值)与指令地址之间的差异(1000001F - 10000010 = F)相同:我们应该添加的另一个小细节是CPU只在之后执行一条指令完全“读取”它,这意味着当 CPU 开始“执行”时,EIP(指令指针)已经指向要执行的下一条指令。这就是为什么这些偏移实际上会考虑到这种行为,这意味着为了获得目标函数的真实地址,我们还必须添加调用指令的大小: 5. 现在让我们应用所有这些步骤来解决printf()在例子中第一条指令的地址: 1) 从指令中提取偏移量:E8 (DB F8 FF FF) -> FFFFF8DB (-1829) ...┌─── 10000018 EB 0D jmp short loc_10000027 ...└── loc_10000027: 10000027 5D pop ebp...
这个例子中唯一的区别是 EB XX 是一个短版本的 jmp 指令——这意味着它只需要一个 8 位(1 个字节)的偏移量。而已!您现在应该有足够的信息(希望还有动力)开始逆向工程可执行文件的旅程。首先编写虚拟 C 代码,编译并调试它,同时单步执行反汇编指令(顺便说一下,Visual Studio 允许您执行此操作)。之后,您可以在 Ghidra 和 IDA 等反汇编器和 x64dbg 等调试器的帮助下,尝试使用封闭源代码的本机二进制文件。注意:如果您发现本文的信息不准确或有改进的空间,并希望改进它,请随时在 GitHub 上提交拉取请求。