注:题目来自于以下链接地址:http://www.pediy.com/kssd/
目录:第13篇 论坛活动 \ 金山杯2007逆向分析挑战赛 \ 第一阶段 \ 第二题 \ 题目 \ [第一阶段 第二题]
题目描述:
己知是一个 PE 格式 EXE 文件,其三个(section)区块的数据文件依次如下:(详见附件)
_text,_rdata,_data
1. 将 _text, _rdata, _data 合并成一个 EXE 文件,重建一个 PE 头,一些关键参数,如 EntryPoint,ImportTable 的 RVA,请自己分析文件获得。合并成功后,程序即可运行。
2. 请在第1步获得的EXE文件基础上,增加菜单。具体见图:
3. 执行菜单 Help / About 弹出如下图所示的 MessageBox 窗口:
题目分析和解答:
(一)拼接可执行文件:
首先下载题目的附件,附件中已经有三个文件,分别是 PE 文件的三个 section,可以看到三个 section 文件已经按照 0x1000 大小对齐。这样我们只需要把这三个文件依次连接在一起,接在一个正确的 PE 文件头后面就可以了。
可以先用 VC (我采用 VS2005)创建一个 Windows 窗口程序(它将提供一些主要样本,所以称这个程序为样本程序),把程序写的尽可能和题目中的程序类似,然后编译,即首先得到了一个 PE 文件头的原型,再次基础上进行修改,也就是根据题目给出的 section,适当调整 PE 文件头中的需要修改的字段。
在本题求解过程中,我严重依赖于我从前写的一个展示 PE 文件格式的应用程序,此程序最近经过我的调整和改进,它的优点是由于此程序基于扩展 TreeView 控件,因此帮助快速理解 PE 文件头的结构,其效果见以下截图:
关于此程序的更多信息,请参见我的博客文章:《[VC6] 图像文件格式数据查看器》。
BmpFileView 的可执行文件的下载链接(不敢说它是最好的,但作为帮助学习PE文件格式的辅助工具而强烈推荐):
http://files.cnblogs.com/hoodlum1980/BmpFileView_V2_Bin.zip
观察题目给出的三个 section 文件,可以给出这三个 section 的基本信息如下:
SectionName | VirtualAddress | RawDataSize | VirtualSize |
---|---|---|---|
.text | 1000h | 6000h | 5B73h |
.rdata | 7000h | 1000h | 0C6Eh |
.data | 8000h | 3000h | 4000h |
.rsrc | B000h |
其中,.rsrc 是需要在稍后插入的资源 section,将在稍后讲解。
这里需要特别注意的是,.data 的虚拟内存尺寸,必须要比文件尺寸(RawDataSize)更大一些,关于这一点我还暂时不能给出详细的解释,有待于在将来做进一步研究。如果把 .data 的 VirtualSize 设置为和 RawDataSize 一样大(3000h),则程序无法运行,会弹出一个消息框提示这不是一个有效的 Win32 程序。所以这一步我也是反复尝试是否是其他字段的问题,纠结了半天才发现原来问题卡在这个地方。
对于 PE 文件头的 IMAGE_OPTINAL_HEADER.CheckSum,Windows 看起来完全忽略这个字段的值,所以这个字段可以不用管。
明确了以上问题,现在可以把这三个 section 和文件头链接成一个新的 PE 文件了,把样本程序 pediy02.exe 和三个 section 文件放在同一个目录下,通过一个辅助的 Console 项目(pediy02_helper 项目)来完成这些工作,生成的新的 PE 文件名为 pediy02_new.exe,使用的辅助函数如下(为了简单明了起见,代码中并没有插入繁琐的检测性代码,例如申请的缓冲区大小,已经根据需要,在编码时被静态的确定了):
Code 1.1 将三个 Section 拼接成 PE 文件的 C++ 代码:
void WriteToFile(FILE *fp, void* pBuf, DWORD nSize); int CreateNewPe() { //PIMAGE_IMPORT_DESCRIPTOR pImportTable = NULL; PIMAGE_DOS_HEADER pDosHdr = NULL; PIMAGE_NT_HEADERS pNtHdrs = NULL; PIMAGE_SECTION_HEADER pSectionHdr = NULL; FILE *fp1, *fp2, *fp3; TCHAR szPath[MAX_PATH]; LPCTSTR szNames[3] = { _T("_text"), _T("_rdata"), _T("_data") }; _stprintf_s(szPath, _T("%s\\pediy02.exe"), THE_DIR); _tfopen_s(&fp1, szPath, _T("rb")); _stprintf_s(szPath, _T("%s\\pediy02_new.exe"), THE_DIR); _tfopen_s(&fp2, szPath, _T("wb")); //读取文件头部 void* buf = malloc(0xD000); fread(buf, 1, 0x1000, fp1); pDosHdr = (PIMAGE_DOS_HEADER)buf; pNtHdrs = (PIMAGE_NT_HEADERS)((DWORD)buf + pDosHdr->e_lfanew); pSectionHdr = (PIMAGE_SECTION_HEADER)((DWORD)pNtHdrs + sizeof(IMAGE_NT_HEADERS)); /* ---------------------------------------------- | section | addr | RawDataSize | VirtualSize | |---------+-------+--------------+-------------| | .text | 1000h | 6000h | 5B73h | | .rdata | 7000h | 1000h | 0C6Eh | | .data | 8000h | 3000h | 4000h | | .rsrc | B000h | 1000h | 1000h | ---------------------------------------------- */ pNtHdrs->FileHeader.NumberOfSections = 4; pNtHdrs->OptionalHeader.BaseOfCode = 0x1000; pNtHdrs->OptionalHeader.BaseOfData = 0x8000; //+1000h 的 .rsrc pNtHdrs->OptionalHeader.SizeOfCode = 0x6000; pNtHdrs->OptionalHeader.SizeOfImage = 0xD000; pNtHdrs->OptionalHeader.SizeOfInitializedData = 0x5000; pNtHdrs->OptionalHeader.SizeOfUninitializedData = 0; pNtHdrs->OptionalHeader.AddressOfEntryPoint = 0x1527; //入口点 //IMAGE_DIRECTORY_ENTRY_IMPORT 需要进一步调整, kernel32.dll, gdi32.dll, user32.dll 加上一个结尾 pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress = 0x7618; pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size = sizeof(IMAGE_IMPORT_DESCRIPTOR) * (3 + 1); //资源表 pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].VirtualAddress = 0xC000; pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].Size = 0x011C; // IMAGE_DIRECTORY_ENTRY_DEBUG 6 pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG].VirtualAddress = 0; pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DEBUG].Size = 0; // IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG].VirtualAddress = 0; pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG].Size = 0; //IMAGE_DIRECTORY_ENTRY_IAT 12; (import address table), IMAGE_IMPORT_DESCRIPTOR.FirstTrunk 中的最小值 //IAT 地址需要在修改后找,需要进一步调整 //IAT 的地址通常就是 .rdata 的起始地址 //Size 是 FirstTrunk 中的最大地址 - IAT 起始地址) + 8; //(其中 +4 是最后一个元素占用的空间,再 +4 是一个NULL元素,表示结尾) pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IAT].VirtualAddress = 0x7000; pNtHdrs->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IAT].Size = 0x012C; //section_headers //.text pSectionHdr[0].VirtualAddress = 0x1000; pSectionHdr[0].SizeOfRawData = 0x6000; pSectionHdr[0].PointerToRawData = 0x1000; pSectionHdr[0].Misc.VirtualSize = 0x5B73; //.rdata pSectionHdr[1].VirtualAddress = 0x7000; pSectionHdr[1].SizeOfRawData = 0x1000; pSectionHdr[1].PointerToRawData = 0x7000; pSectionHdr[1].Misc.VirtualSize = 0x1000; //.data //.data 的虚拟内存大小(VirtualSize)必须比文件中更大,否则无法启动,现在我也不知道为什么 pSectionHdr[2].VirtualAddress = 0x8000; pSectionHdr[2].SizeOfRawData = 0x3000; pSectionHdr[2].PointerToRawData = 0x8000; pSectionHdr[2].Misc.VirtualSize = 0x4000; //【重要!】必须比 SizeofRawData 大一些 //.rsrc (resource) 因为.data 比文件中大,所以.rsrc 相应的要像高地址移动 pSectionHdr[3].VirtualAddress = 0xC000; pSectionHdr[3].SizeOfRawData = 0x1000; pSectionHdr[3].PointerToRawData = 0xB000; //文件中的地址还是紧靠.data pSectionHdr[3].Misc.VirtualSize = 0x011C; //从范本文件中得到该值 fwrite(buf, 1, 0x1000, fp2); fflush(fp2); int i; DWORD dwFileSize; for(i = 0; i < 3; i++) { _stprintf_s(szPath, _T("%s\\%s"), THE_DIR, szNames[i]); _tfopen_s(&fp3, szPath, _T("rb")); fseek(fp3, 0, SEEK_END); dwFileSize = ftell(fp3); fseek(fp3, 0, SEEK_SET); fread(buf, 1, dwFileSize, fp3); fclose(fp3); WriteToFile(fp2, buf, dwFileSize); } //从已有的范本复制 .rsrc 节 fseek(fp1, 0xB000, SEEK_SET); fread(buf, 1, 0x1000, fp1); WriteToFile(fp2, buf, 0x1000); fclose(fp1); fclose(fp2); free(buf); return 0; }
//写入文件,以 1KB 为单位 void WriteToFile(FILE *fp, void* pBuf, DWORD nSize) { //以1KB为基本单位,逐次写入 char* pos = (char*)pBuf; size_t BytesToWrite; while(nSize > 0) { BytesToWrite = min(nSize, 0x400); fwrite(pos, 1, BytesToWrite, fp); fflush(fp); nSize -= BytesToWrite; pos += BytesToWrite; } }
上面的函数已经是最终版本的函数,它已经完成了以下工作:
(1)确定 AddressOfEntryPoint 的地址。
(2)确定 DataDirectory[1]: ImportTable (导入表)的地址和尺寸。
(3)确定 DataDirectory[12]: Import Address Table (绑定导入函数地址表)的地址和尺寸。
(4)从样本程序 pediy02.exe 中插入资源 (.rsrc) section,并确定 DataDirectory[2]: resource Table (资源表)的地址和尺寸。
当然很显然上面的工作并不是一步到位完成的,下面简要介绍上面的工作是如何完成的:
(1)确定入口点地址:
该工作相对简单容易,先把 EntryPoint 设置为 .text (代码段)的起始地址:0x1000,然后生成文件后,加载到 IDA 中分析代码段的内容,就可以很容易的找到以下函数的地址(以下地址为 VA,即加上了 ImageBase 后的地址):
0x00401527: __tmainCRTStartup,是 PE 文件的实际入口点。
0x004011EC: WinMain,高级语言编程时的程序入口点。
0x004012D5: WndProc, 当前的窗口过程(稍后将会被子类化)
0x004059C4: sub_4059C4,基本等价于 MessageBoxA,很重要,称它为 ___crtMessageBoxA。
现在只要知道,在文件头中把入口地址设置到 __tmainCRTStartup 函数即可,文件头要求的是 RVA,因此在代码中设置入口点:
IMAGE_NT_HEADERS.IMAGE_OPTIONAL_HEADER.AddressOfEntryPoint = 0x1527;
这样入口点地址就确定好了。
(2)确定 DataDirectory [1] 导入表的地址和大小:
这一步也相对比较简单,导入表位于 .rdata 中(位于中部)。在此之前,必须了解导入表的结构,导入表是一个由多个 IMAGE_IMPORT_DESCRIPTOR 元素组成的数组,以 NULL 元素(内容全部是 0 )标识结尾(IMAGE_IMPORT_DESCRIPTOR 的数据结构定义参见 winnt.h)。每个元素由 5 个 DWORD 组成,其中倒数第二个 DWORD 是 Name 字段(字符串指针),它的值是一个 RVA(即相对于 ImageBase 的偏移),指向了 Dll 名字(ASCII)字符串(该字符串同样位于 .rdata 中)。
导入表的示意结构如下图所示(图中展示的是两个 Thunk 数组并行情况,因此 FirstThunk 也是字符串指针的大多数情况,图中的字符串虽然位于整齐的矩形格子之内,这只是为了图形外观,应该强调的是这些字符串的长度是不固定的,长度有长有短,所以它们在空间中的分布是参差不齐的):
上图表示了 pediy2_new.exe 的实际导入表,共导入了 3 个 DLL,每个导入 DLL 是导入表中的一个元素,在这个数组中的每个元素大小为 20 Bytes,如果引用了 3 个 DLL,则这个数组一共为 (3 + 1) * 20 = 80 Bytes (最后有一个 null terminator element)。下面是单个元素 descriptor 大小:
sizeof ( IMAGE_IMPORT_DESCRIPTOR ) = sizeof ( DWORD ) * 5 = 20 Bytes;
每个元素的 OriginalFirstTrunk 和 FirstTrunk 是两个指针,指向了两个 并行的指针数组,通常情况下(即没有在链接时事先绑定)这两个数组的内容是相同的(即两个数组的所有元素的值相同),在静态 PE 文件中,都指向相同的长度不固定的函数名称字符串(或者是被导入函数的 Ordinal)。
补充说明:在没有经过事先绑定时,OriginalFirstTrunk 和 FirstTrunk 指向的数组内容在加载之前都指向 .rdata 中的一些长度不固定的 Ascii 编码的字符串,在加载时 FirstTrunk 指向的数组被系统绑定成映射到本进程的 DLL 的实际函数地址(因此该数组称为 IAT),所以这些元素称为 Trunk (意味着其身份的可变性,这些元素在加载后其身份发生了变化),因为指向的是数组头部,所以称之为 First(IMAGE_IMPORT_DESCRIPTOR.(Original)FirstTrunk 表示某个 DLL 被本模块导入的首个函数的 Trunk 的位置,后面还有更多的函数 Trunk,以 NULL 表征结束)。OriginalFirstTrunk 在加载后保持不变(所以称为 Original),所以相当于存储着导入函数名称的一份副本。在模块被加载后,可以通过 OriginalFirstTrunk 数组了解到该模块导入了哪些函数(名称),通过 FirstTrunk 数组的内容可了解到导入函数的运行时虚拟地址。导入函数的实际地址是在加载时绑定的(无法在编译时确定),编译器可能为每个 dll 函数调用生成一个很小的函数体,称为 j_XXX, 该函数体负责 jmp 到 FirstTrunk 数组中的元素给出的运行时函数地址,也可以直接调用 IAT 元素内容指向的 VA 地址。
虽然应用程序可以通过序号导入函数,并具有极高效率,但是这样会导致看不到导入函数的名字,对程序和系统的维护造成障碍。所以除非成本太高(例如 MFC 类库的导出函数过多,且面向对象的 C++ 函数名称也很长,所以 MFC 类库的函数以 Ordinal 方式被导入),按名称导入是普遍做法,显然按名称导入,需要线性搜索模块的导出函数表,这就会消耗一定的加载时间成本。为了提高程序加载时效率,应用程序可以通过 “事先 Rebase” (将程序需要导入的模块自身建议的 ImageBase 进行精心调整,从而避免在加载时重定向) 和 “事先绑定” 提高程序在客户运行环境的加载速度,系统通过时间戳判定绑定信息是否有效,如果时间戳不一致,或者发生重定向,系统则必须再次进行加载时绑定。
OriginalFirstTrunk 和 FirstTrunk 指向的这两个指针数组位于 .rdata 的不同位置,其中 FirstTrunk 指向的数组位于 .rdata 的起始位置(稍后可以看到这就是 IAT),OriginalFirstTrunk 指向的数组位于稍微靠后的位置。两个 Trunk 在 PE 文件中的值都指向相同的 IMAGE_IMPORT_BY_NAME (由 Hint 和 函数名称字符串 组成的数据结构)。IAT 所在的页面将在加载时被临时设定为可写,绑定之后再恢复为只读。有关这部分的细节请参考我的博客文章:《读取PE文件的导入表》。
关于导入表和 IAT 的在内存空间中的位置布局,请参考本文的补充讨论(2)。
了解了导入表结构,就可以很快找到导入表的位置了,首先在 .rdata 中查找 DLL 名称字符串,可以找到如下的字符串:
FA: 0x000077AC: "KERNEL32.dll"; (这里使用的是文件地址 FA,或者说是 RVA)
找到附近指向该位置的指针,即在附近的文件内容中搜索 "AC 77 00 00" 片段,可以找到文件地址:
FA: 0x00007624: AC 77 00 00
这里就是一个 IMAGE_IMPORT_DESCRIPTOR 元素,把该地址减去 3 个 DWORD 值,即得到该元素的起始地址为 0x00007618。由于导入表元素内容非常有特点,很容易就可以判断导入表的两端边界,因此可以很快确定导入表的起始地址(RVA)和 Size 如下:
IMAGE_NT_HEADERS.IMAGE_OPTIONAL_HEADER.DataDirectory[2].VirtualAddress = 0x7618;
IMAGE_NT_HEADERS.IMAGE_OPTIONAL_HEADER.DataDirectory[2].Size = sizeof ( IMAGE_IMPORT_DESCRIPTOR ) * 4;
(3)确定 DataDirectory [12],IAT的地址和大小:
IAT 的地址比较简单,它就是所有 DLL 的 FirstTrunk 字段的最小值,通常就是 .rdata 的起始位置(那些常量字符串位于 IAT 和 ImportTable 的后面),也就是 0x7000 (可以看到这里是从 Gdi32.dll 的导入的第一个函数 DeleteObject)。
要计算 IAT 的大小,需要遍历导入表,找到导入的所有 Dll 的 FirstTrunk 的最后一个元素的位置,同时还要考虑到结尾还需要一个 NULL 指针作为结束标志,所以:
IAT.Size = max ( 所有 DLL 的 FirstTrunk 数组元素所在的地址(RVA) ) - IAT.VirtualAddress (RVA) + 8 。
有关如何遍历导入表的更多内容,请参考我的博客文章(在此就不再详细叙述了):《读取PE文件的导入表》。
本题目中所有的 Trunk 的最大地址(RVA)是 0x7124(从 USER32.dll 导入的 DispatchMessageA),可得:
DataDirectory[12].VirualAddress = 0x7000; // RVA (Relative to ImageBase )
DataDirectory[12].Size = 0x012C;
经过以上修改,可以通过 CreateNewPe 函数,生成一个可以执行的 PE 文件了。题目的前半部分要求此时完成。接下来考虑后半部分要求,为程序添加菜单和相关的命令处理函数。
(二)添加菜单 和 处理函数。
(1)添加 .rsrc section (菜单资源)
添加资源,同样通过在样本程序中实现。在样本程序中,添加题目要求一样的资源(只保留菜单,删除所有其他种类资源,这样可以使 .rsrc 最小,仅占用 1000h 大小),然后可以从样本程序中拷贝 .rsrc 段,追加到我们已经得到的 PE 文件的尾部。同时调整 PE 文件头中的相关字段。
注意:由于 .data 节在加载到虚拟内存中时被扩大了 1000h,所以位于最后的 .rsrc 的文件地址(FA)和虚拟地址(VA)将会偏差 1000h。即:
VA = FA + 1000h;
众所周知,窗口的菜单通常是在注册窗口类时指定的。因此为了添加菜单,在 IDA 中观察 WinMain 函数的代码:
Code 2.1 由 .text 提供的 WinMain 函数的汇编代码:
.text:004011EC ; int __stdcall WinMain(int,int,int,int nCmdShow) .text:004011EC WinMain proc near ; CODE XREF: start+C9