写在前面
编写加壳软件对于熟悉PE文件格式是再好不过的了方法了,这两天脱了一些简单地壳所以想尝试自己写一个加壳程序。此加壳程序非常简单,就是对代码段压缩,修改输入表和重定位表。反调试,反dump等等都没加,但有了基本思路后后面就可以进一步任意发挥了。在写此加壳程序时有一些小麻烦和一些值得思考的地方,在这里把这些问题和加壳思路与大家分享一下。
加壳程序编写思路
此加壳程序通过为PE文件添加一个新的区块,将PE文件的代码段入口点指向此区块。并在此区块中进行代码段的解压缩和输入表的还原以及重定位数据的处理,当然我们如果想加入反调试等技巧也可以在此区块中完成。最后我们在跳回原代码段的入口点处。
下面我们就需要程序来实现将上图左边的PE文件变为上图右边的PE文件,这样就实现了文件的加壳。程序编写的基本思路如下:
- 将待加壳的PE文件的PE头和各个区段一起读入内存
- 将待加壳的PE文件的附加数据读入内存
- 修改待加壳的PE文件的输入表
- 修改待加壳的PE文件的重定位表
- 压缩待加壳的PE文件的代码段
- 构建新区块
- 将内存中修改后PE文件以及构建的新区块和附加数据按照PE文件的格式合并成新的PE文件
- 创建文件并写入文件,PE文件加壳完成
下面就是各个步骤中需要注意的一些问题。
将待加壳的PE文件的PE头和各个区段一起读入内存
因为将PE文件加载到内存中我们需要处理他们,而PE文件中的一些数据大部分都是以RVA的方式查询的。为了在后面的操作中能够方便处理,我们需要以windows加载器的形式将PE文件读入一块连续的内存中(对齐粒度为0x1000)并将读入内存的基地址保存以供后续操作。
在编码时还要注意有的区块在文件中大小为0,只有在PE文件映射到内存中时才会分配内存。还要注意未初始化数据所在的区块,此区块一般在实际大小大于在PE文件中对其后的大小。
将待加壳的PE文件的附加数据读入内存
附加数据在PE文件的尾部,即最后一个区块的后面。因为PE文件在映射到内存中时并不会将附加数据映射到内存,而有的程序又会在程序中使用附加数据。所以我们需要将附加数据读到内存中,在重组PE文件时将附加数据放在PE文件的尾部,即新加区块的后面。
修改待加壳的PE文件的输入表
为了防止破解者在脱壳时轻易得到输入表我们需要将输入表破坏,并将输入表的信息保存到新区块中以供外壳代码恢复IAT表。
原输入表的IID的结构如下:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
此结构中TimeDateStamp与ForwarderChain一般是不使用的,所以我们将IID破坏后将其余三个字段保存形成自己的IID结构。重组PE文件时把我们保存的自己的IID结构放在新区块中指定的位置供新区块修复IAT表使用,当然你也可以形成自己的IID结构。
//新IID结构
typedef struct NEW_IID
{
DWORD FirstThunk;
DWORD Name;
DWORD OriginalFirstThunk;
};
我们将原IID的关键信息保存后,需要将原IID破坏。
pNewIID = (NEW_IID* )stImportTable.dwAddress; //指向新IID结构
while (pOldImportTable->Name != 0)
{
pNewIID->FirstThunk = pOldImportTable->FirstThunk; //创建新的IID结构
pNewIID->Name = pOldImportTable->Name;
pNewIID->OriginalFirstThunk = pOldImportTable->OriginalFirstThunk;
pOldImportTable->FirstThunk = 0; //破坏旧的IID结构
pOldImportTable->OriginalFirstThunk = 0;
pOldImportTable->Name = 0;
pOldImportTable->ForwarderChain = 0;
pOldImportTable->TimeDateStamp = 0;
pNewIID = (NEW_IID*)((BYTE*)pNewIID + sizeof(NEW_IID));
pOldImportTable = (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)pOldImportTable + sizeof(IMAGE_IMPORT_DESCRIPTOR));
}
修改待加壳的PE文件的重定位表
无论是EXE文件还是DLL文件都可能会重定位信息,我们和处理输入表一样将原重定位表破坏,然后将重定位信息在重组PE文件时将其保存到新区块中以供外壳代码修复重定位数据。
我的做法是将所有需要重定位地址的RVA以DWORD保存,然后将原重定位表清空。这里要注意一定不要忘记将PE文件头中数据目录表关于重定位表的信息清空,如果不清空的话加壳后的PE在被windows加载器映射到内存中时会根据此项进行重定位,有可能会破坏程序中的某些数据。
//指向重定位表
dwRelocateAddress = (PIMAGE_BASE_RELOCATION)(dwAddress + ((PIMAGE_DATA_DIRECTORY)(((PIMAGE_NT_HEADERS)((dwAddress + ((PIMAGE_DOS_HEADER)dwAddress)->e_lfanew)))->OptionalHeader.DataDirectory))[5].VirtualAddress);
while (dwRelocateAddress->SizeOfBlock != 0) //循环遍历重定位表的各个块
{
for (int i = 0; i < dwRelocateAddress->SizeOfBlock - 8; i = i + 2)
{
if (0 != *((WORD*)((BYTE*)dwRelocateAddress + 8 + i))) //如果是重定位数据,而不是为了对齐填充的数据就将其保存
{
(*(DWORD*)dwFindAddress) = *(DWORD*)dwRelocateAddress + WORD(WORD((*((WORD*)((BYTE*)dwRelocateAddress + 8 + i))) << 4) >> 4); //写入新的重定位表
*((WORD*)((BYTE*)dwRelocateAddress + 8 + i)) = 0; //清除原重定位表
dwFindAddress = (DWORD)((BYTE*)dwFindAddress + 4);
}
}
dwRelocateAddress = (PIMAGE_BASE_RELOCATION)((BYTE*)dwRelocateAddress + dwRelocateAddress->SizeOfBlock);
}
压缩待加壳的PE文件的代码段
除了压缩代码段还可以将其他区块压缩,但注意有的区块的数据不能压缩(资源块)。压缩是用的APLIB库,通过函数aP_pack()压缩代码段。保存压缩前代码段的大小和压缩后代码段的大小,在重组PE文件时保存到最后一个区块中以供外壳代码解压缩使用。
构建新区块
将上面保存的一些数据(新的IID表和重定位信息等)和外壳代码组成一个新的区块。
这里说一下外壳代码,外壳代码我使用c++裸函数实现的。也就是说我们需要将函数_ShellProc()中的数据保存到新区块中。
#pragma comment(linker, "/INCREMENTAL:NO")
#pragma auto_inline(off)
__declspec(naked)
void _stdcall _ShellProc()
{
//外壳代码
}
void _stdcall _ShellProcEnd()
{
}
#pragma auto_inline(on)
我构建的新区块的组成结构如下图所示,当然也可以采用其他结构。注意新区块的数据部分和代码部分都是以0x200为对齐粒度。
NEW_DATA是我们自己定义的一个结构。
typedef struct NEW_DATA{
TCHAR szSectionName[0x10]; //新区块的名称
TCHAR szDllName[0x10]; //IID指向的DLL的名称
IMAGE_IMPORT_DESCRIPTOR stIID[2]; //加壳程序的IID
IMAGE_THUNK_DATA stINT[3]; //加壳程序的INT
BYTE bProcName1[0x10]; //GetProcAddress
BYTE bProcName2[0x10]; //LoadLibrary
IMAGE_THUNK_DATA stIAT[3]; //加壳程序的IAT
DWORD dwIIDAddress; //新IID相对于最后一个区块的偏移
DWORD dwIIDSize; //新IID的大小
DWORD dwRelocationAddress; //新重定位表相对于最后一个区块的偏移
DWORD dwRelocationSize; //新重定位表的大小
DWORD dwOldEP; //原程序旧的入口点
DWORD dwImageBase; //默认加载基址
DWORD dwPackCodeSize; //压缩后代码段的大小
};
此结构是方便我们外壳代码还原原程序以及构建新的输入表使用的,因为我们在外壳代码中需要使用最基本的两个函数为GetProcAddress()和LoadLibreryA()以此来调用其他API函数,所以我么要构建新的输入表就是包含这两个函数。
RtlZeroMemory(&stNewData, sizeof(NEW_DATA));
lstrcpy(stNewData.szSectionName, TEXT(".pack")); //新区块名为.pack
lstrcpy(stNewData.szDllName, TEXT("kernel32.dll")); //新IID的DLL名称
stNewData.stIID[0].OriginalFirstThunk = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x20 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2;
stNewData.stIID[0].TimeDateStamp = 0;
stNewData.stIID[0].ForwarderChain = 0;
stNewData.stIID[0].Name = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x10;
stNewData.stIID[0].FirstThunk = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x40 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2 + sizeof(IMAGE_THUNK_DATA) * 3;
stNewData.stINT[0].u1.AddressOfData = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x20 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2 + sizeof(IMAGE_THUNK_DATA) * 3;
stNewData.stINT[1].u1.AddressOfData = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x30 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2 + sizeof(IMAGE_THUNK_DATA) * 3;
lstrcpy((LPSTR)(stNewData.bProcName1 + 2), TEXT("GetProcAddress"));
lstrcpy((LPSTR)(stNewData.bProcName2 + 2), TEXT("LoadLibraryA"));
stNewData.stIAT[0].u1.AddressOfData = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x20 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2 + sizeof(IMAGE_THUNK_DATA) * 3;
stNewData.stIAT[1].u1.AddressOfData = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x30 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2 + sizeof(IMAGE_THUNK_DATA) * 3;
stNewData.dwIIDAddress = sizeof(NEW_DATA) + 4;
stNewData.dwIIDSize = stImportTable.dwSize;
stNewData.dwRelocationAddress = stImportTable.dwSize + sizeof(NEW_DATA) + 8;
stNewData.dwRelocationSize = stRelocation.dwSize;
stNewData.dwOldEP = *((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x28));
stNewData.dwImageBase = *((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x34));
stNewData.dwPackCodeSize = dwPackCodeSize;
将修改后的PE文件和新区块和附加数据合并成新的PE文件并写入文件
我们需要将PE文件头部的一些信息修改一下。需要新增一个区块,修改程序代码入口点为外壳代码入口点,修改数据目录表中输入表和输入地址表的地址和大小,还要将数据目录表中对应的重定位表的地质和大小置零,最后修改PE文件的映射大小。
//修改PE文件头信息
lstrcpy((LPSTR)pSection->Name, ((NEW_DATA*)stSection.dwAddress)->szSectionName);
pSection->Misc.VirtualSize = stSection.dwSize;
pSection->VirtualAddress = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50)));
pSection->SizeOfRawData = stSection.dwSize;
pSection->PointerToRawData = dwMaxSectionAddress;
pSection->Characteristics = 0xE0000020;
*((WORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x6)) = *((WORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x6)) + 1; //增加区块的数量
(*(DWORD*)(dwAddress + *((DWORD*)(dwAddress + 0x3c)) + 0x28)) = pSection->VirtualAddress + stSection.dwDataSize; //修改代码入口点
((PIMAGE_NT_HEADERS)(dwAddress + *((DWORD*)(dwAddress + 0x3c))))->OptionalHeader.DataDirectory[1].VirtualAddress = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x20; //修改输入表的地址
((PIMAGE_NT_HEADERS)(dwAddress + *((DWORD*)(dwAddress + 0x3c))))->OptionalHeader.DataDirectory[1].Size = sizeof(IMAGE_IMPORT_DESCRIPTOR); //修改输入表的大小
((PIMAGE_NT_HEADERS)(dwAddress + *((DWORD*)(dwAddress + 0x3c))))->OptionalHeader.DataDirectory[12].VirtualAddress = (*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) + 0x40 + sizeof(IMAGE_IMPORT_DESCRIPTOR) * 2 + sizeof(IMAGE_THUNK_DATA) * 3; //修改输入地址表的地址
((PIMAGE_NT_HEADERS)(dwAddress + *((DWORD*)(dwAddress + 0x3c))))->OptionalHeader.DataDirectory[12].Size = sizeof(IMAGE_THUNK_DATA) * 3; //修改输入地址表的大小
((PIMAGE_NT_HEADERS)(dwAddress + *((DWORD*)(dwAddress + 0x3c))))->OptionalHeader.DataDirectory[5].Size = 0; //修改重定位表的地址为0
((PIMAGE_NT_HEADERS)(dwAddress + *((DWORD*)(dwAddress + 0x3c))))->OptionalHeader.DataDirectory[5].VirtualAddress = 0; //修改重定位表的大小为0
if (pSection->SizeOfRawData % 0x1000 != 0)
(*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) = pSection->VirtualAddress + ((pSection->SizeOfRawData / 0x1000 + 1) * 0x1000); //修改映射大小
else
(*((DWORD*)((dwAddress + *((DWORD*)(dwAddress + 0x3c))) + 0x50))) = pSection->VirtualAddress + ((pSection->SizeOfRawData / 0x1000) * 0x1000);
外壳代码的编写
一般外壳代码编写有一下两种方法
- 外壳代码编写可以用纯汇编编写,
- 也可以用C++利用内嵌汇编然后编写DLL,之后将DLL中的代码段和数据段一起保存到新区块中
用汇编写外壳代码虽然灵活但是麻烦,用第二种也不是很简单。我用的是裸函数结合c++内嵌汇编的特点,其实相当于第一种方法但是编写的时候可以用c++写,而编译器帮我们将其变为了对应的汇编指令,但是注意因为是裸函数所以我们需要自己来构建函数的栈空间和局部变量。不能采用字符串常量和全局变量以及任何不存在栈中的数据。而且对于DLL的加壳和EXE的加壳外壳代码会有一些不同,原因是DLL的入口点是会被多次调用的而EXE的入口点只会被调用一次。
EXE加壳的外壳代码
外壳代码一开始要做一些初始化工作,保存环境并且构建一个足够大的工作栈空间
_asm{
start: push eax //为了方便跳到程序入口点
pushad //保存环境
mov ebp, esp
sub esp, 0x400; //创建工作栈空间
}
结合需要创建一些需要用到的一些局部变量并将其初始化。注意那些字符串变量不能用字符串常量初始化,因为字符串常量在静态存储区不在栈中,我们可以用字符一个一个初始化。
TCHAR szVirtualFree[0x10];
szVirtualFree[0] = 'V';
szVirtualFree[1] = 'i';
szVirtualFree[2] = 'r';
szVirtualFree[3] = 't';
szVirtualFree[4] = 'u';
szVirtualFree[5] = 'a';
szVirtualFree[6] = 'l';
szVirtualFree[7] = 'F';
szVirtualFree[8] = 'r';
szVirtualFree[9] = 'e';
szVirtualFree[10] = 'e';
szVirtualFree[11] = 0;
DWORD dwImageBaseAddress; //被加壳程序加载基地址
DWORD dwPackCodeAddress; //压缩代码段数据的地址
DWORD dwPackCodeSize; //压缩代码段数据的大小
DWORD dwUpackCodeAddress; //解压缩代码数据的地址
DWORD dwUpackCodeSize; //解压缩代码数据的大小
DWORD dwOldVirtualProtect; //代码段内存旧的属性
DWORD dwNewIID; //指向新的IID表
DWORD dwDllName; //指向IID的DLL名称
DWORD dwDllINT; //指向INT表
DWORD dwProcName; //指向INT的函数名称
DWORD dwProcAddress; //函数的地址
DWORD dwRelocAddress; //指向新重定位表的地址
DWORD dwDefultImageAddress; //默认的加载基址
DWORD dwIAT; //指向IAT表
DWORD dwProtect; //用来保存IAT表原来的内存属性
DWORD dwOldEntry; //程序真正的入口点
DWORD dwLoadDllAddress; //加载DLL的基地址
DWORD dwProcNum; //导入函数的序数值
DWORD dwProcIndex; //函数在DLL中的索引
PIMAGE_SECTION_HEADER pSection; //区块表指针
//各个需要用到的函数的指针
pfnMyGetProcAddress MyGetProcAddress;
pfnMyLoadLibraryA MyLoadLibraryA;
pfnMyVirtualAlloc MyVirtualAlloc;
pfnMyVirtualProtect MyVirtualProtect;
pfnMylstrcmpA MylstrcmpA;
pfnMymemset Mymemset;
pfnMymemcpy Mymemcpy;
pfnMyVirtualFree MyVirtualFree;
我们需要获得程序加载的基地址,然后进一步获得GetProcAddress()和LoadLibraryA()函数的地址。对于EXE程序来说我们可以通过PEB结构的ImageBaseAddress字段获得。
_asm
{
mov eax,fs:[0x30] //PEB地址
mov eax,dword ptr [eax + 0x8] //获得程序加载基地址
mov dwImageBaseAddress,eax
}
然后对代码段解压缩,接着修正重定位信息和填充IAT表。其中代码段解压的函数需要将aP_depack()函数的汇编代码通过内嵌汇编保存到外壳代码中。做完这些我们就可以跳转到原程序的OEP中了。
//平衡堆栈,跳到OEP
_asm
{
mov esp,ebp
mov eax,dwOldEntry //dwOldEntry为原代码入口点
mov dword ptr[esp + 0x20],eax
popad
pop eax
jmp eax
}
DLL加壳的外壳代码
DLL加壳的外壳代码大部分都和EXE一样。只不过其需要解决入口点需要被多次调用的问题,以及其获得程序代码入口点的方法和EXE不同。
解决其入口点被多次调用,一般DLL入口点的函数为BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved )
,当入口点被第一次调用时ul_reason_for_call值为1,所以我们可以在入口处加上判断ul_reason_for_call参数值的代码。
_asm
{
mov eax,[esp + 4]
add ebx,[eax + 0x3c]
add ebx,eax
mov ebx,[ebx + 0x28]
add eax,ebx //获得原程序的入口点代码
mov ebx,[esp + 8]
cmp ebx,1 //判断ul_reason_for_call值是否为1
je start
jmp eax //直接跳到原程序入口点
start: push eax //为了方便跳到程序入口点
pushad //保存环境
mov ebp, esp
sub esp, 0x400; //创建工作栈空间
}
在第一调用外壳代码最后需要将PE中的程序入口RVA还原,使得在以后调用外壳代码时上方代码可以得到原程序入口点
MyVirtualProtect((LPVOID)((dwImageBaseAddress + *((DWORD*)(dwImageBaseAddress + 0x3c))) + 0x28), 4, PAGE_READWRITE, &dwProtect); //修改属性
(*((DWORD*)((dwImageBaseAddress + *((DWORD*)(dwImageBaseAddress + 0x3c))) + 0x28))) = dwOldEntry - dwImageBaseAddress; //对于DLL而言修改入口点
MyVirtualProtect((LPVOID)((dwImageBaseAddress + *((DWORD*)(dwImageBaseAddress + 0x3c))) + 0x28), 4, dwProtect, &dwProtect);
DLL外壳代码不能通过PEB获得程序加载基址,因为PEB的ImageBaseAddress字段是其exe对应的加载基地址。DLL通过入口函数BOOL APIENTRY DllMain( HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved )
的参数hModule就是其加载基地址。
总结
此加壳程序没有什么高端技术,但是可以在此基础上添加其他代码,这些就是我编写此加壳器所遇到的一些问题和思路。写一个加壳器对PE文件的格式也能有进一步的认识,而且对于脱壳也能有很大的帮助,进一步了解壳的结构和基本工作原理。附上源代码供大家学习