PE解析器的编写（四）——数据目录表的解析
在PE结构中最重要的就是区块表和数据目录表，上节已经说明了如何解析区块表，下面就是数据目录表，在数据目录表中一般只关心导入表，导出表和资源这几个部分，但是资源实在是太复杂了，而且在一般的病毒木马中也不会存在资源，所以在这个工具中只是简单的解析了一下导出表和导出表。这节主要说明导入表，下节来说导出表。RVA到fRva的转化RVA转化为fRva主要是通过某个数据在内存中的相对偏移地址找到其在文件中的相对偏移地址，在对某个程序进行逆向时，如果找到关键的那个变量或者那句指令，我根据变量或者代码指令在内存中的RVA找到它在文件中的偏移，就可以找到它的位置，修改它可能就可以破解某个程序。废话不多说，直接上代码：DWORD CPeFileInfo::RVA2fOffset(DWORD dwRVA, DWORD dwImageBase) { InitSectionTable(); vector<IMAGE_SECTION_HEADER>::iterator it; for (it = m_SectionTable.begin(); it != m_SectionTable.end(); it++) { if (dwRVA >= (DWORD)(it->VirtualAddress) && dwRVA <= (DWORD)((DWORD)(it->VirtualAddress) + it->Misc.VirtualSize) ) { break; } } if (it == m_SectionTable.end()) { return -1; } return (DWORD)(dwRVA - (DWORD)(it->VirtualAddress) + (DWORD)(it->PointerToRawData) + dwImageBase); }系统在将PE文件加载到内存中时，PE中的区块是按页的方式对齐的，也就是说同一节中的内容在一页内存中的排列方式与在文件中的排列方式相同，所以这里利用这一关系就可以根据RVA推算出它在文件中的偏移，即：fRva - Roffset = Rva - Voffset ,其中fRva是某个成员在文件中的偏移，Roffset是区块在文件中的偏移，Voffset是该区块在内存中的偏移。上述代码就是利用这个原理来计算的，代码中存在一个循环，VirtualAddress 和 VirtualSize分别代表这个区块在内存中的起始地址和这个区块所占内存的大小，当这个RVA大于起始地址，小于起始地址 + 区块大小也就说明这个RVA是处在这个区块中，这样我们就找到RVA所在区块，用RVA - 区块起始地址就得到它在区块中的偏移，这个偏移加上区块在文件中的首地址，得到的就是RVA对应的在文件中的偏移，只要知道文件的起始地址就可以知道它在文件中的详细位置了。获取数据目录表的信息数据目录表的信息主要存储在PE头结构中的OptionHeader中，回顾一下它的定义：typedef struct _IMAGE_OPTIONAL_HEADER { // // Standard fields. // WORD Magic; BYTE MajorLinkerVersion; BYTE MinorLinkerVersion; DWORD SizeOfCode; DWORD SizeOfInitializedData; DWORD SizeOfUninitializedData; DWORD AddressOfEntryPoint; DWORD BaseOfCode; DWORD BaseOfData; // // NT additional fields. // DWORD ImageBase; DWORD SectionAlignment; DWORD FileAlignment; WORD MajorOperatingSystemVersion; WORD MinorOperatingSystemVersion; WORD MajorImageVersion; WORD MinorImageVersion; WORD MajorSubsystemVersion; WORD MinorSubsystemVersion; DWORD Win32VersionValue; DWORD SizeOfImage; DWORD SizeOfHeaders; DWORD CheckSum; WORD Subsystem; WORD DllCharacteristics; DWORD SizeOfStackReserve; DWORD SizeOfStackCommit; DWORD SizeOfHeapReserve; DWORD SizeOfHeapCommit; DWORD LoaderFlags; DWORD NumberOfRvaAndSizes; IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; } IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;这个结构的最后一个结构是数据目录表的数组，而NumberOfRvaAndSizes表示数据目录表中元素的个数，一般都是8，而IMAGE_DATA_DIRECTORY 结构的定义如下：typedef struct _IMAGE_DATA_DIRECTORY { DWORD VirtualAddress; DWORD Size; } IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;第一个是指向某个具体的表结构的RVA，第二个是这个表结构的大小，在这个解析器中，主要显示这两项，同时为了方便在文件中查看，我们新加了一项，就是它在文件中的偏移在这个解析器的代码中，我们定义了一个结构来存储这些信息struct IMAGE_DATA_DIRECTORY_INFO { PVOID pVirtualAddress; PVOID pFileOffset; DWORD dwVirtualSize; };在类中定义了一个该结构的vector结构，同时定义一个InitDataDirectoryTable函数来初始化这个结构void CPeFileInfo::InitDataDirectoryTable() { if (!m_DataDirectoryTable.empty()) { //先清空之前的内容 m_DataDirectoryTable.clear(); } PIMAGE_SECTION_HEADER pSectionHeader = GetSectionHeader(); PIMAGE_OPTIONAL_HEADER pOptionalHeader = GetOptionalHeader(); PIMAGE_DATA_DIRECTORY pDataHeader = pOptionalHeader->DataDirectory; IMAGE_DATA_DIRECTORY_INFO dataInfo; for (int i = 0; i < IMAGE_NUMBEROF_DIRECTORY_ENTRIES; i++) { dataInfo.pVirtualAddress = (PVOID)(pDataHeader[i].VirtualAddress); dataInfo.dwVirtualSize = pDataHeader[i].Size; dataInfo.pFileOffset = (PVOID)RVA2fOffset((DWORD)(dataInfo.pVirtualAddress), 0); //这里调用这个函数计算它的偏移所以这里假定文件的起始地址为0 m_DataDirectoryTable.push_back(dataInfo); } }上述代码比较简单，获得了OptionHeader结构的指针后直接找到DataDirectory的地址，就可以得到数组的首地址，然后在循环中依次遍历这个数组就可以得到各项的内容，对于文件中的偏移直接调用之前写的那个转化函数即可导入表的解析导入的dll的信息的获取导入表在数据目录表的第1项，所以我们只需要区数据目录表数组中的第一个元素，从中就可以得到它的RVA，然后调用RVA到文件偏移的转化函数就可以在文件中找到它的位置，在代码中也是这样做的PIMAGE_IMPORT_DESCRIPTOR CPeFileInfo::GetImportDescriptor() { //由于这个表中保存的是RVA，要在文件中遍历，需要转为在文件中的偏移 PVOID pImportRVA = m_DataDirectoryTable[1].pVirtualAddress; //在读取这些数据的时候，是从内存中读取的，从内存中读取时，需要考虑文件被加载到内存中的基址 return (PIMAGE_IMPORT_DESCRIPTOR)RVA2fOffset((DWORD)pImportRVA, (DWORD)pImageBase); }导入表在Windows中的定义如下：typedef struct _IMAGE_IMPORT_DESCRIPTOR { union { DWORD Characteristics; // 一般给0 DWORD OriginalFirstThunk; //指向first thunk，IMAGE_THUNK_DATA，该 thunk 拥有 Hint 和 Function name 的地址。这个IMAGE_THUNK_DATA在后面解析具体函数时会用到 }; DWORD TimeDateStamp; //忽略，很少使用它 DWORD ForwarderChain; //忽略，很少使用它 DWORD Name; //这个dll的名称 DWORD FirstThunk; } IMAGE_IMPORT_DESCRIPTOR; typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;这个结构是以一个数组的形式存储在对应的位置，所以说我们只要找到第一个结构的位置就可以找到剩余的位置，但是这个数组的个数事先并不知道，数组的最后一个元素都为0，所以在遍历到对应的都为0 的成员是就到了它的尾部，根据这个我们定义了一个函数，来判断它是否到达数组尾部，当不在尾部时将数组成员取出来，存储到事先定义的vector中。void CImportDlg::InitImportTable() { //获取导入函数表在文件中的偏移 PIMAGE_IMPORT_DESCRIPTOR pImportTable = m_pPeFileInfo->GetImportDescriptor(); if (NULL != pImportTable) { int i = 0; while (!IsEndOfTable(&pImportTable[i])) { m_ImportTable.push_back(pImportTable[i]); i++; } } } BOOL CImportDlg::IsEndOfTable(PIMAGE_IMPORT_DESCRIPTOR pImportTable) { //是否到达表的尾部，这个表中没有给出总共有多少项，需要自己判断 //判断条件是最后一项的所有内容都是null if (0 == pImportTable->OriginalFirstThunk && 0 == pImportTable->TimeDateStamp && 0 == pImportTable->ForwarderChain && 0 == pImportTable->Name && 0 == pImportTable->FirstThunk) { return TRUE; } return FALSE; }在显示时需要注意两点：name属性保存的是ASCII码形式的字符串，如果我们程序使用Unicode编码，需要进行对应的转化。. 要将时间戳转化为我们常见的时分秒的格式。下面是显示这些信息的部分代码： //根据Name成员中的RVA推算出其在文件中的偏移 char *pName = (char*)m_pPeFileInfo->RVA2fOffset(it->Name, (DWORD)(m_pPeFileInfo->pImageBase)); if (NULL == pName || -1 == (int)pName) { m_ImportList.InsertItem(i, _T("-")); }else { #ifdef UNICODE //如果是UNICODE字符串，那么需要进行转化 WCHAR wszName[256] = _T(""); MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, pName, strlen(pName), wszName, 256); m_ImportList.InsertItem(i, wszName); #else m_ImportList.InsertItem(i, pName); #endif } //显示时间戳 tm p; errno_t err1; err1 = gmtime_s(&p,(time_t*)&it->TimeDateStamp); TCHAR s[100] = {0}; _tcsftime (s, sizeof(s) / sizeof(TCHAR), _T("%Y-%m-%d %H:%M:%S"), &p); m_ImportList.SetItemText(i, 1, s);导入dll中的函数信息dll中函数的信息需要使用之前的FirstThunk来获取，其实OriginalFirstThunk与FirstThunk指向的是同一个结构，都是指向一个IMAGE_THUNK_DATA STRUC的结构，这个结构的定义如下：typedef struct _IMAGE_THUNK_DATA32 { union { DWORD ForwarderString; DWORD Function; DWORD Ordinal; DWORD AddressOfData; } u1; } IMAGE_THUNK_DATA32;这个结构是一个公用体，它其实只占4个字节，当 它的最高位为 1时，表示函数以序号方式输入，这时候低 31位被看作一个函数序号。 当 它的最高位为 0时，表示函数以字符串类型的函数名方式输入，这时双字的值是一个 RVA，指向一个 IMAGE_IMPORT_BY_NAME 结构。 这个结构的定义如下：typedef struct _IMAGE_IMPORT_BY_NAME { WORD Hint; BYTE Name[1]; } IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;为什么要用两个指针指向同一个结构呢？这个跟dll的加载有关，由OriginalFirstThunk指向的结构是一个固定的值，不会被重写的值，一般它里面保存的是函数的名称，而由FirstThunk 保存的结构一般是由PE解析器进行重写，PE 装载器首先搜索 OriginalFirstThunk ，找到之后加载程序迭代搜索数组中的每个指针，找到每个 IMAGE_IMPORT_BY_NAME 结构所指向的输入函数的地址，然后加载器用函数真正入口地址来替代由 FirstThunk 数组中的一个入口，也就是说此时的FirstThunk 不再指向这个INAGE_IMPORT_BY_NAME结构，而是真实的函数的RVA。因此我们称为输入地址表（IAT）。所以在解析这个PE文件时一般使用OriginalFirstThunk这个成员来获取dll中的函数信息，因为需要获取函数名称。void CImportDlg::ShowFunctionInfoByDllIndex(int nIndex) { IMAGE_IMPORT_DESCRIPTOR ImageDesc = m_ImportTable[nIndex]; //获取到对应项所指向的IMAGE_THUNK_DATA的指针 PIMAGE_THUNK_DATA pThunkData = (PIMAGE_THUNK_DATA)m_pPeFileInfo->RVA2fOffset(ImageDesc.OriginalFirstThunk, (DWORD)m_pPeFileInfo->pImageBase); CString strInfo = _T(""); int i = 0; if (NULL == pThunkData || 0xffffffff == (int)pThunkData) { return; } //这个结构数组以0结尾 while (0 != pThunkData->u1.AddressOfData) { //当它的最高位为0时表示函数以字符串类型的函数名方式输入，此时才解析这个信息得到函数名 strInfo.Format(_T("%08x"), pThunkData); m_TunkList.InsertItem(i, strInfo); strInfo.Format(_T("%08x"), pThunkData->u1.AddressOfData); m_TunkList.SetItemText(i, 1, strInfo); if (0 == (pThunkData->u1.AddressOfData & 0x80000000)) { PIMAGE_IMPORT_BY_NAME pIibn= (PIMAGE_IMPORT_BY_NAME)m_pPeFileInfo->RVA2fOffset(pThunkData->u1.AddressOfData, (DWORD)m_pPeFileInfo->pImageBase); //name 这个域保存的是函数名的第一个字符，所以它的地址就是函数名字符串的地址 char *pszName = (char*)&(pIibn->Name); #ifdef UNICODE WCHAR wszName[256] = _T(""); MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, pszName, strlen(pszName), wszName, 256); m_TunkList.SetItemText(i, 2, wszName); #else m_TunkList.SetItemText(i, 3, pszName); #endif strInfo.Format(_T("%04x"), pIibn->Hint); m_TunkList.SetItemText(i, 3, strInfo); } else { m_TunkList.SetItemText(i, 2, _T("-")); m_TunkList.SetItemText(i, 3, _T("-")); } pThunkData++; } }上面的代码主要用来解析对应dll中的函数。在上面的代码中，根据用户点击鼠标的序号得到对应的dll项的结构信息，根据OriginalFirstThunk中保存的RVA找到结构IMAGE_THUNK_DATA对应的地址，得到地址后利用0x80000000这个值对它的最高位进行判断，如果为0，那么就可以获得函数名称，在获得名称时，也是需要注意函数名称在Unicode环境下需要转化。在这段代码中主要显示了函数的Thunk的rva，这个rva转化后对应的值，函数名，以及里面的Hint导出表的解析一般的exe文件不存在导出表，只有在dll中存在导出表。导出表中主要存储的是一个序号和对应的函数名，序数是指定DLL 中某个函数的16位数字，在所指向的DLL 文件中是独一无二的。 导出表在数据目录表的第0个元素。导出表的结构如下：typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; //属性信息 DWORD TimeDateStamp; //生成日期 WORD MajorVersion; //主版本号 WORD MinorVersion; //副版本号 DWORD Name; //dll的名称 DWORD Base; //导出函数序号的起始值 DWORD NumberOfFunctions; //文件中包含的导出函数的总数。 DWORD NumberOfNames; //文件中命名函数的总数，这个一般与上面的那个总数相同 DWORD AddressOfFunctions; //指向导出函数地址的RVA DWORD AddressOfNames; //指向导出函数名字的RVA DWORD AddressOfNameOrdinals; //指向导出函数序号的RVA } IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;下面对这3个RVA进行详细说明：AddressOfFunctions,这个RVA指向的是一个双字数组，数组中的每一项是一个RVA 值，存储的是所有导出函数的入口地址，数组的元素个数等于NumberOfFunctionsAddressOfNames:这个RVA指向一个包含所有导出函数名称的表的指针，这个地址表是一个双字数组，数组中的每一项指向一个函数名称字符串的RVA。数组的项数等于NumberOfNames 字段的值AddressOfNameOrdinals：指向一个字型数组（注意这里不是双字）存储的是对应函数的地址，假如现在我们使用函数GetProcAddress在dll中导出一个函数A，它会根据这个函数名称在名称的表中查找，假设它找到的是函数名称表中的第x项与之相同，那么它会在AddressOfNameOrdinals表中查找第x项得到函数的序号，最后根据这个序号在AddressOfFunctions中找到对应的函数地址。void CExportInfoDlg::ShowFunctionInfo() { if (NULL == m_pPeFileInfo) { return; } PIMAGE_EXPORT_DIRECTORY pExportTable = m_pPeFileInfo->GetExportDeirectory(); PDWORD pAddressOfFunc = (PDWORD)m_pPeFileInfo->RVA2fOffset((DWORD)pExportTable->AddressOfFunctions, (DWORD)m_pPeFileInfo->pImageBase); PWORD pOriginals = (PWORD)m_pPeFileInfo->RVA2fOffset((DWORD)pExportTable->AddressOfNameOrdinals, (DWORD)m_pPeFileInfo->pImageBase); PDWORD pFuncName = (PDWORD)m_pPeFileInfo->RVA2fOffset((DWORD)pExportTable->AddressOfNames, (DWORD)m_pPeFileInfo->pImageBase); int nCount = pExportTable->NumberOfFunctions; CString strInfo = _T(""); if (pAddressOfFunc == NULL || (int)pAddressOfFunc == -1 || pOriginals == NULL || (int)pOriginals == -1 || pFuncName == NULL || (int)pFuncName == -1) { return; } for (int i = 0; i < nCount; i++) { //导出序号等于base + 在数组中的索引（pOriginals数组保存的值） if (pOriginals[i] > nCount) { //这个索引值无效 strInfo = _T("-"); }else { strInfo.Format(_T("%04x"), pOriginals[i] + pExportTable->Base); } m_FuncInfoList.InsertItem(i, strInfo); strInfo.Format(_T("%08x"), pAddressOfFunc[pOriginals[i]]); m_FuncInfoList.SetItemText(i, 2, strInfo); char *pszName = (char*)m_pPeFileInfo->RVA2fOffset(pFuncName[i], (DWORD)m_pPeFileInfo->pImageBase); #ifdef UNICODE WCHAR wszName[256] = _T(""); MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, pszName, strlen(pszName), wszName, 256); strInfo = wszName; #else strInfo = pszName; #endif m_FuncInfoList.SetItemText(i, 1, strInfo); } }上面的代码描述了这个过程。在代码中首先获取了导出函数表的数据，根据数据中的三个RVA获取它们在文件中的真实地址。首先在名称表中遍历所有函数名称，然后在对应的序号表中找到对应的序号，我在这个解析器中显示出的序号与Windows显示给外界的序号相同，但是在pe文件内部，在进行寻址时使用的是这个序号 - base的值，寻址时使用的是减去base后的值作为元素的位置。pAddressOfFunc[pOriginals[i]] 这句首先找到它在序号表中的序号值，然后根据这个序号在地址表中找到它的地址，在这得到的只是一个RVA地址，如果想得到具体的地址，还需要加上在内存或者文件的起始地址
PE文件中所有节的属性都被定义在节表中，节表由一系列的IMAGE_SECTION_HEADER结构排列而成，每个结构用来描述一个节，结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。 具有相同属性的数据被安排到同一个区块中。区块表的结构为IMAGE_SECTION_HEADER，在PE文件中存在一个该结构的数组，用来保存各个区块的信息，这个数组的大小在PE头的结构 IMAGE_NT_HEADERS 的成员NumberOfSections描述。区块表结构IMAGE_SECTION_HEADER结构如下：typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 节表名称,如“.text” //IMAGE_SIZEOF_SHORT_NAME=8 union { DWORD PhysicalAddress; // 物理地址 DWORD VirtualSize; // 真实长度，这两个值是一个联合结构，可以使用其中的任何一个，一般是取后一个 } Misc; DWORD VirtualAddress; // 节区的 RVA 地址 DWORD SizeOfRawData; // 在文件中对齐后的尺寸 DWORD PointerToRawData; // 在文件中的偏移量 DWORD PointerToRelocations; // 在OBJ文件中使用，重定位的偏移 DWORD PointerToLinenumbers; // 行号表的偏移（供调试使用地) WORD NumberOfRelocations; // 在OBJ文件中使用，重定位项数目 WORD NumberOfLinenumbers; // 行号表中行号的数目 DWORD Characteristics; // 节属性如可读，可写，可执行等 } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;在程序中我们主要列出了，区块名称、RVA、在进行内存对齐后的尺寸，在磁盘中的大小，在文件中的偏移，节属性。在界面中，定义了一个listctrl来显示这些信息。在CPeFileInfo类中定义了一个vector<IMAGE_SECTION_HEADER> m_SectionTable;专门用来存储区块表的属性信息。获取这个信息。在这个类中与区块表有关的函数主要有两个:GetSectionHeader : 用来获取指向表的指针InitSectionTable：初始化上面定义的结构下面来一一说明这两个函数PIMAGE_SECTION_HEADER CPeFileInfo::GetSectionHeader() { PIMAGE_FILE_HEADER pFileHeader = GetFileHeader(); PIMAGE_SECTION_HEADER pSectionHeader = NULL; if (NULL == pFileHeader) { return NULL; } PIMAGE_OPTIONAL_HEADER pOptionHeader = GetOptionalHeader(); pSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD)(pOptionHeader) + pFileHeader->SizeOfOptionalHeader); return pSectionHeader; // PIMAGE_NT_HEADERS pNtHeader = GetNtHeaders(); // return (PIMAGE_SECTION_HEADER)((DWORD)pNtHeader + sizeof(IMAGE_NT_HEADERS)); } 在PE文件中区块表的属性信息是紧密排列在PE头结构后面的，所以我们只要知道OptionHeader结构的指针，然后加上这个结构的大小就可以获取到区块表的地址，上面的代码也是这样做的，首先获取了FileHeader的指针，这个结构中的SizeOfOptionalHeader定义了OptionHeader这个结构的大小，我们利用FileHeader + SizeOfOptionalHeader这样就偏移到了区块表所在的位置。或者更简单的方式是利用PE文件头的地址 + 文件头的大小也一样可以获取到区块表的地址void CPeFileInfo::InitSectionTable() { if (!m_SectionTable.empty()) { return ; } PIMAGE_SECTION_HEADER pSectionHeader = GetSectionHeader(); PIMAGE_FILE_HEADER pFileHeader = GetFileHeader(); if (NULL != pSectionHeader && NULL != pFileHeader) { DWORD dwCountOfSection = pFileHeader->NumberOfSections; int nCount = 0; while (nCount < dwCountOfSection) { IMAGE_SECTION_HEADER ImageSec = pSectionHeader[nCount]; m_SectionTable.push_back(ImageSec); nCount++; } } }后面就是循环遍历将所有信息写入m_SectionTable这个动态数组中。在这份代码中我们首先利用FileHeader的NumberOfSections成员获取区块表的个数，然后在循环中以这个个数作为条件，以此往后寻址，将信息写入到对应的数组中，最后在输出的时候只需要根据需求输出我们感兴趣的内容即可
PE文件解析器的编写（二）——PE文件头的解析
之前在学习PE文件格式的时候，是通过自己查看各个结构，自己一步步计算各个成员在结构中的偏移，然后在计算出其在文件中的偏移，从而找到各个结构的值，但是在使用C语言编写这个工具的时候，就比这个方便的多，只要将对应的指针类型转化为各个结构类型，就可以使用指针中的箭头来直接寻址到结构中的各个成员。这次主要说明的是PE文件头的解析，也就是之前看到的第一个界面中显示的内容,这个部分涉及到CPeFileInfo这个解析类的部分代码，以及CPeFileInfoDlg这个对话框类的代码。选择目标文件首先通过点击open按钮来弹出一个对话框，让用户选择需要解析的文件。这个部分的代码如下：void CPEInfoDlg::OnBnClickedBtnOpen() { // TODO: 在此添加控件通知处理程序代码 TCHAR szFilePath[MAX_PATH] = _T(""); OPENFILENAME ofn = {0}; ofn.lStructSize = sizeof(ofn); ofn.hwndOwner = m_hWnd; ofn.hInstance = GetModuleHandle(NULL); ofn.nMaxFile = MAX_PATH; ofn.lpstrInitialDir = _T("."); ofn.lpstrFile = szFilePath; ofn.lpstrTitle = _T("Open ...[PEInfo] by liuhao"); ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_HIDEREADONLY; ofn.lpstrFilter = _T("*.*\0*.*\0"); GetOpenFileName(&ofn); m_PeFileInfo.strFilePath = szFilePath; GetDlgItem(IDC_FILE_PATH)->SetWindowText(szFilePath); m_PeFileInfo.UnLoadFile(); BOOL bLoadSuccess = m_PeFileInfo.LoadFile(); if (bLoadSuccess) { //成功打开 if (m_PeFileInfo.IsPeFile()) { ShowFileHeaderInfo(); ShowOptionHeaderInfo(); GetDlgItem(IDC_BTN_CALC)->EnableWindow(TRUE); GetDlgItem(IDC_BTN_DATA_DIR_INFO)->EnableWindow(TRUE); GetDlgItem(IDC_BTN_SECTION_INFO)->EnableWindow(TRUE); GetDlgItem(IDC_BTN_CHAR_INFO)->EnableWindow(TRUE); GetDlgItem(IDC_RVA)->EnableWindow(TRUE); }else { MessageBox(_T("打开的文件不是PE文件")); InitCommandCtrl(); } } }在这段代码中首先通过GetOpenFileName函数来弹出一个选择文件的对话框。这个函数需要传入一个OPENFILENAME 结构的指针变量，这个结构比较复杂，在这说下它比较重要的几个成员：lStructSize //结构的大小 hwndOwner //这个对话框所属的父窗口句柄 hInstance //所在模块的句柄 lpstrFile //用来保存用户选择文件的路径的缓冲 nMaxFile //缓冲区的大小 lpstrTitle //这个对话框的标题 Flags//对话框的标识，具体标识请查看MSDN，一般我们用这样几个就足够了一般只需要更改标题，内存缓冲区指针和它的大小，其余按照上面的代码默认就好用户选择后，将用户选择的文件的全路径显示出来，并调用CPefileInfo类中的加载函数进行加载，如果之前加载过，那么先卸载它。然后再在对话框中显示它主要的信息，并且将所有按钮设置为可用状态，加载与卸载PE文件结构在这个里面主要有这样几个函数m_PeFileInfo.UnLoadFile(); m_PeFileInfo.LoadFile(); m_PeFileInfo.IsPeFile(); ShowFileHeaderInfo(); ShowOptionHeaderInfo();接下来转到CPeFileInfo这个类中在这个类中定义了这样四个主要的成员 CString strFilePath; //对应PE文件所在路径 HANDLE hFile; //打开这个文件时的文件句柄 PVOID pImageBase; //文件在内存中的首地址 HANDLE hMapping; //文件映射的句柄BOOL CPeFileInfo::LoadFile() { if(strFilePath.IsEmpty()) { return FALSE; } hFile = CreateFile(strFilePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (INVALID_HANDLE_VALUE == hFile) { return FALSE; } hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL); if (NULL == hMapping) { CloseHandle(hFile); hFile = NULL; return FALSE; } pImageBase = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0); if (NULL == pImageBase) { CloseHandle(hMapping); CloseHandle(hFile); hMapping = NULL; hFile = NULL; return FALSE; } return TRUE; } void CPeFileInfo::UnLoadFile() { if(pImageBase != NULL) { UnmapViewOfFile(pImageBase); } if(NULL != hMapping) { CloseHandle(hMapping); } if (NULL != hFile) { CloseHandle(hFile); } pImageBase = NULL; hFile = NULL; hMapping = NULL; } BOOL CPeFileInfo::IsPeFile() { PIMAGE_DOS_HEADER pDosHeader = NULL; PIMAGE_NT_HEADERS pNtHeader = NULL; if (NULL == pImageBase) { return FALSE; } pDosHeader = (PIMAGE_DOS_HEADER)pImageBase; if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE) { return FALSE; } pNtHeader = (PIMAGE_NT_HEADERS)((DWORD)(pDosHeader->e_lfanew) + (DWORD)pImageBase); if (pNtHeader->Signature != IMAGE_NT_SIGNATURE) { return FALSE; } return TRUE; }在加载的时候，主要通过一个文件映射的方式，将pe文件的整个内容原模原样的拷贝到内存中，并保存这个文件句柄，文件映射句柄，文件所在内存的首地址等信息，在卸载的时候进行关闭句柄，清理资源的操作。在程序中有一个判断该文件是否是PE文件的操作。在PE的DOS头结构中的e_magic结构保存的是'MZ'这个标志对应的16进制数是0x4d5a,另外在pe头中有一个Sinature成员保存了0x50450000这个值，它对应的字符是‘PE’只有满足这两个条件的文件才是一个正常的PE文件。否则就不是。获取DOS头和PE头在之前我们说过，PE文件开始的位置是一个DOS头结构——IMAGE_DOS_HEADER STRUCT，它的第一个成员作为DOS头的标识，一般保存的都是‘MZ’，而它里面的e_lfanew则保存真正的PE头所在的偏移所在获取DOS头的时候简单的将前面的几个字节转化为这个结构即可，在寻址PE头的时候用e_lfanew成员加上文件的起始地址就可以得到PE头的地址。具体对应的代码如下：pDosHeader = (PIMAGE_DOS_HEADER)pImageBase; pNtHeader = (PIMAGE_NT_HEADERS)((DWORD)(pDosHeader->e_lfanew) + (DWORD)pImageBase);显示FileHeader信息和ptionalHeader信息在PE头的结构体定义如下：IMAGE_NT_HEADERS STRUCT { DWORD Signature; IMAGE_FILE_HEADER FileHeader; IMAGE_OPTIONAL_HEADER32 OptionalHeader; } IMAGE_NT_HEADERS ENDS这个里面的第二个第三个成员就分别是FileHeader信息和ptionalHeader信息，剩下的就只是对这个结构的部分重要成员进行解析和显示了void CPEInfoDlg::ShowFileHeaderInfo() { PIMAGE_FILE_HEADER pFileHeader = m_PeFileInfo.GetFileHeader(); if (NULL != pFileHeader) { //省略部分不重要的代码 //时间戳转为具体时间 tm p; errno_t err1; err1 = gmtime_s(&p,(time_t*)&pFileHeader->TimeDateStamp); TCHAR s[100] = {0}; _tcsftime (s, sizeof(s) / sizeof(TCHAR), _T("%Y-%m-%d %H:%M:%S"), &p); GetDlgItem(IDC_TIME_STAMP)->SetWindowText(s); } else { MessageBox(_T("显示数据错误")); } }在这函数中我们直接通过指针寻址的方式来获取其中的信息，比如NumberOfSections（当前文件中的节表数量）、TimeDateStamp（时间戳信息）、PointerToSymbolTable（符号表所在地址相对于文件的偏移）、NumberOfSymbols（符号表的数目）、SizeOfOptionalHeader（OptionalHeader结构的大小）、Characteristics（文件的属性值）。在显示属性值时，另外提供了一个转化函数将这个值转化为具体的标识。需要进行转化的请参考下面的代码void CPeFileInfo::GetFileCharacteristics(CString &strCharacter) { DWORD dwMachine = 0; PIMAGE_FILE_HEADER pFileHeader = GetFileHeader(); if (0 != (pFileHeader->Characteristics & IMAGE_FILE_RELOCS_STRIPPED)) { strCharacter += _T("文件中不存在重定位信息\r\n"); } if(0 != (pFileHeader->Characteristics & IMAGE_FILE_EXECUTABLE_IMAGE)) { strCharacter += _T("文件可执行\r\n"); } if (0 != (pFileHeader->Characteristics & IMAGE_FILE_LARGE_ADDRESS_AWARE)) { strCharacter += _T("可以处理大于2GB内容\r\n"); } if(0 != (pFileHeader->Characteristics & IMAGE_FILE_32BIT_MACHINE)) { strCharacter += _T("目标平台为32位机器\r\n"); } if (0 != (pFileHeader->Characteristics & IMAGE_FILE_SYSTEM)) { strCharacter += _T("该文件是系统文件\r\n"); } if (0 != (pFileHeader->Characteristics & IMAGE_FILE_DLL)) { strCharacter += _T("该文件是dll文件\r\n"); } if(0 != (pFileHeader->Characteristics & IMAGE_FILE_UP_SYSTEM_ONLY)) { strCharacter += _T("该程序只能运行在单核处理器上"); } }对于OptionalHeader结构的解析，目前也只是简单的对它其中的数据结构进行打印，它里面最重要的结构DataDirectory留着在后面的部分进行说明。
之前自己学习了PE文件的格式，后来自己写了个PE文件的解析器，这段时间工作上刚好要用到它，老板需要能查看某个exe中加载的dll的一个工具，我在使用之前自己写的这个东西的时候，发现很多东西都忘记了，所以，我在这回顾下当时的思路，并记录下来，方便以后直接使用。也算是回顾下之前学习的内容，将学的东西学以致用工具总体分为这样几个部分：文件头的信息pe文件节表的信息pe文件数据目录表的信息简单的从RVA到Frva的计算工具主要采用MFC的框架作为界面，pe文件的解析部分完全由自己编写，主要使用了Windows中定义的一些结构体。刚开始开启界面时，所有功能按钮和显示界面都为空，当我们正确加载一个pe文件后这些按钮就都可以使用。加载后效果如图：两侧显示pe文件的基本信息，比如文件头部中的信息，文件的OEP，基地址等等，右侧提供一个根据RVA计算它在文件中偏移的功能，工具可以显示数据目录表的信息和节表的信息。显示节表的信息如下：显示数据目录表的信息如下：一般在数据目录表中关心导出导出表，所以这个工具也提供了导入导出表的解析功能。下面是查看导入表的功能：这个主要分为两个部分，上部分是它导入的dll文件，当点击某个dll的时候，会将这个dll中的函数给列举出来。导出表的界面如下：在这个界面中主要显示这个dll模块的名称，其中导出的函数等信息。以上是程序的主要功能，下面说下程序各个模块的组成：这个是工具中的主要对话框资源，从上到下依次是关于（这个是MFC自己生成的，我只是将它的版本信息作了修改）、显示数据目录表信息的对话框，它对应的是第三个图、用来显示文件具体标识信息的对话框、显示节区表信息的对话框、显示导出表对话框、显示导入表对话框、显示pe文件头信息的对话框，是程序的主界面；我们为每一个对话框都关联了一个类，然后在专门写了一个解析pe文件中各种信息的类，这样整体的类视图如下：到此，我对这个工具中的模块作了简单的说明，后面会一一讲解各个部分的实现。敬请期待！！！！O(∩_∩)O程序源码在此
本篇文章转载自小甲鱼的一篇日志，原文地址我们知道，Windows 将程序的各种界面定义为资源，包括加速键（Accelerator）、位图（Bitmap）、光标（Cursor）、对话框（Dialog Box）、图标（Icon）、菜单（Menu）、串表（String Table）、工具栏（Toolbar）和版本信息（Version Information）等。为了吸引大家的兴趣和目光，咱先来做个学前试验，然后再憧憬一下我们将来学习的内容有啥意义！好，小甲鱼先来演示一下如何用工具来修改资源实现汉化、改图标等，接着我们进一步从原理上来解剖 PE文件如何对资源进行存放和索引。最后，在 PE系列章节讲解完毕后，小甲鱼和大家将所有学到的知识结合在一起，我们自己打造属于我们的个性 PE工具。资源结构资源是PE 文件中非常重要的部分，几乎所有的PE 文件中都包含着资源，与导入表和导出表相比，资源的组织方式要复杂很多，其实我们只要看下图就知道俺所言不虚。我们知道我们的资源有很多种类型，每种类型的资源中可能存在多个资源项，这些资源项用不同的ID 或者名称来区分。但是要将这么多种类型的不同ID 的资源有序地组织起来是一件非常痛苦的事情，因此，我们采取类似于磁盘目录结构的方式保存。从图中我们可以看到，PE 文件中的资源是按照 资源类型 -> 资源ID -> 资源代码页的3层树型目录结构来组织资源的，通过层层索引才能够进入相应的子目录找到正确的资源。资源目录结构数据目录表中的 IMAGE_DIRECTORY_ENTRY_RESOURCE 条目（第三项）包含资源的 RVA 和大小。资源目录结构中的每一个节点都是由 IMAGE_RESOURCE_DIRECTORY 结构和紧跟其后的数个IMAGE_RESOURCE_DIRECTORY_ENTRY 结构组成的。（是不是有点像我们之前提到的文件目录？文件夹每个都长得一样，一个嵌套另一个，这样子可以实现将非常复杂的数据细化切分，小泽玛利亚、苍井空、吉泽明步、松岛枫……）我们再来看这张图：认识了这层关系后，我们来看下 IMAGE_RESOURCE_DIRECTORY 这个结构，该结构长度为 16 字节，共有 6 个字段，定义如下：IMAGE_RESOURCE_DIRECTORY STRUCTCharacteristics DWORD ? ;理论上为资源的属性，不过事实上总是0TimeDateStamp DWORD ? ;资源的产生时刻MajorVersion WORD ? ;理论上为资源的版本，不过事实上总是0MinorVersion WORD ?NumberOfNamedEntries WORD ? ;以名称（字符串）命名的入口数量NumberOfIdEntries WORD ? ;以ID（整型数字）命名的入口数量IMAGE_RESOURCE_DIRECTORY ENDS其实在这里边我们唯一要注意的就是 NameberOfNamedEntries 和 NumberOfIdEntries，它们说明了本目录中目录项的数量。两者加起来就是本目录中的目录项总和。也就是后边跟着的IMAGE_RESOURCE_DIRECTORY_ENTRY 数目。资源目录入口的结构（IMAGE_RESOURCE_DIRECTORY_ENTRY）IMAGE_RESOURCE_DIRECTORY_ENTRY 紧跟在资源目录结构后，此结构长度为 8 个字节，包含 2 个字段。该结构定义如下：IMAGE_RESOURCE_DIRECTORY_ENTRY STRUCTName DWORD ? ;目录项的名称字符串指针或IDOffsetToData DWORD ? ;目录项指针IMAGE_RESOURCE_DIRECTORY_ENTRY ENDSName 字段完全是个百变精灵，改字段定义的是目录项的名称或ID。当结构用于第一层目录时，定义的是资源类型；当结构定义于第二层目录时，定义的是资源的名称；当结构用于第三层目录时，定义的是代码页编号。注意：当最高位为 0 的时候，表示字段的值作为 ID 使用；而最高位为 1 的时候，字段的低位作为指针使用（资源名称字符串是使用 UNICODE编码），但是这个指针不是直接指向字符串哦，而是指向一个 IMAGE_RESOURCE_DIR_STRING_U 结构的。该结构定义如下：IMAGE_RESOURCE_DIR_STRING_U STRUCTLength DWORD ? ; 字符串的长度NameString DWORD ? ; UNICODE字符串，由于字符串是不定长的。由Length 制定长度IMAGE_RESOURCE_DIR_STRING_U ENDSOffsetOfData 字段是一个指针，当最高位为 1 时，低位数据指向下一层目录块的其实地址；当最高位为 0 时，指针指向 IMAGE_RESOURCE_DATA_ENTRY 结构。注意：将 Name 和 OffsetToData 用做指针时需要注意，该指针是从资源区块开始的地方算起的偏移量（即根目录的起始位置的偏移量），不是我们习惯的 RVA 哦。最后，在上图中我们看到，在第一层的时候，IMAGE_RESOURCE_DIRECTORY_ENTRY的Name 字段作为资源类型使用。具体类型匹配见下表：资源数据入口经过三层 IAMGE_RESOURCE_DIRECTORY_ENTRY （一般是3层，偶尔更年期少一些。第一层资源类型，第二层资源名，第三层是资源的 Language），第三层目录结构中的 OffsetOfData 指向 IMAGE_RESOURCE_DATA_ENTRY 结构。该结构描述了资源数据的位置和大小，定义如下：IMAGE_RESOURCE_DATA_ENTRY STRUCTOffsetToData DWORD ? ; 资源数据的RVASize DWORD ? ; 资源数据的长度CodePage DWORD ? ; 代码页, 一般为0Reserved DWORD ? ; 保留字段IMAGE_RESOURCE_DATA_ENTRY ENDS千山万水，此处的 IMAGE_RESOURCE_DATA_ENTRY 结构就是真正的资源数据了。结构中的OffsetOfData 指向资源数据的指针，其为 RVA 值。
本文转载自小甲鱼PE文件详解系列教程原文传送门当应用程序需要调用DLL中的函数时，会由系统将DLL中的函数映射到程序的虚拟内存中，dll中本身没有自己的栈，它是借用的应用程序的栈，这样当dll中出现类似于mov eax, [1000000]这样直接寻址的代码时，由于事先并不知道它会被映射到应用程序中的哪个位置，并且可能这个内存地址已经被使用，所以当调用dll中的函数时，系统会进行一个基址重定位的操作。系统是根据dll中的基址重定位表中的信息决定如何进行基址重定位，哪些位置的指令需要进行基址重定位。所以这次主要说明基址重定位表。这个重定位表位于数据目录表的第六项。这个表的主要结构如下：IMAGE_BASE_RELOCATION STRUC VirtualAddress DWORD ? ; 重定位数据开始的RVA 地址 SizeOfBlock DWORD ? ; 重定位块得长度 TypeOffset WORD ? ; 重定项位数组 IMAGE_BASE_RELOCATION ENDSVirtualAddress 是 Base Relocation Table 的位置它是一个 RVA 值SizeOfBlock 是 Base Relocation Table 的大小；这个结构的示意图如下：TypeOffset 是一个数组，数组每项大小为两个字节（16位），它由高 4位和低 12位组成，高 4位代表重定位类型，低 12位是重定位地址。高4位一般是3，表示这个地址是一个32位的地址，它与 VirtualAddress 相加即是指向PE 映像中需要修改的那个地址的位置，注意这里不是定位到对应代码的位置接下来进行手工的方式找到需要重定位的代码位置：打开一个dll文件，发现它的基址重定位表所在RVA = 0x00004000通过计算得到它是在.relo ，对应文件的偏移为0x800,查看这个位置的值为：VirtualAddress = 0x1000,SizeOfBlock = 0x18通过它的大小，得知需要重定位的位置主要有(0x18 - 8) / 2 = 8，最后一个以0结尾，所以实际上总共有7处需要重定位这8个位置分别为:0x028, 0x02e, 0x003e 0x04b, 0x051, 0x61, 0x6c后面的以此类推，可以发现这些需要重定位的位置，存储的都是一些立即寻址的地址
本文转载自小甲鱼PE文件讲解系列原文传送门这次主要说明导出表，导出表一般记录着文件中函数的地址等相关信息，供其他程序调用，常见的.exe文件中一般不存在导出表，导出表更多的是存在于dll文件中。一般在dll中保存函数名称以及它的地址，当某个程序需要调用dll中的函数时，如果这个dll在内存中，则直接找到对应函数在内存中的位置，并映射到对应的虚拟地址空间中，如果在内存中没有对应的dll，则会先通过PE加载器加载到内存，然后再进行映射导出表结构导出表（Export Table）中的主要成分是一个表格，内含函数名称、输出序数等。序数是指定DLL 中某个函数的16位数字，在所指向的DLL 文件中是独一无二的。在此我们不提倡仅仅通过序数来索引函数的方法，这样会给DLL 文件的维护带来问题。例如当DLL 文件一旦升级或修改就可能导致调用改DLL 的程序无法加载到需要的函数。数据目录表的第一个成员指向导出表，是一个IMAGE_EXPORT_DIRECTORY(以后简称IED)结构，IED 结构的定义如下：IMAGE_EXPORT_DIRECTORY STRUCT Characteristics DWORD ? ; 未使用，总是定义为0 TimeDateStamp DWORD ? ; 文件生成时间 MajorVersion WORD ? ; 未使用，总是定义为0 MinorVersion WORD ? ; 未使用，总是定义为0 Name DWORD ? ; 模块的真实名称 Base DWORD ? ; 基数，加上序数就是函数地址数组的索引值 NumberOfFunctions DWORD ? ; 导出函数的总数 NumberOfNames DWORD ? ; 以名称方式导出的函数的总数 AddressOfFunctions DWORD ? ; 指向输出函数地址的RVA AddressOfNames DWORD ? ; 指向输出函数名字的RVA AddressOfNameOrdinals DWORD ? ; 指向输出函数序号的RVA IMAGE_EXPORT_DIRECTORY ENDSName：一个RVA 值，指向一个定义了模块名称的字符串。如即使Kernel32.dll 文件被改名为”Ker.dll”。仍然可以从这个字符串中的值得知其在编译时的文件名是”Kernel32.dll”。NumberOfFunctions：文件中包含的导出函数的总数。NumberOfNames：被定义函数名称的导出函数的总数，显然只有这个数量的函数既可以用函数名方式导出。也可以用序号方式导出，剩下的NumberOfFunctions 减去NumberOfNames 数量的函数只能用序号方式导出。该字段的值只会小于或者等于 NumberOfFunctions 字段的值，如果这个值是0，表示所有的函数都是以序号方式导出的。AddressOfFunctions：一个RVA 值，指向包含全部导出函数入口地址的双字数组。数组中的每一项是一个RVA 值，数组的项数等于NumberOfFunctions 字段的值。Base：导出函数序号的起始值，将AddressOfFunctions 字段指向的入口地址表的索引号加上这个起始值就是对应函数的导出 序号。假如Base 字段的值为x，那么入口地址表指定的第1个导出函数的序号就是x；第2个导出函数的序号就是x＋1。总之，一个导出函数的导出序号等 于Base 字段的值加上其在入口地址表中的位置索引值。这个只是一个导出序号导出给外部进行使用的，当我们在分析PE文件进行相关函数的定址时，不使用这个序号，表中也没有存储函数的导出序号AddressOfNames 和 AddressOfNameOrdinals：均为RVA 值。前者指向函数名字符串地址表。这个地址表是一个双字数组，数组中的每一项指向一个函数名称字符串的RVA。数组的项数等于NumberOfNames 字段的值，所有有名称的导出函数的名称字符串都定义在这个表中；后者指向另一个word 类型的数组（注意不是双字数组）。数组项目与文件名地址表中的项目一一对应，项目值代表函数入口地址表的索引，这样函 数名称与函数入口地址关联起来。（举个例子说，加入函数名称字符串地址表的第n 项指向一个字符串“MyFunction”。那么可以去查找 AddressOfNameOrdinals 指向的数组的第n 项，假如第n 项中存放的值是x，则表示AddressOfFunctions 字段描述的地址表中的第x 项函数入口地址对应的名称就是“MyFunction”他们的关系如图所示：一般在分析定位函数地址的时候采用的是通过函数名称来定位在定位时可以使用序号的方式，也可以使用函数名的方式来定位，使用序号需要提前知道这个函数对应的序号，这个非常困难，还要一种方式是采用函数名找到对应函数的序号，然后再通过序号定位，一般在进行定位时都是使用函数名进行定位从序号查找函数入口地址 定位到PE 文件头从PE 文件头中的 IMAGE_OPTIONAL_HEADER32 结构中取出数据目录表，并从第一个数据目录中得到导出表的RVA从导出表的 Base 字段得到起始序号将需要查找的导出序号减去起始序号，得到函数在入口地址表中的索引检测索引值是否大于导出表的 NumberOfFunctions 字段的值，如果大于后者的话，说明输入的序号是无效的用这个索引值在 AddressOfFunctions 字段指向的导出函数入口地址表中取出相应的项目，这就是函数入口地址的RVA 值，当函数被装入内存的时候，这个RVA 值加上模块实际装入的基地址，就得到了函数真正的入口地址从函数名称查找入口地址如果已知函数的名称，如何得到函数的入口地址呢？与使用序号来获取入口地址相比，这个过程要相对复杂一点！Windows 装载器的工作步骤如下：最初的步骤是一样的，那就是首先得到导出表的地址从导出表的 NumberOfNames 字段得到已命名函数的总数，并以这个数字作为循环的次数来构造一个循环从 AddressOfNames 字段指向得到的函数名称地址表的第一项开始，在循环中将每一项定义的函数名与要查找的函数名相比较，如果没有任何一个函数名是符合的，表示文件中没有指定名称的函数如果某一项定义的函数名与要查找的函数名符合，那么记下这个函数名在字符串地址表中的索引值，然后在 AddressOfNamesOrdinals 指向的数组中以同样的索引值取出数组项的值，我们这里假设这个值是x最后，以 x 值作为索引值，在 AddressOfFunctions 字段指向的函数入口地址表中获取的 RVA 就是函数的入口地址一帮情况下病毒程序就是通过函数名称查找入口地址的，因为病毒程序作为一段额外的代码被附加到可执行文件中的。如果病毒代码中用到某些 API 的话，这些 API 的地址不可能在宿主文件的导出表中为病毒代码准备好。因此只能通过在内存中动态查找的方法来实现获取API 的地址。接下来就是来实际分析一个PE文件。通过之前的知识，发现这个导出表的RVA = 0x00002060,表所在节区为.rdat，节区在内存中的RVA = 0x00002000，节区在文件中的偏移 = 0x00000600。通过之前的计算公式得到导出表在文件中的偏移为0x00000660.定位到这个地方发现这个表中的内容如下：通过解析知道Name = 0x0000209c ==>0x0000069cBase = 0x00000002 NumberOfFunctions = 0x02NumberOfNames = 0x02AddressOfFunctions = 0x2088 ==>0x688AddressOfNames = 0x2090==>0x690AddressOfNameOrdinals = 0x2098 ⇒ 0x698对于保存的是RVA的变量，后面都是通过换算得到的其值在内存中的偏移对于AddressOfNames来说，它指向的是一个保存了函数名的RVA，我们在对应偏移位置得到它的值为0x20A8 ==> 0x6a8,从文件中的内容来看，这个位置保存到额正好是两个导出函数的值。两个函数名分别为：_DecCount ==>0_IncCount ==> 1后面的是它们在这个位置的编号，等会需要这个编号，中的它们在函数地址表中对应的索引接下来根据AddressOfNameOrdinals中的值，00 01，发现它们在函数地址表中的索引分别为0 1最后再AddressOfFunctions中得到它们分别为0x1046和0x1023也就是_DecCount = 0x1046 _IncCount = 0x1023我们通过反汇编工具W32Dasm，查看这个dll的反汇编代码：这个dll加载到内存中后它的基地址为0x10000000，这样得到两个函数在内存中的地址为:_DecCount =0x10001046_IncCount =0x10001023在它的反汇编中找到函数的地址发现正好是这两个值：
这篇文章转载自小甲鱼的PE文件详解系列原文传送门之前简单提了一下节表和数据目录表，那么他们有什么区别？其实这些东西都是人为规定的，一个数据在文件中或者在内存中的位置基本是固定的，通过数据目录表进行索引和通过节表进行索引都是可以找到的，也可以这么说，同一个数据在节表和数据目录表中都有一份索引值，那么这两个表有什么区别？一般将具有相同属性的值放到同一个节区中，这也就是说同一个节区的值只是保护属性相同，但是他们的用途不一定是一样的，但是在同一数据目录表中的数据的作用是相同的，比如输入函数表中只会保存输入函数的相关信息，输出函数表中只会保存输出函数的信息，而输入输出函数在PE文件中可能都位于.text这个节中。输入函数表输入函数：一般将那些在本程序中调用，但是它的代码不在本程序中的函数称为输入函数，输入函数一般都在另外一个独立的dll中。在之前谈到PE头的时候说到，在PE头中有一个结构是数据目录表，它的结构如下：IMAGE_DATA_DIRECTORY STRUCT VirtualAddress DWORD ? ; 数据的起始RVA isize DWORD ? ; 数据块的长度 IMAGE_DATA_DIRECTORY ENDS这个结构大小为8，相对于PE文件头的偏移为0x78。在PE文件中，通过一个数组来保存多个数据目录表的信息，而输入函数表则是这个数组的第二个元素。而输入表是以一个 IMAGE_IMPORT_DESCRIPTOR(简称IID) 的数组开始。每个被 PE文件链接进来的 DLL文件都分别对应一个 IID数组结构。在这个 IID数组中，并没有指出有多少个项(就是没有明确指明有多少个链接文件)，但它最后是以一个全为NULL(0) 的 IID 作为结束的标志。IMAGE_IMPORT_DESCRIPTORIMAGE_IMPORT_DESCRIPTOR STRUCT union Characteristics DWORD ? OriginalFirstThunk DWORD ? ends TimeDateStamp DWORD ? ForwarderChain DWORD ? Name DWORD ? FirstThunk DWORD ? IMAGE_IMPORT_DESCRIPTOR ENDSOriginalFirstThunk它指向first thunk，IMAGE_THUNK_DATA，该 thunk 拥有 Hint 和 Function name 的地址。TimeDateStamp该字段可以忽略。如果那里有绑定的话它包含时间/数据戳（time/data stamp）。如果它是0，就没有绑定在被导入的DLL中发生。在最近，它被设置为0xFFFFFFFF以表示绑定发生。ForwarderChain一般情况下我们也可以忽略该字段。在老版的绑定中，它引用API的第一个forwarder chain（传递器链表）。它可被设置为0xFFFFFFFF以代表没有forwarder。Name它表示DLL 名称的相对虚地址（译注：相对一个用null作为结束符的ASCII字符串的一个RVA，该字符串是该导入DLL文件的名称。如：KERNEL32.DLL）。FirstThunk它包含由IMAGE_THUNK_DATA定义的 first thunk数组的虚地址，通过loader用函数虚地址初始化thunk。在Orignal First Thunk缺席下，它指向first thunk：Hints和The Function names的thunks。这个OriginalFirstThunk 和 FirstThunk明显是亲家，两家伙首先名字就差不多哈。那他们有什么不可告人的秘密呢？IMAGE_THUNK_DATA STRUC union u1 ForwarderString DWORD ? ; 指向一个转向者字符串的RVA Function DWORD ? ; 被输入的函数的内存地址 Ordinal DWORD ? ; 被输入的API 的序数值 AddressOfData DWORD ? ; 指向 IMAGE_IMPORT_BY_NAME ends IMAGE_THUNK_DATA ENDS我们可以看出由于是union结构，所以IMAGE_THUNK_DATA 事实上是4个字节大小。这个共用体是怎么使用的呢：当 IMAGE_THUNK_DATA 值的最高位为 1时，表示函数以序号方式输入，这时候低 31位被看作一个函数序号。当 IMAGE_THUNK_DATA 值的最高位为 0时，表示函数以字符串类型的函数名方式输入，这时双字的值是一个 RVA，指向一个 IMAGE_IMPORT_BY_NAME 结构。接下来说明IMAGE_IMPORT_BY_NAME 结构：IMAGE_IMPORT_BY_NAME STRUCT Hint WORD ? Name BYTE ? IMAGE_IMPORT_BY_NAME ENDS结构中的 Hint 字段也表示函数的序号，不过这个字段是可选的，有些编译器总是将它设置为 0。Name 字段定义了导入函数的名称字符串，这是一个以 0 为结尾的字符串。输入函数表的加载从上面的图上来看，OriginalFirstThunk与FirstThunk指向的是同一个数据结构，在PE文件中既可以通过OriginalFirstThunk来找到函数名，也可以通过FirstThunk来找到函数名，为什么会出现两个指针指向同一个数据结构的现象呢，其实这个与PE文件的加载有关第一个数组（由 OriginalFirstThunk 所指向）是单独的一项，而且不能被改写，我们前边称为 INT。第二个数组（由 FirstThunk 所指向）事实上是由 PE 装载器重写的。PE 装载器首先搜索 OriginalFirstThunk ，找到之后加载程序迭代搜索数组中的每个指针，找到每个 IMAGE_IMPORT_BY_NAME 结构所指向的输入函数的地址，然后加载器用函数真正入口地址来替代由 FirstThunk 数组中的一个入口，也就是说此时的FirstThunk 不在指向这个INAGE_IMPORT_BY_NAME结构，而是真实的函数的RVA。因此我们称为输入地址表（IAT）。所以，当我们的 PE 文件装载内存后准备执行时，刚刚的图就会转化为下图：实验操作我们来编译一个具体的程序，源代码如下:#include <windows.h> int WINAPI WinMain( HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR szCmdLine, int iCmdShow ) { MessageBox( NULL, TEXT("Hello, welcome to Fishc.com!"), TEXT("Welcome!"), MB_OKCANCEL | MB_OK ); return 0; }这个程序就是弹出一个MessageBox，通过W32Dasm静态反汇编发现MessageBox函数所在地址应该在0x0042A2AC在数据目录表中根据OriginalFirstThunk 项获取函数名称用UE打开这个PE文件，发现输入函数表的RVA = 0x0002A000在节表中查询发现它是在.idata这个节中通过之前说的公式，可以得到，这个RVA在文件中的偏移地址为0x0002A000 - 0x0002A000 + 0x00028000 = 0x00028000读取在这个位置的信息发现，OriginalFirstThunk = 0x0002A15C，这个偏移，发现它仍然在这个节中，通过上述公式计算得出，他在文件中的偏移地址为:0x0002815C从这个位置得到的值来看，它的最高值为0，也就是IMAGE_THUNK_DATA保存的是函数名的字符串，字符串的RVA为0x0002A2DC，通过计算得到它在文件中的偏移为:0x000282DC.从图上可以看出这个地址所对应的值正好是函数的名称MessgeBoxA通过FirstThunk成员找到函数名称首先根据PE文件的内容，可以知道，输入函数表在PE文件的偏移为0x00028000，而根据这个结构来看，FirstThunk在x00028000 + 16 = 0x00028010的位置，在这位置，我们发现它里面的值为0x0002A2AC计算得到在磁盘中的偏移为0x000282AC，在PE文件中这个值为0x0002A2DC，它的最高位仍然为0，也就是说这个地址保存的内容为函数名称。另外我们发现这个值与之前用OriginalFirstThunk 寻址到的函数名称所在RVA一样，也就是说到此成功找到函数名称查找函数在内存的偏移地址根据上面所说的内容，只有当这个PE文件被加载到内存中，PE加载器才会将IMAGE_IMPORT_BY_NAME结构中的值替换为对应函数的地址，所以要查找函数的地址就需要先将PE文件加载到内存，然后再将内存中的数据抓取下来，最后再来分析得出这个函数的偏移地址。其实这个工作可以由lordPE工具来帮忙完成。首先是启动程序，然后打开lordPE，找到程序的进程，然后选择dump full抓取全部即可这样会生成一个dump文件，分析这个文件，就可以得出相应的内容：由于这个是内存镜像的拷贝，所以在这在内存中的RVA就是在文件中的偏移。首先得到导入表的偏移为0x0002a000，这个值里面存储的值为0x0002A15C，这个值是OriginalFirstThunk的值，通过这个值找到对应的IMAGE_THUNK_DATA地址：0x0002A2DC我们发现这个值得高地址为是0，那么它所指向的应该就是函数名称，我们寻址到这个地址，发现它正好是函数名称接下来，再来解析函数地址，在0x0002a010中找到对应的FirstThunk值，这个值为0x0002A2AC，它是指向一个IMAGE_THUNK_DATA结构，在这个地址处，发现它的值为0x77D507EA，这个值的最高位为1，所以它对应的是一个函数地址，它的低32位是一个函数编号，此时0x0002A2AC指向的不在是一个IMAGE_IMPORT_BY_NAME结构，而是函数地址的偏移，而这个程序是由VC6.0编译而成，VC6默认的加载地址为0x00400000，所以基址 + 偏移地址就是函数的正确地址，也就是0x0042A2AC，与之前用静态反汇编得到的值相同
在前面几节中经常提到相对虚拟地址RVA，在这篇博客中主要说明这个概念。本来是想接着转载小甲鱼的，但是我自己根据这篇文章和他的视频来学习的时候，发现在RVA与文件的相对偏移地址进行转化的时候，那块我看不懂，不知道为什么要这样转化，而且前面很多东西都反复讲了好多遍，比如对齐的问题，所以，这篇我就自己根据自己掌握的情况来写，还是在此处放上原文的连接：原文(上)传送门原文(下)传送门什么是RVA某个位置的RVA是该位置在内存中的地址相对于整个文件在内存中首地址的偏移值，举个例子，当PE文件中某个全局区中的一个数值所在内存的地址为0x0040871234，而PE文件被系统加载到内存时，它的的首地址为0x00400000(PS:评论区的老哥说的不错，这个地址只是一个参考值，加载器会优先选择以这个地址作为加载的首地址，如果该地址被占用，则另外选取，一般在加载DLL中出现占用情况，由于exe一般作为进程来加载，它占用4GB的独立地址空间，所以一般不会出现占用)，那么这个数值的RVA就是0x0040871234 - 0x00400000 = 0x871234RVA到文件偏移的转化有了RVA之后，发现所有数据在内存中进行索引都十分的方便，只要有了基地址和RVA后，通过基地址 + RVA就可以找到对应的数据，但是在PE中并没有类似值来记录某个数据到文件头的偏移，而且由于PE文件在内存中和在磁盘中的对齐值不同，造成某个数据在内存中的偏移和在磁盘中的偏移也不同，这样在磁盘中找到某些值就比较困难虽然PE文件在内存中的对齐值与在磁盘中的不同，各个区块在内存中的地址与在文件中的不同，但是各个区块里面数据的摆放基本是一样的，也就是在区块中，某个值相对于区块首地址的偏移是一样的，现在定义这样几个变量：Rva：某个数值在内存中的偏移Voffset：节点首地址在内存中相当于基地址的偏移Roffset：节点首地址在磁盘中相当于文件头的偏移fRva：某个数值在磁盘中相对于文件头的偏移根据上面的等量关系，可以得到这样的方程:fRva - Roffset = Rva - Voffset==>fRva = Rva- Voffset + Roffset根据这个公式来计算时需要进行如下的步骤：根据某个Rva落在哪个区块中：取每个节区在内存中的首地址（IMAGE_SECTION_HEADER中的VirtualAddress值） + 区块真实大小（IMAGE_SECTION_HEADER中的VirtualSize），如果Rva小于这个值得话，说明是落在这个节中通过第一步，我们可以知道Rva处于哪个区块，这样就可以得到区块的首地址在磁盘中相对于文件头的偏移就也就是Voffset的值。在文件中根据IMAGE_SECTION_HEADER中的PointerToRawData值可以得到这个区块在磁盘中相当于文件头的首地址，这样通过上述公式可以计算出Rva在文件中的偏移最终得到的公式如下：fRva = Rva - VirtualAddress + PointerToRawData举个例子，用lordPE这个工具打开一个.exe文件：我们来在磁盘中找到ImportTable在磁盘中的入口位置，即0x0001B1C4这个RVA对应在磁盘中的位置一个个对比发现.textbss:这个区块的RVA从0x00001000~0x00011000,这个值不在这个区块内.text:这个区块的RVA从0x00011000~0x00016060,这个值不在这个区块内.rdata这个区块的RVA从0x00017000~0x0001903d,这个值不在这个区块内.data这个区块的RVA从0x0001A000~0x000A158C,这个值不在这个区块内.idata这个区块的RVA从0x0001B000~0x0001BAF2，这个值在这个区块内根据上面的信息可以得到fRva = Rva - VirualAddress + PointerToRawData = 0x0001B1C4 - 0x0001B000 + 0x00007A00 = 7BC4
本文转自小甲鱼的PE文件详解系列原文传送门到此为止，小甲鱼和大家已经学了许多关于 DOS header 和 PE header 的知识。接下来就该轮到SectionTable (区块表，也成节表)。越学越多的结构，大家可能觉得PE挺乱挺杂的哈，所以这里插播下一下必要知识的详细注释，大伙可以按需要看。PE文件中所有节的属性都被定义在节表中，节表由一系列的IMAGE_SECTION_HEADER结构排列而成，每个结构用来描述一个节，结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束，所以节表中总的IMAGE_SECTION_HEADER结构数量等于节的数量加一。节表总是被存放在紧接在PE文件头的地方。另外，节表中 IMAGE_SECTION_HEADER 结构的总数总是由PE文件头 IMAGE_NT_HEADERS 结构中的 FileHeader.NumberOfSections 字段来指定的。typedef struct _IMAGE_SECTION_HEADER { BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 节表名称,如“.text” //IMAGE_SIZEOF_SHORT_NAME=8 union { DWORD PhysicalAddress; // 物理地址 DWORD VirtualSize; // 真实长度，这两个值是一个联合结构，可以使用其中的任何一个，一般是取后一个 } Misc; DWORD VirtualAddress; // 节区的 RVA 地址 DWORD SizeOfRawData; // 在文件中对齐后的尺寸 DWORD PointerToRawData; // 在文件中的偏移量 DWORD PointerToRelocations; // 在OBJ文件中使用，重定位的偏移 DWORD PointerToLinenumbers; // 行号表的偏移（供调试使用地) WORD NumberOfRelocations; // 在OBJ文件中使用，重定位项数目 WORD NumberOfLinenumbers; // 行号表中行号的数目 DWORD Characteristics; // 节属性如可读，可写，可执行等 } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;Name：区块名。这是一个由8位的ASCII 码名，用来定义区块的名称。多数区块名都习惯性以一个“.”作为开头（例如：.text），这个“.” 实际上是不是必须的。值得我们注意的是，如果区块名超过 8 个字节，则没有最后的终止标志“NULL” 字节。并且前边带有一个“$” 的区块名字会从连接器那里得到特殊的待遇，前边带有“$” 的相同名字的区块在载入时候将会被合并，在合并之后的区块中，他们是按照“$” 后边的字符的字母顺序进行合并的。另外小甲鱼童鞋要跟大家啰嗦一下的是：每个区块的名称都是唯一的，不能有同名的两个区块。但事实上节的名称不代表任何含义，他的存在仅仅是为了正规统一编程的时候方便程序员查看方便而设置的一个标记而已。所以将包含代码的区块命名为“.Data” 或者说将包含数据的区块命名为“.Code” 都是合法的。因此，小甲鱼建议大家：当我们要从PE 文件中读取需要的区块时候，不能以区块的名称作为定位的标准和依据。正确的方法是按照 IMAGE_OPTIONAL_HEADER32 结构中的数据目录字段结合进行定位。Virtual Size:对应的区块的大小，这是区块的数据在没有进行对齐处理前的实际大小。Virtual Address:该区块装载到内存中的RVA 地址。这个地址是按照内存页来对齐的，因此它的数值总是 SectionAlignment 的值的整数倍。在Microsoft 工具中，第一个块的默认 RVA 总为1000h。在OBJ 中，该字段没有意义地，并被设为0。SizeOfRawData：该区块在磁盘中所占的大小。在可执行文件中，该字段是已经被FileAlignment 潜规则处理过的长度。PointerToRawData：该区块在磁盘中的偏移。这个数值是从文件头开始算起的偏移量哦。PointerToRelocations：这哥们在EXE文件中没有意义，在OBJ 文件中，表示本区块重定位信息的偏移值。（在OBJ 文件中如果不是零，它会指向一个IMAGE_RELOCATION 结构的数组）PointerToLinenumbers：行号表在文件中的偏移值，文件的调试信息，于我们没用，鸡肋。NumberOfRelocations：这哥们在EXE文件中也没有意义，在OBJ 文件中，是本区块在重定位表中的重定位数目来着。NumberOfLinenumbers：该区块在行号表中的行号数目，鸡肋。Characteristics：该区块的属性。该字段是按位来指出区块的属性（如代码/数据/可读/可写等）的标志。具体内容可以参考MSDN在线文档：传送门.aspx)下面通过一个例子来详细朔门这些内容：还是以上次那个为例根据以前的内容可以知道这个文件PE头在0xf0的位置，上一次是通过各个结构体大小来找到PE头中这个OptionalHeader结构的地址，但是当时我忘记了，在FileHeader 这个结构中有一个SizeOfOptionalHeader这个域专门用来记录OptionalHeader结构的大小，它在PE头的偏移为0x14也就是在0xf0 + 0x14 = 0x104的位置查看文件得知这个值为0xe0， OptionalHeader偏移0x18 + 大小0xe0 + pe头的偏移0xf0 = 0x1e8根据这个结构中的成员很容易计算出来，这个结构占0x28个字节，这样根据上一个的起始地址 + 0x28就可以得到下一个的地址，这样可以陆陆续续找到所有的节节表中的最后一个为全0，这样这个PE文件中总共有.textbss、.text、.radta、.data、.idata、.rsrc、.reloc这样几个节。接下来读取各个部分的内容，比如说在text节中，VirtualSize = 0x00014360PointerToRawData = 0x000400 VirtualAddress = 0x00011000SizeOfRawData = 0x00014400Characteristics = 0x60000020这些节区都是按照文件中的某个值对齐，然后在紧密排列的，所以根据它在文件中的偏移 + 对齐后的值可以得到下一个节在文件中的偏移地址，根据这点在text节中 PointerToRawData + SizeOfRawData = 0x000400 + 0x00014400 = 0x00014800,而下一个的文件偏移地址正好是这个，这个根据在PE中查找到的数据，发现下一个确实是这个值