任何语言只要表达能力足够强,都可用于编写PE病毒。但现存的绝大部分PE病毒都是直接用汇编编写的,一方面是因为汇编编译后的代码短小精悍,可以充分进行人工优化,以满足隐蔽性的要求;另外一方面之所以用汇编是因为其灵活和可控,病毒要同系统底层有时甚至是硬件打交道,由于编译器的特点不尽相同,用高级语言实现某些功能甚至会更加麻烦,比如用汇编很方便地就可以直接进行自身重定位、自身代码修改以及读写IO端口等操作,而用高级语言实现则相对烦琐。用汇编还可以充分利用底层硬件支持的各种特性,限制非常少。但是用汇编编写病毒的主要缺点就是编写效率低,加上使用各种优化手段使得代码阅读起来相当困难,不过作为一种极限编程技术,对病毒作者而言,这些似乎都已经不再重要。本文假设读者熟悉汇编语言,各种举例使用Intel格式的汇编代码,编译器可使用MASM或FASM进行编译,由于汇编语言表述算法较为不便,因此算法和原理性表述仍然采用C语言。在讲述各种技术时,部分代码直接取自病毒Elkern的源代码,该病毒在2002年曾经大规模流行,其代码被收录于著名病毒杂志29A第7期中,有兴趣的读者可参阅其完整代码。
* 重定位
病毒自身的重定位是病毒代码在得以顺利运行前应解决的最基本问题。病毒代码在运行时同样也要引用一些数据,比如API函数的名字、杀毒软件的黑名单、系统相关的特殊数据等,由于病毒代码在宿主进程中运行时的内存地址是在编译汇编代码时无法预知的,而病毒在感染不同的宿主时其位于宿主中的准确位置同样也无法提前预知,因此病毒就要在运行时动态确定其引用数据的地址,否则,引用数据时几乎肯定会发生错误。对于普通的PE文件比如动态链接库而言,在被加载到不同地址处时由加载器根据PE中一个被称为重定位表的特殊结构动态修正引用数据指令的地址,而重定位表是由编译器在编译阶段生成的,因此动态链接库本身无需为此做任何额外处理。病毒代码则不同,必须自己动态确定需引用数据的地址。比如一段病毒代码被加载在0x400000处,地址0x401000处的一条语句及其引用的数据定义如下所示,相关地址是编译器在编译时计算得到的,这里假设编译时预设的基地址也是0x400000:
401000:
mov eax,dword ptr [402035]
......
402035:
db "hello world!",0
如果病毒代码在宿主中也加载到基地址0x400000,显然是能够正常执行的,但如果这段代码被加载在基地址0x500000运行时则出错,对病毒而言,这是大多数时候都会遇到的情况,因为指令中引用的仍然是0x402035这个地址。如果病毒代码不是在宿主进程中而是作为一个具有重定位表的独立PE文件运行,正常情况下由系统加载器根据重定位表表项将 mov eax,dword ptr [402035]中的0x402035修改为正确值0x502305,这样这句代码就变成了mov eax,dword ptr [5402035],程序也就能准确无误地运行了。不过很可惜,对在其它进程内运行病毒代码而言,必须采取额外的手段、付出额外的代价感染宿主PE文件时就及时加以解决,否则将导致宿主进程无法正常运行。
至少有两种方法可以解决重定位的问题:
A)第一种方法就是利用上述PE文件重定位表项的特殊作用构造相应的重定位表项。在感染目标PE文件时,将引用自身数据的需要被重定位的地址全部写入目标PE文件的重定位表中,如果目标PE无任何重定位表项(如用MS linker的/fixed)则创建重定位表节并插入新的重定位项;若已经存在重定位表项,则在修改已存在的重定位表节,在其中插入包含了这些地址的新表项。重定位的工作就完全由系统加载器在加载PE文件的时候自动进行了。重定位表项由PE文件头的DataDirectory数据中的第6个成员IMAGE_DIRECTORY_ENTRY_BASERELOC指向。该方法需要的代码稍多,实现起来也相对比较复杂,另外如果目标文件无重定位表项(为了减小代码体积,这种情况也不少见),处理起来就比较麻烦,只有用高级语言编写病毒才常用该种方法,在一般的PE病毒中很少使用。
B)利用Intel X86体系结构的特殊指令,call或fnstenv等指令动态获取当前指令的运行时地址,计算该地址与编译时预定义地址的差值(被称为delta offset),再将该差值加到原编译时预定的地址上,得到的就是运行时数据的正确地址。对于intel x86指令集而言,在书写代码时,通过将delta offset放在某个寄存器中,然后通过变址寻址引用数据就可以解决引用数据重定位的难题。还以上例说明,假如上述指令块被操作系统映射在0x500000处那么代码及其在内存中的地址将变为:
501000:
mov eax,dword ptr [402035]
......
502035:
db "hello world!",0
显然,mov指令引用的操作数地址是不正确的,如果我们知道了mov指令运行时地址是0x501000,那么计算该地址和编译时该指令预设地址的差值:0x501000-0x401000 = 0x100000。很显然指令引用的实际数据地址应该为0x402035+0x100000 = 0x502035。从上例可以看出,只要能够在运行时确定某条指令动态运行时的地址,而其编译时地址已知,我们就能够通过将delta offset加到相应的地址上正确重定位任何代码或数据的运行时地址。原理如图4所示:
图4 delta iffset
通常只要在病毒代码的开始计算出delta offset,通过变址寻址的方式书写引用数据的汇编代码,即可保证病毒代码在运行时被正确重定位。假设ebp包含了delta offset,使用如下变址寻址指令则可保证在运行时引用的数据地址是正确的:
;ebp包含了delta offset值
401000:
mov eax,dword ptr [ebp+0x402035]
......
402035:
db "hello world!",0
在书写源程序时可以采用符号来代替硬编码的地址值,上述的例子中给出的不过是编译器对符号进行地址替换后的结果。现在的问题就转换成如何获取delta offset的值了,显然:
call delta
delta:
pop ebp
sub ebp,offset delta
在运行时就动态计算出了delta offset值,因为call要将其后的第一条指令的地址压入堆栈,因此pop ebp执行完毕后ebp中就是delta的运行时地址,减去delta的编译时地址“offset delta”就得到了delta offset的值。除了用明显的call指令外,还可以使用不那么明显的fstenv、fsave、fxsave、fnstenv等浮点环境保存指令进行,这些指令也都可以获取某条指令的运行时地址。以fnstenv为例,该指令将最后执行的一条FPU指令相关的协处理器的信息保存在指定的内存中,结构如下图5所示:
图5 浮点环境块的结构
该结构偏移12字节处就是最后执行的浮点指令的运行时地址,因此我们也可以用如下一段指令获取delta offset:
fpu_addr:
fnop
call GetPhAddr
sub ebp,fpu_addr
GetPhAddr:
sub esp,16
fnstenv [esp-12]
pop ebp
add esp,12
ret
delta offset也不一定非要放在ebp中,只不过是ebp作为栈帧指针一般过程都不将该寄存器用于其它用途,因此大部分病毒作者都习惯于将delta offset保存在ebp中,其实用其他寄存器也完全可以。
在优化过的病毒代码中并不经常直接使用上述直接计算delta offset的代码,比如在Elkern开头写成了类似如下的代码:
call _start_ip
_start_ip:
pop ebp
;...
;使用
call [ebp+addrOpenProcess-_start_ip]
;...
addrOpenProcess dd 0
;而不是
call _start_ip
_start_ip:
pop ebp
sub ebp,_start_ip
call [ebp+addrOpenProcess]
为什么不采用第二种书写代码的方式?其原因在于尽管第一种格式在书写源码时显得比较罗嗦,但是addrOpenProcess-_start_ip是一个较小相对偏移值,一般不超过两个字节,因此生成的指令较短,而addrOpenProcess在32 Win32编译环境下一般是4个字节的地址值,生成的指令也就较长。有时对病毒对大小要求很苛刻,更多时候也是为了显示其超俗的编程技巧,病毒作者大量采用这种优化,对这种优化原理感兴趣的读者请参阅Intel手册卷2中的指令格式说明。
* API函数地址的获取
在能够正确重定位之后,病毒就可以运行自己代码了。但是这还远远不够,要搜索文件、读写文件、进行进程枚举等操作总不能在有Win32 API的情况下自己用汇编完全重新实现一套吧,那样的编码量过大而且兼容性很差。Win9X/NT/2000/XP/2003系统都实现了同一套在各个不同的版本上都高度兼容的Win32 API,因此调用系统提供的Win32 API实现各种功能对病毒而言就是自然而然的事情了。
所以接下来要解决的问题就是如何动态获取Win32 API的地址。最早的PE病毒采用的是预编码的方法,比如Windows 2000中CreateFileA的地址是0x7EE63260,那么就在病毒代码中使用call [7EE63260h]调用该API,但问题是不同的Windows版本之间该API的地址并不完全相同,使用该方法的病毒可能只能在Windows 2000的某个版本上运行。因此病毒作者自然而然地回到PE结构上来探求解决方法,我们知道系统加载PE文件的时候,可以将其引入的特定DLL中函数的运行时地址填入PE的引入函数表中,那么系统是如何为PE引入表填入正确的函数地址的呢?答案是系统解析引入DLL的导出函数表,然后根据名字或序号搜索到相应引出函数的的RVA(相对虚拟地址),然后再和模块在内存中的实际加载地址相加,就可以得到API函数的运行时真正地址。在研究操作系统是如何实现动态PE文件链接的过程中,病毒作者找到了以下两种解决方案:
A)在感染PE文件的时候,可以搜索宿主的函数引入表的相关地址,如果发现要使用的函数已经被引入,则将对该API的调用指向该引入表函数地址,若未引入,则修改引入表增加该函数的引入表项,并将对该API的调用指向新增加的引入函数地址。这样在宿主程序启动的时候,系统加载器已经把正确的API函数地址填好了,病毒代码即可正确地直接调用该函数。
B)系统可以解析DLL的导出表,自然病毒也可以通过这种手段从DLL中获取所需要的API地址。要在运行时解析搜索DLL的导出表,必须首先获取DLL在内存中的真实加载地址,只有这样才能解析从PE的头部信息中找到导出表的位置。应该首先解析哪个DLL呢?我们知道Kernel32.DLL几乎在所有的Win32进程中都要被加载,其中包含了大部分常用的API,特别是其中的LoadLibrary和GetProcAddress两个API可以获取任意DLL中导出的任意函数,在迄今为止的所有Windows平台上都是如此。只要获取了Kernel32.DLL在进程中加载的基址,然后解析Kernel32.DLL的导出表获取常用的API地址,如需要可进一步使用Kernel32.DLL中的LoadLibrary和GetProcAddress两个API更简单地获取任意其他DLL中导出函数的地址并进行调用。
* 获取Kernel32.DLL基址
获取Kernel32.DLL基址的方法很多,最常见的一种是搜索法,如果已知Kernel32.DLL加载的大致地址,那么可由该地址向高地址或低地址进行搜索可以找到其基址。另外一种方法是搜索NT PEB结构中的模块列表获取Kernel32.DLL的准确加载基址。下面看一下具体的实现代码:
方法1:暴力搜索获取Kernel32.DLL的基址
最初的病毒是指定一个大致的加载地址,比如根据实验在9X下其加载地址是0xBFF70000;在Windows 2000下加载基址是0x77E80000;在XP和2003下其加载基址是0x77E60000,因此在NT系统下就可以从0x77e00000开始向高地址搜索,在9X下可以从0xBFF00000开始向高地址搜索,如果搜索到Kernel32.DLL的加载地址,其头部一定是“MZ”标志,由模块起始偏移0x3C的双字确定的PE头部标志必然是“PE”标志,因此可根据这两个标志判断是否找到了模块加载地址,也许有人认为该方法不可靠,因为如果恰好有某段数据符合这两个特征,那么找到的基址可能就是错误的,但经实验证明,该判断方法非常可靠,基本不会出现错误。有一点需要注意的是,在所有版本的Windows系统下Kernel32.DLL的加载基址都是按照0x10000对齐的,根据这一特点可以不必逐字节搜索,按照64K对齐的边界地址搜索即可。
从大致的一个地址开始搜索Kernel32.DLL基址可能会出现读写到未映射内存区域的情况,因此需要和SEH配合使用。如果有在各个版本下准确获取Kernel32.DLL中某地址的通用方法,那么就可以更可靠地从该地址开始向低地址搜索,显然会更加通用。事实上,这种方法是存在的。在系统加载PE文件跳转到PE入口点第一条指令的时候,堆栈顶保存的就是Kernel32.DLL中的某个地址,Elkern中采用的就是这种方法:
_start:
pushfd ;If some flags,especial DF,changed,some APIs can crash down!!!
pushad
_start_@1 equ $
;......
mov ebx,[esp+9*4] ;前面已经由pushfd和pushad压入了9个双字
and ebx,0ffe00000h ;该地址为Kernel32.dll模块下方的某个地址
;先减去0x100000确保该地址处于Kernel32.dll的下方
;向高地址搜索如果将来Windows的发行版本中Kernel32.dll
;大小和代码结构发生变化,该方法可能无效
ebx中现在已经是Kernel32.DLL基址之前某个地址了,后续代码可以向高地址搜索其基址。该方法有一个缺点,就是必须明确知道程序入口的堆栈指针值,或间接可计算出该值,对于那些在程序入口获取控制权的病毒代码而言,是可以的,但对于采用EPO技术的病毒而言,该方法则不适用。事实上还有另外一种更加通用的方法,我们知道在Win32程序执行过程中fs段寄存器的基址总是指向进程的TEB,TEB的第一个成员指向SEH链表,该链表每个节点都是一个EXCEPTION_REGISTRATION结构,该结构定义如下:
struct EXCEPTION_REGISTRATION{
struct EXCEPTION_REGISTRATION *prev;
void* handler;
};
在Windows下SEH链表最后一个成员的handler指向Kernel32.DLL中函数UnhandledExceptionFilter的起始地址,利用这一特性我们可以写出更通用的代码:
xor esi,esi
lods dword [fs:esi];取得SEH链表的头指针
@@:
inc eax ;是否是最后一个SEH节点,检查prev是否为0xFFFFFFFF
je @F
dec eax
xchg esi,eax
LODSD ;下一个SEH节点
jmp near @B
@@:
LODSD ;取得Kernel32.dll中UnhandledExceptionFilter的地址
在有的病毒直接以0x7FFDE000作为TEB的指针值,其原因在于在Windows 2003 SP1、Windows XP SP2以前的NT类系统上,该值是固定的,这样的确可以节省一两个字节。但是在Windows 2003 SP1、Windows XP SP2中,情况已经发生了变化,出于安全性的考虑,Windows系统开始动态映射TEB了,也就是说,指向TEB的指针值不再固定,因此这种硬编码的方法也就走到了尽头。此时可以按照前面的方法向低地址搜索判断直到找到Kernel32.dll的基址为止。Elkern中判断是否找到了Kernel32.dll基址的相关代码如下:
search_api_addr_@1:
add ebx,10000h
jz short search_api_addr_seh_restore
cmp word ptr [ebx],'ZM' ;是否是MZ标志
jnz short search_api_addr_@1
mov eax,[ebx+3ch]
add eax,ebx
cmp word ptr [eax],'EP' ;是否具有PE标志
jnz short search_api_addr_@1
;找到了kernel32.dll的基址
方法2:搜索PEB的相关结构获取Kernel32.DLL的基址
前述TEB偏移0x30处,亦即FS:[0x30]地址处保存着一个重要的指针,该指针指向PEB(进程环境块),PEB成员很多,这里并不介绍PEB的详细结构。我们只需要知道PEB结构的偏移0xC处保存着另外一个重要指针ldr,该指针指向PEB_LDR_DATA结构:
typedef struct _PEB_LDR_DATA
{
ULONG Length; // +0x00
BOOLEAN Initialized; // +0x04
PVOID SsHandle; // +0x08
LIST_ENTRY InLoadOrderModuleList; // +0x0c
LIST_ENTRY InMemoryOrderModuleList; // +0x14
LIST_ENTRY InInitializationOrderModuleList;// +0x1c
} PEB_LDR_DATA,*PPEB_LDR_DATA; // +0x24
该结构的后三个成员是指向LDR_MODULE链表结构中相应三条双向链表头的指针,分别是按照加载顺序、在内存中的地址顺序和初始化顺序排列的模块信息结构的指针。LDR_MODULE结构如下所示:
typedef struct _LDR_MODULE
{
LIST_ENTRY InLoadOrderModuleList; // +0x00
LIST_ENTRY InMemoryOrderModuleList; // +0x08
LIST_ENTRY InInitializationOrderModuleList; // +0x10
PVOID BaseAddress; // +0x18
PVOID EntryPoint; // +0x1c
ULONG SizeOfImage; // +0x20
UNICODE_STRING FullDllName; // +0x24
UNICODE_STRING BaseDllName; // +0x2c
ULONG Flags; // +0x34
SHORT LoadCount; // +0x38
SHORT TlsIndex; // +0x3a
LIST_ENTRY HashTableEntry; // +0x3c
ULONG TimeDateStamp; // +0x44
// +0x48
} LDR_MODULE, *PLDR_MODULE;
Peb->Ldr->InInitializationOrderModuleList指向按照初始化顺序排序的第一个LDR_MODULE节点的InInitializationOrderModuleList成员的指针,在WinNT平台(不包含Win9X)下,该链表头节点的LDR_MODULE结构包含的是NTDLL.DLL的相关信息,而链表的下一个节点所包含的就是Kernel32.dll相关的信息了,该节点LDR_MODULE结构中的BaseAddress不正是我们所苦苦寻找的吗。注意InInitializationOrderModuleList是LDR_MODULE的第3个成员,因此要获取BaseAddress的地址,只需将其指针加8再derefrence即可。因此下面的汇编代码即可获取Kernel32.DLL的基址:
mov eax, dword ptr fs:[30h] ;获取PEB基址
mov eax, dword ptr [eax+0ch] ;获取PEB_LDR_DATA结构指针
mov esi, dword ptr [eax+1ch]
;获取InInitializationOrderModuleList链表头第一个LDR_MODULE节点
InInitializationOrderModuleList成员的指针
lodsd ;获取双向链表当前节点后继的指针
mov ebx, dword ptr [eax+08h] ;取其基地址,该结构当前包含的是
;kernel32.dll相关的信息
该方法在所有的Windows NT(包括Windows 2003 SP1和Windows XP SP2)操作系统上都是有效的,唯一的缺憾是由于PEB结构不同,该方法在Win9X系统上无效。听起来可能比较费解,还是用一张图更加清晰一些:
图6 利用PEB搜索kernel32.dll基地址的过程
* 解析PE文件的导出函数表
PE文件的函数导出机制是进行模块间动态调用的重要机制,对于正常的程序,相关操作是由系统加载器在程序加载前自动完成的,对用户程序是透明的。但要想在病毒代码中实现函数地址的动态解析以取代加载器,那就有必要了解函数导出表的结构了。在图1中可以看到在PE头结构IMAGE_OPTIONAL_HEADER32结构中包含一个DataDirectory数组结构,该结构包含16个成员,每个成员都是一个IMAGE_DATA_DIRECTORY结构:
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress;
DWORD Size;
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
DataDirectory数组的每个结构都指向一个重要的数据结构,第一个成员指向导出函数表(索引0),第2个成员指向PE文件的引入函数表(索引1)。DataDirectory中的第一个成员指向导出函数表的IMAGE_EXPORT_DIRECTORY结构:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
AddressOfFunctions是一个双字数组,包含了所有导出函数的RVA,另外两个成员AddressOfNames也是一个双字数组,包含了指向导出函数名字的字符串的RVA,AddressOfNameOrdinals是一个字数组(16bit),和AddressOfNames数组是并行的,和AddressOfNames数组一起确定了相应引出函数的序号,该序号可直接用于索引AddressOfFunctions数组获取导出函数的地址。因此病毒搜索指定的API就包含了如下步骤:
a)获取NumberOfNames的值以及AddressOfNames、AddressOfNameOrdinals和AddressOfFunctions的数组的地址。
b)搜索AddressOfNames数组,按字符串对比,若找到相应的API,转d
c)若NumberOfNames名字尚未全部搜索完毕,转b继续搜索,若搜索完毕,则表明未找到进行错误处理,这一步通常可以省略,因为我们已经知道相应的DLL中肯定导出了相应的函数。
d)获取当前函数名字指针在AddressOfNames数组中的索引,在AddressOfNameOrdinals数组中取出以该值索引的函数序号,以该序号值作为AddressOfFunctions数组的索引,在AddressOfFunctions数组中取出导出函数的RVA值,加上基址就得到了运行时导出函数的地址。
看起来似乎比较罗嗦,实际上这是PE设计时为考虑灵活性而做出的牺牲。不过实现起来还是比较简单的,通常汇编代码编译后不到100字节。以下是在Kernel32搜索GetProcAddress的完整代码:
push esi
;esi=VA Kernel32.BASE
;edi=RVA K32.pehdr
mov ebp,esi
mov edi,[ebp+edi+peh.DataDirectory]
push edi esi
mov eax,[ebp+edi+peexc.AddressOfNames]
mov edx,[ebp+edi+peexc.AddressOfNameOrdinals]
call @F
db "GetProcAddress",0
@@:
pop edi
mov ecx,15
sub eax,4
next_:
add eax,4
add edi,ecx
sub edi,15
mov esi,[ebp+eax]
add esi,ebp
mov ecx,15
repz cmpsb ;进行字符串比较,判断是否为要查找的函数
jnz next_
pop esi edi
sub eax,[ebp+edi+peexc.AddressOfNames]
shr eax,1
add edx,ebp
movzx eax,word [edx+eax]
add esi,[ebp+edi+peexc.AddressOfFunctions]
add ebp,[esi+eax*4] ;ebp=Kernel32.GetProcAddress.addr
;use GetProcAddress and hModule to get other func
pop esi ;esi=kernel32 Base
在前面解析导出函数表获取API地址的时候,采用的是直接比较字符串的方法判断是不是找到了相应的API,其实还可以计算函数名字的hash,然后同预计算的hash进行比对,现代的PE病毒更多采用的hash的方法,其原因在于一般的函数名字长度都大于4字节,而用hash只要占用4个字节或2个字节,可以节省空间,此外还有抗病毒分析的作用,因为hash要比字符串名字费解得多。hash算法的设计只要能保证无冲突即可,可以用crc等成熟算法,也可以设计自己的简单算法。在Elkern中就使用了crc16算法。