1.数据与指令,以及加载前期相关概念(已完成) 学写压缩壳心得系列之一 熟悉概念,未雨绸缪
2.pe文件的结构解析(已完成) 学写压缩壳心得系列之二 掌握PE结构 ,曲径通幽
3.pe文件load的过程(已完成) 学写压缩壳心得系列之三 模拟加载,步步为营
4.壳的处理(已完成)
我们要了解压缩壳处理PE的方式,即是要按照正常的方式载入PE,然后在
内存中进行各个操作,完成后写回磁盘中,下图简单的表述了这个过程:
加载待压缩PE文件
壳在模拟系统修改pe文件的时候,有两种方式:
1.直接读取文件,按照各个结构进行拷贝。这里存在一个问题,也是第一篇中介绍过的,磁盘文件的对齐粒度和内存文件对齐粒度不一样。而在PE文件中,各个数据结构依赖于偏移的定位,而相对偏移恰恰是内存中相对基址的偏移,存在一个对齐粒度的转换。第一篇中也提供了文件偏移转内存偏移的函数,在定位各个结构之前,需要调用此函数来转换指定偏移。
2.直接模拟系统loader装载的方法。类似于第三篇中前面部分介绍过系统装载的方法。在一段内存空间内,依此读取pe各个部分的结构,按照各个结构所给定要加载到内存中的偏移,填充的偏移指定的内存处。这样直接的填充方法就避免了文件偏移转换内存偏移,清晰直观。在模拟系统装载的时候,也需要用到一个对其粒度函数的转换。与方式1中的不同,这个转换仅仅是为了填充因为对其粒度与实际大小之间的空隙,满足实际pe装进内存大小的填充。
代码:
AlignmentNum(D
word address, D
word Alignment)
{
int align = address % Alignment;
return address + Alignment - align;
}
获取目标文件hFile和NT头NtHeader后,要模拟装载PE,首先要知道目标PE头中的下列参数:
D
word MemAlignment = NtHeader.OptionalHeader.SectionAlignment;//内存对其粒度
D
word FileAlignment = NtHeader.OptionalHeader.FileAlignment;//磁盘内存对其粒度
D
word PeSize = AlignmentNum(NtHeader.OptionalHeader.ImageBase, MemAlignment);//载入总大小
根据之前获得经过内存对其的总大小,可以开辟空间
char *pMemPo
inter = (char *)GlobalAlloc(GMEM_FIXED | GMEM_ZEROINIT, PeSize);
D
word SecNum = NtHeader.FileHeader.NumberOfSections;//读取节数目
D
word SizeOfHeader = NtHeader.OptionalHeader.SizeOfHeader;//读取PE头大小
ReadFile(hFile, pMemPointer, SizeOfHeader, NULL, NULL);//从文件读取PE头
获取的了节表头pSectionHeader后,就可以进行装载了。
for (i = 0; i < SecNum; i++)
{
//定位文件第一个节磁盘偏移处
SetFilePointer(hFile, pSectionHeader.PointerToRawData , NULL, FILE_BEGIN);
//读取整个节内存,并转换到内存对其粒度
ReadFile(hFile, (char *)pMemPointer + AlignmentNum(pSectionHeader.VirtualAddress, MemAlignment), pSectionHeader.SizeOfRawData, NULL, NULL);
//读取完之后转到下一个节表
pSectionHeader++;
}
在加载完PE之后,整个pe文件在内存中的排布一目了然。其实壳的关键点,就是如何将按照某种方法处理以后的pe文件,再次还原成以前的格式布局,这样系统loader可以像加载以前的文件那样来加载它。我们可以这样来看,我们自己写的外壳代码就自己完成了一次系统loader要做的工作,将处理过的PE恢复成以前的样子,然后系统再进行一次pe加载,知道文件正常的运行。因为压缩后的pe和原始pe对于loader来说,是没有区别的。壳要操作的就是如何将保持在磁盘中的,压缩好的文件,在内存中编排布局,更换变换成未压缩pe的内存布局情况。
导入表的处理
经过压缩以后的pe文件一般是要经过重构导入表的,很多的压缩壳仅仅提供了自己要使用的导入函数。然后,模拟loader,利用保存或者解压后的导入表重新定位各个项,并完成填充。
关于导入表的处理有两种方式:
1.将原始导入表数据拷贝出来,然后清零原始的导入表,进行压缩。导入表中有两个关键的结构name和FristThunk,一般来说,只需要这两个关键项,就可以定位填充IAT了。2.将原始导入表RVA保存,将导入表一并压缩,待到含有导入表区段解压之后,壳模拟系统loader,找到原始导入表的IID的name项和FristThunk项,利用壳提供的导入函数LoadLibraryA载入name指定的模块,和通过GetPro
caddress搜索FristThunk指向的函数名来定位需要导入函数的实际地址。
处理重定位表
重定位的数据一般对于EXE是无用的,遇到EXE中包含的重定位信息可以删掉。对于DLL来说,重定位信息很关键,压缩重定位信息之后,对于已近压缩的DLL来说,此时,数据目录中指定的重定位信息已不准确了,这时候,可以清空已载入内存后PE重定位表目录,待到解压完成的时候,根据之前保存的地址,外壳再模拟loader,自动填充重定位数据。
代码:
//保存重定位表的原始数据
if(NtHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].Size > 0 )
{
IsRESOURCE == TURE;
D
word BASERELOC_VA = Headers.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
D
word BASERELOC_SIZE = Headers.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;
}
//清空重定位表目录
NtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress = 0;
NtHeader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size = 0;
处理资源
某些资源是不能被压缩的,比如版本信息和图标,因为这些项目在pe还未载入内存之前,系统就要将他们读取并且显示出来。这里为了处理的方便,可以将整个资源区段完整的拷贝到压缩文件中,所有的资源段都不进行压缩,仅仅是对资源项的一个移植。待到加载至内存的时候,将原始资源目录信息导入定位即可。压缩之前可以定义一个全局变量BOOL IsRESOURCE,来判断是否含有压缩节。
代码:
//保存原始PE资源目录表的信息
if(NtHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].Size > 0 )
{
IsRESOURCE == TURE;
D
word RESOURCE_VA = NtHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].VirtualAddress;
D
word RESOURCE_SIZE = NtHeader.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_RESOURCE].Size;
}
//这里保存要原始资源目录的VA和SIZE,载入内存之后,不进行处理,直接copy到压缩数据节之后。
压缩区段
占位区段
占位区段也是一个区段,是解压后还原成原始PE的数据要保存的地方。upx在压缩完PE后,第一个upx0区段,就是一个占位区段。占位区段其实可以理解成壳代码工作区间。壳在压缩原始pe时,势必是要保存两个关键的数据结构:1.压缩后数据地址2.待解压放入的地址。待解压放入地址就是占位区段。PE结构中,区段结构有4个很重要的参数:VSize(虚拟大小)、VOffset(虚拟地址) 、RSize(实际大小)和ROffset(磁盘地址)。对于占位区段来说,VSize就应该是所有待压缩区段VSize的总和,VOffset若按照之前介绍的架构,就是第一个待压缩区段的地址。回过头来,既然叫占位段,那么究竟是怎么个占位呢?这里的占位,其实指的是占加载后内存空间,仅仅是通知加载器,加载这个pE到内存时,要从VOffset按照VSize的大小分配一段内存出来。 那么RSize和ROffset呢?RSize置0,ROffset可以置下一个区段的Offset,因为这个区段不需要映射任何的数据到内存中,仅仅是通知加载器分配内存而已。或者说,这个区段就是“没有本体”(同naruto中自来也传递佩恩情报时留下的情报:)。
压缩函数封装
压缩函数这里介绍的是APlib库进行数据的压缩,aplib提供了一个压缩接口函数aP_pack,因为该函数使用之前需要调用aplib库进行初始化,这里参考计算机
病毒揭秘对抗一书中提供的函数:
代码:
//pSource压缩源,lInLength数据的大小,lOutLenght判断宏
Compress(PVOID pSource, long lInLength, OUT long ;lOutLenght)
{
//packed保存压缩数据的空间,workmem为完成压缩需要使用的空间
BYTE *packed, *workmem;
if ((packed = (BYTE *) malloc(aP_max_packed_size(lInLength))) == NULL ||
(workmem = (BYTE *) malloc(aP_workmem_size(lInLength))) == NULL)
{
return NULL;
}
//调用aP_pack压缩函数
lOutLenght = aP_pack(pSource, packed, lInLength, workmem, NULL, NULL);
if (lOutLenght == APLIB_ERROR)
{
return NULL;
}
if (NULL != workmem)
{
free(workmem);
workmem = NULL;
}
return packed;//返回保存地址
}
区段的处理
为了保存各个压缩区段的信息,可以定义一个结构体,保存压缩区段信息
typedef struct _CompessSection
{
D
word VA;
D
word CompessVA;
D
word CompessSize;//
LPVOID lpCompessData;//
}CompessSection, *PCompessSection;
首先,要提取出不能压缩的区段,然后根据之前压缩壳的构建,确定要压缩区段写入的地址,应该是紧接在占位区间之后。这里全局变量pMemPointer为之前获得的载入内存基址。
//PeSectionHeader此时指向占位区段最后一个节首地址
D
word LastSecRva = PeSectionHeader.VirtualAddress;
//最后一个节经过内存对齐后的大小
D
word LastSecSize = AlignmentNum(pPeSectionHeader[m_iSecNum-1].Misc.VirtualSize,
MemAlignment);
//获得区段压缩后保存的地址
D
word CompressRva = LastSecRva + LastSecSize;
//获取要压缩区段数
D
word CompressSecNum = SecNum;
//如果存在资源区段,则不压缩这个区段,被压缩区段数减1
if (IsRESOURCE == TURE)
{
CompressSecNum --;
}
//配置压缩信息
m_pComSec = new CompessSection[CompressSecNum];
int iPos = 0;
int j = 0;
for (unsigned int i = 0; i < SecNum; i++)
{
//跳过资源目录和重定位目录
if (i == 2 )
{
iPos++;
continue;
}
else
{
if (mPeSectionHeader[iPos].SizeOfRawData == 0)
{ //空节不压缩
iPos++;
continue;
}
long lCompressSize = 0;
PVOID pCompressData;
PVOID pInData = (BYTE *)pMemPointer + PeSectionHeader[iPos].VirtualAddress;
//调用Compress函数
pCompressData = Compress(pInData,
PeSectionHeader[iPos].Misc.VirtualSize,
lCompressSize);
//压缩数据指针
m_pComSec[j].lpCompessData = pCompressData;
//解压后内存地址
m_pComSec[j].VA = PeSectionHeader[iPos].VirtualAddress;
//压缩数据加载后的内存地址
m_pComSec[j].CompessVA = CompressRva;
//压缩数据大小
m_pComSec[j].CompessSize = CompressSize;
//下一个节压缩数据加载的地址
iCompressRva += AlignmentNum(CompressSize, MemAlignment);
iPos++;
j++;
}
}
}
构建外壳的导入表
因为压缩后的pe文件相对于之前的pe文件完全是一个全新的文件,所以,构造压缩后的pe时候,只要构造外壳代码调用函数时使用的导入表。前面提到过,模拟loade装入pe的时候,要利用壳提供的导入函数LoadLibraryA载入name指定的模块,和通过GetProcAddress搜索FristThunk指向的函数名来定位需要导入函数的实际地址。然后,再填充IAT以后,需要将原始PE头中的相关数据改回来,而原始载入pe内存是不可写的,这样,需要VirtualProtect来更改载入pe头所在内存地址的属性。这个三个函数都是kernel32.dll导出。这样,就完成一个新的导入表。在生成压缩PE之时,填入已经构造好的导入表地址。
kernel32.dll对应的IMAGE_IMPORT_DESCRIPTOR:
代码:
D
word OriginalFirstThunk //预留
D
word TimeDateStamp //预留
D
word ForwarderChain //预留
D
word Name1 //kernel32.dll
D
word FirstThunk //指向LoadLibraryA,GetProcAddress和VirtualProtect三个函数地址RVA
FirstThunk这个RVA地址可以在构造完成之后,固定下来。必要的三个函数已经确定,那么整个导入表结构不会有的变动了。在填充的时候,我们按照MS给导入表的定义,组织一下要填充数据的格式:
代码:
IAT:D
word(LoadLibraryA) D
word (GetProcAddress) D
word (VirtualProtect) D
word(置0)
IID:sizeof(IMAGE_IMPORT_DESCRIPTOR)*2
INT:D
word*4(同IAT结构)
string:sizeof(“LoadLibraryA”+“GetProcAddress”+“VirtualProtect”+3*2+“kernel32.dll”)//每一个函数包含
word大小的函数序号结构,所有要加3*2个字节
按照这个结构,直接构造好新导入表,将返回操作的起始内存地址保存即可。
原始PE各项信息的设置
外壳代码始终要将原始pe进行还原,就要按照下面保存的信息,在外壳代码正确解压PE载入内存后,设置回来,修改完毕之后,跳到原始入口点即可。有几个数据是需要进行保存的
1.各个节解压前保存的位置
2.各个节解压后在内存中的位置
3.原始导入表地址
4.原始重定位目录表
5.原始资源目录表
6.原始加载基址
7.原始入口点
区段的融合
按照上述方法压缩后的文件,区段会比原始文件多。那么我们可以将已经压缩的区段统一的融合到一起,将区段按照,可压缩区段、不能压缩区段,外壳代码区段分开来。这里的融合,不能够移动已经压缩好区段了,因为之前的所有地址已经保存,后期外壳的装载都要通过这些地址进行定位,移动会引起偏移的错误。这里的融合仅仅是告诉系统,压缩后的pe区段数要减少而已。表面上区段数确实“减少了”,实际上,对于装载程序定位地址来说,并没有实质的改变。
可以将第一个压缩区段的虚拟地址和磁盘地址作为所可压缩区段的虚拟地址和磁盘地址,并将所有的可压缩区段的虚拟大小相加,磁盘大小相加,分别作为可压缩区段的虚拟大小和磁盘大小。修改节表头的相关节名,删除多余的节表头即可。加密与解密三中15章处理区段有详细代码,可以参考。至此,所有在内存加载的数据已经全部填充完毕了,现在只要依次将载入内存中的数据,保存到磁盘中即可。
外壳loader处理
外壳loader要面对的就是已经压缩好的pe文件,它的任务就是一边解压pe文件,同时模拟系统laoder加载PE到内存中去,完成任务之后将保存的原始PE信息写回到已经加载内存中的pe文件中去,跳到原始oep执行。
这里外壳代码段可以用c来实现的,然后通过反汇编二进制拷贝到外壳代码区段中。这里外壳代码主要是处理2个地方:
1.恢复原始导入表数据,传递资源目录RESOURCE_VA,linxer版主给出过详细源码:
代码:
IMAGE_IMPORT_DESCRIPTOR *pIID =(PIMAGE_IMPORT_DESCRIPTOR)RESOURCE_VA;
for(; pIID->Name != NULL; pIID++)
{
//直接定位FirstThunk即可
IMAGE_THUNK_DATA *pITD = (IMAGE_THUNK_DATA *)(pMemPointer + pIID->FirstThunk);
//调用LoadLibraryA载入DLL
HINSTANCE hInstance = LoadLibraryA(pMemPointer + pIID->Name);
for(; pITD->u1.Ordinal != 0; pITD++)
{
FARPROC fpFun;
if(pITD->u1.Ordinal ; IMAGE_ORDINAL_FLAG32)
{
///函数是以序号的方式导入的
fpFun = GetProcAddress(hInstance, (LPCSTR)(pITD->u1.Ordinal ; 0x0000ffff));
}
else
{ //函数是以名称方式导入的
IMAGE_IMPORT_BY_NAME * pIIBN = (IMAGE_IMPORT_BY_NAME *)(pITD->u1.Ordinal);
fpFun = GetProcAddress(hInstance, (LPCSTR)pIIBN->Name);
}
if(fpFun == NULL)
{
return false;
}
pITD->u1.Ordinal = (long)fpFun;
}
}
}
2.重定位代码的修复,
要修复重定位,我们应该知道实际载入地址,期待载入地址,和修复数据,后两者都定位在PE中了,那么我们进行修复工作,还需要知道实际载入地址。获得实际载入地址,通过栈的机制。
代码:
pushad //保存环境
call Getaddr
Getaddr:
pop eax //当前代码地址就保持在eax中了,以后的寻址可以以此为标杆地址
通过eax作为的标杆地址,又知道dll加载地址为入口代码最顶层。例如执行了pushad,esp增长32字节,未增长前,模块地址是[esp+4], 现在应该是[esp+24]。以此可以获得代码运行的实际地址保存在[esp+24],将其通过dwInfactBase存储,知道了实际载入地址,传递重定位目录参数pRelocateTable下面就可以进行修复工作了。
代码:
while(pRelocateTable->VirtualAddress != NULL)
{ //定位到数据处
parrOffset = (
word*)((PBYTE)pRelocateTable + sizeof(IMAGE_BASE_RELOCATION));
//重定位数据个数,每一个数据是
word类型
dwRvaCount = (pRelocateTable->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / 2;
for(D
word i=0; i<dwRvaCount; i++)
{
dwRva = parrOffset;
//最高位nType != 3,不作处理,因为PE常见3或者0的类型
if((dwRva ; 0xf000) != 0x3000)
{
continue;
}
dwRva ;= 0x0fff;
dwRva = (D
word)(dwInfactBase + pRelocateTable->VirtualAddress + dwRva);
*(D
word*)dwRva += dwRelocOffset;
}
//指向下一个重定位块
pRelocateTable = (PIMAGE_BASE_RELOCATION)((PBYTE)pRelocateTable + pRelocateTable->SizeOfBlock);
}
当所有的loader模拟工作完成之后,在内存呈现的PE布局应该就如下图所示:
处理完之后恢复堆栈平衡,通过之前保存的原始文件的OEP值,通过一个jmp OEP即跳到原始文件入口点。至此,一个简单压缩壳就完成了它的使命:)
后记:本文形成参考了很多
书籍、大牛的代码和实例以及很多开源壳的代码,感谢前辈无私的奉献。上述成文都是自己学习压缩壳的一些心得笔记和体会。小弟水平有限,很多细节没有涉及到,而这也恰恰是完成一个压缩壳重要的因素,所有推荐想了解的朋友多读几遍开源壳源码,可能会收获更多:)上文肯定有不足和错误之处,没有什么
技术含量,希望各位能多多的指点。最后,很感谢
大家耐心的看完: