《C++ 黑客编程揭秘与防范(第2版)》—第6章6.4节PE相关编程实例

简介:

本节书摘来自异步社区《C++ 黑客编程揭秘与防范(第2版)》一书中的第6章6.4节PE相关编程实例,作者冀云,更多章节内容可以访问云栖社区“异步社区”公众号查看。

6.4 PE相关编程实例
C++ 黑客编程揭秘与防范(第2版)
前面讲的都是概念性的知识,本节主要编写一些关于PE文件结构的程序代码,以帮助读者加强对PE结构的了解。

6.4.1 PE查看器
写PE查看器并不是件复杂的事情,只要按照PE结构一步一步地解析就可以了。下面简单地解析其中几个字段内容,显示一下节表的信息,其余的内容只要稍作修改即可。PE查看器的界面如图6-26所示。

PE查看器的界面按照图6-26所示的设置,不过这个可以按照个人的偏好进行布局设置。编写该PE查看器的步骤为打开文件并创建文件内存映像,判断文件是否为PE文件并获得PE格式相关结构体的指针,解析基本的PE字段,枚举节表,最后关闭文件。需要在类中添加几个成员变量及成员函数,添加的内容如图6-27所示。


11ee60ace42a48953d929fa8b21d5bb8eb0efd80

按照前面所说的顺序,依次实现添加的各个成员函数。

BOOL CPeParseDlg::FileCreate(char *szFileName)
{
  BOOL bRet = FALSE;

  m_hFile = CreateFile(szFileName, 
             GENERIC_READ | GENERIC_WRITE,
             FILE_SHARE_READ,
             NULL,
             OPEN_EXISTING,
             FILE_ATTRIBUTE_NORMAL,
             NULL);
  if ( m_hFile == INVALID_HANDLE_VALUE )
  {
    return bRet;
  }

  m_hMap = CreateFileMapping(m_hFile, NULL, 
                PAGE_READWRITE | SEC_IMAGE, 
                0, 0, 0);
  if ( m_hMap == NULL )
  {
    CloseHandle(m_hFile);
    return bRet;
  }

  m_lpBase = MapViewOfFile(m_hMap, 
               FILE_MAP_READ | FILE_SHARE_WRITE, 
               0, 0, 0);
  if ( m_lpBase == NULL )
  {
    CloseHandle(m_hMap);
    CloseHandle(m_hFile);
    return bRet;
  }

  bRet = TRUE;
  return bRet;
}

这个函数的主要功能是打开文件并创建内存文件映像。通常对文件进行连续读写时直接使用ReadFile()和WriteFile()两个函数。当不连续操作文件时,每次在ReadFile()或者WriteFile()后就要使用SetFilePointer()来调整文件指针的位置,这样的操作较为繁琐。内存文件映像的作用是把整个文件映射入进程的虚拟空间中,这样操作文件就像操作内存变量或内存数据一样方便。

创建内存文件映像所使用的函数有两个,分别是CreateFileMapping()和MapViewOfFile()。CreateFileMapping()函数的定义如下:

HANDLE CreateFileMapping(
 HANDLE hFile,                   // handle to file
 LPSECURITY_ATTRIBUTES lpAttributes,    // security
 DWORD flProtect,                 // protection
 DWORD dwMaximumSizeHigh,          // high-order DWORD of size
 DWORD dwMaximumSizeLow,           // low-order DWORD of size
 LPCTSTR lpName                 // object name
);

参数说明如下。

hFile:该参数是CreateFile()函数返回的句柄。

lpAttributes:是安全属性,该值通常是NULL。

flProtect:创建文件映射后的属性,通常设置为可读可写PAGE_READWRITE。如果需要像装载可执行文件那样把文件映射入内存的话,那么需要使用SEC_IMAGE。

最后3个参数在这里为0。如果创建的映射需要在多进程中共享数据的话,那么最后一个参数设定为一个字符串,以便通过该名称找到该块共享内存。

该函数的返回值为一个内存映射的句柄。

MapViewOfFile()函数的定义如下:

LPVOID MapViewOfFile(
 HANDLE hFileMappingObject,      // handle to file-mapping object
 DWORD dwDesiredAccess,        // access mode
 DWORD dwFileOffsetHigh,        // high-order DWORD of offset
 DWORD dwFileOffsetLow,         // low-order DWORD of offset
 SIZE_T dwNumberOfBytesToMap     // number of bytes to map
);

参数说明如下。

hFileMappingObject:该参数为CreateFileMapping()返回的句柄。

dwDesiredAccess:想获得的访问权限,通常情况下也是可读可写FILE_MAP_READ、FILE_MAP_
WRITE。

最后3个参数一般给0值就可以了。

按照编程的规矩,打开要关闭,申请要释放。CreateFileMapping()的关闭需要使用CloseHandle()函数。MapViewOfFile()的关闭,要使用UnmapViewOfFile()函数,该函数的定义如下:

BOOL UnmapViewOfFile(
 LPCVOID lpBaseAddress  // starting address
);

该函数的参数就是MapViewOfFile()函数的返回值。

接着说PE查看器,文件已经打开,就要判断文件是否为有效的PE文件了。如果是有效的PE文件,就把解析PE格式的相关结构体的指针也得到。代码如下:

BOOL CPeParseDlg::IsPeFileAndGetPEPointer()
{
  BOOL bRet = FALSE;

  // 判断是否为MZ头
  m_pDosHdr = (PIMAGE_DOS_HEADER)m_lpBase;

  if ( m_pDosHdr->e_magic != IMAGE_DOS_SIGNATURE )
  {
    return bRet;
  }

  // 根据IMAGE_DOS_HEADER的e_lfanew的值得到PE头的位置
  m_pNtHdr = (PIMAGE_NT_HEADERS)((DWORD)m_lpBase + m_pDosHdr->e_lfanew);

  // 判断是否为PE\0\0
  if ( m_pNtHdr->Signature != IMAGE_NT_SIGNATURE )
  {
    return bRet;
  }

  // 获得节表的位置
  m_pSecHdr = (PIMAGE_SECTION_HEADER)((DWORD)&(m_pNtHdr->OptionalHeader) 
        + m_pNtHdr->FileHeader.SizeOfOptionalHeader);

  bRet = TRUE;
  return bRet;
}
这段代码应该非常容易理解,继续看解析PE格式的部分。

VOID CPeParseDlg::ParseBasePe()
{
  CString StrTmp;

  // 入口地址
  StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.AddressOfEntryPoint);
  SetDlgItemText(IDC_EDIT_EP, StrTmp);

  // 映像基地址
  StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.ImageBase);
  SetDlgItemText(IDC_EDIT_IMAGEBASE, StrTmp);

  // 连接器版本号
  StrTmp.Format("%d.%d", 
    m_pNtHdr->OptionalHeader.MajorLinkerVersion,
    m_pNtHdr->OptionalHeader.MinorLinkerVersion);
  SetDlgItemText(IDC_EDIT_LINKVERSION, StrTmp);

  // 节表数量
  StrTmp.Format("%02X", m_pNtHdr->FileHeader.NumberOfSections);
  SetDlgItemText(IDC_EDIT_SECTIONNUM, StrTmp);

  // 文件对齐值大小
  StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.FileAlignment);
  SetDlgItemText(IDC_EDIT_FILEALIGN, StrTmp);

  // 内存对齐值大小
  StrTmp.Format("%08X", m_pNtHdr->OptionalHeader.SectionAlignment);
  SetDlgItemText(IDC_EDIT_SECALIGN, StrTmp);
}

PE格式的基础信息,就是简单地获取结构体的成员变量,没有过多复杂的内容。获取导入表、导出表比获取基础信息复杂。关于导入表、导出表的内容将在后面介绍。接下来进行节表的枚举,具体代码如下:

VOID CPeParseDlg::EnumSections()
{
  int nSecNum = m_pNtHdr->FileHeader.NumberOfSections;

  int i = 0;
  CString StrTmp;

  for ( i = 0; i < nSecNum; i ++ )
  {
    m_SectionLIst.InsertItem(i, (const char *)m_pSecHdr[i].Name);

    StrTmp.Format("%08X", m_pSecHdr[i].VirtualAddress);
    m_SectionLIst.SetItemText(i, 1, StrTmp);

    StrTmp.Format("%08X", m_pSecHdr[i].Misc.VirtualSize);
    m_SectionLIst.SetItemText(i, 2, StrTmp);

    StrTmp.Format("%08X", m_pSecHdr[i].PointerToRawData);
    m_SectionLIst.SetItemText(i, 3, StrTmp);

    StrTmp.Format("%08X", m_pSecHdr[i].SizeOfRawData);
    m_SectionLIst.SetItemText(i, 4, StrTmp);

    StrTmp.Format("%08X", m_pSecHdr[i].Characteristics);
    m_SectionLIst.SetItemText(i, 5, StrTmp);
  }
}

最后的动作是释放动作,因为很简单,这里就不给出代码了。将这些自定义函数通过界面上的“查看”按钮联系起来,整个PE查看器就算是写完了。

6.4.2 简单的查壳工具
前面介绍了通过编程解析PE文件格式的基础数据,对于PE文件格式的解析其实并不难,难点在于兼容性。从前面的内容中可以看到,PE文件结构中大多用的是偏移地址,因此,只要偏移地址和实际的数据相符,那么PE文件格式有可能是嵌套的。也就是说,PE文件是可以变形的,只要保证其偏移地址和PE文件格式的结构基本就没多大问题。

对于PE可执行文件来说,为了保护可执行文件或者是压缩可执行文件,通常会对该文件进行加壳。接触过软件破解的人应该都清楚壳的概念。关于壳的概念,这里就不多说了。下面来写一个查壳的工具。

首先,用ASPack给前面写的程序加个壳。打开ASPack加壳工具,如图6-28所示。


be548b677b84ccaf3bd8b9187546b8c88f8fb0af

对测试用的软件进行一次加壳,不过在加壳前先用PEiD查看一下,如图6-29所示。


97eeb566dfb76d84911e7b5a270950a29c15f5f8

从图6-29可以看出,该程序是Visual C++ 5.0 Debug版的程序。其实该程序是用Visual C++ 6.0写的,这里是PEiD识别有误。不过只要用Visual C++ 6.0进行编译选择Release版时,PEiD是可以正确进行识别的。使用ASPack对该程序进行加壳,然后用PEiD查壳,如图6-30所示。

从图6-30中可以看出,PEiD识别出文件被加过壳,且是用ASPack进行加壳的。PEiD如何识别程序被加壳,以及加了哪种壳呢?在PEiD的目录下有一个特征码文件,名为“userdb.txt”。打开这个文件,看大概内容就能知道里边保存了壳的特征码。程序员的任务就是自己实现一个这个壳的识别工具。


eed48033d1209d5ff4ac95e6f58a43dcff07d61b

壳的识别是通过特征码进行的,特征码的提取通常是选择文件的入口处。壳会修改程序的入口处,因此对于壳的特征码来说,选择入口处比较合适。这里的工具主要是用来学习和演示用的,因此写的查壳工具要能识别两种类型,第一种类型是可以识别用Visual C++ 6.0编译出来的文件,第二种类型是可以识别ASPack加壳后的程序。当然,ASPack加壳工具的版本众多,这里只要能识别上面所演示版本的ASPack就可以了。

如何提取特征码呢?程序无论是在磁盘上还是在内存中,都是以二进制的形式存在的。前面也提到,特征码是从程序的入口处进行提取的,那么可以使用C32Asm以十六进制的形式打开这些文件,在入口处提取特征码,也可以用OD将程序载入内存后提取特征码。这里选择使用OD提取特征码。用OD载入未加壳的程序,如图6-31所示。


b0d6f523e4eb00696ef80d193662c2923cce597a

可以看到,这就是未加壳程序的入口处代码。在图6-31中,“HEX数据”列中就是代码对应的十六进制编码,这里要做的就是提取这些十六进制编码。提取结果如下:

"\x55\x8B\xEC\x6A\xFF\x68\x00\x65\x41\x00" \
"\x68\xE8\x2D\x40\x00\x64\xA1\x00\x00\x00" \
"\x00\x50\x64\x89\x25\x00\x00\x00\x00\x83" \
"\xC4\x94"

根据这个步骤,把ASPack的特征码也提取出来,提取结果如下:

"\x60\xE8\x03\x00\x00\x00\xE9\xEB\x04\x5D" \
"\x45\x55\xC3\xE8\x01\x00\x00\x00\xEB\x5D" \
"\xBB\xED\xFF\xFF\xFF\x03\xDD\x81\xEB\x00"
"\xC0\x01"

有了这些特征码,就可以开始编程了。先来定义一个数据结构,用来保存特征码,该结构如下:

#define NAMELEN 20
#define SIGNLEN 32

typedef struct _SIGN
{
  char szName[NAMELEN];
  BYTE bSign[SIGNLEN + 1];
}SIGN, *PSIGN;
利用该数据结构定义2个保存特征码的全局变量,如下:

SIGN Sign[2] = 
{
  {
    // VC6
    "VC6",
    "\x55\x8B\xEC\x6A\xFF\x68\x00\x65\x41\x00" \
    "\x68\xE8\x2D\x40\x00\x64\xA1\x00\x00\x00" \
    "\x00\x50\x64\x89\x25\x00\x00\x00\x00\x83" \
    "\xC4\x94"
  },
  {
    // ASPACK
    "ASPACK",
    "\x60\xE8\x03\x00\x00\x00\xE9\xEB\x04\x5D" \
    "\x45\x55\xC3\xE8\x01\x00\x00\x00\xEB\x5D" \
    "\xBB\xED\xFF\xFF\xFF\x03\xDD\x81\xEB\x00"
    "\xC0\x01"
  }};

程序界面是在PE查看器的基础上完成的,如图6-32所示。


52a339d818c12e184d4028590d386ef868b56615

提取特征码后,查壳工作只剩特征码匹配了。这非常简单,只要用文件的入口处代码和特征码进行匹配,匹配相同就会给出相应的信息。查壳的代码如下:

VOID CPeParseDlg::GetPeInfo()
{
  PBYTE pSign = NULL;

  // 定位文件入口位置
  pSign = (PBYTE)((DWORD)m_lpBase 
      + m_pNtHdr->OptionalHeader.AddressOfEntryPoint);

  // 比较入口特征码
  if ( memcmp(Sign[0].bSign, pSign, SIGNLEN) == 0 )
  {
    SetDlgItemText(IDC_EDIT_PEINFO, Sign[0].szName);
  }
  else if ( memcmp(Sign[1].bSign, pSign, SIGNLEN) == 0 )
  {
    SetDlgItemText(IDC_EDIT_PEINFO, Sign[1].szName);
  }
  else
  {
    SetDlgItemText(IDC_EDIT_PEINFO, "未知");
  }
}

这样,查壳程序的功能就完成了。在程序中提取的特征码的长度为32字节,由于这里只是一个简单的例子,读者在提取特征码的时候,为了提高准确率,需要多进行一些测试。

6.4.3 地址转换器
前面介绍了关于PE文件的3种地址,分别是VA(虚拟地址)、RVA(相对虚拟地址)和FileOffset(文件偏移地址)。这3种地址的转换如果始终使用手动来计算会非常累,因此通常的做法是借助工具来完成。前面介绍了使用LordPE来计算这3种地址的转换,现在来编写一个对这3种地址进行转换的工具。该工具如图6-33所示。


b457759038e19f18b4fd9224a5b9b8fc667c60d5

这个工具是在前两个工具的基础上完成的。因此,在进行计算的时候,应该先要进行“查看”,再进行“计算”。否则,该获取的指针还没有获取到。

在界面上,左边的3个按钮是“单选框”,单选框的设置方法如图6-34所示。


ef080ddcdc533becf14e0fd9744fed4fd0cac953

3个单选框中只能有一个是选中状态,为了记录哪个单选框是选中状态,在类中定义一个成员变量m_nSelect。对3个单选框,分别使m_nSelect值为1、2和3。关于界面的编程,请读者参考源代码,这里就不进行过多的介绍了。下面来看主要的代码。

在单击“计算”按钮后,响应该按钮的代码如下:

void CPeParseDlg::OnBtnCalc() 
{
  // TODO: Add your control notification handler code here
  DWORD dwAddr = 0;
  // 获取的地址
  dwAddr = GetAddr();

  // 地址所在的节
  int nInNum = GetAddrInSecNum(dwAddr); 

  // 计算其他地址
  CalcAddr(nInNum, dwAddr);
}
分别看一下GetAddr()、GetAddrInSecNum()和CalcAddr()的实现。

获取在编辑框中输入的地址内容的代码如下:

DWORD CPeParseDlg::GetAddr()
{
  char szAddr[10] = { 0 };
  DWORD dwAddr = 0;
  switch ( m_nSelect )
  {
  case 1:
    {
      GetDlgItemText(IDC_EDIT_VA, szAddr, 10);
      HexStrToInt(szAddr, &dwAddr);
      break;
    }
  case 2:
    {
      GetDlgItemText(IDC_EDIT_RVA, szAddr, 10);
      HexStrToInt(szAddr, &dwAddr);
      break;
    }
  case 3:
    {
      GetDlgItemText(IDC_EDIT_FILEOFFSET, szAddr, 10);
      HexStrToInt(szAddr, &dwAddr);
      break;
    }
  }

  return dwAddr;
}
获取该地址所属的第几个节的代码如下:

int CPeParseDlg::GetAddrInSecNum(DWORD dwAddr)
{
  int nInNum = 0;
  int nSecNum = m_pNtHdr->FileHeader.NumberOfSections;

  switch ( m_nSelect )
  {
  case 1:
    {
      DWORD dwImageBase = m_pNtHdr->OptionalHeader.ImageBase;
      for ( nInNum = 0; nInNum < nSecNum; nInNum ++ )
      {
        if ( dwAddr >= dwImageBase + m_pSecHdr[nInNum].VirtualAddress
          && dwAddr <= dwImageBase + m_pSecHdr[nInNum].VirtualAddress 
            + m_pSecHdr[nInNum].Misc.VirtualSize)
        {
          return nInNum;
        }
      }
      break;
    }
  case 2:
    {
      for ( nInNum = 0; nInNum < nSecNum; nInNum ++ )
      {
        if ( dwAddr >= m_pSecHdr[nInNum].VirtualAddress
          && dwAddr <= m_pSecHdr[nInNum].VirtualAddress 
            + m_pSecHdr[nInNum].Misc.VirtualSize)
        {
          return nInNum;
        }
      }
      break;
    }
  case 3:
    {
      for ( nInNum = 0; nInNum < nSecNum; nInNum ++ )
      {
        if ( dwAddr >= m_pSecHdr[nInNum].PointerToRawData
          && dwAddr <= m_pSecHdr[nInNum].PointerToRawData 
            + m_pSecHdr[nInNum].SizeOfRawData)
        {
          return nInNum;
        }
      }
      break;
    }
  }

  return -1;
}
计算其他地址的代码如下:

VOID CPeParseDlg::CalcAddr(int nInNum, DWORD dwAddr)
{
  DWORD dwVa = 0;
  DWORD dwRva = 0;
  DWORD dwFileOffset = 0;

  switch ( m_nSelect )
  {
  case 1:
    {
      dwVa = dwAddr;
      dwRva = dwVa - m_pNtHdr->OptionalHeader.ImageBase;
      dwFileOffset = m_pSecHdr[nInNum].PointerToRawData 
              + (dwRva - m_pSecHdr[nInNum].VirtualAddress);
      break;
    }
  case 2:
    {
      dwVa = dwAddr + m_pNtHdr->OptionalHeader.ImageBase;
      dwRva = dwAddr;
      dwFileOffset = m_pSecHdr[nInNum].PointerToRawData 
              + (dwRva - m_pSecHdr[nInNum].VirtualAddress);
      break;
    }
  case 3:
    {
      dwFileOffset = dwAddr;
      dwRva = m_pSecHdr[nInNum].VirtualAddress 
          + (dwFileOffset - m_pSecHdr[nInNum].PointerToRawData);
      dwVa = dwRva + m_pNtHdr->OptionalHeader.ImageBase;
      break;
    }
  }

  SetDlgItemText(IDC_EDIT_SECTION, (const char *)m_pSecHdr[nInNum].Name);

  CString str;
  str.Format("%08X", dwVa);
  SetDlgItemText(IDC_EDIT_VA, str);

  str.Format("%08X", dwRva);
  SetDlgItemText(IDC_EDIT_RVA, str);

  str.Format("%08X", dwFileOffset);
  SetDlgItemText(IDC_EDIT_FILEOFFSET, str);
}

代码都不复杂,关键就是CalcAddr()中3种地址的转换。如果读者没能理解代码,请参考前面手动转换3种地址的方法,这里就不进行介绍了。

6.4.4 添加节区
添加节区在很多场合都会用到,比如在加壳中、在免杀中都会经常用到对PE文件添加一个节区。添加一个节区的方法有4步,第1步是在节表的最后面添加一个IMAGE_SECTI ON_HEADER,第2步是更新IMAGE_FILE_HEADER中的NumberOfSections字段,第3步是更新IMAGE_OPTIONAL_
HEADER中的SizeOfImage字段,最后一步则是添加文件的数据。当然,前3步是没有先后顺序的,但是最后一步一定要明确如何改变。

注:某些情况下,在添加新的节区项以后会向新节区项的数据部分添加一些代码,而这些代码可能要求在程序执行之前就被执行,那么这时还需要更新IMAGE_OPTIONAL _HEADER中的AddressOfEntryPoint字段。

1.手动添加一个节区
先来进行一次手动添加节区的操作,这个过程是个熟悉上述步骤的过程。网上有很多现成的添加节区的工具。这里自己编写工具的目的是掌握和了解其实现方法,锻炼编程能力;手动添加节区是为了巩固前面的知识,熟悉添加节区的步骤。

接下来还是使用前面的测试程序。使用C32Asm用十六进制编辑方式打开这个程序,并定位到其节表处,如图6-35所示。


24d7a3a3e602263f352da12b4a8cfc1846998a32

从图6-35中可以看到,该PE文件有3个节表。直接看十六进制信息可能很不方便(看多了就习惯了),为了直观方便地查看节表中IMAGE_SECTION_HEADER的信息,那么使用LordPE进行查看,如图6-36所示。

5080db18d00a28214c0c72d53348b9a454019711

用LordPE工具查看的确直观多了。对照LordPE显示的节表信息来添加一个节区。回顾一下IMAGE_SECTION_HEADER结构体的定义,如下:

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;

IMAGE_SECTION_HEADER结构体的成员很多,但是真正要使用的只有6个,分别是Name、VirtualSize、VritualAddress、SizeOfRawData、PointerToRawData和Characteristics。这6项刚好与LordPE显示的6项相同。其实IMAGE_SECTION_HEADER结构体中其余的成员几乎不被使用。下面介绍如何添加这些内容。

IMAGE_SECTION_HEADER的长度为40字节,是十六进制的0x28,在C32Asm中占用2行半的内容,这里一次把这两行半的内容手动添加进去。回到C32Asm中,在最后一个节表的位置处开始添加内容,首先把光标放到右边的ASCII字符中,输入“.test”,如图6-37所示。


c66f3724a612009ae782fa8702e06c4698a4dcc2

接下来在00000240位置处添加节的大小,该大小直接是对齐后的大小即可。由于文件对齐是0x1000字节,也就是4096字节,那么采用最小值即可,使该值为0x1000。不知道读者是否还记得前面提到的字节顺序的问题,在C32Asm中添加时,正确的添加应当是“00 10 00 00”,以后添加时也要注意字节顺序。在添加后面几个成员时,不再提示注意字节顺序,读者应时刻清楚这点。在添加该值时,应当将光标定位在十六进制编辑处,而不是刚才所在的ASCII字符处。顺便要把VirutalAddress也添加上,VirtualAddress的值是前一个节区的起始位置加上上一个节对齐后的长度的值,上一个节区的起始位置为0x6000,上一个节区对齐后的长度为0x3000,因此新节区的起始位置为0x9000。添加VirtualSize和VirtualAddress后如图6-38所示。

3d98799ad1b0a12f69c1ac71568aa65b55198345

接下来的两个字段分别是SizeOfRawData和PointerToRawData,其添加方法类似前面两个字段的添加方法,这里就不细说了。分别添加“0x9000”和“0x1000”两个值,如图6-39所示。

b8003c39568439008f17ecb7cb1b3c010d502d99

PointerToRawData后面的12字节都可以为0,只要修改最后4字节的内容,也就是Characteristics的值即可。这个值直接使用上一个节区的值即可,实际添加时应根据所要节的属性给值。这里为了省事而直接使用上一个节区的属性,如图6-40所示。

f14e5d94d7bbbfe9b2daae19bdd65a863951fade

整个节表需要添加的地方就添加完成了,接下来需要修改该PE文件的节区数量。当前节区数量是3,这里要修改为4。虽然可以通过LordPE等修改工具完成,但是这里仍然使用手动修改。对于修改的位置,请读者自行定位找到,修改如图6-41所示。


a61c1ed5e384b7860814e78a0c7c731e375f3910

除了节区数量以外,还要修改文件映像的大小,也就是前面提到的SizeOfImage的值。由于新添加了节区,那么应该把该节区的大小加上SizeOfImage的大小,即为新的SizeOfImage的大小。现在的SizeOfImage的大小为0x9000,加上新添加节区的大小为0xa000。SizeOfImage的位置请读者自行查找,修改如图6-42所示。


6f78c5c53c36f78851e3f2963d070e45d9155399

修改PE结构字段的内容都已经做完了,最后一步就是添加真实的数据。由于这个节区不使用,因此填充0值就可以了,文件的起始位置为0x9000,长度为0x1000。把光标移到文件的末尾,单击“编辑”->“插入数据”命令,在“插入数据大小”文本框中输入十进制的4096,也就是十六进制的0x1000,如图6-43所示。


d694790f86d810f8cabb836bcc2c0d77923d8e73

单击“确定”按钮,可以看到在刚才的光标处插入了很多0值,这样工作也完成了。单击“保存”按钮进行保存,提示是否备份,选择“是”。然后用LordPE查看添加节区的情况,如图6-44所示。


61d88a71d5bee8064a9716fe502602149d2eae30

对比前后两个文件的大小,如图6-45所示。

350b011b98b685181bdad90b57d31bd3baee5e75

从图6-45中可以看出,添加节区后的文件比原来的文件大了4KB,这是由于添加了4096字节的0值。也许读者最关心的不是大小问题,而是软件添加了大小后是否真的可以运行。其实试运行一下,是可以运行的。

上面的整个过程就是手动添加一个新节区的全部过程,除了特有的几个步骤以外,要注意新节区的内存起始位置和文件起始位置的值。相信通过上面手动添加节区,读者对此已经非常熟悉了。下面就开始通过编程来完成添加节区的任务。

补充:在C32Asm软件中可以快速定位PE结构的各个结构体和字段的位置,在菜单栏单击“查看(V)”->“PE信息(P)”即可在C32Asm工作区的左侧打开一个PE结构字段的解析面板,在面板上双击PE结构的每个字段则可在C32Asm工作区中定位到十六进制形式的PE结构字段的数据。

2.通过编程添加节区
通过编程添加一个新的节区无非就是文件相关的操作,只是多了一个对PE文件的解析和操作而已。添加节区的步骤和手动添加节区的步骤是一样的,只要一步一步按照上面的步骤写代码就可以了。在开始写代码前,首先修改FileCreate()函数中的部分代码,如下:

m_hMap = CreateFileMapping(m_hFile, NULL, 
                PAGE_READWRITE /*| SEC_IMAGE*/, 
                0, 0, 0);
  if ( m_hMap == NULL )
  {
    CloseHandle(m_hFile);
    return bRet;
  }

这里要把SEC_IMAGE宏注释掉。因为要修改内存文件映射,有这个值会使添加节区失败,因此要将其注释掉或者直接删除掉。


2b995f19000c34232ec5ee3faf18085c84f16a1d

程序的界面如图6-46所示。

首先编写“添加”按钮响应事件,代码如下:

void CPeParseDlg::OnBtnAddSection() 
{
   // TODO: Add your control notification handler code here
  // 节名
  char szSecName[8] = { 0 };
  // 节大小
  int nSecSize = 0;

  GetDlgItemText(IDC_EDIT_SECNAME, szSecName, 8);
  nSecSize = GetDlgItemInt(IDC_EDIT_SEC_SIZE, FALSE, TRUE);

  AddSec(szSecName, nSecSize);
}

按钮事件中最关键的地方是AddSec()函数。该函数有2个参数,分别是添加节的名称与添加节的大小。这个大小无论输入多大,最后都会按照对齐方式进行向上对齐。看一下AddSec()函数的代码,如下:

VOID CPeParseDlg::AddSec(char *szSecName, int nSecSize)
{
  int nSecNum = m_pNtHdr->FileHeader.NumberOfSections;
  DWORD dwFileAlignment = m_pNtHdr->OptionalHeader.FileAlignment;
  DWORD dwSecAlignment = m_pNtHdr->OptionalHeader.SectionAlignment;

  PIMAGE_SECTION_HEADER pTmpSec = m_pSecHdr + nSecNum;

  // 拷贝节名
  strncpy((char *)pTmpSec->Name, szSecName, 7);
  // 节的内存大小
  pTmpSec->Misc.VirtualSize = AlignSize(nSecSize, dwSecAlignment);
  // 节的内存起始位置
  pTmpSec->VirtualAddress=m_pSecHdr[nSecNum-1].VirtualAddress+AlignSize(m_pSecHdr 
[nSecNum - 1].Misc.VirtualSize, dwSecAlignment);
  // 节的文件大小
  pTmpSec->SizeOfRawData = AlignSize(nSecSize, dwFileAlignment);
  // 节的文件起始位置
  pTmpSec->PointerToRawData=m_pSecHdr[nSecNum-1].PointerToRawData+AlignSize(m_pSe 
cHdr[nSecNum - 1].SizeOfRawData, dwSecAlignment);

  // 修正节数量
  m_pNtHdr->FileHeader.NumberOfSections ++;
  // 修正映像大小
  m_pNtHdr->OptionalHeader.SizeOfImage += pTmpSec->Misc.VirtualSize;

  FlushViewOfFile(m_lpBase, 0);

  // 添加节数据
  AddSecData(pTmpSec->SizeOfRawData);

  EnumSections();
}

代码中每一步都按照相应的步骤来完成,其中用到的2个函数分别是AlignSize()和AddSecData()。前者是用来进行对齐的,后者是用来在文件中添加实际的数据内容的。这两个函数非常简单,代码如下:

DWORD CPeParseDlg::AlignSize(int nSecSize, DWORD Alignment)
{
  int nSize = nSecSize;
  if ( nSize % Alignment != 0 )
  {
    nSecSize = (nSize / Alignment + 1) * Alignment;
  }

  return nSecSize;
}

VOID CPeParseDlg::AddSecData(int nSecSize)
{
  PBYTE pByte = NULL;
  pByte = (PBYTE)malloc(nSecSize);
  ZeroMemory(pByte, nSecSize);

  DWORD dwNum = 0;
  SetFilePointer(m_hFile, 0, 0, FILE_END);
  WriteFile(m_hFile, pByte, nSecSize, &dwNum, NULL);
  FlushFileBuffers(m_hFile);

  free(pByte);
}

整个添加节区的代码就完成了,仍然使用最开始的那个简单程序进行测试,看是否可以添加一个节区,如图6-47所示。


5518ed63b9251ed0a6d0b39363ce697573e8134f

从图6-47中可以看出,添加节区是成功的。试着运行一下添加节区后的文件,可以正常运行,而且添加节区的文件比原文件大了4KB,和前面手动添加的效果是一样的。

至此,对PE文件结构的介绍就结束了。其实,PE文件结构还有很多比较重要的内容,但是这里只介绍了一些基础的知识。至于其他的内容,请读者自行学习。PE结构查看器最关

键的是兼容性,PE结构是可以进行各种变形的。常规的PE结构也许比较好解析,但是经过变形的PE结构解析起来就可能会出错,因此要不断地尝试去解析不同的PE文件结构,PE查看器兼容性才会不断地完善。前面介绍了通过C32Asm手动分析PE文件结构,这种方法有助于完善PE查看器。这就好比数据恢复一样,数据恢复高手绝对不是简单地通过数据恢复工具来进行。虽然高手也在使用工具,但是如果遇到较为复杂的情况,数据恢复工具可能就会显得无力,那么手动分析文件系统格式将是唯一的方法。

本文仅用于学习和交流目的,不代表异步社区观点。非商业转载请注明作译者、出处,并保留本文的原始链接。

相关文章
|
22天前
|
安全 算法 C++
【C/C++ 泛型编程 应用篇】C++ 如何通过Type traits处理弱枚举和强枚举
【C/C++ 泛型编程 应用篇】C++ 如何通过Type traits处理弱枚举和强枚举
46 3
|
24天前
|
安全 算法 编译器
【C++ 泛型编程 进阶篇】深入探究C++模板参数推导:从基础到高级
【C++ 泛型编程 进阶篇】深入探究C++模板参数推导:从基础到高级
240 3
|
24天前
|
存储 算法 编译器
【C++ TypeName用法 】掌握C++中的TypeName:模板编程的瑞士军刀
【C++ TypeName用法 】掌握C++中的TypeName:模板编程的瑞士军刀
234 0
|
24天前
|
安全 算法 C++
【C++泛型编程 进阶篇】模板返回值的优雅处理(二)
【C++泛型编程 进阶篇】模板返回值的优雅处理
31 0
|
24天前
|
安全 算法 编译器
【C++泛型编程 进阶篇】模板返回值的优雅处理(一)
【C++泛型编程 进阶篇】模板返回值的优雅处理
42 0
|
24天前
|
存储 网络协议 C语言
【C/C++ 串口编程 】深入探讨C/C++与Qt串口编程中的粘包现象及其解决策略
【C/C++ 串口编程 】深入探讨C/C++与Qt串口编程中的粘包现象及其解决策略
76 0
|
24天前
|
算法 编译器 数据库
【C++ 泛型编程 高级篇】使用SFINAE和if constexpr灵活处理类型进行条件编译
【C++ 泛型编程 高级篇】使用SFINAE和if constexpr灵活处理类型进行条件编译
243 0
|
24天前
|
机器学习/深度学习 算法 编译器
【C++ 泛型编程 中级篇】深度解析C++:类型模板参数与非类型模板参数
【C++ 泛型编程 中级篇】深度解析C++:类型模板参数与非类型模板参数
46 0
|
24天前
|
设计模式 程序员 C++
【C++ 泛型编程 高级篇】C++模板元编程:使用模板特化 灵活提取嵌套类型与多容器兼容性
【C++ 泛型编程 高级篇】C++模板元编程:使用模板特化 灵活提取嵌套类型与多容器兼容性
241 2
|
24天前
|
算法 安全 C++
【C++ 泛型编程 入门篇】深入探索C++的numeric_limits:全面理解数值界限(一)
【C++ 泛型编程 入门篇】深入探索C++的numeric_limits:全面理解数值界限
43 0