精通逆向工程(二)
原文:
annas-archive.org/md5/4edb9a969a22ca6c6dd931a00e8c6024译者:飞龙
第六章:在 Linux 平台上的逆向工程
很多我们的工具在 Linux 中都运行得很好。在上一章中,我们介绍了一些已经默认内置的 Linux 命令行工具。Linux 也已经安装了 Python 脚本语言。在本章中,我们将讨论一个用于分析 Linux 文件和托管 Windows 沙箱客户端的良好设置。
我们将通过探索逆向工具学习如何逆向一个 ELF 文件。我们将通过设置一个 Windows 沙箱客户端、在其中运行程序并监控来自沙箱的网络流量来结束本章。
并不是所有人都喜欢使用 Linux。Linux 是一个开源系统。它是一项将伴随我们的技术。作为逆向工程师,任何技术都不应该成为障碍,学会这项技术永远不会太晚。关于 Linux 系统的基础知识可以轻松地在互联网上找到。本章尽可能详细地描述了安装和执行所需内容的步骤,确保你能跟上。
本章将讨论以下内容:
-
理解 Linux 可执行文件
-
逆向一个 ELF 文件
-
Linux 中的虚拟化 – 在 Linux 主机下分析 Windows 可执行文件
-
网络流量监控
设置
本章讨论了 Linux 逆向工程,因此我们需要进行 Linux 设置。对于逆向工程,建议在裸机上部署 Linux。由于大多数已开发的分析工具基于 Debian,因此我们使用 32 位 Ubuntu Desktop。 我选择 Ubuntu 是因为它有一个强大的社区。正因如此,大多数问题可能已经有了解决方案,或者解决方案很容易获得。
为什么要在裸机上构建我们的设置?它是我们沙箱客户端的更好主机,尤其是在监控网络流量时。它还在正确处理 Windows 恶意软件方面具有优势,可以防止由于恶意软件执行而导致的安全问题。
你可以访问 www.ubuntu.com/ 获取 Ubuntu 安装程序的 ISO 文件。该网站包含了安装指南。如需更多帮助,可以访问社区论坛 ubuntuforums.org/。
“裸机”是指直接在硬件上执行代码的计算机。它通常用来指代硬件,而不是虚拟机。
Linux 可执行文件 – hello world
首先,让我们创建一个 hello world 程序。在此之前,我们需要确保已经安装了构建该程序所需的工具。打开一个终端(终端是 Linux 中类似于 Windows 命令提示符的工具),并输入以下命令。这可能需要你输入超级用户密码:
sudo apt install gcc
C 程序编译器,*gcc, *通常预装在 Linux 中。
打开任何文本编辑器,输入以下代码,并将其保存为 *hello.c*:
#include <stdio.h>
void main(void)
{
printf ("hello world!\n");
}
你可以通过在终端中运行 vi 来使用 vim 作为文本编辑器。
要编译并运行程序,请使用以下命令:
hello 文件是我们用于显示控制台消息的 Linux 可执行文件。
现在,开始对这个程序进行逆向分析。
dlroW olleH
作为一种良好实践,逆向分析程序的过程应从正确的识别开始。让我们从 file 命令开始:
它是一个 32 位 ELF 文件类型。ELF 文件是 Linux 平台的原生可执行文件。
下一站,让我们快速查看文本字符串,使用 strings 命令:
这个命令将产生类似以下的输出:
/lib/ld-linux.so.2
libc.so.6
_IO_stdin_used
puts
__libc_start_main
__gmon_start__
GLIBC_2.0
PTRh
UWVS
t$,U
[^_]
hello world!
;*2$"(
GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609
crtstuff.c
__JCR_LIST__
deregister_tm_clones
__do_global_dtors_aux
completed.7209
__do_global_dtors_aux_fini_array_entry
frame_dummy
__frame_dummy_init_array_entry
hello.c
__FRAME_END__
__JCR_END__
__init_array_end
_DYNAMIC
__init_array_start
__GNU_EH_FRAME_HDR
_GLOBAL_OFFSET_TABLE_
__libc_csu_fini
_ITM_deregisterTMCloneTable
__x86.get_pc_thunk.bx
_edata
__data_start
puts@@GLIBC_2.0
__gmon_start__
__dso_handle
_IO_stdin_used
__libc_start_main@@GLIBC_2.0
__libc_csu_init
_fp_hw
__bss_start
main
_Jv_RegisterClasses
__TMC_END__
_ITM_registerTMCloneTable
.symtab
.strtab
.shstrtab
.interp
.note.ABI-tag
.note.gnu.build-id
.gnu.hash
.dynsym
.dynstr
.gnu.version
.gnu.version_r
.rel.dyn
.rel.plt
.init
.plt.got
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.jcr
.dynamic
.got.plt
.data
.bss
.comment
字符串按文件开头的顺序列出。列表的前两部分包含了我们的消息和编译器信息。前两行还显示了程序使用的库:
/lib/ld-linux.so.2
libc.so.6
列表的最后部分包含了文件各个区段的名称。我们只知道一些文本片段,它们被放入我们的 C 代码中。其余的则是由编译器本身放入的,作为其准备并结束代码优雅执行的一部分。
在 Linux 中,反汇编只是一个命令行的事情。使用 objdump 命令的 -d 参数,我们应该能够显示可执行代码的反汇编。你可能需要通过以下命令将输出结果写入文件:
objdump -d hello > disassembly.asm
输出文件 disassembly.asm 应包含以下代码:
如果你注意到,反汇编语法与我们学过的 Intel 汇编语言格式不同。我们在这里看到的是 AT&T 反汇编语法。要获取 Intel 语法,我们需要使用 -M intel 参数,如下所示:
objdump -M intel -d hello > disassembly.asm
输出应该给我们以下的反汇编结果:
结果显示了每个函数的反汇编代码。总的来说,从可执行部分有 15 个函数:
Disassembly of section .init:
080482a8 <_init>:
Disassembly of section .plt:
080482d0 <puts@plt-0x10>:
080482e0 <puts@plt>:
080482f0 <__libc_start_main@plt>:
Disassembly of section .plt.got:
08048300 <.plt.got>:
Disassembly of section .text:
08048310 <_start>:
08048340 <__x86.get_pc_thunk.bx>:
08048350 <deregister_tm_clones>:
08048380 <register_tm_clones>:
080483c0 <__do_global_dtors_aux>:
080483e0 <frame_dummy>:
0804840b <main>:
08048440 <__libc_csu_init>:
080484a0 <__libc_csu_fini>:
Disassembly of section .fini:
080484a4 <_fini>:
我们代码的反汇编通常位于 .text 区段。由于这是一个由 GCC 编译的程序,我们可以跳过所有初始化代码,直接进入 main 函数,那里存放着我们的代码:
我已经标出了 puts 的 API 调用。puts API 也是 printf 的一个变种。GCC 足够聪明,选择了 puts 而不是 printf,原因是该字符串没有被解释为 C 风格的 格式化字符串。格式化字符串或 formatter 包含控制字符,这些字符用 % 符号表示,例如 %d 表示整数,%s 表示字符串。实际上,puts 用于非格式化字符串,而 printf 用于格式化字符串。
到目前为止,我们收集了什么信息?
假设我们对源代码没有任何了解,这是我们迄今为止收集到的信息:
-
该文件是一个 32 位 ELF 可执行文件。
-
它是使用
GCC编译的。 -
它有 15 个可执行函数,包括
main()函数。 -
代码使用了常见的 Linux 库:
libc.so和ld-linux.so。 -
根据反汇编代码,预计该程序只是显示一条消息。
-
程序预计会使用puts显示消息。
动态分析
现在让我们进行一些动态分析。请记住,动态分析应在沙箱环境中进行。Linux 中通常预安装了一些可以用来显示更详细信息的工具。在这次逆向工程中,我们将介绍ltrace、strace和gdb。
这是ltrace的使用方法:
ltrace的输出显示了程序执行的可读代码。ltrace记录了程序调用和接收的库函数。它调用了puts来显示一条消息。当程序终止时,它还收到了一个退出状态*13*。
地址*0x804840b*也是反汇编结果中列出的main函数的地址。
strace是我们可以使用的另一种工具,但它会记录系统调用。下面是我们在 hello world 程序上运行strace的结果:
strace记录了所有发生的系统调用,从程序被系统执行开始。execve是记录的第一个系统调用。调用execve会运行由其函数参数中的文件名指向的程序。open和read是用来读取文件的系统调用。mmap2、mprotect和brk负责内存活动,如分配、权限和段边界设置。
在puts的代码内部,它最终会执行一个write系统调用。write通常会将数据写入它所指向的对象。通常,它用于写入文件。在这个例子中,write的第一个参数值为1。1的值表示STDOUT,这是控制台输出的句柄。第二个参数是消息,因此它将消息写入STDOUT。
进一步调试
首先,我们需要通过运行以下命令安装gdb:
sudo apt install gdb
安装应该是这样的:
然后,使用gdb来调试hello程序,如下所示:
gdb ./hello
gdb可以通过命令进行控制。这些命令在在线文档中有详细列出,但只需输入help就可以帮助我们掌握基础。
你还可以使用gdb显示指定函数的反汇编,通过disass命令。例如,让我们看看如果我们使用disass main命令会发生什么:
然后,我们再次获得了以 AT&T 语法表示的反汇编。要将gdb设置为使用 Intel 语法,请使用以下命令:
set disassembly-flavor intel
这应该会给我们 Intel 汇编语言语法,如下所示:
要在main函数处设置断点,命令是b *main。
请注意,星号 (***) 指定了程序中的地址位置。
设置断点后,我们可以使用 run 命令运行程序。我们应该最终到达 main 函数的地址:
要获取当前寄存器的值,请输入 info registers。由于我们处于 32 位环境中,因此会使用扩展寄存器(即 EAX、ECX、EDX、EBX 和 EIP)。如果是 64 位环境,寄存器会以 R 为前缀(即 RAX、RCX、RDX、RBX 和 RIP)。
现在我们已经进入了主函数,我们可以逐步执行每条指令(使用 stepi 命令)并跳过指令(使用 nexti 命令)。通常,我们会跟随 info registers 命令,查看哪些值发生了变化。
stepi 和 nexti 的简写命令分别是 si 和 ni。
继续输入 si 和 disass main,直到你看到包含 call 0x80482e0 <puts@plt> 的行。你应该会得到以下 disass 和 info registers 的结果:
左侧的 => 指示了指令指针所在的位置。寄存器应如下所示:
在 puts 函数被调用之前,我们可以检查栈中推入的值。我们可以通过 x/8x $esp 查看:
x 命令用于显示指定地址的内存转储。语法为 x/FMT ADDRESS。FMT 有三个部分:重复次数、格式字母和大小字母。你可以通过 help x 查看更多关于 x 命令的信息。x/8x $esp 会从 esp 寄存器指向的地址处显示 8 个 DWORD 十六进制值。由于地址空间是 32 位的,因此默认的大小字母是 DWORD。
puts 期望一个单一的参数。因此,我们只关注在 0x080484c0 栈位置推送的第一个值。我们预计该参数应该是一个消息存放的地址。因此,输入 x/s 命令应该给出消息的内容,如下所示:
接下来,我们需要对调用指令行进行跳过(ni)。这应该会显示以下消息:
但是,如果你使用了 si,指令指针将位于 puts 包装代码中。我们仍然可以使用 until 命令回到我们离开的地方,简写为 u。只使用 until 命令将步进一条指令。你需要指定停止的位置地址。这就像是一个临时的断点。记得在地址前加上星号:
剩下的 6 行代码会在进入 main 函数后恢复 ebp 和 esp 的值,然后通过 ret 返回。记住,调用指令会在跳转到函数地址之前,将返回地址存储在栈顶。ret 指令将读取 esp 寄存器指向的返回值。
esp 和 ebp 的值应在执行 ret 指令之前恢复。通常,函数开始时会设置自己的栈帧,以便与函数的局部变量一起使用。
下面是展示给定地址指令执行后 esp、ebp 和 ecx 寄存器值变化的表格。
请注意,栈由 esp 寄存器表示,栈从高地址开始,随着数据的存储,地址逐渐下降。
| 地址 | 指令 | esp | ebp | ecx | 备注 |
|---|---|---|---|---|---|
0x0804840b | lea ecx,[esp+0x04] | 0xbffff08c | 0 | 0xbffff090 | 进入 main 后的初始值。[0xbffff08c] = 0xb7e21637,这是返回地址。 |
0x0804840f | and esp,0xfffffff0 | 0xbffff080 | 0 | 0xbffff090 | 将栈对齐到 16 字节边界。实际上,这将 esp 减去 0xc。 |
0x08048412 | push DWORD PTR [ecx-0x4] | 0xbffff07c | 0 | 0xbffff090 | [0xbffff07c] = 0xb7e21637,ecx - 4 = 0xbffff08c 指向返回地址。现在返回地址被放置在两个栈地址中。 |
0x08048415 | push ebp | 0xbffff078 | 0 | 0xbffff090 | 开始设置栈帧。[0xbffff078] = 0 |
0x08048416 | mov ebp,esp | 0xbffff078 | 0xbffff078 | 0xbffff090 | 保存 esp。 |
0x08048418 | push ecx | 0xbffff074 | 0xbffff078 | 0xbffff090 | 保存 ecx。[0xbffff074] = 0xbffff090。 |
0x08048419 | sub esp,0x4 | 0xbffff070 | 0xbffff078 | 0xbffff090 | 为栈帧分配 4 字节空间。 |
0x0804841c | sub esp,0xc | 0xbffff064 | 0xbffff078 | 0xbffff090 | 为栈帧分配额外的 12 字节空间。 |
0x0804841f | push 0x80484c0 | 0xbffff060 | 0xbffff078 | 0xbffff090 | [0xbffff060] = 0x080484c0,[0x080484c0] = "hello world!" |
0x08048424 | call 0x80482e0 <puts@plt> | 0xbffff060 | 0xbffff078 | 0xffffffff | 调用后栈没有变化。 |
0x08048429 | add esp,0x10 | 0xbffff070 | 0xbffff078 | 0xffffffff | 将 0x10 加到 esp,减少栈帧大小。 |
0x0804842c | nop | 0xbffff070 | 0xbffff078 | 0xffffffff | 无操作 |
0x0804842d | mov ecx,DWORD PTR [ebp-0x4] | 0xbffff070 | 0xbffff078 | 0xbffff090 | 恢复调用前的 ecx 值。 |
| 0x08048430 | leave | 0xbffff07c | 0 | 0xbffff090 | leave 相当于 mov esp, ebp。 |
pop ebp |
0x08048431 | lea esp,[ecx-0x4] | 0xbffff08c | 0 | 0xbffff090 | ecx - 4 = 0xbffff08c,[0xbffff08c] = 0xb7e21637,恢复了 esp 的地址。 |
|---|---|---|---|---|---|
0x08048434 | ret | - | - | - | 返回到 0xb7e21637。 |
你可以继续探索ret后的清理代码,或者通过使用continue或它的缩写c让程序最终结束,如下所示:
一个更好的调试器
在进行更多 Linux 可执行文件逆向操作之前,让我们先探索更多工具。gdb看起来可以,但如果我们能使用可视化调试工具进行交互式调试会更好。在第五章,工具的使用部分,我们介绍了 Radare,作为一个既能进行反汇编也能进行调试的工具。所以,让我们感受一下使用 Radare 的体验。
设置
Radare 已经是第二个版本。要安装它,你需要git从 GitHub 仓库进行安装,步骤如下:
git clone https://github.com/radare/radare2.git
安装说明写在README文件中。根据写作时的建议,可以通过运行sys/install.sh或sys/user.sh的 shell 脚本在终端中安装Radare2。
Radare2 中的 Hello World
除了反汇编器和调试器,Radare2还包含了一堆工具。大多数都是静态分析工具。
要获取 hello world 二进制文件的MD5哈希值,可以使用rabin2:
通过使用ls命令和rahash2,我们能够确定以下信息:
filesize: 7348 bytes
time stamp: July 12 21:26 of this year
md5: 799554478cf399e5f87b37fcaf1c2ae6
sha256: 90085dacc7fc863a2606f8ab77b049532bf454badefcdd326459585bea4dfb29
rabin2是另一个可以从文件中提取静态信息的工具,例如文件类型、头信息、部分内容和字符串。
首先通过使用rabin2 -I hello命令获取文件类型:
bintype、class、hascode 和 os 字段表明该文件是一个可执行的 32 位 ELF 文件,并且可以在 Linux 上运行。arch、bits、endian 和 machine 表示该文件是用 x86 代码构建的。此外,lang字段表明该文件是从 C 语言编译而来的。这些信息无疑将帮助我们在反汇编和调试时做好准备。
要列出导入的函数,我们使用rabin2 -i hello:
我们关注的有两个全局函数:puts和__libc_start_main。如我们所讨论,puts用于打印消息。__libc_start_main是一个初始化堆栈帧、设置寄存器和一些数据结构、设置错误处理并最终调用main()函数的函数。
要获取 ELF 头信息,可以使用rabin2 -H hello:
如果我们只对从数据段中找到的字符串感兴趣,可以使用rabin2 -z hello命令:
使用rabin2,我们获得了关于文件的额外信息,如下所示:
filetype: 32-bit elf file and has executable code for Linux
architecture: x86 Intel
functions: imports puts and has a main function
notable strings: hello world!
现在让我们尝试使用radare2调试器。从终端控制台,你可以使用radare2的缩写r2,或者直接使用radare2,并将-d <file>作为参数:
这将带你进入radare2控制台。在方括号中,地址表示当前eip的位置。这不是 hello 程序的入口点,而是动态加载器中的一个地址。与gdb类似,你需要输入命令。要调出帮助,只需使用***?***,它将显示如下命令列表:
我们首先使用aaa命令。此命令分析代码中的函数调用、标志、引用,并尝试生成有意义的函数名称:
使用V!命令将控制台设置为可视模式。在此模式下,我们应该能够在交互式查看寄存器和栈的同时调试程序。输入:应该会显示命令控制台。按Enter键将使我们返回可视模式。输入V?以显示更多可视模式命令。最好将终端窗口最大化,以便更好地查看调试器:
在命令控制台中,输入db entry0。这将设置一个断点,位于我们程序的入口地址。但由于我们也知道该程序有一个主函数,你还可以输入db sym.entry,在主函数处设置断点。
在可视模式中,你可以使用默认提供的这些按键开始实际的调试:
| F2 toggle breakpoint
| F4 run to cursor
| F7 single step
| F8 step over
| F9 continue
通过设置入口点和主函数的断点,按F9运行程序。我们应该会到达入口点地址。
你需要通过重新打开radare2的可视模式来刷新它,以查看更改。为此,只需按q两次退出可视模式。但在再次运行V!之前,你需要通过使用s eip命令来查找当前的eip。
再次按F9应该将你带到程序的主函数。记得刷新可视模式:
按F7或F8可以跟踪程序的执行,同时查看栈和寄存器的变化。在0x0804840b这一行地址左侧的字母b表示该地址已设置断点。
到目前为止,我们已经了解了基本的命令和按键。随时探索其他命令,你肯定能获取更多信息,并学到一些简单的技巧来分析文件。
密码是什么?
既然我们已经知道如何进行“Unix 风格”调试,让我们尝试密码程序。你可以从github.com/PacktPublishing/Mastering-Reverse-Engineering/raw/master/ch6/passcode下载密码程序。
尝试获取一些静态信息。以下是你可以使用的命令列表:
ls -l passcode
rahash2 -a md5,sha256 passcode
rabin2 -I passcode
rabin2 -i passcode
rabin2 -H passcode
rabin2 -z passcode
此时,我们正在寻找的信息如下:
-
文件大小:7,520 字节
-
MD5 哈希:
b365e87a6e532d68909fb19494168bed -
SHA256 哈希:
68d6db63b69a7a55948e9d25065350c8e1ace9cd81e55a102bd42cc7fc527d8f -
文件类型:ELF
-
32 位 x86 Intel
-
编译后的 C 代码中有一些显著的导入函数:
printf、puts、strlen和__isoc99_scanf
-
-
显著的字符串如下:
-
输入密码:
-
正确的密码!
-
密码错误!
-
现在,为了进行快速动态分析,让我们使用ltrace ./passcode:
我们尝试了几个密码,但没有一个返回“正确密码!”该文件甚至没有字符串列表中的任何提示供我们使用。让我们尝试strace:
包含read(0, asdf123的那一行是密码被手动输入的地方。之后的代码进入退出流程。让我们基于反汇编代码做一个死名单活动,但这次我们将使用radare2的图形视图。请打开radare2,并使用radare2 -d passcode命令。在radare2控制台中,使用以下命令序列:
aaa
s sym.main
VVV
这些应该打开来自main函数的反汇编代码块的图形表示。向下滚动,你应该能看到条件分支,其中绿色线条表示true,红色线条表示false流程。继续向下滚动,直到看到Correct password!文本字符串。我们将从这里向后工作:
在0x80485d3块中,显示Correct password!字符串,我们看到该消息是通过puts函数显示的。进入该块的是来自0x80485c7块的红线。在0x80485c7块中,local_418h的值与0x2de(即十进制的 734)进行了比较。为了跳转到Correct password!块,值应该等于 734。如果我们尝试反编译 C 代码,它应该类似于下面这样:
...
if (local_418h == 734)
puts("Correct password!)
...
向上滚动查看红线的来源:
从这个图形来看,存在一个循环,退出循环需要local_414h的值大于或等于local_410h的值。循环会跳转到0x80485c7块。在0x8048582块中,local_418h和local_414h的值都被初始化为 0。这些值在0x80485b9块中进行比较。
检查0x8048598块时,有三个需要关注的变量:local_40ch、local_414h 和 local_418h。如果我们为这个块编写伪代码,它应该是这样的:
eax = byte at address [local_40ch + local_414h]
add eax to local_418h
increment local_414h
local_414h似乎是指向local_40c指向的数据的指针。local_418从 0 开始,每次添加local_40ch中的一个字节。从概览来看,这里似乎发生了一个校验和算法:
...
// unknown variables for now are local_40ch and local_410h
int local_418h = 0;
for (int local_414h = 0; local_414h < local_410h; local_414++)
{
local_418h += local_40ch[local_414h];
}
if (local_418h == 734)
puts("Correct password!)
...
让我们进一步向上滚动,找出local_40ch和local_410h应该是什么:
这是主块。这里有三个命名的函数:
-
printf() -
scanf() -
strlen()
这里使用了local_40ch和local_410h。local_40ch是scanf的第二个参数,而0x80486b1地址中的数据应该包含期望的格式。local_40ch包含输入的缓冲区内容。要检索0x80486b1处的数据,只需输入冒号(:),输入s 0x80486b1,然后返回可视模式。再次按q查看数据:
local_40ch中数据的长度被识别并存储在local_410h中。local_410h中的值与 7 进行比较。如果相等,程序会沿着红线进入0x8048582块,即校验和循环的开始。如果不相等,程序会沿着绿线进入0x80485e5块,其中包含显示“密码错误!”的代码。
总结来说,代码可能是这样的:
...
printf ("Enter password: ");
scanf ("%s", local_40ch);
local_410h = strlen(local_40ch);
if (local_410h != 7)
puts ("Incorrect password!);
else
{
int local_418h = 0;
for (int local_414h = 0; local_414h < local_410h; local_414++)
{
local_418h += local_40ch[local_414h];
}
if (local_418h == 734)
puts("Correct password!)
}
输入的密码应该有7 个字符,并且密码中所有字符的总和应该等于 734。因此,密码可以是任何内容,只要满足给定的条件即可。
使用 ASCII 表,我们可以确定每个字符的等效值。如果 7 个字符的总和为 734,我们只需将 734 除以 7,这样得到的结果是 104,或者 0x68,余数为 6。我们可以将余数 6 分配给 6 个字符,得到如下字符集:
| 十进制 | 十六进制 | ASCII 字符 |
|---|---|---|
105 | 0x69 | i |
105 | 0x69 | i |
105 | 0x69 | i |
105 | 0x69 | i |
105 | 0x69 | i |
105 | 0x69 | i |
104 | 0x68 | h |
让我们尝试密码*iiiiiih*或*hiiiiii*,如下面所示:
网络流量分析
这次,我们将编写一个接收网络连接并返回一些数据的程序。我们将使用可在github.com/PacktPublishing/Mastering-Reverse-Engineering/raw/master/ch6/server下载的文件。下载完成后,通过终端执行该文件,命令如下:
该程序是一个等待连接到端口9999的服务器程序。要进行测试,请打开浏览器,使用服务器运行机器的 IP 地址和端口。例如,如果您在自己的机器上尝试,可以使用127.0.0.1:9999。您可能会看到如下输出:
要了解网络流量,我们需要使用诸如tcpdump之类的工具来捕获一些网络数据包。tcpdump通常在 Linux 发行版中预安装。打开另一个终端并使用以下命令:
sudo tcpdump -i lo 'port 9999' -w captured.pcap
下面是所用参数的简要解释:
-i lo 使用 loopback 网络接口。我们在这里使用它,因为我们计划在本地访问该服务器。
'port 9999',带单引号的部分,仅筛选使用端口 9999 的报文。
-w captured.pcap将数据包写入名为captured.pcap的 PCAP 文件中。
一旦tcpdump开始监听数据,尝试通过浏览器访问127.0.0.1:9999来连接服务器。如果你希望从服务器所在机器外部连接,则重新运行tcpdump,不要加上-i lo参数。这样会使用默认的网络接口。而且,你需要使用默认网络接口的 IP 地址,而不是127.0.0.1来进行访问。
要停止tcpdump,只需通过Ctrl + C中断它。
要以人类可读的形式查看captured.pcap文件的内容,可以使用以下命令:
sudo tcpdump -X -r captured.pcap > captured.log
此命令应将tcpdump的输出重定向到captured.log。-X参数以十六进制和 ASCII 形式显示数据包的内容。-r captured.pcap表示从PCAP文件captured.pcap中读取数据。打开captured.log文件时,应该类似以下内容:
在继续之前,让我们先了解一下两种最常见的网络协议,传输控制协议(TCP)和用户数据报协议(UDP)。TCP 是一种网络传输协议,它建立了发送方和接收方之间的通信。通信开始时进行三次握手,发送方向接收方发送 SYN 标志,接收方再向发送方发送 SYN 和 ACK 标志,最后发送方再发送 ACK 标志给接收方,从而开启通信。发送方和接收方之间的进一步数据交换是以分段的方式进行的。每个段都有一个 20 字节的 TCP 头部,包含了发送方和接收方的 IP 地址以及当前的状态标志。接下来是数据的大小和数据本身。UDP 使用较短的头部,因为它只发送数据,并不需要接收方的确认。UDP 不需要进行三次握手。UDP 的主要目的是持续不断地将数据发送给接收方。虽然 TCP 在数据交换方面更可靠,但 UDP 的数据发送速度更快,因为没有必要的开销。UDP 通常用于通过文件传输协议传输大量数据,而 TCP 则用于需要数据完整性的通信。
在前面的截图中,第 1 行到第 15 行显示了一个 TCP 三次握手。第一次从本地主机端口55704(客户端)到本地主机端口9999(服务器)的连接是一个 SYN,标志位为S。然后,服务器响应了一个S.标志,表示 SYN 和 ACK。最后是一个 ACK,用 .表示。这时,客户端端口 55704 是一个临时端口。临时端口是系统为客户端连接生成的端口。服务器端口9999在服务器程序中是固定的。
在第 16 行到第 23 行,我们可以看到服务器到客户端的实际响应数据。服务器返回一个包含 55 个字符的数据,包含字符串"You have connected to the Genie. Nothing to see here."和 2 个换行(0x0A)字符。55 个字符字符串前的数据是数据包的头部,包含有关数据包的信息。解析后的数据包头部内容在第 16 行中描述。TCP 标志为P.,表示 PUSH 和 ACK。数据包头部结构中的信息已在 TCP 和 UDP 规范中进行了文档化。你可以从RFC 675(tools.ietf.org/html/rfc675)和RFC 768(tools.ietf.org/html/rfc768)开始查找这些规范。为了加速处理过程,我们可以使用 Wireshark,稍后会讨论它,来帮助我们解析数据包信息。
在第24行到第28行,FIN 和 ACK 标志,格式为F.,从服务器发送到客户端,表示服务器正在关闭连接。第 29 行到第 33 行是一个 ACK 响应,.,确认连接正在关闭。
捕获和查看图形化信息的更好工具是Wireshark。之前称为Ethereal,Wireshark 具有与tcpdump相同的功能。Wireshark 可以从www.wireshark.org/手动下载和安装。也可以通过以下apt命令进行安装:
sudo apt install wireshark-qt
捕获网络数据包需要 root 权限才能访问网络接口。这也是我们在运行tcpdump时使用sudo的原因。使用Wireshark时也是如此。因此,要在 Linux 中执行Wireshark,我们使用以下命令:
sudo wireshark
除了捕获流量并实时显示外,您还可以在Wireshark中打开并查看 PCAP 文件:
要开始捕获,双击接口列表中的any。这基本上会同时捕获默认网络接口和回环接口lo的流量。你将看到连续的网络流量数据包行。Wireshark 有一个显示过滤器,可以最小化我们看到的所有噪音。对于我们的练习,在过滤器字段中输入以下显示过滤器:
tcp.port == 9999
这应该只显示使用9999端口的 TCP 数据包。你还可以尝试其他过滤器。更多内容请参考 Wireshark 的手册页面。
点击数据包后,可以查看解析的信息,这将帮助你更好地理解数据包字段,如下图所示:
Wireshark 对标准数据包有广泛的知识。这使得 Wireshark 成为每个分析师必备的工具。
概要
本章我们讨论了已经内置在 Linux 系统中的逆向工程工具。基于 Debian 的操作系统,如 Ubuntu,由于广泛的社区支持和可用的工具,常被用于逆向工程目的。我们更多地关注了如何分析 Linux 原生可执行文件——ELF 文件。我们首先使用 GCC 将一个 C 程序源代码编译成 ELF 可执行文件。接着,我们使用静态信息收集工具,包括ls、file、strings和objdump,对可执行文件进行了分析。然后我们使用ltrace和strace进行动态分析。之后我们使用gdb调试程序,展示了 Intel 汇编语言的语法。
我们还介绍并探讨了radare2工具包。我们使用了rahash2和rabin2来收集静态信息,并使用radare2在交互式视图中进行反汇编和调试。网络分析工具也没有被忽视,我们使用了tcpdump和Wireshark。
在信息安全领域,待分析的大多数文件都是基于微软 Windows 的可执行文件,我们将在下一章进行讨论。虽然在行业中我们可能不会遇到太多 Linux 文件的分析,但了解如何进行分析一定会在任务需要时派上用场。
深入阅读
本章使用的文件和源代码可以在github.com/PacktPublishing/Mastering-Reverse-Engineering/tree/master/ch6找到。
第七章:Windows 平台的逆向工程
由于 Windows 是全球最流行的操作系统之一,网络世界中的大多数软件都为其编写。这其中包括恶意软件。
本章聚焦于 Windows 本地可执行文件 PE 文件的分析,并通过文件分析直接进行演变,即收集静态信息并执行动态分析。我们将深入了解 PE 文件如何与 Windows 操作系统交互。以下主题将在本章中进行讲解:
-
分析 Windows PE 文件
-
工具
-
静态分析
-
动态分析
技术要求
本章需要读者具备 Windows 环境及其管理的知识。读者还应了解如何在命令提示符中使用命令。本章的第一部分要求读者具备使用 Visual Studio 或类似软件构建和编译 C 程序的基本知识。
Hello World
Windows 环境中的程序通过使用 Windows API 与系统进行通信。这些 API 是围绕文件系统、内存管理(包括进程、栈和分配)、注册表、网络通信等构建的。在逆向工程方面,广泛覆盖这些 API 及其库模块,在通过低级语言等效视角理解程序的工作方式时具有很大优势。因此,开始探索 API 及其库的最佳方式是自己开发一些程序。
开发者使用的高级语言有很多,比如 C、C++、C# 和 Visual Basic。C、C++ 和 Visual Basic(本地)编译为可执行文件,直接执行 x86 语言的指令。C# 和 Visual Basic(p-code)通常会被编译成使用解释器的形式,解释器将 p-code 转换为实际的 x86 指令。本章将重点讨论从 C/C++ 和汇编语言编译的可执行二进制文件。目标是更好地理解使用 Windows API 的程序行为。
对于本章,我们选择使用 Visual Studio Community 版来构建 C/C++ 程序。Visual Studio 是广泛用于构建 Microsoft Windows 程序的工具。由于它也是微软的产品,已包含编译程序所需的兼容库。你可以从 visualstudio.microsoft.com/downloads/ 下载并安装 Visual Studio Community 版。
这些程序既不有害也不恶意。以下 C 编程活动可以在裸机上使用 Visual Studio 完成。如果你计划在 Windows 虚拟机上安装 Visual Studio,根据本书的编写时间,Visual Studio 2017 Community 版的推荐系统要求如下:
-
1.8 GHz 双核
-
4 GB 的内存
-
130 GB 的磁盘空间
这些系统要求可以在docs.microsoft.com/en-us/visualstudio/productinfo/vs2017-system-requirements-vs找到。你可能需要执行一些 Windows 更新,并安装.NET 框架。也可以从我们之前下载的 Windows 7 安装包中安装,下载链接为developer.microsoft.com/en-us/microsoft-edge/tools/vms/。请访问微软 Visual Studio 网站,了解新版的要求。
有许多 Visual Studio 的替代工具,它们有较小的系统要求,例如 Bloodshed Dev C++、Zeus IDE 和 Eclipse。然而,这些 IDE 中的一些可能不是最新的,或者可能需要正确设置编译器及其依赖项。
学习 API
我们将在此跳过Hello World,因为我们在前面的章节已经做过了。相反,我们将研究以下示例程序:
-
将键盘记录器保存到
filez中 -
枚举注册表键并打印输出
-
列出进程并打印输出
-
加密数据并将其存储到文件中
-
解密加密文件
-
监听端口
9999并在连接时发送回一个消息
这些程序的源代码可以在github.com/PacktPublishing/Mastering-Reverse-Engineering/tree/master/ch7找到。可以随意使用这些程序,添加自己的代码,甚至创建自己的版本。这里的目标是让你学习这些 API 如何协同工作。
确定程序行为的关键之一是学习如何使用 API。每个 API 的使用方法都在微软开发者网络(MSDN)文档库中有记录。我们即将查看的程序只是程序行为的示例。我们利用这些 API 在这些行为的基础上进行扩展。我们在这里的目标是学习这些 API 的使用方法以及它们如何相互交互。
作为一名逆向工程师,读者应当并且要求使用 MSDN 或其他资源进一步了解 API 的工作原理。可以在 MSDN 文档库中搜索 API 名称,网址为msdn.microsoft.com。
键盘记录器
键盘记录器是一个记录用户按键的程序。日志通常保存在一个文件中。这里使用的核心 API 是GetAsyncKeyState。每个可以从键盘或鼠标按下的按钮都有一个被称为虚拟键代码的分配 ID。指定虚拟键代码后,GetAsyncKeyState会提供关于该键是否被按下的信息。
这个程序的源代码可以在github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/keylogger.cpp找到。
为了使键盘记录功能正常工作,我们需要检查每个虚拟键码的状态,并将它们放入一个循环中。一旦识别到一个按键被按下,虚拟键码就会被存储到文件中。以下代码实现了这一功能:
while (true) {
for (char i = 1; i <= 255; i++) {
if (GetAsyncKeyState(i) & 1) {
sprintf_s(lpBuffer, "\\x%02x", i);
LogFile(lpBuffer, (char*)"log.txt");
}
}
LogFile 是一个函数,接受两个参数:它写入的数据和日志文件的文件路径。lpBuffer 包含数据,并通过 sprintf_s API 格式化为 \\x%02x。因此,格式会将任何数字转换为两位数的十六进制字符串。数字 9 会变成 \x09,数字 106 会变成 \x6a。
我们只需要三个 Windows API 函数来实现将数据存储到日志文件中——CreateFile、WriteFile 和 CloseHandle——如下面的代码所示:
void LogFile(char* lpBuffer, LPCSTR fname) {
BOOL bErrorFlag;
DWORD dwBytesWritten;
HANDLE hFile = CreateFileA(fname, FILE_APPEND_DATA, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
bErrorFlag = WriteFile(hFile, lpBuffer, strlen(lpBuffer), &dwBytesWritten, NULL);
CloseHandle(hFile);
return;_
}
CreateFileA 用于根据文件名和文件的使用方式创建或打开一个新文件。由于这个练习的目的是不断记录按键的虚拟键码,我们需要以追加模式打开文件(FILE_APPEND_DATA)。返回的文件句柄存储在 hFile 中,并被 WriteFile 使用。lpBuffer 包含格式化的虚拟键码。WriteFile 需要的参数之一是要写入的数据大小。这里使用了 strlen API 来确定数据的长度。最后,使用 CloseHandle 关闭文件句柄。关闭文件句柄是很重要的,这样文件才会可供使用。
有不同的键盘变体,旨在适应用户的语言。因此,不同的键盘可能具有不同的虚拟键码。在程序开始时,我们使用 GetKeyboardLayoutNameA(lpBuffer) 来识别正在使用的键盘类型。在读取日志时,将使用键盘类型作为参考,以正确识别哪些键被按下。
regenum
如下所述,regenum 程序旨在枚举给定注册表项中的所有值和数据。API 所需的参数取决于前一个 API 的结果。就像我们在键盘记录器程序中能够写入数据到文件一样,注册表枚举的 API 也需要一个句柄。在这种情况下,RegEnumValueA 和 RegQueryValueExA API 使用的是注册表项的句柄。
这个程序的源代码可以在 github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/regenum.cpp 找到。
int main()
{
LPCSTR lpSubKey = "Software\\Microsoft\\Windows\\CurrentVersion\\Run";
HKEY hkResult;
DWORD dwIndex;
char ValueName[1024];
char ValueData[1024];
DWORD cchValueName;
DWORD result;
DWORD dType;
DWORD dataSize;
HKEY hKey = HKEY_LOCAL_MACHINE;
if (RegOpenKeyExA(hKey, lpSubKey, 0, KEY_READ, &hkResult) == ERROR_SUCCESS)
{
printf("HKEY_LOCAL_MACHINE\\%s\n", lpSubKey);
dwIndex = 0;
result = ERROR_SUCCESS;
while (result == ERROR_SUCCESS)
{
cchValueName = 1024;
result = RegEnumValueA(hkResult, dwIndex, (char *)&ValueName, &cchValueName, NULL, NULL, NULL, NULL);
if (result == ERROR_SUCCESS)
{
RegQueryValueExA(hkResult, ValueName, NULL, &dType, (unsigned char *)&ValueData, &dataSize);
if (strlen(ValueName) == 0)
sprintf((char*)&ValueName, "%s", "(Default)");
printf("%s: %s\n", ValueName, ValueData);
}
dwIndex++;
}
RegCloseKey(hkResult);
}
return 0;
}
枚举从通过 RegOpenKeyExA 获取注册表项的句柄开始。成功的返回值应该是非零的,而输出应该显示存储在 hkResult 中的句柄。这里要访问的注册表项是 HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Run。
hkResult中的句柄由RegEnumValueA使用,用于开始枚举注册表键下的每个注册表值。后续对RegEnumValueA的调用将返回下一个注册表值条目。因此,这段代码被放在循环中,直到返回ERROR_SUCCESS结果为止。ERROR_SUCCESS结果表示成功检索到注册表值。
对于每个注册表值,都会调用RegQueryValueExA。记住,我们只获取了注册表值,但没有获取其对应的数据。通过使用RegQueryValueExA,我们应该能够获取到注册表数据。
最后,我们需要通过使用RegCloseKey来关闭句柄。
这里使用的其他 API 包括printf、strlen和sprintf。printf在程序中用于将目标注册表键、值和数据打印到命令行控制台。strlen用于获取文本字符串的长度。每个注册表键都有一个默认值。由于RegEnumValueA将返回ERROR_SUCCEPantf,我们可以将ValueName变量替换为一个名为(Default)的字符串:
processlist
类似于枚举注册表值的方式,列出进程也基于相同的概念。由于实时进程变化快速,需要获取进程列表的快照。该快照包含快照创建时的进程信息列表。可以使用CreateToolhelp32Snapshot来获取快照。结果存储在hSnapshot中,它是快照句柄。
要开始枚举列表,使用Process32First来获取列表中的第一个进程信息。该信息存储在pe32变量中,类型为PROCESSENTRY32。通过调用Process32Next来检索后续的进程信息。当处理完列表后,最终使用CloseHandle。
再次使用printf来打印出可执行文件名和进程 ID:
int main()
{
HANDLE hSnapshot;
PROCESSENTRY32 pe32;
hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
pe32.dwSize = sizeof(PROCESSENTRY32);
if (Process32First(hSnapshot, &pe32))
{
printf("\nexecutable [pid]\n");
do
{
printf("%ls [%d]\n", pe32.szExeFile, pe32.th32ProcessID);
} while (Process32Next(hSnapshot, &pe32));
CloseHandle(hSnapshot);
}
return 0;
}
该程序的源代码可以在github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/processlist.cpp找到。
加密和解密文件
勒索软件已成为全球传播的最流行恶意软件之一,其核心要素是能够加密文件。
在这些加密和解密程序中,我们将学习一些用于加密和解密的基本 API。
用于加密的 API 是CryptEncrypt,而CryptDecrypt用于解密。然而,这些 API 至少需要一个加密密钥的句柄。为了获得加密密钥的句柄,需要先获得加密服务提供商(CSP)的句柄。从本质上讲,在调用CryptEncrypt或CryptDecrypt之前,必须先调用一些 API 来设置将要使用的算法。
在我们的程序中,CryptAcquireContextA用于从 CSP 获取一个CryptoAPI密钥容器句柄。在这个 API 中,算法 AES 被指定。加密将使用的密钥由用户定义的密码控制,该密码设置在password[]字符串中。为了获取派生密钥的句柄,使用了CryptCreateHash、CryptHashData和CryptDeriveKey这些 API,并将用户定义的password传递给CryptHashData。要加密并赋值给buffer变量的数据,会传递给CryptEncrypt。最终加密后的数据会被写入同一数据缓冲区,并在此过程中覆盖原数据:
int main()
{
unsigned char buffer[1024] = "Hello World!";
unsigned char password[] = "this0is0quite0a0long0cryptographic0key";
DWORD dwDataLen;
BOOL Final;
HCRYPTPROV hProv;
printf("message: %s\n", buffer);
if (CryptAcquireContextA(&hProv, NULL, NULL, PROV_RSA_AES, CRYPT_VERIFYCONTEXT))
{
HCRYPTHASH hHash;
if (CryptCreateHash(hProv, CALG_SHA_256, NULL, NULL, &hHash))
{
if (CryptHashData(hHash, password, strlen((char*)password), NULL))
{
HCRYPTKEY hKey;
if (CryptDeriveKey(hProv, CALG_AES_128, hHash, NULL, &hKey))_
{
Final = true;
dwDataLen = strlen((char*)buffer);
if (CryptEncrypt(hKey, NULL, Final, NULL, (unsigned char*)&buffer, &dwDataLen, 1024))
{
printf("saving encrypted buffer to message.enc");
LogFile(buffer, dwDataLen, (char*)"message.enc");
}
printf("%d\n", GetLastError());
CryptDestroyKey(hKey);
}
}
CryptDestroyHash(hHash);
}
CryptReleaseContext(hProv, 0);
}
return 0;
}
使用修改后的LogFile函数,该函数现在包括写入数据的大小,已将加密数据存储在message.enc文件中:
void LogFile(unsigned char* lpBuffer, DWORD buflen, LPCSTR fname) {
BOOL bErrorFlag;
DWORD dwBytesWritten;
DeleteFileA(fname);
HANDLE hFile = CreateFileA(fname, FILE_ALL_ACCESS, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
bErrorFlag = WriteFile(hFile, lpBuffer, buflen, &dwBytesWritten, NULL);
CloseHandle(hFile);
Sleep(10);
return;
}
为了优雅地关闭CryptoAPI句柄,使用了CryptDestroyKey、CryptDestroyHash和CryptReleaseContext。
加密后的消息Hello World!现在会变成这样:
解密消息的方法是使用相同的CryptoAPI,但这次使用CryptDecrypt。这时,message.enc的内容会被读入数据缓冲区,解密后存储在message.dec中。CryptoAPI 的使用方式与获取密钥句柄时相同。缓冲区的长度应存储在dwDataLen中,初始值应为缓冲区的最大长度:
int main()
{
unsigned char buffer[1024];
unsigned char password[] = "this0is0quite0a0long0cryptographic0key";
DWORD dwDataLen;
BOOL Final;
DWORD buflen;
char fname[] = "message.enc";
HANDLE hFile = CreateFileA(fname, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
ReadFile(hFile, buffer, 1024, &buflen, NULL);
CloseHandle(hFile);
HCRYPTPROV hProv;
if (CryptAcquireContextA(&hProv, NULL, NULL, PROV_RSA_AES, CRYPT_VERIFYCONTEXT))
{
HCRYPTHASH hHash;
if (CryptCreateHash(hProv, CALG_SHA_256, NULL, NULL, &hHash))
{
if (CryptHashData(hHash, password, strlen((char*)password), NULL))
{
HCRYPTKEY hKey;
if (CryptDeriveKey(hProv, CALG_AES_128, hHash, NULL, &hKey))
{
Final = true;
dwDataLen = buflen;
if ( CryptDecrypt(hKey, NULL, Final, NULL, (unsigned char*)&buffer, &dwDataLen) )
{
printf("decrypted message: %s\n", buffer);
printf("saving decrypted message to message.dec");
LogFile(buffer, dwDataLen, (char*)"message.dec");
}
printf("%d\n", GetLastError());
CryptDestroyKey(hKey);
}
}
CryptDestroyHash(hHash);
}
CryptReleaseContext(hProv, 0);
}
return 0;
}
加密和解密程序的源代码可以在以下链接中找到:
加密:github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/encfile.cpp。
解密:github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/decfile.cpp。
服务器
在第六章,Linux 平台上的逆向工程中,我们学习了如何使用套接字 API 来控制客户端和服务器之间的网络通信。相同的代码也可以在 Windows 操作系统中实现。对于 Windows,使用套接字 API 之前,需要通过WSAStartupAPI 初始化套接字库。与 Linux 的函数相比,不再使用write,而是使用send来向客户端发送数据。同时,关于close,它在 Windows 中对应的是closesocket,用于释放套接字句柄。
这是一个图示,展示了服务器和客户端通常如何通过使用套接字 API 进行通信。请注意,下面图示中显示的函数是 Windows API 函数:
socket 函数用于初始化一个套接字连接。完成连接后,通过 closesocket 函数关闭通信。服务器要求我们将程序与一个网络端口 bind 绑定。listen 和 accept 函数用于等待客户端连接。send 和 recv 函数用于服务器和客户端之间的数据传输。send 用于发送数据,而 recv 用于接收数据。最后,closesocket 用于终止传输。以下代码显示了一个实际的服务器端程序 C 源代码,它接受连接并回复 You have connected to the Genie. Nothing to see here.
int main()
{
int listenfd = 0, connfd = 0;
struct sockaddr_in serv_addr;
struct sockaddr_in ctl_addr;
int addrlen;
char sendBuff[1025];
WSADATA WSAData;
if (WSAStartup(MAKEWORD(2, 2), &WSAData) == 0)
{
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd != INVALID_SOCKET)
{
memset(&serv_addr, '0', sizeof(serv_addr));
memset(sendBuff, '0', sizeof(sendBuff));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(9999);
if (bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == 0)
{
if (listen(listenfd, SOMAXCONN) == 0)
{
printf("Genie is waiting for connections to port 9999.\n");
while (1)
{
addrlen = sizeof(ctl_addr);
connfd = accept(listenfd, (struct sockaddr*)&ctl_addr, &addrlen);
if (connfd != INVALID_SOCKET)
{
printf("%s has connected.\n", inet_ntoa(ctl_addr.sin_addr));
snprintf(sendBuff, sizeof(sendBuff), "You have connected to the Genie. Nothing to see here.\n\n");
send(connfd, sendBuff, strlen(sendBuff), 0);
closesocket(connfd);
}
}
}
}
closesocket(listenfd);
}
WSACleanup();
}
return 0;
}
该程序的源代码可以在 github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/server.cpp 上找到。
密码是什么?
在这一部分,我们将对 passcode.exe 程序进行逆向工程。作为练习,我们将通过使用静态和动态分析工具来收集我们需要的信息。我们将使用前几章介绍的一些 Windows 工具。不要仅限于我们在这里使用的工具,实际上有很多其他工具也能完成相同的任务。用于分析该程序的操作系统环境是一个 Windows 10、32 位、2 GB 内存、2 核处理器的虚拟机环境。
静态分析
除了文件名,你还需要知道的第二个信息是文件的哈希值。我们可以使用 Quickhash (quickhash-gui.org/) 来帮助完成这个任务。在使用 Quickhash 打开 passcode.exe 文件后,我们可以获得各种算法的哈希计算。以下截图显示了 passcode.exe 文件的 SHA256 哈希值:
该文件的扩展名为 .exe。这使得我们首先想到使用用于分析 Windows 可执行文件的工具。但是,为了确保它确实是一个 Windows 可执行文件,我们可以使用 TriD 来获取文件类型。TrID (mark0.net/soft-trid-e.html) 是基于命令行的工具,应在命令提示符下运行。我们还需要从 mark0.net/download/triddefs.zip 下载并解压 TrID 的定义文件。在下面的截图中,我们使用了 dir 和 trid。通过使用 dir 获取目录列表,我们得到了文件的时间戳和文件大小。使用 trid 工具后,我们能够识别 passcode.exe 是什么类型的文件:
现在我们已经验证它是一个 Windows 可执行文件,使用 CFF Explorer 应该能给我们更多的文件结构细节。从 ntcore.com/ 下载并安装 CFF Explorer。打开后,你会看到以下界面:
TrID 和 CFF Explorer 都将该文件识别为 Windows 可执行文件,但它们的识别结果不一致。这可能会令人困惑,因为 TrID 将该文件识别为 Win64 可执行文件,而 CFF Explorer 将其识别为 可移植可执行文件 32。这需要从 PE 头文件本身识别机器类型。PE 文件的头文件参考可以在 www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx 查看。
我们可以使用 CFF Explorer 的 Hex Editor 查看二进制文件。第一列显示文件偏移量,中间列显示二进制的十六进制表示,最右边一列显示可打印字符:
文件以 MZ 魔术头(即 0x4d5a)开始,表示这是一个 Microsoft 可执行文件。在文件偏移量 0x3c 处,DWORD 值(按小端格式读取)为 0x00000080。这就是 PE 头部所在的文件偏移位置。PE 头部以 DWORD 值 0x00004550 或 PE 开头,后面跟随两个空字节。接下来是一个 WORD 值,告诉你程序可以运行的机器类型。在本程序中,我们得到 0x014c,这相当于 IMAGE_FILE_MACHINE_I386,意味着它可以在 Intel 386(32 位微处理器)及更高版本的处理器上运行,也可以在其他兼容的处理器上运行。
此时,我们已经知道的信息如下:
Filename: passcode.exe
Filesize: 16,766 bytes
MD5: 5D984DB6FA89BA90CF487BAE0C5DB300
SHA256: A5A981EDC9D4933AEEE888FC2B32CA9E0E59B8945C78C9CBD84085AB8D616568
File Type: Windows PE 32-bit
Compiler: MingWin32 - Dev C++
为了更好地了解文件,我们将其在沙盒中运行。
快速运行
从虚拟机中打开 Windows 沙盒,然后将 passcode.exe 的副本拖放并运行:
程序要求输入密码。猜测密码后,程序突然关闭。从这一事件中,我们获得的信息如下:
-
第一条信息是关于程序要求输入密码的。
-
第二条信息是程序打开了命令提示符。
这只是意味着程序应该在命令提示符下运行。
死亡列表
对于密码,我们可能能在文件本身的文本字符串中找到它。为了从文件中获取字符串列表,我们需要使用 SysInternal Suite 的 Strings 工具(docs.microsoft.com/en-us/sysinternals/downloads/strings)。Strings 是一个基于控制台的工具,输出的字符串列表将打印到控制台上。
该程序的源代码可以在 github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/passcode.c 找到。
我们应该通过运行 strings.exe passcode.exe > strings.txt 将输出重定向到文本文件:
尽管如此,当我们尝试字符串时仍然得到错误密码。也就是说,字符串确实显示了一个正确消息很可能会显示correct password. bye!。列表还显示了程序使用的许多 API。但是,知道这是使用 MingWin-Dev C++编译的,大部分使用的 API 可能是程序的初始化的一部分。
使用 IDA Pro 32 位反编译器对文件进行反汇编,我们可以看到主函数的代码。您可以从github.com/PacktPublishing/Mastering-Reverse-Engineering/tree/master/tools/Disassembler%20Tools下载并安装 IDA Pro。由于我们在 Windows 32 位环境中工作,请安装 32 位的idafree50.exe文件。这些安装程序是从官方 IDA Pro 网站获取的,并托管在我们的 GitHub 存储库中以确保可用性。
这个文件是一个 PE 文件,或者可移植可执行文件。应该以可移植可执行文件的形式打开,以读取 PE 文件的可执行代码。如果使用 MS-DOS 可执行文件打开,结果代码将是 16 位 MS-DOS 存根:
IDA Pro 能够识别主函数。它位于地址0x004012B8。向下滚动到图形概览,显示了块的分支情况,可能会让你了解程序代码在执行时的流程。要查看纯汇编代码,即没有图形表示,只需切换到文本视图模式:
由于这是一个 C 编译代码,我们只需要关注_main函数的分析。我们将尝试从分析中生成伪代码。将收集的信息包括 API,因为它们在代码流程中使用,使跳转分支的条件,以及使用的变量。程序中可能会注入一些特定的编译器代码,我们可能需要识别并跳过:
快速检查函数sub_401850和sub_4014F0,我们可以看到这里使用了_atexit API。atexit API 用于设置程序正常终止后将执行的代码。atexit和类似的 API 通常由高级编译器使用来运行清理代码。这些清理代码通常设计用于防止可能的内存泄漏,关闭已打开但未使用的句柄,释放已分配的内存,和/或为了优雅退出重新调整堆栈和堆:
在_atexit中使用的参数指向sub_401450,包含清理代码。
接下来,我们将调用 printf 函数。在汇编语言中,调用 API 需要将其参数按照顺序放置在栈顶。我们通常使用 push 指令将数据存入栈中。这段代码正是做了同样的事情。如果你右击 [esp+88h+var_88],会弹出一个下拉菜单,显示可能的变量结构列表。可以将指令行理解为 mov dword ptr [esp], offset aWhatIsThePassw:
这与 push offset aWhatIsThePassw 做的事情相同。方括号用于定义一个数据容器。在这个例子中,esp 是容器的地址,容器中存储的是 "what is the password?" 的地址。使用 push 和 mov 之间有区别。在 push 指令中,栈指针 esp 会被递减。总体而言,printf 得到了它需要的参数,用来将信息显示到控制台。
下一个 API 是 scanf。scanf 需要两个参数:输入格式和存储输入的地址。第一个参数位于栈顶,应该是输入格式,后面是存储输入的地址。修改后的变量结构应该是这样的:
给定的格式是 "%30[0-9a-zA-Z ]",这意味着 scanf 只会从输入的开头读取 30 个字符,并且只会接受方括号内的第一个字符集。接受的字符仅限于 "0" 到 "9"、"a" 到 "z"、"A" 到 "Z" 和空格字符。此类型的输入格式用于防止超过 30 个字符的输入。它还用于防止其余代码处理非字母数字字符,空格字符除外。
第二个参数,位于 [esp+4],应该是存储输入的地址。追溯回来,eax 寄存器的值设置为 [ebp+var_28]。我们只需注意,var_28 存储的地址是输入的密码。
strlen API 紧接其后,且只需要一个参数。追溯 eax 寄存器的值,var_28,即输入的密码,它是 strlen 将要使用的字符串。字符串的最终长度将存储在 eax 寄存器中。字符串大小与 11h 或 17 进行比较。在 cmp 之后,通常会有一个条件跳转。使用了 jnz 指令。如果比较结果为假,则跟随红线。如果条件为真,则跟随绿线。蓝线则直接跳到下一个代码块,如下所示:
跟随红线表示字符串长度为 17。此时,我们的伪代码如下:
main()
{
printf("what is the password? ");
scanf("%30[0-9a-zA-Z ]", &password);
password_size = strlen(password);
if (password_size == 17)
{ ... }
else
{ ... }
}
如果密码的长度不是 17,很可能会显示错误密码。 让我们首先跟随绿色路径:
绿线进入 loc_4013F4 块,随后是结束 _main 函数的 loc_401400 块。 loc_4013F4 处的指令是对 sub_401290 的调用。 该函数包含显示错误密码消息的代码。 请注意,许多行指向 loc_4013F4:
下面是使用错误密码功能构建伪代码的延续:
wrong_password()
{
printf("wrong password. try again!\n");
}
main()
{
printf("what is the password? ");
scanf("%30[0-9a-zA-Z ]", &password);
password_size = strlen(password);
if (password_size == 17)
{ ... }
else
{
wrong_password();
}
}
在逆向工程中的一个好技巧是尽可能找到最短的退出路径。 然而,这需要实践和经验。 这使得更容易描绘代码的整体结构。
现在,让我们分析 17 个字符长度的代码的其余部分。 让我们跟踪分支指令,并根据条件向后工作:
jle 的条件是对 var_60 和 0 的比较。 var_60 的值为 5,来自 var_5c。 这促使代码的方向沿着红线进行,如下所示:
放大一点,我们正在查看的代码实际上是一个具有两个退出点的循环。 第一个退出点是 var_60 的值小于或等于 0 的条件。 第二个退出点是寄存器 eax 指向的字节不应等于 65h 的条件。 如果进一步检查循环中的变量,可以看到 var_60 的初始值为 5。 var_60 的值在 loc_401373 块中递减。 这意味着循环将迭代 5 次。
我们还可以在循环中看到 var_8 和 var_5c。 但是,自主代码的开始以来,var_8 从未被设置。 var_5c 也不是作为变量使用,而是作为计算地址的一部分。 IDA Pro 帮助识别了可能作为 main 函数堆栈帧一部分使用的变量,并将其基础设置为 ebp 寄存器中。 这次,我们可能需要通过仅在循环代码中选择给出的列表中的结构来取消对 var_8 和 var_5c 的变量识别。 这可以通过右键单击变量名称来完成:
因此,计算 eax 中的值时,我们从 lea 指令行开始。存储到 edx 中的值是从 ebp 中减去 8 后得到的差值。这里的 lea 并不会获取 ebp-8 中存储的值,不同于使用 mov 指令时的行为。存储在 ebp 中的值是进入 main 函数后 esp 寄存器中的值。这使得 ebp 成为堆栈帧的基址。引用堆栈帧中的变量需要使用 ebp。记住,堆栈是通过从高地址向低地址递减来使用的。这就是为什么从 ebp 寄存器引用时需要相对减法的原因:
现在,在 add 指令行中,要存储到 edx 中的值将是 edx 和从计算地址中存储的值的总和。这个计算出的地址是 eax*4-5Ch。eax 是来自 var_60 的值,包含从 5 到 0 递减的值。但由于当 var_60 达到 0 时循环结束,这行中的 eax 只会有从 5 到 1 的值。计算所有五个地址时,应该得到以下输出:
[ebp+5*4-5ch] -> [ebp-48h] = 10h
[ebp+4*4-5ch] -> [ebp-4Ch] = 0eh
[ebp+3*4-5ch] -> [ebp-50h] = 7
[ebp+2*4-5ch] -> [ebp-54h] = 5
[ebp+1*4-5ch] -> [ebp-58h] = 3
也正因为如此,在调用第一个 printf 函数之前,这些堆栈帧地址中存储的值就已设置。在此时,给定 eax 从 5 到 1 的值,edx 应该具有以下结果值:
eax = 5; edx = ebp-8+10h; edx = ebp+8
eax = 4; edx = ebp-8+0eh; edx = ebp+6
eax = 3; edx = ebp-8+7; edx = ebp-1
eax = 2; edx = ebp-8+5; edx = ebp-3
eax = 1; edx = ebp-8+3; edx = ebp-5
edx 的结果值随后通过 mov 指令存储到 eax 中。然而,在这之后,eax 会被减去 20h:
from eax = 5; eax = ebp+8-20h; eax = ebp-18h
from eax = 4; eax = ebp+6-20h; eax = ebp-1ah
from eax = 3; eax = ebp-1-20h; eax = ebp-21h
from eax = 5; eax = ebp-3-20h; eax = ebp-23h
from eax = 5; eax = ebp-5-20h; eax = ebp-25h
接下来的两行代码是循环的第二个退出条件。cmp 指令将 65h 与 eax 指向的地址中存储的值进行比较。65h 的等效 ASCII 字符是 "e"。如果 eax 指向的地址中的值与 65h 不匹配,代码将退出循环。如果发生不匹配,跟随红色线条会调用 sub_401290,这恰好是错误密码函数。与字符 "e" 进行比较的地址必须是输入字符串的一部分。
如果我们将堆栈帧绘制成一个表格,它看起来可能是这样的:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| -60h | 03 | 00 | 00 | 00 | 05 | 00 | 00 | 00 | ||||||||
| -50h | 07 | 00 | 00 | 00 | 0e | 00 | 00 | 00 | 10 | 00 | 00 | 00 | ||||
| -40h | ||||||||||||||||
| -30h | X | X | X | e | X | e | X | e | ||||||||
| -20h | X | X | X | X | X | X | e | X | e | |||||||
| -10h | ||||||||||||||||
| ebp |
我们需要考虑 scanf 将输入的密码存储在 ebp-var_28 或 ebp-28 中。知道正确密码恰好有 17 个字符,我们用 X 标记了这些输入位置。我们还需要设置那些应该与 "e" 匹配的地址以继续。记住,字符串从偏移量 0 开始,而不是 1。
现在我们对循环很熟悉了,那么我们的伪代码到目前为止应该是这个样子:
wrong_password()
{
printf("wrong password. try again!\n");
}
main()
{
e_locations[] = [3, 5, 7, 0eh, 10h];
printf("what is the password? ");
scanf("%30[0-9a-zA-Z ]", &password);
password_size = strlen(password);
if (password_size == 17)
{
for (i = 5; i >= 0; i--)
if (password[e_locations[i]] != 'e')
{
wrong_password();
goto goodbye;
}
...
}
else
{
wrong_password();
}
goodbye:
}
在循环之后,我们会看到另一个使用strcmp的块,这次我们校正了一些变量结构,以更好地了解我们的栈帧可能是什么样子:
前两条指令从ebp-1Ah和ebp-25h读取DWORD值,并用于计算二进制 AND。查看我们的栈帧,这两个位置都在输入密码字符串区域内。最终再次使用二进制 AND 处理结果值和0FFFFFFh。最终值存储在ebp-2Ch。然后使用strcmp比较存储在ebp-2Ch处的值与字符串"ere"。如果字符串比较不匹配,绿线会进入错误密码代码块。
使用AND指令和0FFFFFFh意味着只限于 3 个字符。对来自密码字符串的两个DWORD使用AND将意味着两者应该相等,至少在 3 个字符上。因此,ebp-1Ah和ebp-25h应包含"ere":
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| -60h | 03 | 00 | 00 | 00 | 05 | 00 | 00 | 00 | ||||||||
| -50h | 07 | 00 | 00 | 00 | 0e | 00 | 00 | 00 | 10 | 00 | 00 | 00 | ||||
| -40h | ||||||||||||||||
| -30h | e | r | e | X | X | X | e | r | e | X | e | |||||
| -20h | X | X | X | X | X | X | e | r | e | |||||||
| -10h | ||||||||||||||||
| ebp |
让我们继续下一个代码集,按照红线操作:
所有的绿线都指向错误密码代码块。因此,为了继续前进,我们必须遵循与红线相关的条件。在前面截图的第一个代码块中,使用XOR指令验证ebp-1Eh和ebp-22h处的字符是否相等。第二个块将来自相同偏移量ebp-1Eh和ebp-22h的字符值相加。总和应为40h。在这种情况下,字符应具有 ASCII 值20h,即空格字符。
第三块从ebp-28h读取DWORD值,然后使用 AND 指令仅取前 3 个字符。结果与647541h进行比较。如果转换为 ASCII 字符,读作"duA"。
第四个块执行与第三个相同的方法,但从ebp-1Dh中取出DWORD,并将其与636146h或"caF"进行比较。
最后一个块从ebp-20h读取一个 WORD 值,并将其与7473h或"ts"进行比较。
把这些写到我们的栈帧表中应该用小端法完成:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | A | B | C | D | E | F | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| -60h | 03 | 00 | 00 | 00 | 05 | 00 | 00 | 00 | ||||||||
| -50h | 07 | 00 | 00 | 00 | 0e | 00 | 00 | 00 | 10 | 00 | 00 | 00 | ||||
| -40h | ||||||||||||||||
| -30h | e | r | e | A | u | d | e | r | e | e | ||||||
| -20h | s | t | F | a | c | e | r | e | ||||||||
| -10h | ||||||||||||||||
| ebp |
密码应该是 "Audere est Facere"。如果成功,它应该会运行正确的密码函数:
为了完成我们的伪代码,我们需要计算从 ebp-28h 开始的字符串相对偏移量。ebp-28h 是密码字符串的偏移量,值为 0,而字符串中的最后一个偏移量,即偏移量 16,应位于 ebp-18h:
wrong_password()
{
printf("\nwrong password. try again!\n");
}
correct_password()
{
printf("\ncorrect password. bye!\n");
}
main()
{
e_locations[] = [3, 5, 7, 0eh, 10h];
printf("what is the password? ");
scanf("%30[0-9a-zA-Z ]", &password);
password_size = strlen(password);
if (password_size == 17)
{
for (i = 5; i >= 0; i--)
if (password[e_locations[i]] != 'e')
{
wrong_password();
goto goodbye;
}
if ( (password[6] ^ password[10]) == 0 ) // ^ means XOR
if ( (password[6] + password[10]) == 0x40 )
if ( ( *(password+0) & 0x0FFFFFF ) == 'duA' )
if ( ( *(password+11) & 0x0FFFFFF ) == 'caF' )
if ( ( *(password+8) & 0x0FFFF ) == 'ts' )
{
correct_password();
goto goodbye
}
}
wrong_password();
goodbye:
}
使用调试器进行动态分析
没有什么比验证我们在静态分析中假设的内容更好的了。只需运行程序并输入密码,任务就完成了:
死列表与调试程序同样重要。两者可以同时进行。调试有助于加速死列表过程,因为它也能同时验证。对于本次练习,我们将通过使用 x32dbg 重新分析 passcode.exe,下载地址为 x64dbg.com。
在 x32dbg 中打开 passcode.exe 后,注册 EIP 时会位于一个较高的内存区域。这绝对不在 passcode.exe 映像的任何部分:
为了绕过这个问题,点击“选项->首选项”,然后在“事件”标签下,取消选中 系统断点:
点击保存按钮,然后使用“调试->重启”或按 Ctrl + F2。这将重启程序,但现在 EIP 应该会停在 PE 文件的入口点地址:
由于我们也知道 main 函数的地址,我们需要在该地址设置一个断点并让程序运行(*F9*)。为此,在命令框中输入以下内容:
bp 004012b8
运行后,EIP 应该停在 main 函数的地址。我们能看到与死列表时相同的一段代码:
F7 和 F8 是进入单步执行和单步跳过的快捷键。点击调试菜单,你应该可以看到分配给调试命令的快捷键。继续尝试这些命令;如果弄乱了,随时可以重启。
使用调试器的好处是你应该能轻松看到堆栈帧。有五个内存转储窗口组成堆栈帧。我们来使用转储 2 来展示堆栈帧。执行两步指令,让 ebp 设置为堆栈帧的基址。在左侧窗格的寄存器列表中,右击寄存器 EBP,然后选择“跟随转储->转储 2”。这将把转储 2 显示出来。由于堆栈是从较高地址向下移动的,你需要将滚动条向上滚动,显示堆栈帧中的初始数据:
这是输入 scanf 后相同的栈帧。另外,在 scanf 期间,您需要切换到命令提示符窗口以输入密码,然后再切换回来。以下截图中还包括了栈窗口,位于右侧窗格:
即使在调试器中,我们也可以随时更改输入字符串的内容,从而强制程序继续执行,直到正确的密码条件出现。我们所需要做的就是右键点击 Dump 窗口中的字节,并选择修改值*。* 例如,在比较 65h ("e") 和寄存器 eax 指向地址中存储的值的循环中,在执行 cmp 指令之前,我们可以更改该地址处的值。
在下面的截图中,地址 0060FF20h(EAX)处存储的值正在从 35h 修改为 65h:
也可以通过右键点击字节进行二进制编辑,然后选择 Binary->Edit 来进行相同的修改。
如果我们输入了正确的密码,这里应该是我们最终的结果:
反编译器
如果伪代码能够自动提供给我们,那可能会更容易。确实存在一些工具,可能能帮助我们实现这一点。我们来尝试反编译 passcode.exe(github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch7/passcode.exe)使用 Snowman 的独立版本(derevenets.com/)。打开文件后,点击 View->Inspector。这将显示一个包含程序解析函数的框。寻找函数定义 _main,选择它以显示与汇编语言等效的伪代码。此时,左侧窗格会突出显示汇编语言行,中央窗格则显示伪代码:
截至撰写本书时,输出的 C 源代码可能有所帮助,但并非所有代码都正确反编译。例如,比较 "e" 的循环未能正确反编译。输出显示为一个 while 循环,但我们预期 v10 变量的值应该是从密码字符串中计算出的偏移量读取的。然而,大部分代码应该能够在某种程度上帮助我们理解程序的工作方式。该反编译器引擎是开源的(www.capstone-engine.org/),因此不应期待过多支持,因为它并非时刻可用。
好消息是,已经有更强大的反编译工具,例如 HexRays。大多数机构以及一些进行逆向工程的独立分析师和研究人员愿意为这些反编译工具付费。对大多数逆向工程师来说,HexRays 性价比非常高。
这是passcode.exe的 HexRays 反编译版本:
反编译工具在不断发展,因为这些工具可以加快分析速度。它们并不能完美地反编译,但应该接近源代码。
总结
在本章中,我们介绍了逆向工程,首先通过学习 API 在功能程序中的使用来开始。接着,我们使用静态和动态分析工具对程序进行了反向分析。
总体来说,Windows 平台上有很多可用的逆向工具。这些工具也包含了大量的关于如何在特定逆向场景中使用它们的信息和研究。逆向工程主要是通过获取来自互联网的资源,以及你已经掌握的知识,我们已经做到了这一点。
进一步阅读
-
visualstudio.microsoft.com: 这是 Visual Studio 的下载站点 -
docs.microsoft.com/en-us/visualstudio/productinfo/vs2017-system-requirements-vs:该网站展示了安装 Visual Studio 的推荐系统要求 -
sourceforge.net/projects/orwelldevcpp/: 该网站包含了 Dev C++的二进制下载文件 -
developer.microsoft.com/en-us/microsoft-edge/tools/vms/: 可以在这里下载预装的 Microsoft Windows 的虚拟机版本 -
mark0.net/soft-trid-e.html:TrID 工具及其签名数据库文件的下载站点 -
www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx:Microsoft Portable E 的文档
第八章:沙盒 - 作为逆向工程组成部分的虚拟化
在之前的章节中,我们使用了虚拟化软件,特别是 VirtualBox 或 VMware,来设置 Linux 和 Windows 环境进行分析。虚拟化工作得很好,因为这些虚拟化软件仅支持 x86 架构。虚拟化是逆向工程中非常有用的组件。事实上,大多数软件都是在 x86 架构下构建的。虚拟化通过虚拟机监控器使用主机计算机的 CPU 资源。
不幸的是,还有其他不支持虚拟化的 CPU 架构。VirtualBox 和 VMware 不支持这些架构。如果我们被给定了一个非 x86 可执行文件来处理,而我们所有的设备都只安装了 x86 操作系统,怎么办?嗯,这并不会阻止我们进行逆向工程。
为了解决这个问题,我们将使用模拟器。模拟器早在虚拟机监控器引入之前就已经存在。模拟器本质上是模拟一个 CPU 机器。把它当作一台新机器,运行在非 x86 架构上的操作系统可以被部署。然后,我们就可以运行原生的可执行文件。
在本章中,我们将学习如何使用 QEMU 部署非 x86 操作系统。我们还将学习如何使用 Bochs 模拟 x86 计算机的启动过程。
模拟
模拟的魅力在于它可以欺骗操作系统,让操作系统认为它在某种 CPU 架构上运行。缺点是性能明显较慢,因为几乎每一条指令都需要解释执行。简要说明 CPU,有两种 CPU 架构设计:复杂指令集计算(CISC)和简化指令集计算(RISC)。在汇编编程中,CISC 只需要少量指令。例如,一个单一的算术指令,如 MUL,会在其内部执行更低级的指令。而在 RISC 中,低级程序需要仔细优化。实际上,CISC 的优点在于需要较少的内存空间,但每条指令的执行时间较长。另一方面,RISC 由于以简化的方式执行指令,因此具有更好的性能。然而,如果代码没有得到适当的优化,针对 RISC 构建的程序可能无法达到预期的执行速度,且可能会占用较多空间。高级编译器应该能够优化 RISC 的低级代码。
这里有一个简短的 CPU 架构列表,按照 CISC 和 RISC 分类:
-
CISC:
-
摩托罗拉 68000
-
x86
-
z/Architecture
-
-
RISC:
-
ARM
-
ETRAX CRIS
-
DEC Alpha
-
LatticeMico32
-
MIPS
-
MicroBlaze
-
Nios II
-
OpenRISC
-
PowerPC
-
SPARC
-
SuperH
-
惠普 PA-RISC
-
英飞凌 TriCore
-
UNICORE
-
Xtensa
-
在 CISC 和 RISC 架构中,x86 和 ARM 都非常流行。x86 由 Intel 和 AMD 的计算机使用,目的是减少程序使用的指令数。新的设备,如智能手机和其他移动设备,采用 ARM 架构,因为它具有低功耗和高性能的优势。
本章讨论的目的是在 x86 机器上模拟 ARM 架构。我们选择 ARM 架构,因为它目前是手持设备中最常用的处理器。
在 x86 主机上模拟 Windows 和 Linux
我们解释了在虚拟机上安装操作系统时,它遵循主机机器的架构。例如,Windows x86 版本只能安装在安装在 x86 机器上的虚拟机上。
许多 Linux 操作系统,包括 Arch Linux、Debian、Fedora 和 Ubuntu,都支持在 ARM 处理器上运行。另一方面,Windows RT 和 Windows Mobile 是为使用 ARM CPU 的设备构建的。
由于我们在使用 x86 处理器的 PC 上工作,分析一个非 x86 架构的可执行文件仍然遵循相同的静态和动态分析逆向工程概念。唯一的不同是,我们需要为可执行文件运行设置环境,并学习可以在这个模拟环境中使用的工具。
模拟器
我们将介绍两种最流行的模拟器:QEMU(快速模拟器)和 Bochs。
QEMU 因其支持多种架构(包括 x86 和 ARM)而被认为是最广泛使用的模拟器。它还可以安装在 Windows、Linux 和 macOS 上。QEMU 通过命令行使用,但也有可用的 GUI 工具,如 virt-manager,可以帮助设置和管理来宾操作系统镜像。然而,virt-manager 仅适用于 Linux 主机。
Bochs 是另一种模拟器,但仅支持 x86 架构。值得一提的是,这个模拟器用于调试内存引导记录(MBR)代码。
在不熟悉的环境中进行分析
在这里,逆向工程概念是相同的。然而,工具的可用性是有限的。静态分析仍然可以在 x86 环境中进行,但当我们需要执行文件时,它将需要沙箱模拟。
最好在模拟环境中本地调试本地可执行文件。但如果本地调试条件有限,另一种选择是进行远程调试。对于 Windows,最常用的远程调试工具是 Windbg 和 IDA Pro。对于 Linux,我们通常使用 GDB。
分析 ARM 编译的可执行文件与分析 x86 可执行文件的过程差别不大。我们遵循与 x86 相同的步骤:
-
学习 ARM 低级语言
-
使用反汇编工具进行死机列表分析
-
在操作系统环境中调试程序
学习 ARM 低级语言的方式与我们学习 x86 指令的方式相同。我们只需要理解内存地址空间、通用寄存器、特殊寄存器、栈和语言语法。这还包括如何调用 API 函数。
可以使用 IDA Pro 等工具,以及其他 ARM 反汇编工具,来显示本地 ARM 可执行文件的 ARM 反汇编代码。
QEMU 中的 Linux ARM 客户机
Linux ARM 可以安装在 QEMU 的 ARM CPU 客户机中,该客户机运行在 Windows 系统的 x86 CPU 上。那么,让我们直接开始部署 Arch Linux ARM 吧。由于有很多可供下载的资源,运行 Arch Linux 实例作为 QEMU 客户机并不难。为了演示,我们将使用一个预先安装的 Arch Linux 镜像并在 QEMU 中运行它。准备下载以下文件:
-
QEMU:
qemu.weilnetz.de/ -
Arch Linux 镜像:
downloads.raspberrypi.org/arch/images/archlinuxarm-29-04-2012/archlinuxarm-29-04-2012.img.zip -
系统内核:
github.com/okertanov/pinguin/blob/master/bin/kernel/zImage-devtmpfs
在本书中,我们将在 Windows 主机上安装 QEMU。在安装过程中,注意 QEMU 的安装位置。这一点尤其重要,因为 QEMU 的路径将在后续使用。
将 archlinuxarm-29-04-2012.img.zip 的镜像文件解压到一个新目录中,并将 zImage-devtmpfs 复制到同一目录下。
打开镜像和内核文件所在目录的命令行。然后,执行以下命令:
"c:\Program Files\qemu\qemu-system-arm.exe" -M versatilepb -cpu arm1136-r2 -hda archlinuxarm-29-04-2012.img -kernel zImage-devtmpfs -m 192 -append "root=/dev/sda2" -vga std -net nic -net user
在这里,将 C:\Program Files\qemu 更改为 QEMU 安装的路径。这应该会启动 QEMU 并运行 Arch Linux,如下所示:
现在,使用以下凭据登录:
alarmpi login: root
Password: root
你可以像使用常规的 Linux 控制台一样进行操作。Arch Linux 是一款由 Raspberry Pi 爱好者安装的流行操作系统。
使用 Bochs 进行 MBR 调试
当我们开启计算机时,首先执行的代码来自 BIOS(基本输入输出系统),它是嵌入在 CPU 中的程序。它执行一个开机自检(POST),以确保连接的硬件正常工作。BIOS 将主引导记录(MBR)加载到内存中,然后将代码执行传递下去。主引导记录(MBR)是从指定启动磁盘的第一个磁盘扇区读取的。MBR 包含引导加载程序,负责加载操作系统。
例如,如果我们想要调试给定的 MBR 镜像,我们可以使用一个名为 Bochs 的模拟器来进行调试。Bochs 可以从 bochs.sourceforge.net/ 下载。
为了测试这个,我们提供了一个可以从 github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch8/mbrdemo.zip 下载的磁盘镜像。这个 ZIP 压缩包解压后约为 10MB,文件中包含 mre.bin 磁盘镜像和将传递给 Bochs 的 bochsrc 配置文件。
如果我们使用 IDA Pro 打开 mre.bin,应该能够静态分析 MBR 代码。MBR 几乎总是从 0x7c00 地址开始。它是一个 16 位代码,使用硬件中断来控制计算机。
在 IDA Pro 中加载文件时,请确保将加载偏移量更改为0x7c00,如下面的截图所示:
当询问反汇编模式时,选择 16 位模式。由于一切仍然是未定义的,我们需要将数据转换为代码。选择第一个字节代码,右键单击以打开上下文菜单,然后选择 Code,如下所示:
当转换为反汇编代码时,我们可以看到 IDA Pro 也能识别中断函数及其使用方式。以下截图展示了 16 位的反汇编代码,以及使用中断 13h 从磁盘扇区读取数据:
要使用 Bochs 调试 MBR,我们必须确保 bochsrc 文件中包含以下行:
display_library: win32, options="gui_debug"
这一行启用了 Bochs 图形界面调试器的使用。
如果我们有不同的磁盘镜像,可以在 at0-master 行中更改磁盘镜像文件的文件名。在这个演示中,磁盘镜像的文件名是 mre.bin:
ata0-master: type=disk, path="mre.bin", mode=flat
要模拟该磁盘镜像,执行以下命令:
set $BXSHARE=C:\Program Files (x86)\Bochs-2.6.8
"C:\Program Files (x86)\Bochs-2.6.8\bochsdbg.exe" -q -f bochsrc
你可能需要将 C:\Program files (x86)\Bochs-2.6.8 更改为你安装 Bochs 的路径。请注意,对于 $BXSHARE 环境变量,没有引号。
这里 Bochs 是在 Windows 环境下安装的。如果在 Linux 环境下工作,可以更改路径。
一旦运行,控制台将会显示日志输出,如下所示:
这将打开调试控制台,界面应如下面的截图所示:
另一个显示输出的窗口也应该出现:
MBR 代码从 0x7c00 地址开始。我们需要在 0x7c00 设置一个断点。Bochs 的图形界面有一个命令行,我们可以在这里设置指定地址的断点。它位于窗口的底部。请参阅下面截图中高亮的区域:
要在 0x7c00 设置断点,请输入 lb 0x7c00。要查看命令列表,请输入 help。常用的命令如下:
c Continue/Run
Ctrl-C Break current execution
s [count] Step. count is the number of instructions to step
lb address Set breakpoint at address
bpe n Enable breakpoint where n is the breakpoint number
bpd n Disable breakpoint where n is the breakpoint number
del n Delete breakpoint where n is the breakpoint number
info break To list the breakpoints and its respective numbers
GUI 界面还将键盘按键与命令进行了映射。选择命令菜单查看这些按键。
按下F5继续代码执行,直到它到达0x7c00处的 MBR 代码。我们现在应该能看到与在 IDA Pro 中看到的相同的反汇编代码。然后我们可以开始按F11逐步调试每一行指令:
在某个时刻,代码将进入无限循环状态。如果我们查看输出窗口,最终结果应该会显示相同的消息,如以下截图所示:
总结
在本章中,我们了解到,即使文件不是 Windows 或 Linux x86 本地可执行文件,我们仍然可以分析非 x86 可执行文件。仅通过静态分析,我们可以分析一个文件,而无需进行动态分析,尽管我们仍然需要参考资料来理解非 x86 架构的低级语言,这些架构被分类为 RISC 或 CISC。正如我们学习 x86 汇编语言一样,像 ARM 汇编语言这样的语言也可以通过相同的概念来学习。
然而,通过实际代码执行,使用动态分析仍然可以证明分析的有效性。为此,我们需要设置一个可以本地运行可执行文件的环境。我们介绍了一种名为 QEMU 的仿真工具,它可以为我们完成这项工作。它支持多种架构,包括 ARM。今天,使用 ARM 架构的最流行操作系统之一是 Arch Linux。这个操作系统通常由树莓派爱好者部署。
我们还学习了如何调试从磁盘镜像中提取的 MBR 代码。通过使用 Bochs,一个能够模拟 x86 系统启动顺序的工具,我们能够演示如何加载和调试使用硬件中断的 16 位代码。此外,一些勒索软件采用了可以注入或替换 MBR 为恶意代码的功能。通过我们在本章学到的内容,没有什么可以阻止我们逆向这些代码片段。
进一步阅读
-
KVM 和 CPU 功能启用 -
wiki.qemu.org/images/c/c8/Cpu-models-and-libvirt-devconf-2014.pdf -
在 QEMU 中安装 Windows ARM 的方法 -
withinrafael.com/2018/02/11/boot-arm64-builds-of-windows-10-in-qemu/ -
如何在 Windows PC 上使用 Bochs 仿真器调试系统代码 -
thestarman.pcministry.com/asm/bochs/bochsdbg.html
第九章:二进制混淆技术
二进制混淆是一种使程序代码难以理解或逆向的技术。它也用于隐藏数据,以防止数据轻易被查看。它可以被归类为一种逆向防护技术,通过增加逆向处理时间来提高难度。混淆还可以使用加密和解密算法,以及其硬编码或代码生成的密码密钥。
本章将讨论数据和代码如何被混淆。我们将展示如何在示例中应用混淆,包括简单的 XOR、简单的算术运算、在堆栈中构建数据,以及关于多态性和变形代码的讨论。
在恶意软件的世界里,二进制混淆是病毒常用的一种技术,旨在击败基于签名的防病毒软件。当病毒感染文件时,它会通过多态性或变形来混淆其代码。
本章将实现以下学习目标:
-
确定正在堆栈上组装的数据
-
确定数据在使用前是否经过 XOR 或解混淆
-
修改文本或其他段中的数据,并在堆上组装
堆栈上的数据组装
堆栈是一个内存空间,可以在其中存储任何数据。可以使用堆栈指针寄存器来访问堆栈(对于 32 位地址空间,使用 ESP 寄存器)。让我们考虑以下代码片段的示例:
push 0
push 21646c72h
push 6f57206fh
push 6c6c6548h
mov eax, esp
push 74h
push 6B636150h
mov edx, esp
push 0
push eax
push edx
push 0
mov eax, <user32.MessageBoxA>
call eax
这最终会显示以下消息框:
在没有引用可见文本字符串的情况下,为什么会发生这种情况?在调用MessageBoxA函数之前,堆栈看起来是这样的:
这些 push 指令将以 null 终止的消息文本组装到堆栈中。
push 0
push 21646c72h
push 6f57206fh
push 6c6c6548h
另一字符串是通过这些 push 指令组装的:
push 74h
push 6B636150h
实际上,堆栈转储将如下所示。
每次字符串组装后,寄存器 ESP 的值会存储到 EAX 中,然后是 EDX。也就是说,EAX 指向第一个字符串的地址,EDX 指向第二个组装字符串的地址。
MessageBoxA接受四个参数。第二个参数是消息文本,第三个参数是标题文本。从上面的堆栈转储中,字符串位于地址0x22FE50和0x22FE54。
push 0
push eax
push edx
push 0
mov eax, <user32.MessageBoxA>
MessageBoxA已经具备了所需的所有参数。尽管字符串是在堆栈上组装的,但只要数据可以访问,就可以使用它。
代码组装
代码方面也可以使用相同的概念。这是另一个代码片段:
push c3
push 57006a52
push 50006ad4
push 8b6b6361
push 5068746a
push c48b6c6c
push 6548686f
push 57206f68
push 21646c72
push 68006a5f
mov eax, esp
call eax
mov eax, <user32.MessageBoxA>
call eax
这会产生与之前相同的消息框。不同之处在于,这段代码将opcode字节推送到堆栈中,并将代码执行传递给它。在进入第一个call eax指令之后,堆栈将如下所示:
记住,栈顶的值应包含call指令设置的返回地址。到目前为止,我们的指令指针应该在这里:
pop edi指令将返回地址存储到EDI寄存器中。组装消息文本的同一指令集在这里也被使用。最后,执行push edi,然后是ret指令,应该会返回到返回地址。
结果栈应该是这样的:
接下来是一些调用MessageBoxA的指令。
在栈中运行代码的这种技术被许多恶意软件采用,包括软件漏洞利用。作为防止恶意软件代码执行的措施,一些操作系统已经发布了安全更新,以禁止栈中代码的执行。
加密数据识别
杀毒软件的主要功能之一是通过签名检测恶意软件。签名是恶意软件特有的一组字节序列。虽然现在这种检测技术被认为对杀毒软件不再有效,但它仍然在文件检测中扮演着重要角色,尤其是在操作系统断网时。
简单的签名检测可以通过加密恶意软件的数据和/或代码轻松破解。这样,新的签名将从加密数据的独特部分生成。攻击者可以简单地使用不同的密钥重新加密相同的恶意软件,从而生成另一个签名。但恶意软件的行为依然保持不变。
当然,杀毒软件已经做出了很大改进来应对这种技术,使得签名检测成为过去的技术。
另一方面,这是一种混淆技术,增加了逆向软件的额外时间消耗。在静态分析下,识别加密数据和解密例程能让我们了解在分析过程中应该预期什么,尤其是在调试时。首先,我们将查看几个代码片段。
循环代码
通过检查在循环中运行的代码,可以轻松识别解密过程:
mov ecx, 0x10
mov esi, 0x00402000
loc_00401000:
mov al, [esi]
sub al, 0x20
mov [esi], al
inc esi
dec ecx
jnz loc_00401000
这个循环代码由条件跳转控制。要识别解密或加密代码,它应该有一个源地址和一个目的地址。在这段代码中,源地址从0x00402000开始,目的地址也位于相同的地址。数据中的每个字节都会被算法修改。在这个例子中,算法是从字节中减去0x20。只有在0x10字节数据被修改时,循环才会结束。0x20被确定为加密/解密密钥。
算法可以有所不同,可以使用标准算术和二进制运算,或仅使用标准算术。只要源数据在循环中被修改并写入目的地,我们就可以说已识别出加密例程。
简单算术
除了使用按位操作外,基本的数学运算也可以使用。如果加法有一个对应的减法运算,我们可以使用加法对文件进行加密,并使用减法解密,反之亦然。以下代码演示了使用加法进行解密:
mov ecx, 0x10
mov esi, 0x00402000
loc_00401000:
mov al, [esi]
add al, 0x10
mov [esi], al
inc esi
dec ecx
jnz loc_00401000
字节值的美妙之处在于它们可以作为有符号数字处理,例如,给定这组加密信息:
data = 0x00, 0x01, 0x02, 0x0a, 0x10, 0x1A, 0xFE, 0xFF
key = 0x11
encrypt algorithm = byte subtraction
decrypt algorithm = byte addition
每个字节都减去0x11后,加密数据将如下所示:
encrypted data = 0xEF, 0xF0, 0xF1, 0xF9, 0xFF, 0x09, 0xED, 0xEE
为了恢复它,我们需要加回之前减去的相同值0x11:
decrypted data = 0x00, 0x01, 0x02, 0x0a, 0x10, 0x1A, 0xFE, 0xFF
如果我们查看前面字节在无符号和有符号形式下的等效十进制值,数据将如下所示:
data (unsigned) = 0, 1, 2, 10, 16, 26, 254, 255
data (signed) = 0, 1, 2, 10, 16, 26, -2, -1
这是以十进制值显示的加密数据:
encrypted data (unsigned) = 239, 240, 241, 249, 255, 9, 237, 238
encrypted data (signed) = -17, -16, -15, -7, -1, 9, -19, -18
总结一下,如果我们使用基本的算术运算,我们应该以值的有符号形式来看待它。
简单的 XOR 解密
XOR 是软件加密中最常用的操作符。如果我们要修改前面代码片段中的代码算法,它会变成这样:
mov ecx, 0x10
mov esi, 0x00402000
loc_00401000:
mov al, [esi]
xor al, 0x20
mov [esi], al
inc esi
dec ecx
jnz loc_00401000
它之所以受欢迎,是因为相同的算法可以用来加密和解密数据。使用相同的密钥,XOR可以恢复原始数据。与使用SUB时不同,数据恢复的对应操作需要使用ADD算法。
这里有一个快速演示:
Encryption using the key 0x20:
data: 0x46 = 01000110b
key: 0x20 = 00100000b
0x46 XOR 0x20 = 01100110b = 0x66
Decryption using the same key:
data: 0x66 = 01100110b
key: 0x20 = 00100000b
0x66 XOR 0x20 = 01000110b = 0x46
在其他内存区域装配数据
可以在进程的图像空间之外的其他内存区域执行数据。类似于如何在堆栈空间执行代码,堆和新分配的内存空间等内存区域,可以用来操控数据并执行代码。这是一种不仅恶意软件,甚至合法应用程序也常用的技术。
访问堆需要调用 API,如HeapAlloc(Windows)或通常的malloc(Windows 和 Linux)。每个创建的进程都会分配一个默认的堆空间。Heap通常用于请求一小块内存空间。堆的最大大小在操作系统之间有所不同。如果请求的内存空间大小无法适配当前堆空间,HeapAlloc或malloc会内部调用VirtualAlloc(Windows)或sbrk(Linux)函数。这些函数会直接向操作系统的内存管理器请求内存空间。
分配的内存空间有明确的访问权限。就像程序的各个段一样,它们通常有读取、写入和执行权限。如果该区域需要执行代码,则应设置读取和执行权限。
查看以下代码片段,它实现了将数据解密到堆空间:
call GetProcessHeap
push 1000h ; dwBytes
mov edi, eax
push 8 ; dwFlags
push edi ; hHeap
call HeapAlloc
push 1BEh ; Size
mov esi, eax
push offset unk_403018 ; Src
push esi ; Dst
call memcpy
add esp, 0Ch
xor ecx, ecx
nop
loc_401030:
xor byte ptr [ecx+esi], 58h
inc ecx
cmp ecx, 1BEh
jl short loc_401030
该代码分配了1000h字节的堆空间,然后将1BEh字节的数据从地址0x00403018复制到分配的堆空间中。解密循环可以在这段代码中轻松识别出来。
算法使用XOR,密钥值为58h。数据大小为1BEh,数据直接更新到相同的已分配堆空间中。迭代由ECX寄存器控制,而加密数据的位置(在堆地址处)存储在ESI寄存器中。
让我们看看在调试工具的帮助下,哪些内容被解密。
使用 x86dbg 解密
上述代码片段来自HeapDemo.exe文件。你可以从github.com/PacktPublishing/Mastering-Reverse-Engineering/tree/master/ch9下载该文件。开始使用x86dbg调试该文件吧。该截图展示了在x86dbg中加载文件后的WinMain函数反汇编代码:
从可执行文件的代码入口点开始,我们遇到了通过GetProcessHeap和RtlAllocateHeap API 进行的堆分配。接着使用了_memcpy函数,它将0x1BE字节的数据从heapdemo.enc指定的地址复制过来。让我们来看看heapdemo.enc的内存转储。为此,右键点击push <heapdemo.enc>,然后选择“Follow in Dump”。点击给定地址,而不是选中的地址。这应该会改变当前聚焦的Dump窗口中的内容:
这应该是下一行代码将在循环中解密的数据。我们还应该能在执行_memcpy后,在已分配的堆空间中看到相同的加密数据。已分配堆空间的地址应仍然存储在寄存器ESI中。在显示寄存器和标志列表的窗口中右键点击ESI寄存器的值,然后选择“Follow in Dump”。这将显示堆地址空间中相同的数据内容。下图显示的转储是加密数据:
接下来是有趣的部分——解密。在查看堆的转储时,继续执行调试步骤。你应该注意到随着xor byte ptr ds:[ecx+esi], 58指令的执行,值会发生变化:
由于逐步调试这些字节 0x1BE 次会非常繁琐,我们可以简单地在jl指令后的行设置断点,然后按*F9*继续运行指令。这应该会生成这个解密后的转储:
继续调试代码;它的结束通过清理分配的堆并退出进程来完成。使用HeapFree API 来释放已分配的堆。通常,使用ExitProcess API 来退出程序。这次,它使用GetCurrentProcess和TerminateProcess来执行这一操作。
其他混淆技术
我们讨论的混淆技术是基于使用简单的加密方法来隐藏实际的字符串和代码。然而,还有其他方法可以混淆代码。只要有阻止数据和代码轻易提取与分析的概念,混淆就会发生。让我们再讨论一些混淆技术。
控制流平坦化混淆
控制流平坦化的目的是让简单的代码看起来像一组复杂的条件跳转。我们来考虑这段简单的代码:
cmp byte ptr [esi], 0x20
jz loc_00EB100C
mov eax, 0
jmp loc_00EB1011
loc_00EB100C:
mov eax, 1
loc_00EB1011:
test eax, eax
ret
当使用控制流平坦化方法进行混淆时,代码将像这样:
mov ecx, 1
mov ebx, 0 ; initial value of control variable
loc_00EB100A:
test ecx, ecx
jz loc_00EB103C ; jump will never happen, an endless loop
loc_00EB100E:
cmp ebx, 0 ; is control variable equal to 0?
jnz loc_00EB102B
loc_00EB1013:
cmp byte ptr [esi], 0x20
jnz loc_00EB1024
loc_00EB1018:
mov eax, 0
mov ebx, 2
jmp loc_00EB103E
loc_00EB1024:
mov ebx, 1 ; set control variable to 1
jmp loc_00EB103E
loc_00EB102B:
cmp ebx, 1 ; is control variable equal to 1?
jnz loc_00EB103C
loc_00EB1030:
mov eax, 1
mov ebx, 2 ; set control variable to 2
jmp loc_00EB103E
loc_00EB103C:
jmp loc_00EB1040 ; exit loop
loc_00EB103E:
jmp loc_00EB100A ; loop back
loc_00EB1040:
test eax, eax
ret
混淆后的代码最终将与原始代码产生相同的结果。在控制流平坦化混淆中,代码的流动是由一个控制变量引导的。在前面的代码中,控制变量是EBX寄存器。为了直观地查看差异,下面是原始代码的样子:
这是应用混淆后的代码样子:
代码被放置在一个循环中,并通过控制变量EBX寄存器中的值来控制。每个代码块都有一个 ID。在离开第一个代码块之前,控制变量会设置为第二个代码块的 ID。流再一次循环,进入第二个代码块,离开之前会设置为第三个代码块的 ID。这个过程会持续下去,直到执行到最后一个代码块。代码块中的条件可以设置控制变量,以选择下一个要跳转的代码块。在我们之前的代码中,循环只会执行两次就结束。
看看前面的两个图表,我们可以看到一个简单的代码在混淆后如何变得复杂。作为一个逆向工程师,挑战在于如何将复杂的代码还原为更易理解的代码。这里的技巧是识别是否存在一个控制变量。
垃圾代码插入
垃圾代码插入是一种廉价的让代码看起来复杂的方法。代码中简单地插入一些实际上没有任何作用的代码或代码序列。在下面的代码片段中,尝试识别所有的垃圾代码:
mov eax, [esi]
pushad
popad
xor eax, ffff0000h
nop
call loc_004017f
shr eax, 4
add ebx, 34h
sub ebx, 34h
push eax
ror eax, 5
and eax, 0ffffh
pop eax
jmp loc_0040180
loc_004017f:
ret
去除垃圾代码后,代码应该简化成这样:
mov eax, [esi]
xor eax, ffff0000h
shr eax, 4
jmp loc_0040180
很多恶意软件使用这种技术快速生成其自身代码的变种。它可能增加代码的大小,但结果是使其无法被基于签名的反恶意软件检测到。
使用形态变换引擎的代码混淆
一个程序可以用不同的方式编码。要“增加一个变量的值”意味着给它加一。在汇编语言中,INC EAX也等同于ADD EAX, 1。用等效指令替换相同指令或指令集的概念与形态变换相关。
这里有几个可以互换的代码示例:
|
mov eax, 78h
|
push 78h
pop eax
|
|
mov cl, 4
mul cl
|
shl eax, 2
|
|
jmp 00401000h
|
push 00401000h
ret
|
|
xchg eax, edx
|
xor eax, edx
xor edx, eax
xor eax, edx
|
|
rol eax, 7
|
push ebx
mov ebx, eax
shl eax, 7
shr ebx, 25
or eax, ebx
pop ebx
|
|
push 1234h
|
sub esp, 4
mov [esp], 1234h
|
这个概念最初出现在计算机病毒中,这些病毒能够感染具有不同代际的文件。引入这一概念的计算机病毒包括 Zmist、Ghost、Zperm 和 Regswap。这些病毒中的变形引擎面临的挑战是使感染的文件依然像原始文件一样正常工作,并防止它们被破坏。
那么,变形代码和多态代码有何不同呢?首先,这两种技术都是为了阻止反病毒软件检测多个代际的恶意软件。反病毒软件通常通过签名来检测恶意软件。这些签名是恶意文件中的独特字节序列。为了防止反病毒软件进一步检测,使用加密来隐藏整个病毒代码或其中的部分代码。桩代码负责解密病毒的自加密代码。以下图示展示了多态病毒的文件代际表示:
如我们所见,桩代码通常带有相同的代码,但密钥发生变化。这使得加密后的代码与前一代有所不同。在前面的图示中,我们通过改变加密代码的颜色来表示这种差异。如果代码涉及解密和加密,它可以被称为多态代码。一些反病毒软件使用代码仿真或添加特定的解密算法来解密病毒代码,从而使签名得以匹配并进行检测。
对于变形代码,不涉及加密。这个概念是用不同的代码替换原有代码,且实现相同的行为。每一代病毒代码都会发生变化。多态代码很容易被识别,因为它有固定的桩代码。但是,变形代码的识别几乎是不可能的,因为它看起来就像一段普通的代码。以下是变形代码的文件代际表示:
所有这些变形代际都会产生相同的结果,保持其代码序列不变。反病毒软件的签名很难检测到变形病毒,因为代码本身会发生变化。变形代码只能通过比较两个变体来识别。在变形病毒中,生成新代码的过程涉及变形引擎,该引擎与代码本身一起存在。即使是引擎的代码行本身也可以被修改。
动态库加载
在静态分析过程中,我们可以立即看到可供程序使用的导入函数。可能在导入表中只看到两个 API 函数,但程序却使用了几十个 API。在 Windows 系统中,这两个 API 函数是LoadLibrary和GetProcAddress,而在 Linux 系统中,则是dlopen和dlsym。
LoadLibrary 只需要目标 API 函数所在库的名称。GetProcAddress 负责从库中检索该 API 名称对应的 API 函数的地址。库加载完成后,程序就可以通过 API 的地址来调用 API 函数。
以下代码片段演示了如何进行动态库加载。最终代码会显示一个 "hello world 消息框:
; code in the .text section
push 00403000h
call LoadLibrary
push 00403010h
push eax
call GetProcAddress
push 0
push 00403030h
push 00403020h
push 0
call eax ; USER32!MessageBoxA
; data in the .data section
00403000h "USER32.DLL", 0
00403010h "MessageBoxA", 0
00403020h "Hello World!", 0
00403030h "Packt Demo", 0
一些程序会加密文本字符串,包括 API 函数的名称,并在运行时解密,之后才进行动态导入。这可以防止像 Strings 或 BinText 这样的工具列出程序可能使用的 API。分析人员在进行调试时,可以看到这些加载的函数。
使用 PEB 信息
进程环境块(PEB)包含关于正在运行的进程的有用信息。这包括为进程加载的模块列表、结构化错误处理程序(SEH)链,甚至程序的命令行参数。在这里,混淆技术直接从 PEB 读取这些信息,而不是使用如 GetCommandLine 和 IsDebuggerPresent 等 API 函数。
例如,IsDebuggerPresent API 包含以下代码:
单独使用以下代码将返回1或0的值,保存在EAX寄存器中。它位于 FS 段中,其中包含PEB和线程信息块(TIB)。这段代码显示调试标志可以在PEB的偏移量2处找到。
mov eax, large fs:30h
movzx eax, byte ptr [eax+2]
混淆的实现方式有很多种。它可以根据开发者的创造力来实现。只要隐蔽显而易见的目标存在,它就能让逆向工程师难以分析二进制文件。对各种混淆技术的更好理解,肯定能帮助我们克服在逆向过程中分析复杂代码的难题。
总结
在本章中,我们了解了混淆的含义。作为一种隐藏数据的手段,简单的加密学是最常用的技术之一。识别简单的解密算法需要寻找密码密钥、待解密数据和数据的大小。在识别这些解密参数后,我们只需要在解密代码的退出点设置断点。我们还可以通过调试工具的内存转储来监控解密后的代码。
我们列举了一些混淆中使用的方法,比如控制流扁平化、垃圾代码插入、形态变换代码、动态导入 API 函数以及直接访问进程信息块。识别混淆代码和数据有助于我们克服分析复杂代码的难题。混淆技术的引入是为了隐藏信息。
在下一章,我们将继续介绍相同的概念,特别是我们将探讨它们是如何通过使用 Packer 工具和加密技术在可执行文件中实现的。
第十章:打包和加密
作为我们学习混淆的延续,我们现在将介绍一组工具,这些工具被分类用于防止软件被逆向工程。使用这些工具(如打包器和加密器)的结果是将原始可执行文件转换成一个新的版本,而新版本的文件仍然完全保持原有的代码行为流。根据所使用的工具,我们将讨论转换后的可执行文件会是什么样子,以及转换后的文件是如何执行的。
我们选择了 UPX 工具来演示打包器如何在低级别上工作,并展示可以用来反向工程的技术。
互联网上有很多免费的打包器,通常被恶意作者用来打包他们的软件(如 fsg、yoda、aspack),但为了简便起见,我们将重点介绍最简单的 UPX。
本章将以 Windows 作为我们的环境,并使用x86Dbg或OllyDbg进行调试。我们还将展示如何使用 Volatility 工具。我们会涉及脚本语言中的混淆,并使用一些 Cyber Chef 来解密数据。
本章将涵盖以下主题:
-
使用 UPX 工具解包
-
识别解包存根,并使用调试器设置断点以提取内存
-
转储内存,提取在内存中执行的程序
-
使用可执行文件中的密钥识别和解密段
回顾原生可执行文件如何被操作系统加载
为了更好地理解打包程序如何修改文件,我们先快速回顾一下操作系统如何加载可执行文件。原生可执行文件通常被称为 Windows 的 PE 文件和 Linux 的 ELF 文件。这些文件被编译成低级格式;也就是说,使用类似于x86指令的汇编语言。每个可执行文件都由头部、代码段、数据段和其他相关部分组成。代码段包含实际的低级指令代码,而数据段包含代码使用的实际数据。头部包含关于文件、各个段以及文件如何映射为内存中的进程的信息。以下图示展示了这一过程:
头部信息可以分为原始信息和虚拟信息。原始信息包含关于物理文件的相关信息,如文件偏移量和大小。偏移量是相对于文件偏移量 0 的。虚拟信息则包含关于进程中内存偏移的相关信息,虚拟偏移通常是相对于图像基址的,图像基址是进程映像在内存中的起始位置。图像基址是操作系统分配的进程空间中的一个地址。基本上,头部信息告诉我们操作系统应如何将文件(原始)及其各个部分映射到内存(虚拟)。此外,每个部分都有一个属性,告诉我们该部分是否可以用于读取、写入或执行。在第四章,静态与动态逆向分析中,我们在“进程的内存区域与映射”一节中展示了如何将原始文件映射到虚拟内存空间。下图显示了当磁盘上的文件(左)映射到虚拟内存空间(右)时的样子:
包含代码所需函数的库或模块也列在文件的一个部分中,该部分可以在代码和数据部分之外的其他部分看到。这部分称为导入表。它是一个 API 函数及其所属库的列表。文件映射后,操作系统在相同的进程空间中加载所有库。这些库的加载方式与可执行文件相同,但位于同一进程空间的较高内存区域。关于库加载位置的更多信息,请参阅第四章](1017358e-f842-4115-8779-f721299bbe3c.xhtml),静态与动态逆向分析中的“进程的内存区域与映射”部分。
当所有内容都正确映射并加载后,操作系统从头部信息中读取入口点地址,然后将代码执行传递到该地址。
文件中还有其他部分会使操作系统以特殊方式运行。例如,文件资源部分中包含的图标就是一个例子,它们会在文件资源管理器中显示。文件还可以包含数字签名,作为指示文件是否允许在操作系统中运行的标志。CFF Explorer 工具应该能帮助我们查看头部信息及这些部分,如下图所示:
到目前为止,我们已经涵盖了基础内容,但所有这些结构都已由微软和 Linux 社区进行良好的文档化。Windows PE 文件的结构可以在以下链接中找到:docs.microsoft.com/en-us/windows/desktop/debug/pe-format。而 Linux ELF 文件的结构可以在以下链接中找到:refspecs.linuxbase.org/elf/elf.pdf.
打包器、加密器、混淆器、保护器和自解压文件(SFX)
可执行文件可以通过打包、加密和混淆来保护其代码,但仍然保持可执行,且程序本身完好无损。这些技术主要旨在防止程序被反向工程。规则是,如果原始程序能够正常运行,那么它是可以被反向的。接下来我们将定义术语“宿主”或“原始程序”,指的是在文件被打包、加密、混淆或保护之前的可执行文件、数据或代码。
打包器或压缩器
打包器,也称为压缩器,是用于将宿主文件压缩为更小文件的工具。压缩数据的概念帮助我们减少传输数据时所需的时间。在混淆方面,压缩后的数据通常不会显示完整的可读文本。
在下图中,左侧窗格显示了压缩前的代码的二进制和数据,而右侧则显示其压缩后的形式。注意,压缩后的文本字符串并不完全显示出来:
由于代码和数据现在已被压缩,执行文件时需要一个解压缩的代码。这个代码被称为解压缩代码段。
在下图中,左侧显示的是文件的原始结构,其中程序的入口点位于代码段。一个可能的打包版本将会有一个新的结构(右侧),其中入口点位于解压缩代码段。
当打包的可执行文件被执行时,首先运行的是代码段,然后将代码执行权交给解压后的代码。文件头中的入口点应指向代码段的地址。
打包器减少了部分段的大小,因此必须修改文件头中的值。文件头中各段的原始位置和大小会被修改。事实上,一些打包器会将文件视为一个包含代码和数据的大段。诀窍是将这个大段设置为可读、可写且可执行。然而,这可能会带来错误处理不当的风险,尤其是当代码不小心写入一个应为只读的区域,或执行代码时访问了一个应为不可执行的区域。
打包文件的最终结果是保留宿主的行为完整,同时使打包文件的大小变小。
加密器
通过加密进行的混淆是由加密程序完成的。打包程序压缩段,而加密程序则加密段。与打包程序类似,加密程序也有一个存根用于解密加密的代码和数据。因此,加密程序可能会增加宿主文件的大小。
以下图像展示了一个由Yoda Crypter加密的文件:
段偏移量和大小已被保留,但已加密。存根被放置在一个新添加的名为*yC*的段中。如果我们比较原始操作码字节和加密后的字节,就会注意到操作码字节中有零字节分布。这是一个可以用来识别加密字节的特征。
打包程序和加密程序的另一个特征是它们如何导入 API 函数。使用 CFF Explorer 查看导入目录时,我们只看到两个导入的 API:LoadLibrary和GetProcAddress。这两个函数都来自Kernel32.DLL,并且注意到它的名称使用了混合字符大小写:KeRnEl32.Dll,如下所示:
仅通过这两个 API 函数,它所需的每个功能都可以动态加载。
以下图像展示了GetProcAddress API:
以下图像展示了LoadLibrary API:
看存根时,我们预计它会包含一个包含解密算法的循环代码。以下图像展示了Yoda Crypter使用的解密算法:
混淆器
混淆器也被归类为代码修改器,它们在保留程序流程的同时更改代码的结构。在前一章中,我们介绍了控制流扁平化(CFF)技术。CFF 技术将小段代码转换为在循环中运行,并通过控制标志进行控制。然而,混淆不仅限于 CFF 技术。编译后的文件结构也可以被修改,特别是对于基于伪代码执行的程序,如 Visual Basic 和.NET 编译程序。
混淆的主要技术之一是使函数名变得模糊不清或加密,使反编译器无法正确识别函数。此类高阶混淆工具的例子有Obfuscar、CryptoObfuscator和Dotfuscator。
变量名的重命名,使用随机生成的文本字符串,转换代码文本为十六进制文本,以及将文本分割供代码拼接,是用于脚本(如 JavaScript 和 Visual Basic 脚本)的一些混淆技术。
以下截图展示了一个使用在线混淆工具的混淆 JavaScript 代码示例:
原始代码在左侧,混淆后的版本在右侧。
保护程序
保护工具通过结合使用打包器和加密器,以及其他反调试特性来保护软件。受保护的软件通常有多层解压和解密过程,可能会使用像blowfish、sha512或bcrypt这样的加密算法。一些复杂的保护工具甚至使用自己的代码虚拟化技术,这类似于伪代码的概念。保护工具通常是商业销售的,并用于防止盗版。
Windows 可执行文件保护工具的示例包括Themida、VMProtect、Enigma和Asprotect。
SFX 自解压归档
我们通常使用 ZIP 和 RAR 来归档文件。但是,你知道这些归档文件可以转换为自解压执行文件(SFX)吗?这些工具的目的在于轻松地为任何需要多个文件的软件生成安装程序,比如主程序及其依赖的库模块。SFX 归档中嵌入了一个 SFX 脚本。该脚本负责指示文件要解压到哪些目录。如下图所示:
通常,SFX 具有可以执行以下操作的脚本功能:
-
提取归档文件
-
从提取的文件中运行文件
-
从系统中运行任何文件
-
删除文件
-
创建注册表项
-
从互联网访问网站
-
创建文件
基本上,它几乎可以做任何常规程序能对系统做的事情。SFX 工具的示例包括Winzip SFX、RARSFX和NSIS。
解包
在这一阶段,使用x86dbg,我们将解包一个已压缩的可执行文件。在这个调试会话中,我们将解包一个 UPX 打包的文件。我们的目标是达到原始主机的入口点。除了这个 UPX 压缩文件外,我们在 GitHub 页面上还提供了可以用于练习的压缩样本。
UPX 工具
eXecutables的终极打包器,也称为 UPX,可以从upx.github.io/下载。该工具本身可以打包 Windows 可执行文件。它还能够恢复或解包 UPX 打包的文件。为了展示其功能,我们使用该工具对文件original.exe进行了操作,如下所示:
注意,在被打包后,原文件的大小已经减少。
通过打包器进行调试
打包器对文件做了重大修改,特别是在 PE 文件头中。为了更好地理解打包器如何工作,让我们比较主机和打包后的可执行文件版本。使用 CFF 工具,我们将检查头部的差异。
上图显示了原始版本和 UPX 压缩版本之间的 NT 头差异:
这里唯一的区别是节的数量,从四个减少到三个,如下例所示:
在前面的示例中的可选头比较中,变化如下:
-
SizeOfCode:
0x0C00 到 0x1000 -
SizeOfInitializedData:
0x0e00到0x5000 -
AddressOfEntryPoint:
0x157e到0x6b90 -
BaseOfCode:
0x1000到0x6000 -
BaseOfData:
0x2000到0x7000 -
SizeOfImage:
0x5000到0x8000 -
SizeOfHeaders:
0x0400到0x1000 -
CheckSum:
0x4a92到0
下图展示了原始和 UPX 压缩后版本的数据目录表之间的对比。
前面的示例展示了数据目录中的变化:
-
导入目录 RVA:
0x234c到0x71b4 -
导入目录大小:
0x0078到0x017c -
资源目录 RVA:
0x4000到0x7000 -
资源目录大小:
0x01b0到0x01b4 -
调试目录 RVA:
0x2110到0 -
调试目录大小:
0x001c到0 -
配置目录 RVA:
0x2240到0x6d20 -
配置目录大小:
0x40到0x48 -
导入地址目录 RVA:
0x2000到0 -
导入地址目录大小:
0xf4到0
下面的图片展示了原始程序和 UPX 压缩后版本之间头部节区的对比。
前面的示例展示了在 UPX 压缩版本中,原始节区头部几乎所有信息都发生了变化。原始和虚拟偏移量、大小及特性都已变化。
对于 UPX0 节区,Characteristics 字段中位标志的含义在下面的示例中列出:
以下示例显示,虽然导入的 API 函数数量减少,但原始的静态导入库文件依然保持不变:
下图展示了将要为 KERNEL32.dll 导入的 API 函数。它们拥有完全不同的 API 函数:
至于资源目录的内容,似乎大小没有变化,唯一的变化是偏移量,以下示例中可以看到这一点:
以下列表展示了在压缩文件中基于哪些特征所做的更改:
-
有三个节区,即
UPX0、UPx1和.rsrc:-
UPX0具有虚拟节区属性,但没有原始节区属性。这仅意味着该节区将由操作系统分配,但不会从文件中映射数据到该节区。该节区设置了读、写和执行标志。 -
入口点地址位于
UPX1节区内。存根应位于此节区内,并且压缩的代码和数据也应存放在此处。 -
.rsrc节区似乎保留了其内容。保留资源节区仍能提供操作系统文件浏览器读取的正确图标和程序详细信息。
-
-
由于打包程序具有自己结构,导致节区发生了重大变化,像
BaseOfCode和BaseOfData这样的头部字段已完全修改。 -
虚拟大小是基于
SectionAlignment对齐的。例如,.rsrc的虚拟大小最初为0x1b0,通过与SectionAlignment对齐,使其变为0x1000。 -
由于打包器插入了一个存根,
ImageSize已经增加。
入口点是ImageBase和AddressOfEntryPoint之和。原始入口点位于0x0040157e。该地址位于UPX0区段范围内,UPX0从0x00401000开始,大小为0x5000。存根位于打包文件的入口点,位于UPX1区段内。我们期望的结果是,打包器解压代码,动态导入 API 函数,最后将代码执行传递给原始入口点。为了加快调试,我们应该寻找一条或一组指令,将执行传递到0x0040157e,即原始入口点。
让我们通过在x86dbg中打开upxed.exe来观察这一过程。我们从入口点0x00406b90开始,如下图所示:
操作系统将文件映射到内存,并且所有虚拟区段也都已分配。第一条指令使用pushad保存所有初始标志状态。如果它保存了所有标志,那么在跳转到原始入口点之前,应该恢复这些标志。接下来的指令将地址0x00406000存储到寄存器esi中。这个地址是UPX1区段的起始位置,压缩数据就在这里。下一行将0x00401000存储到寄存器edi中。可以清楚地看出,压缩数据将从esi解压到edi。开启调试后,解压代码位于0x00406b91到0x00406c5d之间。
在0x00406c62处放置断点之前,设置一个地址为0x00401000的转储窗口。这将帮助我们查看主机的解压部分。一直运行代码直到0x00406c62,应完成解压过程。下图展示了这一过程:
下一组指令修复使用相对跳转地址的调用指令。该代码从0x00406c65运行到0x00406c94。只需放置另一个断点,或者使用“运行直到”选项,选择在0x00406c96这一行,便可通过这段修复调用的代码循环。
接下来的几行是打包器动态加载主机使用的 API 函数的部分。代码将0x00405000存储到寄存器edi中。这个地址包含了数据,可以在其中找到原始模块的名称列表以及与每个模块相关的 API 函数名称。
对于每个模块名称,它使用LoadLibraryA来加载主机稍后将使用的库。下图展示了这一过程:
加载模块后,它使用 GetProcAddress 获取主机将使用的 API 地址,如以下截图所示:
每个检索到的 API 地址都存储在主机的导入表中,位于 0x00402000。将函数地址恢复到相同的导入表地址应该可以让主机正常调用 API。在 0x00406cde 处设置断点应执行动态导入例程。
下一例程将设置映射头部的访问权限为只读,防止其被写入或执行代码,如以下截图所示:
VirtualProtect 用于设置内存访问标志,并且还需要四个参数。以下代码显示了根据 MSDN 的参数:
BOOL WINAPI VirtualProtect(
_In_ LPVOID lpAddress,
_In_ SIZE_T dwSize,
_In_ DWORD flNewProtect,
_Out_ PDWORD lpflOldProtect
);
第一次调用 VirtualProtect 时,lpAddress 设置为 0x00400000,dwSize 设置为 0x1000 字节,保护标志设置为 4。值 4 表示 PAGE_READWRITE 常量。之后的 VirtualProtect 调用将保护标志设置为 PAGE_READONLY。如以下截图所示:
记住,在代码开始时,我们遇到了一个 pushad 指令。此时,我们正处于其对立指令 popad 处。这很可能是执行将传递给原始入口点的部分。查看 0x00406D1B 处的 jmp 指令,地址跳转到 UPX0 区段中的某个地址。根据我们的主机打包比较,原始入口点确实位于 0x0040157e。
到达原始入口点应结束调试打包程序代码。
从内存中转储进程
打包文件的数据无法直接看到,但如果让它运行,所有内容都应解包到其进程空间中。我们的目标是生成一个解包状态下的文件版本。为此,我们需要转储整个内存,然后将可执行文件的进程映像提取到文件中。
使用 VirtualBox 进行内存转储
我们将使用 Volatility 从暂停的 VirtualBox 映像中转储进程。首先,我们需要了解如何转储 VirtualBox 映像:
-
启用 VirtualBox 的调试菜单:
-
对于 Windows VirtualBox 主机:
- 输入一个名为
VBOX_GUI_DBG_ENABLED的新环境变量,并将其设置为true。如以下截图所示:
- 输入一个名为
-
-
-
对于 Linux 主机:
-
以 root 用户身份编辑 /etc/environment
-
添加一个新的条目
VBOX_GUI_DBG_ENABLED=true -
执行命令:
source /etc/environment -
如果 VirtualBox 已经打开,请重新启动
-
-
-
在 Windows 客户机中运行打包的可执行文件。我们将从我们的 GitHub 页面运行
upxed.exe。 -
在 VBoxDbg 控制台中,执行以下命令将整个内存转储保存到文件中。注意,
pgmphystofile命令前应该加上一个点,如下所示:.pgmphystofile memory.dmp -
memory.dmp 是文件名,并存储在登录用户的主目录中。它是 Windows 中的
%userprofile%文件夹,Linux 中的~/文件夹。
接下来,我们将使用 Volatility 解析内存转储并提取我们需要的数据。
使用 Volatility 将进程提取到文件
Volatility 可以从www.volatilityfoundation.org/releases下载。在这一部分中,我们的 VirtualBox 主机运行的是 Linux Ubuntu 系统。这里展示的 Volatility 命令参数,在 Windows 中使用时也应该是一样的。
首先,我们需要使用 Volatility 的imageinfo参数来识别确切的操作系统版本,以下是一些示例:
vol -f ~/memory.dmp imageinfo
再次说明,~/memory.dmp是我们刚刚转储的内存文件路径。结果应显示已识别的操作系统配置文件列表。对于 Windows 7 SP1 32 位,我们将使用Win7SP1x86作为后续Volatility命令的配置文件。
接下来,我们需要列出正在运行的进程并识别哪个是我们的打包可执行文件。为了列出运行的进程,我们将使用pslist参数,如以下示例所示:
volatility --profile=Win7SP1x86 -f ~/memory.dmp pslist
查看上一张截图中第二列的最后一行,我们发现upxed.exe。我们需要记下进程 ID(PID),它的值是2656。现在我们已经获取了打包可执行文件的 PID,我们可以使用procdump参数将进程导出为文件,如以下代码所示:
volatility --profile=Win7SP1x86 -f ~/memory.dmp procdump -D dump/ -p 2656
procdump将把进程可执行文件保存在-D参数设置的dump/文件夹中,如下图所示:
Volatility 有很多功能可供选择。请随意探索这些参数,它们可能有助于适应分析情况。
解包后的可执行文件怎么样?
现在我们从 Volatility 获取了一个可执行文件,将其在我们的 Windows 虚拟沙盒中运行,得到以下信息:
请记住,打包的可执行文件有自己的 PE 头和存根,而不是原始主机的。头部、存根和压缩数据被直接映射到进程空间。每个 API 函数都是动态导入的。即使代码和数据已经解压,头部中设置的入口点仍然是打包可执行文件的,而不是原始主机的。
幸运的是,x86dbg有一个插件叫做 Scylla。在到达原始入口点后,这意味着我们已经进入解包状态,我们可以将正在调试的进程重建为一个全新的可执行文件。这个新的可执行文件已经解包,可以单独执行。
这仍然要求我们调试打包后的可执行文件,直到我们到达原始入口点(OEP)。一旦到达 OEP,从插件下拉菜单中打开 Scylla。这应该会打开 Scylla 窗口,如以下示例所示:
当前活动进程已经设置为upxed.exe进程。OEP 也已经设置为指令指针所在的位置。接下来要做的是点击 IAT Autosearch,让 Scylla 解析进程空间并定位最可能的导入表。这将填充 IAT 信息框中的 VA 和Size字段,显示可能的导入表位置和大小。点击Get Imports,让 Scylla 扫描已导入的库和 API 函数。如下图所示:
展开其中一个库,它会显示出它找到的 API 函数。现在,在 Dump 框架下,点击 Dump 按钮。这会弹出一个对话框,询问保存可执行文件的位置。这只是将可执行文件的进程转储出来。我们仍然需要应用 IAT 信息和导入。点击 Fix Dump 并打开转储的可执行文件。这会生成一个新的文件,并在文件名后附加_SCY,如下图所示:
运行这个新的可执行文件应该会给我们与原始主机行为相同的结果。
在 Volatility 中,我们没有足够的信息来重建可执行文件。然而,使用x86dbg和 Scylla,尽管需要我们绕过打包器的调试,我们仍然能够得到一个重建后的可执行文件。
其他文件类型
如今,网站通常将二进制数据转换为可打印的 ASCII 文本,以便网站开发人员轻松地将这些数据与 HTML 脚本一起嵌入。其他网站则将数据转换成不容易被人类读取的形式。在本节中,我们将目标是解码那些已被隐藏的不可直观理解的数据。在第十三章 反向工程各种文件类型中,我们将处理如何反向工程除了 Windows 和 Linux 可执行文件之外的其他文件类型。在此之前,我们将仅解码明显的数据。
让我们去浏览器并访问www.google.com,在写作时(我们存储了该页面的源代码副本,见github.com/PacktPublishing/Mastering-Reverse-Engineering/blob/master/ch10/google_page_source.txt),查看源代码时会显示一部分包含b64编码文本,如下图所示:
使用 Cyberchef 工具,这个工具可以帮助解码多种编码数据,包括 base 64,我们可以将这些数据转化为我们能理解的内容。只需将 base-64 数据复制并粘贴到输入框中,然后双击 From Base64。这样应该会在输出框中显示解码后的二进制内容,如下图所示:
请注意,输出开头写有 PNG。这很可能是一个 PNG 图像文件。此外,如果我们仔细查看源代码,还可以看到在 base-64 编码数据之前也有标明数据类型,如以下示例所示:
data:image/png;base64
如果我们点击磁盘图标,我们可以将输出数据保存到文件并命名为 .png 扩展名。这样我们就能查看图像,如以下截图所示:
Cyberchef 工具支持其他编码类型。如果我们遇到类似的编码文本,互联网提供了所有可用的工具来帮助我们。
总结
逆向工程就是如何在正确的情境中使用工具。即使是经过压缩、加密和混淆的可执行文件,隐藏的信息仍然可以被提取出来。
在本章中,我们介绍了如何通过打包工具、加密工具、混淆器、保护器甚至自解压工具隐藏数据的各种概念。我们遇到了一个由 UPX 工具生成的打包文件,并且我们仍然能够通过调试器进行逆向工程。通过注意指令指针的位置,我们可以判断自己是否已经到达原始入口点。通常来说,如果指令指针已经跳转到不同的区域,我们可以认为自己已经到达了原始入口点。
使用另一种查看程序解包状态的方案,我们使用 Volatility 结合来自 VirtualBox 虚拟机的内存转储,并提取了我们刚刚运行的可执行文件的进程。通过 Scylla 工具,我们还能够重建解包后的可执行文件状态。
本章最后我们介绍了 CyberChef 工具,它能够解码流行的编码数据,如 base-64。这个工具可能在我们遇到编码数据时非常有用,不仅在网站上的脚本中,在我们遇到的每个可执行文件中都可能出现。
在下一章中,我们将继续我们的旅程,通过识别恶意软件执行的恶意行为。