手寫PE結構解析工具


PE格式是 Windows下最常用的可執行文件格式,理解PE文件格式不僅可以了解操作系統的加載流程,還可以更好的理解操作系統對進程和內存相關的管理知識,而有些技術必須建立在了解PE文件格式的基礎上,如文件加密與解密,病毒分析,外掛技術等,在PE文件中我們最需要關注,PE結構,導入表,導出表,重定位表,下面將具體介紹PE的關鍵結構,並使用C語言編程獲取到這些結構數據.

參考文獻: [琢石成器 Win32匯編語言程序設計 - 羅雲彬] 整理學習筆記,精簡內容,翻譯匯編代碼C語言化

在任何一款操作系統中,可執行程序在被裝入內存之前都是以文件的形式存放在磁盤中的,在早期DOS操作系統中,是以COM文件的格式存儲的,該文件格式限制了只能使用代碼段,堆棧尋址也被限制在了64KB的段中,由於PC芯片的快速發展這種文件格式極大的制約了軟件的發展。

為了應對這種局面,微軟的工程師們就發明了新的文件格式(EXE文件),該文件格式在代碼段前面增加了文件頭結構,文件頭中包括各種說明數據,如程序的入口地址,堆棧的位置,重定位表等,顯然可執行文件的格式是操作系統工作方式的真實寫照,不同的系統之間文件格式千差萬別,從而導致不同系統中的可執行文件無法跨平台運行。

Windows NT 系統中可執行文件使用微軟設計的新的文件格式,也就是至今還在使用的PE格式,PE文件的基本結構如下圖所示:

在PE文件中,代碼,已初始化的數據,資源和重定位信息等數據被按照屬性分類放到不同的Section(節區/或簡稱為節)中,而每個節區的屬性和位置等信息用一個IMAGE_SECTION_HEADER結構來描述,所有的IMAGE_SECTION_HEADER結構組成了一個節表(Section Table),節表數據在PE文件中被放在所有節數據的前面.

在PE文件中將同樣屬性的數據分類放在一起是為了統一描述這些數據裝入內存后的頁面屬性,由於數據是按照屬性在節中放置的,不同用途但是屬性相同的數據可能被放在同一個節中,PE文件頭被放置在節和節表的前面,上面介紹的是真正的PE文件,為了兼容以前的DOS系統,所以保留了DOS的文件格式,接下來將依次介紹這幾種數據結構.

我們需要編程實現讀取PE結構,在讀取PE文件中的數據的前提下,我們先來打開文件,然后才能讀取。

#include <stdio.h>
#include <Windows.h>
#include <Imagehlp.H>
#pragma comment(lib,"Imagehlp.lib")

HANDLE OpenPeByFileName(LPTSTR FileName)
{
    LPTSTR peFile = FileName;
    HANDLE hFile, hMapFile, lpMapAddress = NULL;
    DWORD dwFileSize = 0;

    hFile = CreateFile(peFile, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    dwFileSize = GetFileSize(hFile, NULL);
    hMapFile = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, dwFileSize, NULL);
    lpMapAddress = MapViewOfFile(hMapFile, FILE_MAP_READ, 0, 0, dwFileSize);
    if (lpMapAddress != NULL)
        return lpMapAddress;
}

int main()
{
    HANDLE lpMapAddress = NULL;
    lpMapAddress = OpenPeByFileName(L"d://x32dbg.exe");
    return 0;
}

◆DOS/NT 頭結構◆

在上面PE結構圖中可知PE文件的開頭部分包括了一個標准的DOS可執行文件結構,這看上去有些奇怪,但是這對於可執行程序的向下兼容性來說卻是不可缺少的,當然現在已經基本不會出現純DOS程序了,現在來說這個IMAGE_DOS_HEADER結構純粹是歷史遺留問題。

DOS頭結構: PE文件中的DOS部分由MZ格式的文件頭和可執行代碼部分組成,可執行代碼被稱為DOS塊(DOS stub),MZ格式的文件頭由IMAGE_DOS_HEADER結構定義,在C語言頭文件winnt.h中有對這個DOS結構詳細定義,如下所示:

typedef struct _IMAGE_DOS_HEADER { 
    WORD   e_magic;                     // DOS的頭部
    WORD   e_cblp;                      // Bytes on last page of file
    WORD   e_cp;                        // Pages in file
    WORD   e_crlc;                      // Relocations
    WORD   e_cparhdr;                   // Size of header in paragraphs
    WORD   e_minalloc;                  // Minimum extra paragraphs needed
    WORD   e_maxalloc;                  // Maximum extra paragraphs needed
    WORD   e_ss;                        // Initial (relative) SS value
    WORD   e_sp;                        // Initial SP value
    WORD   e_csum;                      // Checksum
    WORD   e_ip;                        // Initial IP value
    WORD   e_cs;                        // Initial (relative) CS value
    WORD   e_lfarlc;                    // File address of relocation table
    WORD   e_ovno;                      // Overlay number
    WORD   e_res[4];                    // Reserved words
    WORD   e_oemid;                     // OEM identifier (for e_oeminfo)
    WORD   e_oeminfo;                   // OEM information; e_oemid specific
    WORD   e_res2[10];                  // Reserved words
    LONG   e_lfanew;                    // 指向了PE文件的開頭(重要)
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

在DOS文件頭中,第一個字段e_magic被定義為MZ,標志着DOS文件的開頭部分,最后一個字段e_lfanew則指明了PE文件的開頭位置,現在來說除了第一個字段和最后一個字段有些用處,其他字段幾乎已經廢棄了,這里附上讀取DOS頭的代碼。

void DisplayDOSHeadInfo(HANDLE ImageBase)
{
    PIMAGE_DOS_HEADER pDosHead = NULL;
    pDosHead = (PIMAGE_DOS_HEADER)ImageBase;

    printf("DOS頭:        %x\n", pDosHead->e_magic);
    printf("文件地址:     %x\n", pDosHead->e_lfarlc);
    printf("PE結構偏移:   %x\n", pDosHead->e_lfanew);
}

PE頭結構: 從DOS文件頭的e_lfanew字段向下偏移003CH的位置,就是真正的PE文件頭的位置,該文件頭是由IMAGE_NT_HEADERS結構定義的,定義結構如下:

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;                   // PE文件標識字符
    IMAGE_FILE_HEADER FileHeader;
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

如上PE文件頭的第一個DWORD是一個標志,默認情況下它被定義為00004550h也就是P,E兩個字符另外加上兩個零,而大部分的文件屬性由標志后面的IMAGE_FILE_HEADERIMAGE_OPTIONAL_HEADER32結構來定義,我們繼續跟進IMAGE_FILE_HEADER這個結構:

typedef struct _IMAGE_FILE_HEADER {
    WORD    Machine;                  // 運行平台
    WORD    NumberOfSections;         // 文件的節數目
    DWORD   TimeDateStamp;            // 文件創建日期和時間
    DWORD   PointerToSymbolTable;     // 指向符號表(用於調試)
    DWORD   NumberOfSymbols;          // 符號表中的符號數量
    WORD    SizeOfOptionalHeader;     // IMAGE_OPTIONAL_HANDLER32結構的長度
    WORD    Characteristics;          // 文件的屬性 exe=010fh dll=210eh
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

繼續跟進 IMAGE_OPTIONAL_HEADER32 結構,該結構體中的數據就豐富了,重要的結構說明經備注好了:

typedef struct _IMAGE_OPTIONAL_HEADER {
    WORD    Magic;
    BYTE    MajorLinkerVersion;           // 連接器版本
    BYTE    MinorLinkerVersion;
    DWORD   SizeOfCode;                   // 所有包含代碼節的總大小
    DWORD   SizeOfInitializedData;        // 所有已初始化數據的節總大小
    DWORD   SizeOfUninitializedData;      // 所有未初始化數據的節總大小
    DWORD   AddressOfEntryPoint;          // 程序執行入口RVA
    DWORD   BaseOfCode;                   // 代碼節的起始RVA
    DWORD   BaseOfData;                   // 數據節的起始RVA
    DWORD   ImageBase;                    // 程序鏡像基地址
    DWORD   SectionAlignment;             // 內存中節的對其粒度
    DWORD   FileAlignment;                // 文件中節的對其粒度
    WORD    MajorOperatingSystemVersion;  // 操作系統主版本號
    WORD    MinorOperatingSystemVersion;  // 操作系統副版本號
    WORD    MajorImageVersion;            // 可運行於操作系統的最小版本號
    WORD    MinorImageVersion;
    WORD    MajorSubsystemVersion;        // 可運行於操作系統的最小子版本號
    WORD    MinorSubsystemVersion;
    DWORD   Win32VersionValue;
    DWORD   SizeOfImage;                  // 內存中整個PE映像尺寸
    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;

IMAGE_DATA_DIRECTORY數據目錄列表,它由16個相同的IMAGE_DATA_DIRECTORY結構組成,這16個數據目錄結構定義很簡單僅僅指出了某種數據的位置和長度,定義如下:

typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;      // 數據起始RVA
    DWORD   Size;                // 數據塊的長度
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

上方的結構就是PE文件的重要結構,接下來將通過編程讀取出PE文件的開頭相關數據。

讀取NT文件頭: 第1個函數用於判斷是否為可執行文件,第2個函數用於讀取PE文件頭信息。

BOOL IsPEFile(HANDLE ImageBase)
{
    PIMAGE_DOS_HEADER pDosHead = NULL;
    PIMAGE_NT_HEADERS pNtHead = NULL;
    if (ImageBase == NULL){ return FALSE; }
    pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
    if (IMAGE_DOS_SIGNATURE != pDosHead->e_magic){ return FALSE; }
    pNtHead = (PIMAGE_NT_HEADERS)((DWORD)pDosHead + pDosHead->e_lfanew);
    if (IMAGE_NT_SIGNATURE != pNtHead->Signature){ return FALSE; }
    return TRUE;
}
PIMAGE_NT_HEADERS GetNtHead(HANDLE ImageBase)
{
    PIMAGE_DOS_HEADER pDosHead = NULL;
    PIMAGE_NT_HEADERS pNtHead = NULL;
    pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
    pNtHead = (PIMAGE_NT_HEADERS)((DWORD)pDosHead + pDosHead->e_lfanew);
    return pNtHead;
}

讀取PE文件結構:

void DisplayFileHeaderInfo(HANDLE ImageBase)
{
    PIMAGE_NT_HEADERS pNtHead = NULL;
    PIMAGE_FILE_HEADER pFileHead = NULL;
    pNtHead = GetNtHead(ImageBase);
    pFileHead = &pNtHead->FileHeader;
    printf("運行平台:     %x\n", pFileHead->Machine);
    printf("節區數目:     %x\n", pFileHead->NumberOfSections);
    printf("時間標記:     %x\n", pFileHead->TimeDateStamp);
    printf("可選頭大小    %x\n", pFileHead->SizeOfOptionalHeader);
    printf("文件特性:     %x\n", pFileHead->Characteristics);
}

讀取OptionalHeader結構:

void DisplayOptionalHeaderInfo(HANDLE ImageBase)
{
    PIMAGE_NT_HEADERS pNtHead = NULL;
    pNtHead = GetNtHead(ImageBase);
    printf("入口點:        %x\n", pNtHead->OptionalHeader.AddressOfEntryPoint);
    printf("鏡像基址:      %x\n", pNtHead->OptionalHeader.ImageBase);
    printf("鏡像大小:      %x\n", pNtHead->OptionalHeader.SizeOfImage);
    printf("代碼基址:      %x\n", pNtHead->OptionalHeader.BaseOfCode);
    printf("區塊對齊:      %x\n", pNtHead->OptionalHeader.SectionAlignment);
    printf("文件塊對齊:    %x\n", pNtHead->OptionalHeader.FileAlignment);
    printf("子系統:        %x\n", pNtHead->OptionalHeader.Subsystem);
    printf("區段數目:      %x\n", pNtHead->FileHeader.NumberOfSections);
    printf("時間日期標志:  %x\n", pNtHead->FileHeader.TimeDateStamp);
    printf("首部大小:      %x\n", pNtHead->OptionalHeader.SizeOfHeaders);
    printf("特征值:        %x\n", pNtHead->FileHeader.Characteristics);
    printf("校驗和:        %x\n", pNtHead->OptionalHeader.CheckSum);
    printf("可選頭部大小:  %x\n", pNtHead->FileHeader.SizeOfOptionalHeader);
    printf("RVA 數及大小:  %x\n", pNtHead->OptionalHeader.NumberOfRvaAndSizes);
}

◆內存節與節表◆

在執行PE文件的時候,Windows 並不在一開始就將整個文件讀入內存,PE裝載器在裝載的時候僅僅建立好虛擬地址和PE文件之間的映射關系,只有真正執行到某個內存頁中的指令或者訪問頁中的數據時,這個頁面才會被從磁盤提交到內存中,這種機制極大的節約了內存資源,使文件的裝入速度和文件的大小沒有太多的關系。

Windows 裝載器在裝載DOS部分PE文件頭部分和節表部分時不進行任何處理,而在裝載節區的時候會根據節的不同屬性做不同的處理,一般需要處理以下幾個方面的內容:

節區的屬性: 節是相同屬性的數據的組合,當節被裝入內存的時候,同一個節對應的內存頁面將被賦予相同的頁屬性,Windows系統對內存屬性的設置是以頁為單位進行的,所以節在內存中的對其單位必須至少是一個頁的大小,對於X86來說這個值是4KB(1000h),而對於X64來說這個值是8KB(2000h),磁盤中存儲的程序並不會對齊4KB,而只有被PE加載器載入內存的時候,PE裝載器才會自動的補齊4KB對其的零頭數據。

節區的偏移: 節的起始地址在磁盤文件中是按照IMAGE_OPTIONAL_HEADER結構的FileAhgnment字段的值對齊的,而被加載到內存中時是按照同一結構中的SectionAlignment字段的值對齊的,兩者的值可能不同,所以一個節被裝入內存后相對於文件頭的偏移和在磁盤文件中的偏移可能是不同的。

節區的尺寸: 由於磁盤映像和內存映像的對齊單位不同,磁盤中的映像在裝入內存后會自動的進行長度擴展,而對於未初始化的數據段(.data?)來說,則沒有必要為它在磁盤文件中預留空間,只要可執行文件裝入內存后動態的為其分配空間即可,所以包含未初始化數據的節在磁盤中長度被定義為0,只有在運行后PE加載器才會動態的為他們開辟空間。

不進行映射的節: 有些節中包含的數據僅僅是在裝入的時候用到,當文件裝載完畢時,他們不會被遞交到物理內存中,例如重定位節,該節的數據對於文件的執行代碼來說是透明的,他只供Windows裝載器使用,可執行代碼根本不會訪問他們,所以這些節存在於磁盤文件中,不會被映射到內存中。

節表結構定義: PE文件中的所有節的屬性定義都被定義在節表中,節表由一系列的IMAGE_SECTION_HEADER結構排列而成,每個結構郵過來描述一個節,節表總被存放在緊接在PE文件頭的地方,也即是從PE文件頭開始偏移為00f8h的位置。

typedef struct _IMAGE_SECTION_HEADER {
    BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];
    union {
            DWORD   PhysicalAddress;
            DWORD   VirtualSize;           // 節區尺寸
    } Misc;
    DWORD   VirtualAddress;                // 節區RVA
    DWORD   SizeOfRawData;                 // 在文件中對齊后的尺寸
    DWORD   PointerToRawData;              // 在文件中的偏移
    DWORD   PointerToRelocations;          // 在OBJ文件中使用
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
    DWORD   Characteristics;               // 節區屬性字段
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

讀取所有節表: 通過編程實現讀取節區中的所有節,並打印出來。

void DisplaySectionHeaderInfo(HANDLE ImageBase)
{
    PIMAGE_NT_HEADERS pNtHead = NULL;
    PIMAGE_FILE_HEADER pFileHead = NULL;
    PIMAGE_SECTION_HEADER pSection = NULL;
    DWORD NumberOfSectinsCount = 0;
    pNtHead = GetNtHead(ImageBase);
    pSection = IMAGE_FIRST_SECTION(pNtHead);
    pFileHead = &pNtHead->FileHeader;

    NumberOfSectinsCount = pFileHead->NumberOfSections;        // 獲得區段數量
    DWORD *difA = NULL;   // 虛擬地址開頭
    DWORD *difS = NULL;   // 相對偏移(用於遍歷)
    difA = (DWORD *)malloc(NumberOfSectinsCount*sizeof(DWORD));
    difS = (DWORD *)malloc(NumberOfSectinsCount*sizeof(DWORD));

    printf("節區名稱 相對偏移\t虛擬大小\tRaw數據指針\tRaw數據大小\t節區屬性\n");
    for (int temp = 0; temp<NumberOfSectinsCount; temp++, pSection++)
    {
        printf("%s\t 0x%.8X \t 0x%.8X \t 0x%.8X \t 0x%.8X \t 0x%.8X\n",
            pSection->Name, pSection->VirtualAddress, pSection->Misc.VirtualSize,
            pSection->PointerToRawData, pSection->SizeOfRawData, pSection->Characteristics);
        difA[temp] = pSection->VirtualAddress;
        difS[temp] = pSection->VirtualAddress - pSection->PointerToRawData;
    }
}

使用C語言實現可能有點復雜,我們可以使用Python幾行代碼搞定,最終的調用效果是相同的,格式也是相同的。

import pefile

if __name__ == "__main__":
    pe = pefile.PE("D://run.exe")
    section_count = int(pe.FILE_HEADER.NumberOfSections + 1)
    print("序號\t節區名稱\t\t相對偏移\t\t虛擬大小\t\tRaw數據指針\tRaw數據大小\t節區屬性")
    print("-" * 80)
    for count,item in zip(range(1,section_count),pe.sections):
        print("%d\t\t%-10s\t0x%.8X\t0x%.8X\t0x%.8X\t0x%.8X\t0x%.8X" %(count,(item.Name).decode("utf-8"),item.VirtualAddress,
                   item.Misc_VirtualSize,item.PointerToRawData,item.SizeOfRawData,item.Characteristics))


◆Import 導入表◆

導入表在可執行文件中扮演了重要的角色,在Win32編程中我們會經常用到導入函數,導入函數就是程序調用其執行代碼又不在程序中的函數,這些函數通常是系統提供給我們的API,在調用者程序中只保留一些函數信息,包括函數名機器所在DLL路徑。

對於磁盤上的PE文件來說,它無法得知這些導入函數會在的那個內存的那個地方出現,只有當PE文件被裝入內存時,Windows裝載器才將DLL載入,並調用導入函數指令,和函數實際所處的地址聯系起來。

首先我通過匯編編寫了一段簡單的彈窗代碼,我們觀察下方代碼,有沒有發現一些奇特的地方?

00801000 | 6A 00                    | push 0                                  |
00801002 | 6A 00                    | push 0                                  |
00801004 | 68 00308000              | push main.803000                        | 
00801009 | 6A 00                    | push 0                                  |
0080100B | E8 0E000000              | call 0x0080101E                         | call MessageBoxA
00801010 | 6A 00                    | push 0                                  |
00801012 | E8 01000000              | call 0x00801018                         | call ExitProcess
00801017 | CC                       | int3                                    |
00801018 | FF25 00208000            | jmp dword ptr ds:[0x00802000]           | 導入函數地址
0080101E | FF25 08208000            | jmp dword ptr ds:[0x00802008]           | 導入函數地址

反匯編后,可看到對MessageBoxExitProcess函數的調用變成了對0x0080101E0x00801018地址的調用,但是這兩個地址顯然是位於程序自身模塊,而不是系統的DLL模塊中的,實際上這是由於編譯器在程序的代碼的后面自動添加了一條jmp dword ptr[xxxxx]類型的跳轉指令,其中的[xxxxx]地址中才是真正存放導入函數的地址。

那么在程序沒有被PE裝載器加載之前,0x00802000地址處的內容是什么呢?

節區名稱     相對偏移       虛擬大小       Raw數據指針      Raw數據大小     節區屬性
.text    0x00001000      0x00000024      0x00000400      0x00000200      0x60000020
.rdata   0x00002000      0x00000092      0x00000600      0x00000200      0x40000040
.data    0x00003000      0x0000000E      0x00000800      0x00000200      0xC0000040
.reloc   0x00004000      0x00000024      0x00000A00      0x00000200      0x42000040

此處由於建議裝入地址是0x00800000所以0x00802000地址實際上是處於RVA偏移為2000h的地方,在觀察各個節的相對偏移,可發現2000h開始的地方位於.rdata節內,而這個節的Raw數據指針項為600h,也就是說0x00802000地址的內容實際上對應了PE文件中偏移600h處的數據。

你可以打開任意一款十六進制查看器,將光標拖到600h處,會發現其對應的地址是0000205Ch,這個地址顯然也不會是ExitProcess函數地址,但我們將它作為RVA來看的話。

查看節表可以發現RVA地址0000205C也處於.rdata節內,減去節的起始地址0x00002000得到這個RVA相對於節首的偏移是5Ch,也就是說它對應文件為0x00000600+5c = 065ch開始的地方,接下來觀察可發現,這個字符串正好就是ExitProcess所對應的文件偏移地址。

當PE文件被裝載的時候,Windows裝載器會根據xxxx處的RVA得到函數名,再根據函數名在內存中找到函數地址,並且用函數地址將xxxx處的內容替換成真正的函數地址。


導入表位置和大小可以從PE文件頭中IMAGE_OPTIONAL_HEADER32結構的IMAGE_DATA_DIRECTORY數據目錄字段中獲取,從IMAGE_DATA_DIRECTORY字段得到的是導入表的RVA值,如果在內存中查找導入表,那么將RVA值加上PE文件裝入的基址就是實際的地址。

找到了數據目錄結構,既能夠找到導入表,導入表由一系列的IMAGE_IMPORT_DESCRIPTOR結構組成,結構的數量取決於程序需要使用的DLL文件數量,每個結構對應一個DLL文件,在所有結構的最后,由一個內容全為0的IMAGE_IMPORT_DESCRIPTOR結構作為結束標志,表結構定義如下:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk;     // 包含指向IMAGE_THUNK_DATA(輸入名稱表)結構的數組
    } DUMMYUNIONNAME;
    DWORD   TimeDateStamp;              // 當可執行文件不與被輸入的DLL進行綁定時,此字段為0 
    DWORD   ForwarderChain;             // 第一個被轉向的API的索引
    DWORD   Name;                       // 指向被輸入的DLL的ASCII字符串的RVA
    DWORD   FirstThunk;                 // 指向輸入地址表(IAT)的RVA
} IMAGE_IMPORT_DESCRIPTOR;

OriginalFirstThunkFirstThunk字段是相同的,他們都指向一個包含IMAGE_THUNK_DATA結構的數組,數組中每個IMAGE_THUNK_DATA結構定義了一個導入函數的具體信息,數組的最后以一個內容全為0的IMAGE_THUNK_DATA結構作為結束,該結構的定義如下:

typedef struct _IMAGE_THUNK_DATA32 {
    union {
        DWORD ForwarderString;      // PBYTE 
        DWORD Function;             // PDWORD
        DWORD Ordinal;
        DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
    } u1;
} IMAGE_THUNK_DATA32;

從上方的結構定義不難看出,這是一個雙字共用體結構,當結構的最高位為1時,表示函數是以序號的方式導入的,這時雙字的低位就是函數的序號,當雙字最高位為0時,表示函數以函數名方式導入,這時雙字的值是一個RVA,指向一個用來定義導入函數名稱的IMAGE_IMPORT_BY_NAME結構,此結構定義如下:

typedef struct _IMAGE_IMPORT_BY_NAME {
    WORD    Hint;          // 函數序號
    CHAR   Name[1];        // 導入函數的名稱
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

上面的所有結構就是導入表的全部,總結起來就是下圖這張表,看了之后是不是很懵逼。

現在我們來分析下上圖,導入表中IMAGE_IMPORT_DESCRIPTOR結構的NAME字段指向字符串Kernel32.dll表明當前程序要從Kernel32.dll文件中導入函數,OriginalFirstThunkFirstThunk字段指向兩個同樣的IMAGE_THUNK_DATA數組,由於要導入4個函數,所有數組中包含4個有效項目並以最后一個內容為0的項目作為結束。

第4個函數是以序號導入的,與其對應的IMAGE_THUNK_DATA結構最高位等於1,和函數的序號0010h組合起來的數值就是80000010h,其余的3個函數采用的是以函數名方式導入,所以IMAGE_THUNK_DATA結構的數值是一個RVA,分別指向3個IMAGE_IMPORT_BY_NAME結構,每個結構的第一個字段是函數的序號,后面就是函數的字符串名稱了,一切就這么簡單!

上圖為什么會出現兩個一模一樣的IMAGE_THUNK_DATA數組結構呢? 這是因為PE裝載器會將其中一個結構修改為函數的地址jmp dword ptr[xxxx]其中的xxxx就是由FirstThunk字段指向的那個數組中的一員。

實際上當PE文件被裝載入內存后,內存中的映像會被Windows修正為如下圖所示的樣子:

其中由FristThunk字段指向的那個數組中的每個雙字都被替換成了真正的函數入口地址,之所以在PE文件中使用兩份IMAGE_THUNK_DATA數組的拷貝並修改其中的一份,是為了最后還可以留下一份備份數據用來反過來查詢地址所對應的導入函數名。

最后通過編程實現讀取導入表數據,如果需要64位需要修改GetNtHead里面的Dword = Dword_PTR

void DisplayImportTable(HANDLE ImageBase)
{
    PIMAGE_DOS_HEADER pDosHead = NULL;
    PIMAGE_NT_HEADERS pNtHead = NULL;
    PIMAGE_IMPORT_DESCRIPTOR pInput = NULL;
    PIMAGE_THUNK_DATA _pThunk = NULL;
    DWORD dwThunk = NULL;
    USHORT Hint;

    pDosHead = (PIMAGE_DOS_HEADER)ImageBase;
    pNtHead = GetNtHead(ImageBase);

    if (pNtHead->OptionalHeader.DataDirectory[1].VirtualAddress == 0){ return; }  // 讀取導入表RVA
    pInput = (PIMAGE_IMPORT_DESCRIPTOR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, pNtHead->OptionalHeader.DataDirectory[1].VirtualAddress, NULL);
    for (; pInput->Name != NULL;)
    {
        char *szFunctionModule = (PSTR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)pInput->Name, NULL);  // 遍歷出模塊名稱
        if (pInput->OriginalFirstThunk != 0)
        {
            dwThunk = pInput->OriginalFirstThunk;
            _pThunk = (PIMAGE_THUNK_DATA)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)pInput->OriginalFirstThunk, NULL);
        }
        else
        {
            dwThunk = pInput->FirstThunk;
            _pThunk = (PIMAGE_THUNK_DATA)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)pInput->FirstThunk, NULL);
        }
        for (; _pThunk->u1.AddressOfData != NULL;)
        {
            char *szFunction = (PSTR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)(((PIMAGE_IMPORT_BY_NAME)_pThunk->u1.AddressOfData)->Name), 0);
            if (szFunction != NULL)
                memcpy(&Hint, szFunction - 2, 2);
            else
                Hint = -1;
            printf("%0.4x\t%0.8x\t%s\t %s\n", Hint, dwThunk, szFunctionModule, szFunction);
            dwThunk += 8;  // 32位=4 64位=8
            _pThunk++;
        }
        pInput++;
    }   
}

◆Export 導出表◆

當PE文件執行時 Windows裝載器將文件裝入內存並將導入表中登記的DLL文件一並裝入,再根據DLL文件中函數的導出信息對可執行文件的導入表(IAT)進行修正。

導出函數的DLL文件中,導出信息被保存在導出表,導出表就是記載着動態鏈接庫的一些導出信息。通過導出表,DLL文件可以向系統提供導出函數的名稱、序號和入口地址等信息,以便Windows裝載器能夠通過這些信息來完成動態鏈接的整個過程。

導出函數存儲在PE文件的導出表里,導出表的位置存放在PE文件頭中的數據目錄表中,與導出表對應的項目是數據目錄中的首個IMAGE_DATA_DIRECTORY結構,從這個結構的VirtualAddress字段得到的就是導出表的RVA值,導出表同樣可以使用函數名或序號這兩種方法導出函數。

導出表的起始位置有一個IMAGE_EXPORT_DIRECTORY結構與導入表中有多個IMAGE_IMPORT_DESCRIPTOR結構不同,導出表只有一個IMAGE_EXPORT_DIRECTORY結構,該結構定義如下:

typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;
    DWORD   TimeDateStamp;        // 文件的產生時刻
    WORD    MajorVersion;
    WORD    MinorVersion;
    DWORD   Name;                  // 指向文件名的RVA
    DWORD   Base;                  // 導出函數的起始序號
    DWORD   NumberOfFunctions;     // 導出函數總數
    DWORD   NumberOfNames;         // 以名稱導出函數的總數
    DWORD   AddressOfFunctions;    // 導出函數地址表的RVA
    DWORD   AddressOfNames;        // 函數名稱地址表的RVA
    DWORD   AddressOfNameOrdinals; // 函數名序號表的RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

上面的_IMAGE_EXPORT_DIRECTORY 結構如果總結成一張圖,如下所示:

在上圖中最左側AddressOfNames結構成員指向了一個數組,數組里保存着一組RVA,每個RVA指向一個字符串即導出的函數名,與這個函數名對應的是AddressOfNameOrdinals中的結構成員,該對應項存儲的正是函數的唯一編號並與AddressOfFunctions結構成員相關聯,形成了一個導出鏈式結構體。

獲取導出函數地址時,先在AddressOfNames中找到對應的名字MyFunc1,該函數在AddressOfNames中是第1項,然后從AddressOfNameOrdinals中取出第1項的值這里是1,然后就可以通過導出函數的序號AddressOfFunctions[1]取出函數的入口RVA,然后通過RVA加上模塊基址便是第一個導出函數的地址,向后每次相加導出函數偏移即可依次遍歷出所有的導出函數地址,代碼如下所示:

CHAR *LoadFile(char *filename)
{
    DWORD dwReadWrite, LenOfFile = GetFileSize(filename, NULL);
    HANDLE hFile = CreateFileA(filename, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, 0, OPEN_EXISTING, 0, 0);
    if (hFile != INVALID_HANDLE_VALUE)
    {
        PCHAR buffer = (PCHAR)malloc(LenOfFile);
        SetFilePointer(hFile, 0, 0, FILE_BEGIN);
        ReadFile(hFile, buffer, LenOfFile, &dwReadWrite, 0);
        CloseHandle(hFile);
        return buffer;
    }
    return NULL;
}

VOID DisplayExportTable(char *filename)
{
    PIMAGE_NT_HEADERS pNtHead;
    PIMAGE_DOS_HEADER pDosHead;
    PIMAGE_EXPORT_DIRECTORY pExport;
    char *filedata;
    filedata = LoadFile(filename);
    pDosHead = (PIMAGE_DOS_HEADER)filedata;
    pNtHead = (PIMAGE_NT_HEADERS)(filedata + pDosHead->e_lfanew);
    if (pNtHead->Signature != 0x00004550){return;}        // 無效PE文件
    //if (pNtHead->OptionalHeader.Magic != 0x20b){return;}  // 不是64位PE

    pExport = (PIMAGE_EXPORT_DIRECTORY)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, pNtHead->OptionalHeader.DataDirectory[0].VirtualAddress, NULL);
    DWORD i = 0;
    DWORD NumberOfNames = pExport->NumberOfNames;
    ULONGLONG **ppdwNames = (ULONGLONG **)pExport->AddressOfNames;
    ppdwNames = (PULONGLONG*)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)ppdwNames, NULL);
    ULONGLONG **ppdwAddr = (ULONGLONG **)pExport->AddressOfFunctions;
    ppdwAddr = (PULONGLONG*)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (DWORD)ppdwAddr, NULL);
    ULONGLONG *ppdwOrdin = (ULONGLONG*)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (DWORD)pExport->AddressOfNameOrdinals, NULL);
    char* szFunction = (PSTR)ImageRvaToVa((PIMAGE_NT_HEADERS)pNtHead, pDosHead, (ULONG)*ppdwNames, NULL);
    for (i = 0; i<NumberOfNames; i++)
    {
        printf("%0.8x\t%s\n",*ppdwAddr, szFunction);
        szFunction = szFunction + strlen(szFunction) + 1;
        ppdwAddr++;
    }
}

總結:導入表多出現在EXE可執行文件中,而導出表則多出現在DLL文件中,除此之外重定位表也多出現在DLL文件中,而我們最需要關注的其實就是區段信息和導入表相關的內容其他的不太重要。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM