基于windows PE文件的恶意代码分析;使用SystemInternal工具与内核调试器研究windows用户空间与内核空间
********************
既然本篇的主角是PE文件,那么先对PE文件的结构作大致上的介绍,后文提到特定结构时还会补充说明.
两个与PE相关的缩写经常容易混淆: Windows PE(PreInstallation Environment)是可从光盘启动的操作系统,即预安装环境,这一点类似基于ubuntu的BackTrack系列的LiveCD
Microsoft Portable Executable and Common Object File Format(微软可移植可执行与通用对象文件格式.常并称为PE/COFF文件)则是本篇要介绍的主角.
对于不同的处理器架构/操作系统平台,可编译成不同版本的PE文件(即生成适用于该平台的机器指令).
限于篇幅,这里讨论的PE文件是指运行在 Intel IA-32(x86)架构上的32位windows操作系统的PE文件;而针对运行在 Intel 64(x64)架构上的64位windows操作系统编译的PE+文件,暂且不提及,有兴趣的可以参考微软与英特尔的相关文档.
我们将借助下述几款工具来分析PE文件:
PEiD 用于查看生成该PE文件的编译器与链接器的类型,版本,以及该PE文件是否用加壳器隐藏了程序的入口点以及其它信息,从而阻止静态分析.
PEview 用于查看PE文件的头部以及各分节的详细信息,在获取与理解PE文件中的各种数据结构以及字段方面,发挥着至关重要的作用.
PEtools 用于编辑PE文件中的原始字节,可以充当16进制编辑器,生成新的PE文件进行调试,找出微软的各种PE文件运行时错误信息,背后的真正原因.
PEBrowsePro 可以查看PE文件的各分节的详细信息,例如显示资源节(.rsrc)中,所有的程序图标,菜单,对话框,按钮控件等等,它给出的信息比PEview的还要详细,在制作PE补丁,加密器,脱壳器时经常用到.
下面,使用迅雷精简版轻量级BT下载引擎的主程序(一个命名为ThunderMini.exe的可执行文件,属于PE文件的一种)为样本来分析该文件的结构;
在后续的内容中,我们还将分析一个恶意DLL(.dll动态链接库文件,也属于PE文件的一种)的结构,并且结合IDA Pro对其静态反汇编,以及OllyDGB对其动态调试.
假设迅雷精简版的安装目录为D:\MiniThunder ,将其复制到另一个磁盘分区中,例如
E:\MiniThunder ,由于我们后面要频繁修改ThunderMini.exe的内容并调试,因此随时可以从备份目录还原到初始状态,然后再修改并测试,而备份整个目录的原因在于,
ThunderMini.exe运行时依赖同目录下的很多开发者自定义的动态链接库(这些库又依赖windows内置的各种动态链接库),如果仅复制主程序到其它目录下,则运行时会提示找不到模块(特定的动态链接库)导致测试失败.
假设使用E:\MiniThunder\Bin\ThunderMini.exe 作为样本文件,可以使用D:\MiniThunder\Bin\ThunderMini.exe 来覆盖前者并恢复到初始状态.
另外,一个提供云扫描文件服务的站点 https://www.virustotal.com/zh-cn/
也可以和上述4款工具结合使用,交叉验证分析结果的准确性.该站点主要用于检测本地(通过上传)的各种病毒,木马,蠕虫,rootkit,以及其它恶意程序,通过使用50多种国内外知名的杀毒软件的最新云特征库对上传文件进行匹配,列出并向用户报告分析结果,其结果中也包含了基本的PE文件信息,这就是我们需要的.
回到主题,首先用PEview打开E:\MiniThunder\Bin\ThunderMini.exe ,如下图所示:
在PE文件的DOS头部中,除了架构师的缩写外,位于头部结尾处的一个双字值也非常重要,它是一个偏移指针,指向PE文件的PE头部(相对于PE文件起始处的偏移),如下图所示,如果该指针没有指向正确的PE头部,则运行时会出现错误信息.
我们来看看:如果将DOS头部最后的指向PE头部的指针修改,即没有指向正确的位置,程序在加载时会出现哪种类型的错误提示.
可以通过前面我们备份的目录D:\MiniThunder\Bin\ThunderMini.exe 恢复该文件到正常的状态,当然也可以直接在WinHex中修改成正确的值再保存退出即可正常运行
E:\MiniThunder\Bin\ThunderMini.exe
请参考上图,标准PE头的起始部分是一个非常重要的字段(IMAGE_FILE_HEADER.Machine),在ThunderMini.exe中,该字段的值为 014C(对应符号常量为IMAGE_FILE_MACHINE_I386) ,
所有编译为可以在 Intel 80386以及后续兼容处理器平台上运行的PE文件,应该都具有相同的值.
由于PE文件最初被设计成可以在各种处理器平台架构上运行,因此这个字段可以有其它的取值,例如取值为 01F0时,指明处理器平台为IBM POWER PC(小端法);
取值为01C0时,指明该PE文件需要运行在ARM处理器平台上,这是多数手持移动设备
配置的处理器,并且经常与嵌入式系统配套销售.
由于不同处理器平台使用不同的机器指令,因此如果将ThunderMini.exe中的该字段设置为其它处理器平台,这等于告诉windows PE 加载器:该文件中的指令不是Intel 80386以及后续兼容处理器能够识别的指令,最终导致加载器又给出令人郁闷的错误提示信息.下面以截图演示:
标准PE头部中的最后一个字段,即IMAGE_FILE_HEADER.characteristics ,是一个"字",用来标识该PE文件的各种"特色",同样是一个重要字段.
由于这个"字"包含16个二进制"位",通过将不同的二进制位设置为0或1,可以解释和赋予PE文件的不同属性,16个二进制位的状态(0或1)决定了该PE文件的所有属性.
像PEview这种PE文件查看器,可以读取这些位的状态,并且转换成相应的符号常量显示给用户:
在继续分析ThunderMini.exe的扩展PE头部,以及后续的文件结构前,我们先将其上传至前文讲到的 https://www.virustotal.com/zh-cn/ ,将这个站点给出的PE文件信息与4种PE文件查看工具的进行对比,整理汇总,看看能否挖掘到一些对逆向工程而言有价值的信息:
接下来继续分析ThunderMini.exe的扩展PE头部,由于该头部中的字段较多,这里不会逐条解释,仅介绍重要的字段.
扩展PE头部起始处的一个"字",携带了该PE文件的类型信息,是一个重要的字段.
即IMAGE_OPTIONAL_HEADER.magic ,又称为魔术数字段.
ThunderMini.exe中的这个字段的值为010B, 指出它是一个32位PE文件,对应的符号常量为IMAGE_NT_OPTIONAL_HDR32_MAGIC ,如果这个字段的值为020B,则是64位的PE文件(PE32+),这在64位的应用程序可执行文件中常见.
剩余的扩展PE头部字段解释,参考PEview的输出截图:
接在扩展PE头部后面的,是ThunderMini.exe的 .texe节摘要信息,或称 .text节表
.text节表中的关键字段解释,参考下图:
********************
实例对比分析 C 代码与汇编代码(IDA Pro静态反汇编基础知识)
下面是一个叫做 deletefile.c 的源文件 main 函数部分,假设编译生成的可执行文件为 deletefile.exe ;使用 Intel 汇编语法的 NASM(Netwide Assembler)生成汇编代码;
int main(int argc, char* argv[])
{
if (argc != 3)
{
return 0;
}
if (strncmp(argv[1], "-r", 2) == 0)
{
DeleteFileA(argv[2]);
}
return 0;
}
用 Visual C++ 系列编译后,生成 deletefile.exe 可执行文件,然后在命令提示符下执行:
deletefile.exe -r abc.txt
参数 argc(命令行参数个数,包括程序名) 与 argv(每个参数的名称) 在此时(运行时)确定,如下:
argc = 3
argv[0] = deletefile.exe
argv[1] = -r
argv[2] = abc.txt
接着,对比分析 deletefile.c 的 main 函数部分对应的汇编代码:
004113CE cmp [ebp+argc], 3
004113D2 jz short loc_4113D8
004113D4 xor eax, eax
004113D6 jmp short loc_411414
004113D8 mov esi, esp
004113CE 地址处的汇编指令首先将存储着 main 函数栈底地址的 ebp 寄存器(栈底指针,基指针)加上 argc 的值,然后与数值3做比较,这是因为 ebp 固定指向栈底不变,通过相对 ebp 的偏移即可引用函数的实参与局部变量;[ebp+argc]是目地操作数,3是源操作数,实际上,cmp 将目地减去源,然后根据运算结果设置 eflags 寄存器中的各种标志位,例如,假设用户遗漏输入 -r 参数或者文件名,则 [ebp+argc] 减去3小于0,即目地小于源,此时 cmp 指令将 eflags 寄存器中的 ZF 标志位"置"0,这样就不会执行 004113D2 地址处的第二条 jz 指令,因为 jz(有条件跳转指令)会判断当 ZF 标志位为1时,才跳转到后面的 004113D8 地址处的指令;
也就是说,用户输入参数不等于3个,则执行 004113D2 的有条件跳转指令时,
将不发生跳转到 004113D8 的操作,然后继续执行 004113D4 处的 xor 指令,它将
eax 寄存器的 32 位全部"置"0 (常规的做法应该是 mov eax, 0 ,但是这样最终生成的机器码占5字节,而 xor eax, eax 生成的机器码仅占2字节,也就是编译器的指令优化结果),接下来执行 004113D6 处的无条件跳转(jmp)指令,该指令将程序流程跳转到 00411414 地址处,这相当于 if (argc != 3)为真,则执行 if 中的 return 0 语句块,退出程序.
如果用户输入参数等于3个,则 cmp 指令结果将 ZF 标志位"置"1,这导致执行 004113D2 地址处的有条件跳转 jz 指令,将程序执行流程跳转到 004113D8 地址处的 mov 指令,这相当于 if (argc != 3)为假,即 argc == 3 ,跳转到对应 C 源码中的第8行 if (strncmp(argv[1], "-r", 2) == 0) 起始处继续执行;
004113D8 处的 mov 指令将存储 main 函数当前栈顶地址的 esp 寄存器(栈顶指针,动态变化)复制到 esi 寄存器,经过该操作后,esi 寄存器同样保存了main函数栈顶地址;
004113DA push 2
004113DC push offset Str2
004113E1 mov eax, [ebp+argv]
004113E4 mov ecx, [eax+4]
004113E7 push ecx
004113DC 地址处的 push 指令将字符串 -r 压栈,即写入内存,后面我们会看到,程序调用 strncmp 函数,将用户输入的第二个参数 argv[1] 与 -r 作比较,根据比较结果决定后续的执行分支;
004113E1 地址处的 mov 指令,首先通过 ebp 寄存器获得 main 函数基址,然后加上偏移量为 argv ,即 argv 的起始地址(第一个数组成员 argv[0] 的地址,保存用户输入的可执行程序名),最后将计算得到的内存地址复制到 eax 寄存器;
004113E4 地址处的 mov 指令,通过将 eax 的值(此时保存着 argv[0] 的地址)加上4 ,来计算出 argv[1](第二个数组成员,存储着用户输入的命令行参数选项)的地址;加4意味着每个 argv 数组成员占用4个字节长度的内存地址;最后将 argv[1] 的地址复制 ecx 寄存器;
004113E7 地址处的 push 指令,将 ecx 寄存器中的值(即 argv[1]的地址)写入内存,为后续的 strncmp 函数调用(比较两块内存区域中的值是否相等)做好准备;
004113E8 call strcmp
004113F8 test eax, eax
004113FA jnz short loc_411412
004113FC mov esi, esp
004113FE mov eax, [ebp+argv]
00411401 mov ecx, [eax+8]
00411404 push ecx
00411405 call DeleteFileA
004113E8 地址处的 call 指令调用 strcmp 函数,该函数比较两块内存区域的值是否相等,也就是说,前面 004113DC 地址处的 push 指令用一块4字节内存地址存储字符串 -r ;004113E7 地址处的 push 指令用另一块4字节内存地址存储"argv[1]的内存地址";所以这里的 strcmp 将比较这两块内存地址的"值"是否相等,也就是检查用户输入的命令行参数是否为 -r 。
strcmp 根据比较结果,会将 eflags 寄存器的 ZF 与 CF 标志位,设置不同的值,而后面的 004113FA 地址处的 jnz 有条件跳转指令则会根据 ZF 与 CF 的值决定是否跳转到 00411412 地址处的指令;
004113F8 地址处的 test 指令用于检查 eax 寄存器的值是否为空(此时的 eax 中存储的值应该是前面 004113E1 地址处的 mov 指令计算出来的 argv[0] 的地址),比较结果应该不为空,即 ZF 被"置"1;
004113FA 地址处的 jnz 有条件跳转指令会检查 ZF 的值,如果 ZF 被"置"0,说明前面的 strcmp 函数调用的比较结果是两块内存的值不同,即用户没有输入 -r 参数,此时会程序执行流程会跳转到 00411412 地址处的指令,虽然超出上面的汇编代码范围,但应该可以推测到该地址处的指令会退出程序;
如果用户确实输入了 -r 参数,strcmp 比较后将 ZF "置"1,jnz 检查后不会跳转到 00411412,而是继续执行 004113FC 地址处的指令;
到此为止,我们可以看出,一条简洁的 C 判断语句 if (strncmp(argv[1], "-r", 2) == 0)
需要众多条汇编指令的组合才能实现相同功能(从 004113DA~004113FA,共8条汇编指令),C 语言的这个简洁的优点同时也是缺点—它无法让程序员认清,在系统底层,尤其是硬件级别究竟发生了什么事,相反,通过汇编指令,我们可以得知数据是如何在硬件之间传递的;什么指令操作了哪些硬件;以及根据硬件中存储的值来执行不同的程序分支。
与此类似,C 源码中第10行一条简洁的 DeleteFileA 函数调用,实际上需要5条汇编指令操作才能实现,这就是从地址 004113FC~00411405 的最后5条汇编指令:
004113FC 地址处的 mov 指令将 esp 寄存器中的值(指向 main 函数"当前"栈顶地址)复制到 esi 寄存器;
004113FE 地址处的 mov 指令将 argv[0] 的地址复制到 eax 寄存器;
00411401 地址处的 mov 指令将 argv[2] 的地址(通过 eax+8,即argv[0]+8,偏移8字节处为第三个数组成员,保存着用户输入的文件名)复制到 ecx 寄存器;
0041104 地址处的 push 指令将 ecx 寄存器中保存的值,即 argv[2] 的内存地址,写入到一块内存地址中;
最后,0041105 地址处的 call 指令调用 DeleteFileA 函数,该函数通过前面 push 指令压栈的 argv[2] 地址处的值获得要删除的文件名,执行相应的系统调用删除文件。
************************
关于 push(压栈)与 pop(弹栈)的解释(图片与文字引用自<深入理解计算机系统原书第二版,略作修改>)
push 指令将一个双字值(4字节)数据压入(写入)栈中,首先需要将栈顶指针 esp 寄存器的当前值减 4(导致栈向内存低址处栈增长),然后将数据写入 esp 指向的新栈顶;例如,
push ebp
上面这条汇编指令,最终生成的机器码占1字节(1个16进制数);它等价于下面这两条最终生成占6字节(6个16进制数)机器码的汇编指令
sub esp 4
mov [esp] ebp
pop 指令从栈中弹出(读取)一个双字值数据,首先从 esp 指向的当前栈顶地址读取数据,然后将 esp 的地址加4(导致栈向内存高址处缩减);例如,
pop eax
等价于
mov eax [esp]
add esp 4
push 指令先将 esp 值
下面用图片说明执行 push 与 pop 指令导致的栈布局变化,图中的栈布局是基于 x86(Intel IA-32)处理器架构的内存寻址与其兼容操作系统的内存管理方案,即:
栈从内存高址向内存低址增长(地址减小)
可以通过 mov 指令任意读取栈内的数据,例如,在上图中的第二步执行 push eax 后,栈的范围涵盖了地址 0x0012F028(esp 寄存器指向此处,为栈顶) ,此时可以执行
mov edx [esp+4]
上述指令将读取 0x0012F028 + 4 = 0x0012F02C 地址处的"值",并复制到 edx 寄存器中;
但是,如果在上图第三步执行 pop edx 指令后,栈的范围将减少至地址 0x0012F02C 处,地址 0x0012F028 属于栈外,因此再执行
mov edx [esp+4]
上述指令将读取 0x0012F02C + 4 = 0x0012F030 地址处的"值",并复制到 edx 寄存器中,而不是将 0x0012F02C 的值复制;而且此时任何对栈外地址 0x0012F028 的读写操作都被认为是无效和非法的.
******************************
关于结合使用 .text段与栈来实现函数(过程)调用的基本概念,参考下图:
注意,图中的各个函数的指令地址不是连续给出的,我们省略的其中一些指令及其地址;
(例如,main函数调用A函数后,A函数不是立即调用B函数,而是先执行A函数内的部分代码后,才调用B函数.....因此我在下图中省略了A函数内,这部分代码的内存地址,其余函数与此类似,这样可以更逼真的模仿实际的函数嵌套调用过程)
此外,程序执行流程从蓝色路线开始,首先执行main函数;
绿色路线表示第一次从B函数返回到A函数;紫色路线表示第二次从B函数返回到A函数;黄~色路线表示从A函数返回到main函数;最终,程序在标识有黑色横线的地址
0x00401101 处结束。
**************************************************
参考下面更多的 mov (数据传送指令)实例:
mov 指令的源操作数总是"值",如果是寄存器,如 eax ,则复制寄存器中的"值",如果是内存地址,如 [eax],则复制该地址处的"值";
mov 指令的目地操作数可以是寄存器,如 eax ,也可以是内存地址,如 [ebp-12]
mov eax, 0x4050
上面这条指令将立即数 0x4050 复制到 eax 寄存器.
mov sp, bp
上面这条指令将 ebp 寄存器32位(双字)中的低16位(字)存储的"值"复制到 esp 寄存器的低16位.
mov [esp], 17
上面这条指令将立即数 17 复制到一个内存地址,该地址保存在 esp 寄存器中,通过[esp] 引用该地址.
mov [ebp-12], eax
上面这条指令将 eax 寄存器中的"值"复制到一个内存地址,该地址通过 ebp 寄存器中保存的地址减去12后计算得出,通过 [ebp-12] 引用该地址.
来看另一个例子,它说明了 C 语言中的指针在处理器寄存器与存储器等硬件级别是如何实现与操作的.
int exchange(int *xp, int y)
{
int x = *xp;
*xp = y;
return x;
}
接着是与其等价的汇编代码,揭示了指针相关操作的本质:
mov edx, [ebp+8]
mov eax, [edx]
mov ecx, [ebp+12]
mov [edx], ecx
在汇编代码中,我们省略了为 exchange 函数执行前分配栈空间的 push 指令与返回前回收栈空间的 pop 指令,以及其它相关指令,
并且假设变量 xp 的地址为相对 ebp 寄存器存储的地址偏移 8 字节处([ebp+8]); 变量 y 的地址为相对 ebp 寄存器存储的地址偏移 12 字节处([ebp+12]);
我们首先将 C 代码中第三行语句 int x = *xp; 拆成左右两边,右边的 *xp ,是对指针的间接引用(dereferencing,又称解引用,其直接引用形式为 xp),
第一行汇编指令 mov edx, [ebp+8]
就是将 xp 的地址复制到 edx 寄存器中,然后通过 [edx] (称为"存储器地址引用")
来引用 edx 存储的地址处的值,这就等同于 *xp ,即获取 xp 指向的地址处的值;
因此,在 C 代码中,将 xp 指向的地址处的值赋给变量 x,这等同于第二行汇编代码的 mov eax, [edx]
注意,变量 x 在 C 代码中,属于函数 exchange 内的"局部变量",编译器在生成对应 C 代码的机器指令时,为了优化指令的执行速度,通常用寄存器来存储局部变量(实际执行的机器码没有局部变量的概念,而是通过将 xp 的值复制到 eax 寄存器,然后操作 eax 寄存器,就等于操作了局部变量 x)
我们同样将 C 代码中第 4 行语句 *xp = y; 拆成左右两边,因为每一边都需要用一条独立的汇编指令来解释:
整条 C 语句的含义是将变量 y 的值覆盖(替换) xp 指向地址处的值,
而在汇编指令中,要取得变量 y 的值,需先通过 [ebp+12] 计算出 y 的地址,然后将该地址复制到 ecx 寄存器,这就是第三行汇编代码 mov ecx, [ebp+12] 的效果;
此时,如果直接引用 ecx ,就等于变量 y 的值;
C 代码中第 4 行语句的右边 *xp ,等于汇编指令中的 [edx];
因此,第四行汇编指令 mov [edx], ecx ,就是将 ecx 中存储的值(y的值)"替换"
edx 中存储的地址处的值([edx],即 xp 指向地址处的值),从而实现 *xp = y;
*****************
汇编代码如何在机器指令级别实现 C 语言中的强制类型转换
C 语言中的强制类型转换分为三种情况,下面依次讨论:
一,相同长度,不同符号之间的数据类型转换.例如
unsigned v;
int *p;
*p = (int)v;
上例是将 C 语言中的32位无符号整型变量转换为32位(有符号)整型变量,
假设变量 v 的地址存储在寄存器 eax 中,通过 eax 即可取得 v 的值;
变量 p 指向的地址存储在寄存器 edx 中,通过 edx 只能取得 p 指向的地址;
需要通过 [edx]才能取得 p 指向地址处的值;
实现上面转换的汇编指令为:
Intel/NASM 汇编代码格式 | AT&T/GCC 汇编代码格式 |
mov [edx], eax | movl %eax, (%edx) |
可以看到,从无符号整型数转换到有符号整型数,对于兼容 x86(指令集体系结构)的处理器而言,实际上没有做任何额外的操作,右边的 AT&T/GCC 汇编指令表示从 eax
寄存器中,复制双字(32位)长度( movl 指令的作用)的值到 edx 寄存器中存储的地址处,即指针 p 指向的地址,该地址处的32位值被替换为 eax 寄存器的内容
AT&T/GCC 的源操作数在前面;而 Intel/NASM 则是目地操作数在前面,而且在复制相同长度的数据时,省略了指示长度单位的 mov 指令后缀.
二,从小数据类型转换到大数据类型,符号相同.例如
char v;
int *p;
*p = (int)v;
上例是将 C 语言中的8位字符型变量转换为32位整型变量,
实现上面转换的汇编指令为:
Intel/NASM 汇编代码格式 | AT&T/GCC 汇编代码格式 |
mov [edx], al | movsbl %al, (%edx) |
可以看到,从8位数转换到32位数,对于 x86 处理器而言,其指令应该是将 al 寄存器(代表 32 位 eax 寄存器中的最低 8 位,也就是最低的字节)作"符号扩展"(AT&T/GCC 的 movsbl 指令效果,其中的 s 标识符号扩展; bl 标识将字节传送到双字),
扩展成与目地操作数相同长度大小后,再将值复制到 edx 寄存器中存储的地址处,即指针 p 指向的地址.
例如,假设 al 寄存器的字节为 CD ,则符号扩展会将原本是 000000CD 的 eax 寄存器,前面的三个字节全都改写为 F ,即 FFFFFFCD ,然后将这个值复制到 edx 寄存器
中存储的地址处,即指针 p 指向的地址,该地址处的32位值即变成 FFFFFFCD.
Intel/NASM 的 mov 指令省略了 sbl 后缀,但有相同的效果.
三,从大数据类型转换到小数据类型,符号相同.例如
int v;
char *p;
*p = (char)v;
上例是将 C 语言中的32位整型变量转换为8位字符型变量,
实现上面转换的汇编指令为:
Intel/NASM 汇编代码格式 | AT&T/GCC 汇编代码格式 |
mov [edx], al | movb %al, (%edx) |
可以看到,从32位数转换到8位数,对于 x86 处理器而言,其指令应该是仅复制 eax 寄存器中的最低8位,即 al 寄存器中的内容(AT&T/GCC 的 movb 指令效果,其中的 b,指出了这是一个字节传送指令),
复制到 edx 寄存器中存储的地址处,即指针 p 指向的地址,该地址处的8位值,被替换成 al 寄存器的内容.