学写压缩壳心得系列之一 熟悉概念,未雨绸缪

from:http://www.2cto.com/Article/201202/118214.html

这是我对之前学习写一个简单压缩壳的总结。期间查阅了很多的资料,借鉴了很多的代码,做了很多的笔记,有一些小心得和体会,希望与大家共同的交流和学习。再一次的感谢各位前辈们提供的无数资料。
  
     文章分为了4个部分,从最基本的概念,到最后学习实践的代码,都在这个系列之中。文章包括:
 
1.数据与指令,以及加载前期相关概念
2.pe文件的结构解析
3.pe文件load的过程
4.壳的处理
 
     因为小弟才疏学浅,肯定有不足和错误之处,希望各位大虾能多多的指点。希望为学习写压缩壳的朋友提供一点参考,哪怕是一点点,就很知足了:)
 
1.数据与指令
 
     冯诺依曼计算机体系中,数据和指令本质上如同棋盒里的棋子和棋盘上对弈的棋子一样。数据与指令从广义的定义上来说是一样的。在某个时候,我们很难去分清出什么是指令,什么是数据。借王爽《汇编语言》中的一个比方,将数据比喻成是棋盘里面的棋子。比赛时的棋子才按照棋盘的规律一个一个的排好,比赛后,所有的棋子都装在棋盒内。不同的时候,棋子比赛中和比赛后的状态,就可以去对应数据和指令的状态。当进行比赛的时候,根据比赛的规则,棋子要放在格子里,而且格子有大小,一个格子只能放一个,不能叠加。而不进行比赛的时候,一大把的棋子任意的放在盒子里面。棋子始终是棋子,促使他们之间状态的不同的是比赛的规则。就如同数据和指令一样,本质上来说是相同的,放在磁盘里保存的时候,就按照了磁盘保存的规则,载入内存运行的时候,就按照了内存载入的规则。
 
     那么,怎么去看这个规则呢?没有比赛的时候,棋子可以是按照装在棋盒子的“规则”(就随意的倒进去,只要还有空隙就可以塞进去了,直到塞不进,我就用另一个棋盘装),也可以按棋盘的“规则”(一个一个的放在棋盘线的交叉点上),但是充其量也只能放361个,因为一个棋盘只有361个交叉点,如果又买了一副棋,放第362粒,就要拿一另外一个棋盘了。通过不同的装棋规则,我们可以知道什么时候是在下棋,什么时候是在休息。由此,我们引入了映射的概念。
 
映射关系:
 
在了解pe装载的过程中,我们假象要载入内存,就是要进行比赛。任何的比赛是有其规矩的,这样棋子就不能像装载棋盒里那样紧凑,必须按照棋盘的格子一格一格的对应。同样,程序从硬盘载入的到内存也是一样。系统规定了程序载入的时候必须按照某一个尺寸来对齐,而这个尺寸和硬盘的对齐尺寸是不一样的。这就是映射的根本。如图:可以看到,程序在硬盘和内存中的大小是不一样的,期间,为了满足内存装载的规则,就必须对原本没有的地方,进行填充。其实,说到底,就是一切要按规则办事情:)

 例如,我要把一个程序载入到内存中,内存中规定,装载的最小单位就是1000字节。我只有1个字节,也要用到1000字节,因为一个最小单位是1000。1001个字节,就要用到2000字节的空间,也是因为一个最小单位是1000,要装剩下的1个字节必须要动用下一个最小单元的1000字节,尽管只装1字节。
 
    回到图。这里在磁盘中的对齐大小是200h,在磁盘中装入数据的最小对齐单位就是200h,而内存中对齐的是1000h,也就是说内存中装入数据的最小对齐单位就是1000h。
 
既然磁盘文件映射到了内存,就会产生出一个映像。我们称之为内存映像。这里的“像”对应的实体就是磁盘文件,我们不能说他是原本复制,因为载入到了内存,就要按照内存的载入规矩。载入规则的不同,导致“像”的内容相同,形态大小不同。
 
这样,根据不同的规则,磁盘文件实体和内存映像虽然是衍生的关系,由于装载对齐的粒度(即装载数据最小对齐单位)不同,成的“像”即不同。
 
 
PE加载准备
    PE loader要想从磁盘装载一个程序到内存中去,必须要知道从哪里装载,装载到哪里去。你写一个PE文件,就要告诉loader要怎么去装载,这就是pe文件各个部分所提供信息的根源。如同给loader一个地图指南,告诉loader一个个关于PE的信息,定位到磁盘中的文件,装载到内存中去。
 
    抛开具体的数据不谈,其实整个PE结构里面许多结构都是通知loader要怎么去加载和在哪里加载它本身。所以,当看到PE结构一大堆结构的时候,不要头疼,细想一下,不就是一堆地址嘛:)
 
    另外,PE中有的信息并不是loader时候必须的,有的仅仅是校验而已。只要正确的按照loader的形式和在loader容错范围内,PE文件都是可以被正常加载的:)。
 
值得注意的是,由于加载粒度的不同,对于多出来的空余加载地址,PE Loader都会填0.
 
ImageBase、RVA 和FileOffset
 
    一个pe放在内存中去,不可能给你一个绝对的地址。因为loader不能确定这个地址一定可用或可被加载。在这样一个前提下,能提供给loader的是许多相对于基址(IMAGEBASE)的偏移(RVA),根据PE加载的基址(IMAGEBASE),所有的要加载的绝对地址VA(IMAGEBASE + RVA),都根据这个偏移即可计算出来。
 
    RVA 和FileOffset这两个名词一个是相对于内存基址的偏移,一个是相对于磁盘基址的偏移。根据装载规则的不同,就有两套偏移。
 
    IMAGEBASE、RVA 和FileOffset这三者结合的寻址方式使loader的时候更加轻松,加载时只需要得到IMAGEBASE,便可以根据PE内部结构中的RVA 和offset按图索骥了。
 
RVA 和FileOffset的转换
 
    内存偏移和磁盘偏移结果不同,最根本的原因是,对齐粒度的不同。那么要进行转换,必须得过渡到统一的粒度标准上来。通常,在首先判断要转换的RVA在哪一个区段中,然后进行再进行粒度的统一。
 
    回到图中:假设我们要求00401010h的磁盘偏移,我们发现该地址在.text段中,而.text起始内存映像加载绝对地址是401000h,IMAGEBASE为4000000h,两者相减得出相对加载地址为1010h。然后再减去.text根据内存对齐粒度对齐后的偏移1000h,加上.text磁盘偏移地址得出了410h,即为所求。
 
    再例如,我们要求出00403030h的磁盘偏移。该地址在.data中,.data起始内存映像加载绝对地址是403000h,IMAGEBASE为4000000h,两者相减得出相对加载地址为3030h。再减去.data根据内存对齐粒度对齐后的偏移3000h,加上.data磁盘偏移地址得出了410h,即为所求。
 
   这样,我们可以统一出通用转换公式:
 
FileOffset = VA - IMAGEBASE - 该地址所在节内存起始偏移+ 该地址所在节磁盘起始偏移
 
很多解析工具里面转换公式还有一个版本的写法:
 
k = 该地址所在节内存起始偏移- 该地址所在节磁盘起始偏移
FileOffset = VA - IMAGEBASE - k
 
其实意思是一样,个人认为第一个版本更加的直观些,任君选择:)
 
微软为此提供了一个函数ImageRvaToVa,注意这里有个名称上的不同,即后面的VA是指的文件中的offset,我么来看看这个函数在MSDN上的说明

 

引用:
LPVOID ImageRvaToVa(
  IN PIMAGE_NT_HEADERS NtHeaders, // NT头              
  IN LPVOID Base,                 // // MapViewOfFile载入磁盘基址          
  IN DWORD Rva,                   // 待转Rva          
  IN OUT PIMAGE_SECTION_HEADER *LastRvaSection  //最后一个节地址,可置NULL
);
 
微软的这个函数有一个bug,就是在对待某些加壳的变形PE时候,会将一些信息定位在表头,这时候函数获取的Offset会出错。当然这都是针对的非正常的PE文件,按照PE规范的都是不会出错的:)
 
我们可以根据第一个版本的算法,可以写出如下的转换函数RVAToOffset:
ps:若对PE结构不了解,可以参考下一篇之后,再回来看:)
 
引用:
//ImageBase为载入基址,VirtualAddress为待转VA,函数返回FileOffset为所求
DWORD RVAToOffset(LPVOID ImageBase,DWORD VirtualAddress)
{
PIMAGE_DOS_HEADER pDH;
PIMAGE_NT_HEADERS pNTH;
PIMAGE_SECTION_HEADER pSH;
int NumOfSections;
// 找到PE头在文件中的偏移地址· www.2cto.com
pDH=(IMAGE_DOS_HEADER*)ImageBase;
pNTH=(IMAGE_NT_HEADERS*)((LPBYTE)ImageBase+pDH->e_lfanew);
// 获得PE文件中节的数量
NumOfSections=pNTH->FileHeader.NumberOfSections; 
 
// 变量所有的节,判断传入虚拟地址是否落在节的内存地址空间中
for (int i=0;i <NumOfSections;i++)
{
    // 获得节表头信息
    pSH=(IMAGE_SECTION_HEADER*)((BYTE*)ImageBase+pDH->e_lfanew+sizeof(IMAGE_NT_HEADERS))+i;
    // 比较虚拟地址是否在某个节中
    if(VirtualAddress>=pSH->VirtualAddress && VirtualAddress <pSH->VirtualAddress+pSH->SizeOfRawData)
    {
        // 待求内存地址在节的空间内
        // VA - IMAGEBASE - 该地址所在节内存起始偏移
        DWORD VA_in_Section_RAV=VirtualAddress-pSH->VirtualAddress;
        // VA_in_SectionRAV + 该地址所在节磁盘起始偏移换算为文件偏移地址
        DWORD FileOffset=pSH->PointerToRawData+VA_in_SectionRAV;
        // 返回
        return FileOffset;
    }   //VA在节头内的时候,直接返回VirtualAddress,
    else if(VirtualAddress < pSH->VirtualAddress && VirtualAddress > ImageBase )
    {   //这里增加增加一个判断,如果待求RVA在表头,判断其是否在磁盘中有对应数据。
        if(VirtualAddress < pSH->PointerToRawData )
        return  VirtualAddress;
        //否则没有对应的数据,无法定位
        else
        return  FALSE;
    }
}
return FALSE;
}
 
这里注意一下,在获取某些PE的SizeOfRawData和PointerToRawData的时候,某些变形的PE文件虽然自己设了一个值,但是loader装载的时候,会有一套自己的规则,所以在正确的判断SizeOfRawData和PointerToRawData的时候,建议采用linxer之前介绍过的方法:
 
 
引用:
SectionAlignmentMask = SectionAlignment - 1;
FileAlignmentMask = 0x200 - 1;
SizeOfRawData = (SizeOfRawData + FileAlignmentMask) & (0xffffffff ^ FileAlignmentMask);
PointerToRawData = PointerToRawData & (0xffffffff ^ FileAlignmentMask);
这样,在获取了与loader同样的偏移之后,再采用如上算法即可:)
 
      感谢3楼的提醒,对给出的TEST1和TEST2附件中的变形PE地址转换,增加了判断,处理了变形PE定位中某些RVA时在表头和获取正确的的SizeOfRawData和PointerToRawData,若大家还有更好的方案,欢迎交流:)
 
     小结:这篇文章主要是介绍了PE文件中贯穿全线的几个重要的概念,例如RVA和Offset,以及之间的转换问题,并给出了解决的代码。希望能对初学者有一定的帮助,尽量地写得通俗易懂,可能写得有点冗长,感谢您耐心的看完:)但水平有限,还请大家多多指正。
 
作者 小小的心

posted @ 2012-04-18 23:19  r3call  阅读(494)  评论(0编辑  收藏  举报