了解了EXE和DLL里面的奥秘,你将成为一名知识更加渊博的程序员!”
可以看到,作为网络安全爱好者的我们,掌握和熟练利用PE(Portable Executable)文件格式的知识将必定能为我们学习黑客技术和攻防知识打下良好的基础。
在网络攻防的对抗中,常常接触到有关PE文件格式方面的技术,比如缓冲区溢出技术中编写Win32 ShellCode时利用PE文件结构的特征查找API函数地址就是一个很经典的例子,又比如,令人寒心的PE型病毒都是利用PE文件结构而大规模感染系统的其他PE文件。
PE文件格式的基础知识,如果读者不熟悉的话,网上这方面的教程比比皆是。另外,对于习惯看书的读者们,强烈推荐看雪软件的《加密解密II》,《软件加密技术内幕》这两本经典之作。
现在,市面上有很多的静态分析PE文件方面的工具,像PeDump和Pe Explorer(附件中均有收录)这两款工具,可算的上是其中的佼佼者。前者是命令行下的,从它的可选参数就可以看出期功能的强大。后者是图形化的,界面友好而且功能也很强大。
读者肯定有疑问了,既然都有这么现成的工具了,那写本文目的是什么呢?这里说明一下,市面上出现的PE分析工具包括推荐的两款工具都有一个共同点,就是他们加载的都是磁盘上的PE文件,但有时候我们却要分析内存中的PE映象,这个又要如何实现呢?我们下面就自己来分析解决这个问题吧!

 

首先,要明确的一点是,PE文件格式在磁盘中的数据结构布局和内存中的数据格式布局是一致的,就是说, 知道如何在PE文件中寻找一些内容,那么几乎都能在被装入到内存的映射文件中找到相同的信息。
这样的话,就好办了,我们可以使用乾坤大挪移,用类似分析磁盘PE文件的方法来分析内存中的PE文件了。大家可以看到,所有PE文件(包括32位的DLL)都是以一个简单MZ-DOS头开始,MZ格式的文件头在WINNT.H中有定义,其IMAGE_DOS_HEADER结构如下(左边数字是到文件头的偏移量):
typedef struct _IMAGE_DOS_HEADER { // DOS .EXE 头
+0h WORD e_magic; // “MZ”
……
+3Ch LONG e_lfanew; //指向PE文件头偏移
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
我只列出了两个最重要的成员,e_magic是DOS可执行文件标记“MZ”,而e_lfanew指向PE文件头“PE”,0,0。执行程序在执行的时候,PE文件装载器将从MZ-DOS头的e_lfanew字段找到PE头起始偏移,再跳到真正的PE文件头处。IMAGE_NT_HEADERS的数据结构如下:
typedef struct _IMAGE_NT_HEADERS {
+0h DWORD Signature; // PE文件标识
+4h IMAGE_FILE_HEADER FileHeader; // 映象文件头
+18h IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选映象头
} IMAGE_NT_HEADERS, *PIMAGE_NT_HEADERS;
PE文件头和原始数据之间存在一个块表,块表包含每个块在映象中的信息。块表IMAGE_SECTION_HEADER包含了PE文件中的 Sectin的重要资料,其结构如下:
typedef struct _IMAGE_SECTION_HEADER {
+0h BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 8个字节的块名
+8h union {
DWORD PhysicalAddress;
DWORD VirtualSize; // 该块真实长度。是块对齐前的长度
} Misc;
+0Ch DWORD VirtualAddress; // 该块的RVA
+10h DWORD SizeOfRawData; // 在文件中对齐后的尺寸
+14h DWORD PointerToRawData; // 在文件中偏移
… …
+28h DWORD Characteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
出于学习的目的,我们就只分析这三个结构,你完全可以获得其他你感兴趣的结构字段。
下面我们具体到程序上来。
在我的程序里,首先定义了一个ShowHelp()的帮助函数,主要用来显示用法。
可以看到,我所实现的PeDump程序,必须带一个进程PID的参数,你可以从任务管理器里查看每个你想要Dump的进程ID号。

而第二个参数Address是可选的,“*”表示Dump所有模块在内存中的PE映像文件,也可选任一有效的地址来Dump,而如果不加第二个参数,则默认Dump第一个模块的PE映象。
主要功能的实现原理说来很简单, 就是利用了ReadProcessMemory()这个函数丛内存中读取信息,然后利用PE映像在内存中和在磁盘中数据结构的布局一致性来分析。我们主要定义了的以下几个函数,功能及注释如下:
1)PageSize():获得内存页大小
DWORD PageSize()
{
SYSTEM_INFO systemInfo; // 系统信息结构
GetSystemInfo(&systemInfo); // 获得系统信息结构
return systemInfo.dwPageSize; // 返回内存页大小
}
2) GetModuleName(): 获得模块文件名
char* GetModuleName(HANDLE handle, DWORD address)
{
HMODULE hModule[1024];
DWORD cbNeeded;
char szName[MAX_PATH], *ptr = NULL;
// 枚举进程模块
if ( EnumProcessModules(handle, hModule, sizeof(hModule), &cbNeeded) )
{
for (int i = 0; i < (cbNeeded / sizeof(HMODULE)); i++)
{
if (address == (DWORD)hModule[i])
{ // 获得模块名
if (GetModuleBaseName(handle, hModule[i],
szName, sizeof(szName)))
{
ptr = strdup(szName);
}
break;
}
}
}
return ptr;
}
注意,这里用到了Psapi.dll库的两个API函数EnumProcessModules()和GetModuleBaseName(),我这里添加了Richard Shupak 写的Psapi.h的头文件(附件内提供),并通过预处理#pragma comment(lib, “psapi.dll”)加载PSAPI.DLL,如果你的sdk没有包含该头文件,也许你需要利用LoadLibrary()和GetProcAddress()来动态加载。

3) DumpPe(): 主功能函数, Dump内存中的PE文件数据
unsigned char* DumpPe(HANDLE hhProcess, unsigned char *szAddress, int *iLen)
{
PIMAGE_DOS_HEADER pDos; // MS-DOS头
PIMAGE_NT_HEADERS pNt; // NT映象头
PIMAGE_SECTION_HEADER pSection; // 区块头

unsigned char szHeader[8192], *szSection;
DWORD dwRet;
// 从指定的地址szAddress读进程内存内容到szHeader
if ( !ReadProcessMemory(hhProcess, (LPVOID)szAddress, (LPVOID)szHeader, sizeof(szHeader), NULL))
return FALSE;
// 通过文件头两个字节是否等于“MZ”来判断是否为PE文件
if ( memcmp(szHeader, "MZ", 2) )
{
return FALSE;
}

printf("映象在0x%p/n", szAddress);

pDos = (PIMAGE_DOS_HEADER)szHeader;
// MS-DOS头的最后一个成员e_lfanew指向NT映象头
pNt = (PIMAGE_NT_HEADERS)(szHeader+pDos->e_lfanew);
// 获得所需分配内存空间的大小
*iLen = pNt->OptionalHeader.SizeOfImage +
pNt->OptionalHeader.SizeOfHeaders + PageSize();
// 分配虚拟内存空间, 注意后面的保护属性为PAGE_READWRITE
if ( !(dwRet = (DWORD)VirtualAlloc(NULL,
pNt->OptionalHeader.SizeOfImage +
pNt->OptionalHeader.SizeOfHeaders + PageSize(),
MEM_RESERVE|MEM_COMMIT, PAGE_READWRITE)) )
{
printf("%d/n", GetLastError());
printf("不能分配有效内存/n");
return FALSE;
}
// 打印Dump的PE头重要信息
printf("DUMPING 头…/n"
" 头大小 : 0x%p/n"
" 虚拟地址 : 0x%p/n"
" 映象地址 : 0x%p/n",
pNt->OptionalHeader.SizeOfHeaders, szAddress, 0);

memcpy((char*)dwRet, szHeader, pNt->OptionalHeader.SizeOfHeaders);

pSection = IMAGE_FIRST_SECTION(pNt);
for(int i=0; i<pNt->FileHeader.NumberOfSections; i++)
{ // 打印Dump的PE块信息
printf("DUMPING 块#%d…/n", i);
printf(" 块大小 : 0x%p/n", pSection[i].SizeOfRawData);
printf(" 虚拟地址 : 0x%p/n", szAddress + pSection[i].VirtualAddress);
printf(" 映象地址 : 0x%p/n", dwRet + pSection[i].PointerToRawData);

if ( !(szSection=(unsigned char*)VirtualAlloc(NULL,
pSection[i].Misc.VirtualSize,
MEM_RESERVE|MEM_COMMIT,
PAGE_READWRITE)) )
{
printf("不能分配有效内存/n");
return FALSE;
}
// 从进程内存里读映像块
if ( !ReadProcessMemory(hhProcess, szAddress+pSection[i].VirtualAddress,
szSection, pSection[i].Misc.VirtualSize, NULL))
{
printf("不能读取映象块/n");
return FALSE;
}

memcpy((char*)dwRet+pSection[i].PointerToRawData, szSection, pSection[i].Misc.VirtualSize);
// 释放申请到的内存
VirtualFree(szSection, 0, MEM_RELEASE);
}

return (unsigned char *)dwRet;
}

4) DumpAddress(): Dump内存地址
void DumpAddress(HANDLE process, DWORD address, unsigned char *pe, int len, int pid)
{
char szFileName[MAX_PATH], *szMouduleName=NULL;
// 获取模块名
szMouduleName = GetModuleName(process, address);
if ( !szMouduleName )
{
szMouduleName = strdup("未知模块.txt");
}

memset(szFileName, 0, MAX_PATH);
//按照 “PID-内存地址-模块名.dat” 构造磁盘文件名
sprintf(szFileName, "%d-%p-%s.dat", pid, address, szMouduleName);
free(szMouduleName); // 释放strup()申请来的资源
// 写PE Dump结果到磁盘
WritePeDump(szFileName, pe, len);
}

5) WritePeDump():写到磁盘文件中
void WritePeDump(char *outfile, unsigned char*pe, int len)
{
HANDLE hFile = NULL;
DWORD cbWritten;
printf("开始写到文件…/n");
hFile = CreateFile(outfile, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, NULL); // 创建文件,具有写属性
if (hFile == INVALID_HANDLE_VALUE)
{
printf("创建文件错误/n");
return;
}
// 写文件操作
WriteFile(hFile, pe, len, &cbWritten, NULL);

if ( len != cbWritten )
{ // 校验写文件操作是否正确
printf("写入文件时错误/n");
}
}
到这,一个简单的从内存DUMP PE文件映像到磁盘的程序就完成了。
编译后我选取了金山词霸的PID作为参数,得到的测试结果。
和PeDump或PE Explorer对比发现,结果是一样的。呵呵。 是不是很有成就感呢。

小结:
本文主要简单讨论了如何从内存中分析PE文件的相关技术, 实现了很简陋的功能, 但理解了思路,你完全可以加入很多诱人的功能。
无论是病毒技术,破解技术还是溢出技术,熟练掌握PE文件格式的分析技术是很有用的,别以为网络上那些琳琅满目的PE分析工具有多么的神奇,只要你掌握了PE文件格式的基本架构和基本的C/C++编程功底,你也能打造自己的PE工具的。记住,伴随着黑防的努力,我们也在一步步成长!