PE解析器的编写(四)——数据目录表的解析

时间:2021-11-12 12:24:22

在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;
}

在显示时需要注意两点:
1. name属性保存的是ASCII码形式的字符串,如果我们程序使用Unicode编码,需要进行对应的转化。
2 . 要将时间戳转化为我们常见的时分秒的格式。
下面是显示这些信息的部分代码:

        //根据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 值,存储的是所有导出函数的入口地址,数组的元素个数等于NumberOfFunctions
AddressOfNames:这个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地址,如果想得到具体的地址,还需要加上在内存或者文件的起始地址