最近潜心研究二进制安全,接触到了shellcode还有加壳脱壳有关的内容,于是心血来潮,想用自己不怎么成熟的编程功夫来实现写一个加壳器,并记录下代码编写过程中遇到的坑。
(以下文章中区段==节区)
pe(Portable Executable)其实就是在windows系统上的程序文件,这种文件格式在windows系列操作系统上基本上是通用的,我们平时见到的.exe、.dll、.obj、.sys都是PE文件
PE文件格式大致如下
其中第一个是DOS头,DOS头中声明用的寄存器
结构体
struct _IMAGE_DOS_HEADER{
0X00 WORD e_magic; //标记是否是可执行文件,我们常见的exe程序这一字段标识就是"MZ"
0X02 WORD e_cblp; //Bytes on last page of file
0X04 WORD e_cp; //Pages in file
0X06 WORD e_crlc; //Relocations
0X08 WORD e_cparhdr; //Size of header in paragraphs
0X0A WORD e_minalloc; //Minimun extra paragraphs needs
0X0C WORD e_maxalloc; //Maximun extra paragraphs needs
0X0E WORD e_ss; //intial(relative)SS value
0X10 WORD e_sp; //intial SP value
0X12 WORD e_csum; //Checksum
0X14 WORD e_ip; //intial IP value
0X16 WORD e_cs; //intial(relative)CS value
0X18 WORD e_lfarlc; //File Address of relocation table
0X1A WORD e_ovno; //Overlay number
0x1C WORD e_res[4]; //Reserved words
0x24 WORD e_oemid; //OEM identifier(for e_oeminfo)
0x26 WORD e_oeminfo; //OEM information;e_oemid specific
0x28 WORD e_res2[10]; //Reserved words
0x3C DWORD e_lfanew; //PE头相对于文件的偏移量
};
其中有中文注释的结构体成员是比较重要,也是我们之后要用到的结构体成员
紧跟在DOS头后面的一段是实模式的残余程序,这一段主要是为了兼容16位的DOS系统,现在已经没有太大用处,暂且忽略
实模式残余程序之后就是PE头也可以叫做NT头
结构体IMAGE_NT_HEADERS
PE头中又包括三部分:
IMAGE_NT_HEADERS
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; //PE标识
IMAGE_FILE_HEADER FileHeader; //文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader;//扩展头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
IMAGE_FILE_HEADER
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
/*机器型号.表名了我们CPU执行的这个PE文件是x86的还是x64的.
有一系列宏标识.*/
WORD NumberOfSections;
/*节表个数. 此成员很重要.标识着我们的节表有多少个.
如果节个数小于节的总数那么程序就不能运行*/
DWORD TimeDateStamp;
//文件时间.不重要.与文件属性里面的创建事件修改时间无关.编译器填写的
DWORD PointerToSymbolTable;
//调试器相关
DWORD NumberOfSymbols;
//调试器相关.
WORD SizeOfOptionalHeader;
//扩展PE头大小,此成员很重要.表明了我们的扩展头总体大小.
WORD Characteristics;
//文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
IMAGE_OPTIONAL_HEADER
typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields.
//
WORD Magic;
//标志.表名了我们的PE是x86还是x64
BYTE MajorLinkerVersion;
//连接器主要版本号
BYTE MinorLinkerVersion;
//连接器次要版本号 例如 3.54 主要版本就是3.次要就是54
DWORD SizeOfCode;
//代码段大小,以字节为单位.
DWORD SizeOfInitializedData;
//初始化数据部分的大小.
DWORD SizeOfUninitializedData;
//未知初始化数据的大小
DWORD AddressOfEntryPoint;
/*OEP 程序入口点,驱动程序也是入口点.
对于DLL而言.是可选的.没有入口则为0*/
DWORD BaseOfCode;
//指向代码部分的指针
DWORD BaseOfData;
//指向数据部分开头的指针
//
// NT additional fields.
//
DWORD ImageBase;
/*基址.PE文件加载到内存中的基址.这个值是64k的倍数.
DLL默认值是0x100000000,应用程序默认是0x00400000*/
windows CE除外.他是0x00010000
DWORD SectionAlignment;
//PE文件加载到内存中.的内存对齐.按照这个成员进行对齐
DWORD FileAlignment;
//文件对齐,PE存数据存放在文件中.按照文件对其值对其
WORD MajorOperatingSystemVersion;
//所需要操作系统的主要版本号.
WORD MinorOperatingSystemVersion;
//所需要操作系统的次要版本号.
WORD MajorImageVersion;
//PE主版本号
WORD MinorImageVersion;
//PE次版本号
WORD MajorSubsystemVersion;
//子系统主要版本号.
WORD MinorSubsystemVersion;
//子系统次要版本号.
DWORD Win32VersionValue;
//保留成员,必须为0
DWORD SizeOfImage;
/*PE镜像大小.
必须是内存对齐的倍数. sizeofImage/SectionAllignment == 0 才可以*/
DWORD SizeOfHeaders;
/* DOS头+NT头+节表的总大小.
按照文件对齐存放 sizeofHeaders / FileAlignment == 0*/
DWORD SubSystem
//表名PE文件是什么程序. 1驱动程序2窗口程序3控制台程序(DLL)
DWORD CheckSum;
WORD DllCharacteristics; //P的文件属性
DWORD SizeOfStackReserve;
/*堆栈保留字节数.我们的程序使用的栈空间多大靠这个成员.
不过操作系统只作为参考*/
DWORD SizeOfStackCommit; //要为堆栈提交的字节数.不做参考
DWORD SizeOfHeapReserve; //堆保留字节数.
DWORD SizeOfHeapCommit;
/*本地堆提交的字节数.
PS: 栈堆保留数值.斗鱼自己的sizeof(Head/stack)Commit成员有关.*/
DWORD LoaderFlags;
//成员已经过时
DWORD NumberOfRvaAndSizes;
//数据目录数组的大小
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
//数据目录
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;
PE头之后就是各节表,我们想要实现程序的加壳,就是要在原有的节区的基础上再添加一个新的节区,然后将程序的入口点重定到我们的新节区去(原本在.text节区)然后在我们的新节区内对原有的text节区进行解密或者解压缩的操作,最后将程序的控制权在交还给text节区。
节区头结构体IMAGE_SECTION_HEADER
typedef struct _IMAGE_SECTION_HEADER
{
+0h BYTE Name[IMAGE_SIZEOF_SHORT_NAME];
// 节表名称,如“.text”
//IMAGE_SIZEOF_SHORT_NAME=8
union
+8h {
DWORD PhysicalAddress;
// 物理地址
DWORD VirtualSize;
// 真实长度,这两个值是一个联合结构,可以使用其中的任何一个,一
// 般是取后一个
} Misc;
+ch DWORD VirtualAddress;
// 节区的 RVA 地址
+10h DWORD SizeOfRawData;
// 在文件中对齐后的尺寸
+14h DWORD PointerToRawData;
// 在文件中的偏移量
+18h DWORD PointerToRelocations;
// 在OBJ文件中使用,重定位的偏移
+1ch DWORD PointerToLinenumbers;
// 行号表的偏移(供调试使用地)
+1eh WORD NumberOfRelocations;
// 在OBJ文件中使用,重定位项数目
+20h WORD NumberOfLinenumbers;
// 行号表中行号的数目
+24h DWORD Characteristics;
// 节属性如可读,可写,可执行等
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
下面就来先实现程序加壳的第一步,给一个程序添加一个新的区段,我在这里写了一个简单的小程序入口函数为main函数,在main函数中调用MessageBox函数实现一个弹窗,程序名ect1.exe,运行效果
点击确认后程序退出,下面就来给这个程序加一个节区,先用010Editor打开他看他的二进制数据,大致如下
我们可以看到他有五个节区,分别是.text、.data、.rdata、.rsrc、.reloc。我们再来看看这五个节区节区表的二进制数据
选中的这一块就是节区表区,节区数据区因为占的空间比较大不方便截图,我就不截图了,他就在节区表后这一大片数据00后面,现在我们要添加一个新的节区,肯定要先添加新节区的节区表,这个截取表应该放在哪呢?没错!就是这一大片没人用的00数据区,现在知道了要在哪添加数据,我们就该准备写代码了,首先肯定是先要解析PE文件,来获取各个区段
获取DOS头
PIMAGE_DOS_HEADER PefileParsing::GetFile_Dosheader
(
_In_ BYTE* File_Data,
_Inout_ DWORD& back
)
{
PIMAGE_DOS_HEADER FH= (PIMAGE_DOS_HEADER)File_Data;
if (FH->e_magic != IMAGE_DOS_SIGNATURE)
{
back = __FILE_NO_PE;
return NULL;
}
back = __SUCCESS;
return FH;
}
在这里我创建了一个类来实现节区添加,其中File_Data就是我们读出来要进行加壳的程序文件数据,第二个参数是我用来给类的调用者反馈错误原因的参数,错误原因在我自定义的一个头文件中,我会将完整项目上传到我的git里,最后我会附上git地址
获取到DOS头后就要获取PE头了
PIMAGE_NT_HEADERS PefileParsing::GetFile_NTheader
(
_In_ BYTE* File_Data,
_Inout_ DWORD& back
)
{
//获取NT头,取到偏移后基址与相加=绝对地址
DWORD B = 0;
PIMAGE_NT_HEADERS NTH= (PIMAGE_NT_HEADERS)
((GetFile_Dosheader(File_Data,B)->e_lfanew)+(DWORD)File_Data);
if (B == __FILE_NO_PE)
{
back = __FILE_NO_PE;
return NULL;
}
if (NTH->Signature != IMAGE_NT_SIGNATURE)
{
back = __FILE_NO_NT;
return NULL;
}
back = __SUCCESS;
return NTH;
}
参数解释同获取DOS头,e_lfanew存放的是PE头的偏移地址,取到他就能很容易的获取PE头,获取到PE头后就来获取文件头与扩展头
文件头
PIMAGE_FILE_HEADER PefileParsing::GetFile_header
(
_In_ BYTE* File_Data,
_Inout_ DWORD& back
)
{
//获取文件头,FileHeader结构体成员类型是一个变量,
//需要引用后返回一个指针
DWORD U = 0;
PIMAGE_FILE_HEADER FFH = &GetFile_NTheader(File_Data,U)->FileHeader;
if (U == __FILE_NO_NT)
{
back = __FILE_NO_NT;
return NULL;
}
if (U == __FILE_NO_PE)
{
back = __FILE_NO_PE;
return NULL;
}
back = __SUCCESS;
return FFH;
}
扩展头
PIMAGE_OPTIONAL_HEADER PefileParsing::GetFile_Optheader
(
_In_ BYTE * File_Data,
_Inout_ DWORD& back
)
{
//获取扩展头,OptionalHeader;结构体成员类型是一个变量,
//要引用后返回一个指针
DWORD O = 0;
PIMAGE_OPTIONAL_HEADER OH = &GetFile_NTheader(File_Data,O)->OptionalHeader;
if (O == __FILE_NO_NT)
{
back = __FILE_NO_NT;
return NULL;
}
if (O == __FILE_NO_PE)
{
back = __FILE_NO_PE;
return NULL;
}
back = __SUCCESS;
return OH;
}
获取完我们需要的单元,我们还需要获取到最后一个节区表,因为我们要在他后面添加我们的新节区表
PIMAGE_SECTION_HEADER PefileParsing::GetLastSection
(
_In_ BYTE * File_Data,
_Inout_ DWORD& back
)
{
//获取最后一个区段以便添加新区段
//获取区段数
DWORD backNum = 0;
DWORD SecNum = GetFile_header(File_Data,backNum)->NumberOfSections;
if (SecNum == 0)
{
back = backNum;
return NULL;
}
//获取第一个区段地址
PIMAGE_SECTION_HEADER Fsec = IMAGE_FIRST_SECTION(GetFile_NTheader(File_Data,backNum));
//返回最后一个区段偏移(=第一个区段偏移+区段数-2)
//在这里获取最后一个区段的后一部分,并判断这其中是否存在数据
//如果区段数是5,那最后一个区段的下标即是0~4,而获取到的值会是6
PIMAGE_SECTION_HEADER s = Fsec + (SecNum - 1);
if ((s->Characteristics != 0) || (s->Name[0] != 0) || (s->SizeOfRawData != 0))
{
back = __LASTSECTION_NO_NULL;
return NULL;
}
back = __SUCCESS;
return s;
}
那块if判断我本来是想用来判断最后一个节区表后的空间是否能够让我们用来添加新节区表的,但明显有点逻辑错误,我后期会进行改正,但这个代码在对大部分可执行程序的时候都是有效的,至于如何判断最后一个节区表后能否添加新的节区表,就只需要判断后面数据为00的空间够不够0x28就行了
这样我们就获取到了最后一个节区表的后一块空白数据区,现在我们还需要知道添加新节区要更改或者增加那些成员值
首先我们要添加一个新的节区表,肯定要更新节区表个数
也就是这一成员值,他位于PE文件头结构体中,代码实现可为
GetFile_header(OldData,backNum)->NumberOfSections++;
z之后我们还需要给新节区表一个名字,就像.text那些区段一样,区段名可选
这以成员位于IMAGE_SECTIONAL_HEADER中,所以在赋名称前,先要调用刚刚我们定义的获取最后一个节区后空白数据区的那个方法函数先来获取一个IMAGE_SECTIONAL_HEADER结构体变量,获取到后直接
memcpy(nSection->Name, Section_name, 8);
在这里Section_name是我通过函数参数传进来的一个字符串值,最后的8代表此成员值最大不得超过8
现在我们还需要设置几个成员值(以下值都位于节区表结构体中):
在这里我们看到有些成员值需要进行对齐,所以我们还要提供一个用于对齐的函数
代码实现
SIZE_T PefileParsing::Section_Alignment
(
_In_ SIZE_T File_Size,
_In_ SIZE_T Alignment
)
{
//对齐区段
return ((File_Size%Alignment == 0) ?
File_Size : File_Size / (Alignment - 1)*Alignment);
}
很简单一段代码,不用解释什么
设置成员值
//设置新区段头物理大小与区段对齐后大小
nSection->Misc.VirtualSize = (DWORD)Section_size;
nSection->SizeOfRawData =
Section_Alignment(Section_size, OptHeader->FileAlignment);
//区段内存偏移=上一个区段的偏移+上一个区段对齐后的大小还要加0x1000
//否则虚拟地址与前一个区段的虚拟地址还是一样,会导致程序无法执行
nSection->VirtualAddress =
(nSection - 1)->VirtualAddress + Section_Alignment(
(nSection - 1)->SizeOfRawData, OptHeader->SectionAlignment)+0x1000;
//设置新区段数据存放位置
nSection->PointerToRawData =
(nSection - 1)->PointerToRawData + (nSection - 1)->SizeOfRawData;
//设置区段属性
nSection->Characteristics = 0xE00000E0;
r然后我们还需要设置扩展头的文件映像大小,来将原来的文件数据空间扩大,毕竟我们添加了新的数据内容旧的空间肯定是存不下的
PIMAGE_OPTIONAL_HEADER OptHeader=GetFile_Optheader(OldData,backNum);
//修改扩展头映像大小,旧的加上新的
OptHeader->SizeOfImage += nSection->SizeOfRawData;
在这里我们要加上对齐后的节区数据内存大小,而不是节区的实际大小
然后我们还要更新新的文件数据大小,新的文件数据肯定已经变得比老的文件数据大了
//修改文件数据大小,旧文件大小加上对齐后的区段大小
SIZE_T NewSize =
nSection->PointerToRawData + nSection->SizeOfRawData;
NewData = new BYTE[NewSize];
y因为老的读入内存的文件数据缓冲区大小以及远不够存放我们的区段数据了,所以我们需要申请一块新的足够大的内存存放老的数据,并在老数据之后的空白数据区填充数据(如果只有节区表没有节区数据的话,这个PE文件会是一个无法执行的无效PE文件)
//修改文件数据大小,旧文件大小加上对齐后的区段大小
SIZE_T NewSize =
nSection->PointerToRawData + nSection->SizeOfRawData;
//申请新的数据空间
NewData = new BYTE[NewSize];
memcpy(NewData, OldData, fsize);
//删除旧的缓冲区,更新文件大小,返回新的文件数据指针
delete[] OldData;
注意为了避免内存泄漏,记得把不用的旧的数据缓冲区释放
z最后更新文件数据大小,并将区段数据拷贝填充进去,并且将新申请的数据缓冲区指当作函数返回值返回
fsize = NewSize;
//这里模拟实现将壳代码数据拷贝进新区段数据区
//我在这其实只是用00来填充,并没有代码数据
memcpy((NewData + nSection->PointerToRawData), Section_data, Section_size);
return NewData;
记得最后在写一个函数释放文件数据缓冲区,防止内存泄漏!!!
void PefileParsing::ClearPEBuff(BYTE * File_Data)
{
delete[] File_Data;
}
再来看看添加完新节区的程序
可以看到新节区已经添加进去了
再运行一下添加了新节区的程序看看能否正常运行,能正常运行就说明没有问题了
运行成功,写完收工 !
github完整项目地址
参考文章:
加壳器编写