实现逆向工程——Printf 程序的逆向工程模式

164 阅读17分钟

每当我们编写程序时,都会使用 printf 函数在输出屏幕上打印各种信息。这些信息可能是面向程序最终用户的提示,也可能是调试用途的日志,或者是一条简单的欢迎消息。恶意软件或病毒编写者也遵循同样的逻辑:程序中会使用 printf 来输出他们需要的信息。因此,作为逆向工程师,在分析任何以病毒或恶意软件形式存在的程序时,都应当了解其 printf 调用模式。大多数病毒或恶意软件在编写时,会打印一些字符串供其自身用途或用来触发目标行为。

因此,理解那些使用 printf 在控制台打印消息的程序是非常重要的。同时,我们还将讨论 printf 在不同变量类型上的使用方式。不同类型的变量在内存中占用的空间各不相同,这也是一个有趣的知识点。本章选取几个使用 printf 打印整数、浮点数和字符的 C/C++ 程序,分别进行逆向分析,以便掌握 printf 程序的汇编模式。

结构

本章将包含以下内容:

  • printf 打印整数时的汇编模式
  • printf 打印浮点数时的汇编模式
  • printf 打印字符时的汇编模式

目标

通过学习本章,你将能够:

  • 理解在包含 printf 调用的程序中,代码优化前后汇编的差异;
  • 掌握整数、浮点和字符变量在内存中的存储方式;
  • 了解浮点变量相较于整数或字符在处理上的不同之处,并通过具体示例进行说明。

使用 printf 打印整数

在下面这个简单的 C/C++ 代码示例中,我们将在控制台上打印 4 个整数,演示如何使用 printf 输出整数值。

image.png

无优化的 printf 打印整数

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

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

cd C:\JitenderN\REBook\printfWithIntegers\printfWithIntegers

cl printfWithIntegers.cpp /FaPrintfWithIntegers.asm /FePrintfWithIntegers.exe

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

image.png

生成的汇编代码如下:

image.png

正如我们在第 6 章“基本代码的逆向工程模式”中已讨论过汇编代码的前几条指令,下面从第 12 行开始分析。

▼第 12–14 行

CONST SEGMENT  
$SG4677 DB 'integer1=%d; integer2=%d; integer3=%d, integer4=%d', 00H  
CONST ENDS

这是内存中常量段(CONST SEGMENT)的开始和结束,段内定义了字符串常量 $SG4677。链接器会将 CONST SEGMENT 重命名为 .rdata,可以使用任何调试器导出该段来查看字符串常量。例如,在 x32dbg 中,你可以这样查看:

image.png

▼第 15 行

PUBLIC _main

所有函数名在汇编中都以下划线开头。main 函数被标记为公共(public),意味着可以被其他模块访问。

▼第 16 行

EXTRN _printf:PROC

EXTRN 指令用于声明外部符号,此处声明了名为 _printf 的外部过程(procedure)。
小贴士EXTRN 的语法为 标签:类型,其中标签可以是变量或函数,类型可选项如下表所示:

类型含义
BYTE8 位变量
WORD16 位变量
DWORD32 位变量
QWORD64 位变量
PROC过程(函数)

▼第 18–19 行

_TEXT SEGMENT  
_main PROC

这两行标志 _TEXT 段(即代码段)的开始,以及 main 过程的起始位置。在 x32dbg 调试器中,可以看到 printfWithIntegers.exe.text 段从地址 0x00401000 开始,而 main 函数的汇编代码也从该地址处开始。

image.png

▼第 20–23 行

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

与之前相同,注释说明了对应的 C/C++ 源文件路径及源代码行号。接着 push ebpmov ebp, esp 构成 main 函数的序言。

▼第 25–30 行

push 4  
push 3  
push 2  
push 1  
push OFFSET $SG4677  
call _printf

从这里开始,就有趣了。所有传给 printf 的参数都以相反的顺序压入栈中,每个参数类型都是整数。在 32 位环境下,每个整数占用 4 个字节。要了解执行时栈的状态,可以在 x32dbg 调试器中,在调用 printf 的那一行设置断点。程序运行至断点时,查看栈内容,就能直观地看到各个参数是如何被依次压入栈中的。

image.png

我们可以看到,整数 4 第一个被压入栈中,随后依次是 3、2 和 1。以下是在断点处栈状态的说明:

0012FF2C  00408140  "integer1=%d; integer2=%d; integer3=%d, integer4=%d"
0012FF30  000000011int 类型参数已压入栈
0012FF34  000000022int 类型参数已压入栈
0012FF38  000000033int 类型参数已压入栈
0012FF3C  000000044int 类型参数已压入栈

在地址 0x0012FF2C 处,常量字符串的指针(0x00408140)被压入栈中。可以在 x32dbg 中转储该地址来查看字符串常量 $SG4677

image.png

当所有参数都压入栈后,通过以下指令调用 printf 函数:
▼第 30 行

call _printf

这将执行 printf 函数,函数执行完毕后,指令指针会返回到调用者处。

▼第 31 行

add esp, 20    ; 00000014H

由于我们使用的是 CDECL 调用约定,调用者负责清理栈空间。因此,从 printf 返回后,执行 add esp, 20 将栈指针 ESP 向上移动 0x20(32)字节,以清理之前压入的参数。

这 20 字节的计算方法为:4 个整数参数各占 4 字节,加上 1 个指向常量字符串的指针同样占 4 字节,总共 5 × 4 = 20 字节(十六进制为 00000014H)。

接下来,我们来观察栈上残留的“垃圾”数据。为了理解这一点,可以在 add esp, 20 之后的下一条指令处设置断点,此时在栈上会看到类似以下内容:

image.png

调用者按照 CDECL 调用约定负责清理栈空间,通过 add esp, 20 指令将 ESP 向上移动 20 字节来完成这一操作。如图所示,ESP 被恢复到 0x0012FF40,但所有参数及指向常量字符串的指针依然留在栈上,这些值并未被清零或删除。在 ESP 之上的所有内容都不再有意义,成为“垃圾”。

▼第 34–39 行

; Line 9  
xor eax, eax  
; Line 10  
pop ebp  
ret 0  
_main ENDP  
_TEXT ENDS  
END

其余指令与前面各章讨论的相同:main 函数通过将 EAX 清零来返回 0;END 语句标志程序结束。

启用优化的 printf 打印整数

在 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\printfWithIntegers\printfWithIntegers

cl printfWithIntegers.cpp /FaPrintfWithIntegers-Optimized.asm /Ox /FePrintfWithIntegers-Optimized.exe

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

image.png

生成的汇编代码如下:

image.png

在优化后的代码中,所有内容都保持不变,唯独编译器移除了主函数的序言和尾序代码,以减少资源消耗。

使用 printf 打印浮点数

大多数计算使用整数完成,但在精度要求较高的场景下,浮点数发挥着重要作用。早期的 x86 处理器家族配备了用于浮点运算的数学协处理器;后来,这一功能被集成到了微处理器内部。该集成单元用于处理浮点数,称为浮点运算单元(FPU)。要支持浮点数运算,需要满足两点:

  1. 必须有空间存储浮点数;
  2. 必须有指令来处理并对浮点数进行运算。

在存储方面,FPU 拥有 8 个寄存器,组成一个栈结构(从 ST0 到 ST7),因此也被称为 “x87 寄存器堆栈” 或简称 “x87 栈”。用于浮点运算的指令统称为 “x87 指令集”。

浮点数通常分为两类:float 类型占 32 位,double 类型占 64 位。为了获得更高的运算精度,FPU 的栈寄存器宽度为 80 位。接下来,我们将通过一个简单的 C/C++ 示例,使用 printf 在控制台上打印两个浮点数,以便观察其汇编模式。

image.png

无优化的 printf 打印浮点数

在同一台 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\printfWithFloat\printfWithFloat

cl printfWithFloat.cpp /FaPrintfWithFloat.asm /FePrintfWithFloat.exe

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

image.png

生成的汇编代码如下:

image.png

让我们逐行分析生成的汇编代码:

▼第 1–10 行

; Listing generated by Microsoft (R) Optimizing Compiler Version 16.00.30319.01  
TITLE C:\JitenderN\REBook\printfWithFloat\printfWithFloat\printfWithFloat.cpp  
.686P  
.XMM  
include listing.inc  
.model flat  
INCLUDELIB LIBCMT  
INCLUDELIB OLDNAMES

前面章节已经讨论过这些指令,此处不再赘述。

▼第 12–14 行

CONST SEGMENT  
$SG4677 DB 'float1=%f, float2=%f', 00H  
CONST ENDS

这是由链接器命名为 .rdata 的常量段(CONST SEGMENT)开始和结束标记。编译器使用 $SG4677 作为该字符串常量的内部名称。DB(Define Byte)用于定义字节数据,字符串以空字符(00H)结尾。可以通过导出 .rdata 段来查看该常量字符串。

image.png

▼第 15–16 行

PUBLIC __real@3ff0000000000000  
PUBLIC __real@40011eb851eb851f

浮点数在二进制中有标准化的表示格式。本例中,编译器将两个双精度常量(分别对应十进制的 1.0 和 2.2)声明为公共符号,名称中即包含了它们的 IEEE 754 二进制编码。

浮点数有三种主要的存储格式:

  • REAL4:32 位,单精度(short real 或 single precision)
  • REAL8:64 位,双精度(long real 或 double precision)
  • REAL10:80 位,扩展精度(temporary real 或 extended precision)

它们各自的数据布局格式如下:

  • REAL4(IEEE 754 单精度)的二进制格式:

    • 1 位符号
    • 8 位指数(偏移量 127)
    • 23 位尾数(有效数字),隐含最高位为 1
  • REAL8(IEEE 754 双精度)的二进制格式:

    • 1 位符号
    • 11 位指数(偏移量 1023)
    • 52 位尾数,隐含最高位为 1
  • REAL10(x87 80 位扩展精度)的二进制格式:

    • 1 位符号
    • 15 位指数(偏移量 16383)
    • 1 位显式整型位(整数部分)
    • 63 位尾数(有效数字)

以上格式使得浮点运算在硬件层面保持一致性和可移植性。

image.png

其中:

  • S = 符号位(0 表示正数,1 表示负数)
  • E = 指数位
  • F = 尾数的有效数字位

REAL8 的格式如下:

image.png

REAL10(扩展精度)的格式如下:

[15 位指数][1 位整数部分][63 位尾数]
  • 符号位 S(与前同)
  • 指数位 E:15 位,偏移量 16383
  • 整数位 I:1 位,显式存储最高有效位
  • 尾数位 F:63 位,表示小数部分
    此结构确保 80 位扩展精度浮点数的高精度表示。

image.png

为简洁易懂起见,我们将不详细讨论各个格式。牢记我们代码中使用的是 REAL8(64 位双精度实数)格式,我们来看以下指令:

PUBLIC __real@3ff0000000000000
PUBLIC __real@40011eb851eb851f

PUBLIC 指令用于将变量声明为公共符号,使其可被其他模块访问。__real 表示实数格式,后面跟着的是我们函数参数的十六进制表示。下面我们使用在线转换工具(gregstoll.com/~gregstoll/…)将这两个十六进制值转换成浮点数:

image.png

这个十六进制值 3ff0000000000000 相当于我们在 C/C++ 代码中 float1(1.0)参数的值。

image.png

这个十六进制值 40011eb851eb851f 对应于我们 C/C++ 代码中的 float2(2.14)参数。

▼第 17 行

PUBLIC _main

main 函数声明为公共函数,使其可被其他模块访问。

▼第 18–20 行

EXTRN _printf:PROC  
EXTRN __fltused:DWORD  
; COMDAT __real@3ff0000000000000

EXTRN 指令声明外部符号,此处包括 printf 函数和 __fltused(用于浮点)。所有函数名以下划线开头。

▼第 22–29 行

CONST SEGMENT  
__real@3ff0000000000000 DQ 03ff0000000000000r  ; 1  
CONST ENDS  
; COMDAT __real@40011eb851eb851f  
CONST SEGMENT  
__real@40011eb851eb851f DQ 040011eb851eb851fr  ; 2.14  
; Function compile flags: /Odtp  
CONST ENDS

这是常量段(由链接器重命名为 .rdata)的开始和结束。在该段内存储了函数的浮点参数:1.02.14。由于 x86 是小端(Little-Endian)存储,十六进制 40011eb851eb851f 会以字节序列 1F 85 EB 51 B8 1E 01 40 保存在 .rdata 段(在 x32dbg 的地址 0x0040C158 可见)。如果你在 .rdata 段看到其他浮点符号,不要着急,接下来的指令会说明它们的用途。

image.png

▼第 30–34 行

_TEXT SEGMENT  
_main PROC  
; Line 7  
push ebp  
mov ebp, esp

在文本段(TEXT segment)中,main 过程以函数序言开始。

▼第 36 行

sub esp, 8

在栈上为 main 函数的局部变量分配 8 字节空间,此处用于存放传给 printf 的第三个参数(2.14)。

▼第 37 行

fld QWORD PTR __real@40011eb851eb851f

FLD 表示“浮点加载”(Floating Point Load),该指令会将指定的 64 位浮点常量压入 FPU 寄存器堆栈(从 ST0 到 ST7)。你可以在调试器中分别观察执行该指令前后的 FPU 栈状态。

小贴士
可以在 main 开始处设置断点,方法是滚动到 .text 段的顶部,找到与 printfWithFloat.asm 中相同的指令序列,设置断点后就可以逐条单步运行这些指令了。

image.png

指令执行后:

image.png

▼第 38 行

fstp QWORD PTR [esp]

FSTP 表示浮点存储并弹出(Floating Point Store and POP)。该指令将 FPU 寄存器堆栈顶(ST0)中的浮点值存储到栈顶地址 [esp] 所指向的内存位置,然后从 ST0 中弹出该值。

image.png

▼第 39 行

sub esp, 8

再次在栈上为 main 函数的局部变量分配 8 字节空间,此处用于存放传给 printf 函数的第二个参数(1.0)。下面是在 x32dbg 中的查看效果:

image.png

▼第 40 行

fld1

此指令将浮点常量 1.0 加载到 FPU 寄存器堆栈的栈顶(ST0)。

image.png

▼第 41 行

fstp QWORD PTR [esp]

FSTP 表示浮点存储并弹出(Floating Point Store and POP),它将 FPU 寄存器堆栈顶(ST0)中的浮点值(此处为 1.0)存储到栈顶地址 [esp] 所指向的内存位置,然后从 ST0 中弹出该值。

image.png

▼第 42 行

push OFFSET $SG4677

现在,在调用 printf 函数之前,需要将字符串常量压入栈中。这条 PUSH 指令就是将字符串常量 $SG4677 的地址压入栈中。

image.png

▼第 43 行

call _printf

在执行 CALL _printf 之前,所有 printf 的参数都已按顺序压入栈中。执行此指令后,两个浮点值将被打印到控制台。

▼第 44 行

add esp, 20    ; 00000014H

由于遵循 CDECL 调用约定,调用者负责清理栈空间。从 printf 返回后,main 通过将 ESP 增加 20 字节来清理栈:

  • 4 字节用于字符串常量指针
  • 8 字节用于存放 1.0 的 80 位(按 8 字节对齐)
  • 8 字节用于存放 2.14 的 80 位(按 8 字节对齐)

在执行此 add 指令前,你可以在 x32dbg 中看到如下栈布局:

小贴士:要直接跳到 add 指令处,可以使用“Step Over”执行完 printf 调用;或者在 add esp, 20 那一行设置断点,让程序在此处暂停。

image.png

在执行此指令之后:

image.png

▼第 45–52 行

; Line 9  
xor eax, eax  
; Line 10  
pop ebp  
ret 0  
_main ENDP  
_TEXT ENDS  
END

剩余指令将 EAX 置零,因为在 C/C++ 代码中 main 返回 0。最后通过函数尾序结束 main、关闭 _TEXT 段并结束程序。

启用优化的 printf 打印浮点数

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

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

cd C:\JitenderN\REBook\printfWithFloat\printfWithFloat

cl printfWithFloat.cpp /FaPrintfWithFloat-Optimized.asm /Ox /FePrintfWithFloat-Optimized.exe

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

image.png

生成的汇编代码如下:

image.png

列表中的所有指令都与之前相同,唯独第 33 行发生了变化。由于启用了优化,函数的序言和尾序代码被移除,下面我们详细说明这一部分。

▼第 33 行

fld QWORD PTR __real@40011eb851eb851f

此指令与之前相同,将浮点值 2.14 压入 FPU 寄存器堆栈的栈顶(ST0)。

▼第 34 行

sub esp, 16    ; 00000010H

在未优化代码中,sub esp,8 被调用两次,用于分别为两个浮点局部变量分配栈空间。但在优化模式下,编译器将它们合并为一次性从 ESP 中减去 16 字节,以同时为 1.0 和 2.14 分配 8+8 字节。

▼第 35 行

fstp QWORD PTR [esp+8]

将 FPU 栈顶(ST0)的浮点值 2.14 存储到 [esp+8] 所指向的内存,然后将该值从 ST0 弹出。

▼第 36 行

fld1

同前,它将浮点常量 1.0 加载到 FPU 栈顶(ST0)。

▼第 37 行

fstp QWORD PTR [esp]

将 FPU 栈顶(ST0)的浮点值 1.0 存储到 [esp] 所指向的内存,然后将该值从 ST0 弹出。

▼第 38–39 行

push OFFSET $SG4677  
call _printf

将字符串常量的地址压入栈中,并调用 printf 函数。

▼第 40–47 行

add esp, 20    ; 00000014H  
; Line 9  
xor eax, eax  
; Line 10  
ret 0  
_main ENDP  
_TEXT ENDS  
END

根据 CDECL 调用约定,调用者负责清理栈空间,即通过 add esp,20 恢复 ESP。剩余指令通过 xor eax,eax 将 EAX 清零以返回 0,并结束 main 过程、文本段和程序。

使用 printf 打印字符

在下面这个简单的 C/C++ 示例中,我们将在控制台上打印两个字符,依然使用 printf 函数。

image.png

无优化的 printf 打印字符

在同一台 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\printfWithChar\printfWithChar

cl printfWithChar.cpp /FaPrintfWithChar.asm /FePrintfWithChar.exe

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

image.png

生成的汇编代码如下:

image.png

大部分汇编清单的内容在前面已有讨论,这里从第 25 行开始。

▼第 25–28 行

push 98      ; 00000062H  
push 97      ; 00000061H  
push OFFSET $SG4677  
call _printf

在调用 printf 函数之前,先将三个参数依次压入栈中:

  1. push 98:98 是字符 'b' 的 ASCII 码,十六进制为 62H
  2. push 97:97 是字符 'a' 的 ASCII 码,十六进制为 61H
  3. push OFFSET $SG4677:将字符串常量 $SG4677 的地址压入栈中,该常量在 CONST SEGMENT 中定义。

在执行 call _printf 之前,可以在 x32dbg 中对这行设置断点,查看栈的状态,以确认各参数的顺序和数值。

image.png

  1. [esp] 00408140 —— 保存了常量字符串 $SG4677 的地址,对应内容为 "char1=%c, char2=%c"
  2. [esp+4] 00000061 —— 第一个字符参数 'a' 的 ASCII 码 0x61,被 PUSH 到栈上
  3. [esp+8] 00000062 —— 第二个字符参数 'b' 的 ASCII 码 0x62,被 PUSH 到栈上
  4. [esp+C] 0012FF88 —— 存放调用前的旧 EBP
  5. [esp+10] 00401209 —— CALL 返回地址,从 printfWithChar.00401000 返回到 printfWithChar.00401209

CALL 返回后,按照 CDECL 调用约定,调用者通过执行 ADD ESP, 12 清理栈空间。接着,main 函数通过 xor eax, eaxEAX 清零以返回 0(函数返回值保存在 EAX 中)。

启用优化的 printf 打印字符

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

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

cd C:\JitenderN\REBook\printfWithChar\printfWithChar

cl printfWithChar.cpp /FaPrintfWithChar-Optimized.asm /Ox /FePrintfWithChar-Optimized.exe

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

image.png

生成的汇编代码如下:

image.png

在优化后的代码中,除了省略了函数的序言和尾序外,其余部分完全相同。

结论

在本章中,我们学习了如何对包含 printf 调用的程序或应用进行逆向工程。同时讨论了在这些程序中进行代码优化的概念,并讲解了整数、浮点和字符变量在内存中的存储方式。浮点变量的处理方式与整数或字符有所不同。下一章,我们将探讨指针及其在逆向工程中的处理方法。