实现逆向工程——基本代码的逆向工程模式

112 阅读13分钟

在本章中,我们将编写一些小片段的代码并进行编译,以理解汇编输出。我们将逐步跟随汇编代码中的指令,理解从汇编角度的代码执行流程。

在本章对小片段代码进行编译的过程中,我们将在 32 位环境下使用 Microsoft 编译器。所有程序均在 Microsoft Windows 32 位环境中编译。我们还将在分析时启用代码优化。

结构

本章将涵盖以下主题:

  • 什么是代码优化
  • 理解 C/C++ 程序的汇编模式
  • 代码优化的概念
  • 用于生成 C/C++ 程序汇编模式的工具

目标

学习本章后,你应能够:

  1. 理解代码优化及其重要性
  2. 比较有优化与无优化的汇编代码

什么是代码优化?

优化意味着以最佳方式利用资源。代码优化是指对源代码进行转换,去除不必要的部分,以便在执行时消耗更少的资源(如内存、CPU 等)。当编译器对代码进行优化时,应确保:

  1. 优化前后的代码含义不变;
  2. 优化后的代码消耗更少资源;
  3. 优化过程不会显著影响编译时间。

让我们从一个简单的 C/C++ 程序开始,逐渐过渡到更复杂的程序。这一过程将帮助你理解 C/C++ 应用程序在汇编层面的模式。

空函数

空函数是指不执行任何操作的函数。下面我们在 C/C++ 代码中定义一个空函数 EmptyFunction()

image.png

无优化的空函数

现在,使用 MSVC 编译器(cl.exe)在无优化模式下编译它。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:

  • /Fa:指定输出的汇编列表文件名
  • /Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\EmptyFunction\EmptyFunction

cl EmptyFunction.cpp /FaEmptyFunction.asm /FeEmptyFunction.exe

下面是运行上述命令后的输出:

image.png

下面是未启用优化时生成的汇编代码:

image.png

我们将逐行分析汇编代码,以理解代码模式的含义和工作方式。

▼第 1 行

; Listing generated by Microsoft (R) Optimizing Compiler Version 16.00.30319.01

这一行是注释,因为其以分号开头。该注释说明用于生成汇编代码的编译器是 Microsoft® 32 位 C/C++ 优化编译器 Version 16.00.30319.01(面向 80x86 平台)。编译器本质上将程序从一种语言翻译到另一种语言;而“优化编译器”则在此基础上对代码进行改进,以提升运行效率。

▼第 3 行

TITLE C:\JitenderN\REBook\EmptyFunction\EmptyFunction\EmptyFunction.cpp

TITLE 定义了所编译的 C/C++ 源文件的绝对路径。

▼第 4 行

.686P

此伪指令启用针对 Pentium Pro 处理器的全部指令集扩展(仅适用于 32 位 MASM)。

▼第 5 行

.XMM

此伪指令表示生成的代码需要支持 Streaming SIMD Extensions(流式 SIMD 扩展)指令集的 CPU。

▼第 6 行

include listing.inc

listing.inc 文件包含了一些汇编器宏(macros),用于在汇编语言中实现模块化编程。为了提升性能并保证代码对齐,Visual C++ 并不会将这些宏展开到最终的汇编代码中。你可以在 Visual C++ 的 include 目录下找到该文件。

image.png

▼第 7 行

.model flat

这是启用平坦内存模型(flat memory model)的指令。要理解内存模型,需要知道内存访问有三种方式:

  • 平坦(Flat) :非分段内存模型,程序将整个内存视为一个连续的大字节数组。代码、数据和堆栈都位于同一地址空间。
  • 分段(Segmented) :将内存划分为若干“段”(segment),每个段有独立的地址空间。
  • 真实模式(Real) :一种非常古老的内存模型,用于 Intel 8086 处理器。

▼第 9–10 行

INCLUDELIB LIBCMT
INCLUDELIB OLDNAMES

通过 INCLUDELIB 指令,我们将链接位于系统库目录下的 LIBCMT.LIBOLDNAMES.LIB 两个库文件。

image.png

▼第 12 行

PUBLIC _main

PUBLIC 是一个指令,用于将名为 _main 的过程声明为公共可见(public)。汇编器指令(directive)可以自动化汇编流程,并提高代码可读性。所有函数名在汇编里都以下划线开头;将 main 标记为公共函数意味着其他模块也能引用它。

▼第 13 行

; Function compile flags: /Odtp

所有注释都以分号开头。这一行说明本代码是使用 /Odtp 开关编译的。

▼第 14 行

_TEXT SEGMENT

这是文本段(text segment,也即代码段)的开始。

▼第 15 行

_main PROC

过程用 PROC 指令开始,必须用 ENDP 结束;在第 25 行可看到 _main ENDP

▼第 16 行

; File c:\jitendern\rebook\emptyfunction\emptyfunction\emptyfunction.cpp

注释,表明对应的 C/C++ 源文件路径。

▼第 17 行

; Line 9

注释,指出接下来这条汇编指令对应源文件的第 9 行。

▼第 18–19 行

push ebp  
mov ebp, esp

这是函数序言(prologue),在函数开始时保存旧的基指针并建立新的基指针。

▼第 20–21 行

; Line 10  
xor eax, eax

xor 为异或运算;EAX 寄存器用于存放函数返回值。由于 main 要返回 0,xor eax,eax 会将 EAX 清零。虽然也可以用 mov eax,0,但 xor 的机器码更短(2 字节 vs. 5 字节)。

▼第 22 行

; Line 11

注释,指出下一条指令对应源文件的第 11 行。

▼第 23 行

pop ebp

这是函数尾序(epilogue)的一部分,用于恢复调用前的基指针。

▼第 24 行

ret 0

RET 是返回指令,将指令指针恢复到调用者处。可选的 nBytes 操作数表示在返回后将 ESP 增加的字节数;这里是 0,表示不调整栈指针。

▼第 25–26 行

_main ENDP  
_TEXT ENDS

分别标志着 _main 过程的结束和文本段(代码段)的结束。

▼第 27 行

PUBLIC ?EmptyFunction@@YAXXZ    ; EmptyFunction

汇编内部使用“修饰名”(decorated name)表示函数:它在函数名后附加调用约定、返回类型、参数列表等信息,帮助链接器在链接时找到正确的符号。这里同样用 PUBLICEmptyFunction 声明为公共可见。

▼第 28 行

; Function compile flags: /Odtp

同第 13 行,说明该函数同样是以 /Odtp 开关编译的。

▼第 29 行

_TEXT SEGMENT

文本段(代码段)再次开始,用于放置 EmptyFunction 的汇编代码。

▼第 30 行

?EmptyFunction@@YAXXZ PROC      ; EmptyFunction

EmptyFunction 过程以 PROC 开始。

▼第 31 行

; Line 15

注释,指出下一条指令对应源文件的第 15 行。

▼第 32–33 行

push ebp  
mov ebp, esp

这是 EmptyFunction 的函数序言,作用同前。

▼第 34 行

; Line 16

注释,指出下一条指令对应源文件的第 16 行。

▼第 35 行

pop ebp

EmptyFunction 的函数尾序,恢复基指针。

▼第 36 行

ret 0

因为 EmptyFunction 是空函数,不修改栈指针,直接返回;0 表示 ESP 无需调整。

▼第 37–38 行

?EmptyFunction@@YAXXZ ENDP      ; EmptyFunction  
_TEXT ENDS

分别标志着 EmptyFunction 过程的结束和文本段的结束。

▼第 39 行

END

END 指令标志着汇编源文件的结束。

启用优化的空函数

现在使用 x86 平台上的 /Ox 开关来编译带优化的代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:

  • /Ox:启用最大优化
  • /Fa:指定输出的汇编列表文件名
  • /Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\EmptyFunction\EmptyFunction

cl EmptyFunction.cpp /FaEmptyFunction-Optimized.asm /Ox /FeEmptyFunction-Optimized.exe

下面是运行上述命令后的输出:

image.png

我们得到的汇编代码如下:

image.png

正如我们所见,当启用优化后,编译器会去除不必要的指令,而不改变代码的含义。如今的编译器在优化方面表现出色。因此,作为逆向工程师,更应关注代码背后的思路和逻辑,而不是逐行还原源代码;只要理解了逻辑,就能编写相应的原型。

回到这段汇编,可以看到大部分指令与前面分析的内容相同,此处不再重复。我们重点关注与优化相关的指令。

▼第 18 行

xor eax, eax

main 函数通过 xor eax, eax 将 EAX 清零以返回 0,因为 EAX 寄存器用于存放函数的返回值。

▼第 28 行

ret 0

EmptyFunction 只用一条 RET 指令将指令指针返回给调用者。

返回值

在本节中,我们将在 C/C++ 代码中创建一个函数来返回一个常量值。我们将定义并声明一个 ReturningValue() 函数。

image.png

无优化的返回值

在同一台 x86 Windows 机器上,使用 MSVC 编译器在无优化模式下编译此代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:

  • /Fa:指定输出的汇编列表文件名
  • /Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\ReturningValue\ReturningValue

cl ReturningValue.cpp /FaReturningValue.asm /FeReturningValue.exe

下面是运行上述命令后的输出:

image.png

下面是编译后得到的内容:

image.png

我们在前面已经讨论了大部分指令,这里只关注主要指令。

▼第 20–24 行

; Line 10  
xor eax, eax  
; Line 11  
pop ebp  
ret 0

这是来自 main 函数的指令:先用 xor eax, eax 将 EAX 清零(返回值为 0),然后通过 pop ebp 完成函数尾序,最后 ret 将执行流返回给调用者,调用者即可从 EAX 寄存器中取走返回值。

▼第 34–38 行

; Line 15  
mov eax, 2020    ; 000007E4H  
; Line 16  
pop ebp  
ret 0

切换到 ReturningValue 函数:先用 mov eax, 2020 将常量 2020 装入 EAX(函数返回值),然后 pop ebp 进行尾序,最后 ret 将执行流返回给调用者,调用者同样从 EAX 中获取结果。

启用优化的返回值函数

在 x86 平台上,使用 MSVC 编译器的 /Ox 开关编译带优化的代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe 环境,然后使用这些开关编译:

  • /Ox:启用最大优化
  • /Fa:指定输出的汇编列表文件名
  • /Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\ReturningValue\ReturningValue

cl ReturningValue.cpp /FaReturningValue-Optimized.asm /Ox /FeReturningValue-Optimized.exe

下面是运行上述命令后的输出:

image.png

生成的汇编代码如下:

image.png

我们可以看到,优化后的代码中所有不必要的指令都已被移除。

主函数只剩下两条指令:
▼第 18、20 行

xor eax, eax  
ret 0

先用 xor eax, eax 将 EAX 清零(作为返回值),然后用 ret 指令将指令指针返回给调用者。

ReturningValue 函数同样只剩下两条指令:
▼第 28、30 行

mov eax, 2020    ; 000007E4H  
ret 0

mov eax, 2020 将返回值 2020 装入 EAX,ret 指令将指令指针返回给调用者。

基本的 “Hello, World” 程序

在这个简单的 C/C++ 代码中,我们仅在控制台上打印 “hello, world”,使用的是 printf() 函数。

image.png

无优化的基础 “Hello, World” 程序

在 x86 平台上使用 MSVC 编译器在无优化模式下编译此代码。请在 Windows 命令提示符下运行以下命令,先设置 cl.exe(VS 编译器)的环境,然后使用如下开关编译代码:

  • /Fa:指定输出的汇编列表文件名
  • /Fe:指定生成的可执行文件名
cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\HelloWorld\HelloWorld

cl HelloWorld.cpp /FaHelloWorld.asm /FeHelloWorld.exe

下面是运行上述命令后的输出:

image.png

以下是生成的汇编代码,下面我们将进行分析:

image.png

▼第 1–10 行
我们在“空函数”章节中已讨论过这部分内容。

▼第 12–14 行

CONST SEGMENT
$SG4677 DB 'hello, world', 0aH, 00H
CONST ENDS

字符串常量(此处为 “hello, world”)被分配到常量段中。CONST SEGMENT 指令用于定义常量段在内存中的起始位置。在我们的示例里,链接器将 CONST SEGMENT 重命名为了 .rdata,可以在调试器中导出查看。下图显示了我们在 x32dbg 中打开编译后生成的 EXE 文件,并使用 CFF Explorer 禁用了地址空间布局随机化(ASLR)。请按照附录中的步骤对 EXE 文件禁用 ASLR,这样每次在调试器中加载该 EXE 时,它都会映射到相同的内存地址。

image.png

$SG4677 是编译器为处理该字符串常量而自动生成的内部名称。
DB(Define Byte)用于定义字节数据类型。
'hello, world', 0aH, 00H 则是以空字符结尾的 ASCII 字符串数据。
CONST ENDS 时,常量段结束。

▼第 15 行

PUBLIC _main

PUBLIC 指令将 _main 过程声明为公共,可被其他模块访问。

▼第 16 行

EXTRN _printf:PROC

注意:代码放在 .code 段,常量字符串放在 CONST(即链接后的 .rdata)段,非常量数据则放在 .data 段。
EXTRN 指令声明了外部函数,此处为 printf。所有函数名汇编中都以下划线开头。

▼第 18 行

_TEXT SEGMENT

开始 _TEXT 段(代码段),存放 main 函数的汇编代码。

▼第 19 行

_main PROC

main 过程开始。

▼第 20–23 行

; File c:\jitendern\rebook\helloworld\helloworld\helloworld.cpp  
; Line 8  
push ebp  
mov ebp, esp

注释以分号开头,一条注明源文件路径,一条注明对应的源代码行号。紧接着 push ebpmov ebp, esp 构成函数序言。

▼第 25–27 行

push OFFSET $SG4677  
call _printf  
add esp, 4

调用 printf 前,用 push 将字符串常量地址压入栈;call 指令调用 printf
printf 返回后,控制流回到 main,此时栈上仍有字符串指针,需要调用者(遵循 CDECL 约定)清理栈,于是执行 add esp, 4,将 ESP 增加 4 字节以移除该指针。32 位程序的指针大小为 4 字节,也可用 pop <寄存器> 来等价地弹出该地址。

▼第 28–29 行

; Line 10  
xor eax, eax

main 要返回 0,EAX 寄存器用于存放返回值,因此用 xor eax, eax 将其清零。

▼第 30–32 行

; Line 11  
pop ebp  
ret 0

pop ebp—函数尾序;ret 0 将指令指针返回给调用者(此处不会调整栈指针,因为为 0)。

▼第 33 行

_main ENDP

关闭 _main 过程。

▼第 34–35 行

_TEXT ENDS  
END

结束代码段和汇编源文件。

基本 “Hello, World” 程序(启用优化)

在 x86 平台上,使用 MSVC 编译器的 /Ox 开关启用最大优化,编译命令与前类似:

cd "C:\Program Files\Microsoft Visual Studio 10.0\VC"
vcvarsall.bat

cd C:\JitenderN\REBook\HelloWorld\HelloWorld

cl HelloWorld.cpp /FaHelloWorld-Optimized.asm /Ox /FeHelloWorld-Optimized.exe

下面是运行上述命令后的输出:

image.png

生成的汇编代码如下:

image.png

所有代码行与上一节讨论的完全相同。唯一的区别在于,为了减少资源消耗(内存、CPU 等),编译器已移除了函数的序言和尾序代码。其余指令与未优化版本一致。需要注意的是,代码的含义与未优化时完全相同——我们仍然将指向常量字符串的指针压入栈中以调用 printf 函数。printf 执行完毕后,会将控制流返回到 main(调用者),由调用者清理栈并将 EAX 置零,作为返回值。

结论

在本章中,我们理解了代码优化的概念。通过空函数、返回常量值的函数以及打印 “hello, world” 程序的示例,我们比较了优化与未优化代码的汇编清单。下一章,我们将讨论包含 printf 函数的程序的优化策略,并探讨整数、浮点和字符变量在内存中的存储方式。