文章分为三大部分
序言
1.PE基础知识概述
1.1:DOS头
1.2:PE文件头
1.3:区段表
2.工具编写
2.1功能简介
2.2界面概览
2.3重要表的遍历思想
2.3.1导出表
2.3.2导入表
2.3.3资源表
2.3.4重定位表
3总结
******************************************我是分割线*******************************************
序言:
PE格式是Windows下最常用的可执行文件格式,所谓PE格式也就是(Portable Executable File Format/可移值的执行体)。最常见的就是.exe .dll .sys .ocx等,了解PE需要具备C语言相关知识,编写这个PeEdit的初衷是把PE知识更好的吸收和总结,并封装成类,为以后工作做准备,同时做成界面版本使之成为一个小工具,这样可以不断积累自己的工具库。文章如有错误,还请大牛指正,以免误导别人
******************************************我是分割线*******************************************
1.PE基础知识概述:
PE文件使用的是一个平面空间,所有代码和数据都被合并在一起,组成一个很大的结构。里面穿插了大量的跳转和结构体,既然说到了跳转,就需要了解相关地址转换的概念,这点很重要
RVA(相对虚拟地址): PE文件的各种数据结构中涉及地址的字段大部分都是以RVA表示的。
FileOffset(文件偏移地址):指的是文件在磁盘中的偏移
VA(虚拟地址) :也就是内存偏移地址
ImageBase(基地址) :文件被加载到内存时的首地址, 这个值是由PE文件决定的,但系统不一定会加载到指定位置,所以产生了重定位,按照默认设置,一般EXE文件的基地址为0x00400000,DLL文件基地址是0x10000000
关于地址转换,大部分都是RVA转FileOffset,公式是:
Offset = RVA –段的RVA +该段的FileOffset
例如:以user32 .dll为例
此处仅列出两个区段,仅为示例
如果RVA=0X30000,此值大于.text段小于.data段,所以该值位于.text段
套用公式 Offset = 0x30000 –0x10000 + 0x400
所以Offset=0x20400,是不是很简单
好了,具备以上知识,开始进入PE的世界吧
1.1:DOS头
DOS部分由MZ格式的文件头和可执行代码的DOS stub组成,这部分是为了向下兼容而保留下的
MZ格式的文件头由IMAGE_DOS_HEADER结构定义:(winnt.h这个文件中定义关于PE的相关结构体)
struct _IMAGE_DOS_HEADER {
0x00 WORD e_magic; --这个就是”MZ”头
0x02 WORD e_cblp;
0x04 WORD e_cp;
0x06 WORD e_crlc;
0x08 WORD e_cparhdr;
0x0a WORD e_minalloc;
0x0c WORD e_maxalloc;
0x0e WORD e_ss;
0x10 WORD e_sp;
0x12 WORD e_csum;
0x14 WORD e_ip;
0x16 WORD e_cs;
0x18 WORD e_lfarlc;
0x1a WORD e_ovno;
0x1c WORD e_res[4];
0x24 WORD e_oemid;
0x26 WORD e_oeminfo;
0x28 WORD e_res2[10];
0x3c DWORD e_lfanew;-指向PE头
};
看似很庞大的结构体,其实我们只需要关注第一个和最后一个字段即可,第一个字段已经在winnt.h中被定义为IMAGE_DOS_SIGNATURE,最后一个e_lfanew字段指向PE文件头, 也就是PE文件头的相对偏移(RVA),偏移的换算我们已经讲过
如图所示:
最后一个字段指向0x000000E8,从此字段开始将是PE文件头,DOS头和PE头之间的部分就是DOS stub部分,如果此程序在DOS下运行会显示”This program cannot be run in DOS mode”后退出,这部分我不需要过多关注
1.2:PE文件头
PE文件头是由IMAGE_NT_HEADERS结构定义的:
struct _IMAGE_NT_HEADERS {
0x00 DWORD Signature;
0x04 _IMAGE_FILE_HEADER FileHeader;
0x18 _IMAGE_OPTIONAL_HEADER OptionalHeader;
};
1.21 Signature 这个字段就是我们看到的0x00004550,ASCII码字符是"PE00",也就是”PE”文件标识, DOS头部的e_lfanew字段正是指向"PE\0\0"。
1.22 FileHeader 这个字段实际上是一个结构体
struct _IMAGE_FILE_HEADER {
0x00 WORD Machine; 运行平台一般是4C 01
0x02 WORD NumberOfSections;区段数目,我们后面讲解
0x04 DWORD TimeDateStamp; 文件创建日期和时间
0x08 DWORD PointerToSymbolTable; 指向符号表(用于调试)
0x0c DWORD NumberOfSymbols; 符号表中的符号数量(用于调试)
0x10 WORD SizeOfOptionalHeader; IMAGE_OPTIONAL_HEADER32结构的大小
0x12 WORD Characteristics; 文件属性
};
我们来看下比较重要的字段
NumberOfSections,指出此文件的区段的数量
SizeOfOptionalHeader:指出紧随其后的IMAGE_OPTIONAL_HEADER32结构的长度,这个值一般等于00e0h
Characteristics:属性标志字段, 不同的值将影响系统对文件的装入方式,例如,当13位为1时,表示这是一个DLL文件,否则这是一个exe文件
如图
1.23 OptionalHeader这个字段是一个扩展头,里面包含的大量的信息
struct _IMAGE_OPTIONAL_HEADER {
0x00 WORD Magic;
0x02 BYTE MajorLinkerVersion;
0x03 BYTE MinorLinkerVersion;
0x04 DWORD SizeOfCode;
0x08 DWORD SizeOfInitializedData;
0x0c DWORD SizeOfUninitializedData;
0x10 DWORD AddressOfEntryPoint;
0x14 DWORD BaseOfCode;
0x18 DWORD BaseOfData;
0x1c DWORD ImageBase;
0x20 DWORD SectionAlignment;
0x24 DWORD FileAlignment;
0x28 WORD MajorOperatingSystemVersion;
0x2a WORD MinorOperatingSystemVersion;
0x2c WORD MajorImageVersion;
0x2e WORD MinorImageVersion;
0x30 WORD MajorSubsystemVersion;
0x32 WORD MinorSubsystemVersion;
0x34 DWORD Win32VersionValue;
0x38 DWORD SizeOfImage;
0x3c DWORD SizeOfHeaders;
0x40 DWORD CheckSum;
0x44 WORD Subsystem;
0x46 WORD DllCharacteristics;
0x48 DWORD SizeOfStackReserve;
0x4c DWORD SizeOfStackCommit;
0x50 DWORD SizeOfHeapReserve;
0x54 DWORD SizeOfHeapCommit;
0x58 DWORD LoaderFlags;
0x5c DWORD NumberOfRvaAndSizes;
0x60 _IMAGE_DATA_DIRECTORY DataDirectory[16];
};
这个结构体比较大,我们关注下比较重要的字段
Magic:一般为0x010b
SizeOfCode:可执行代码的大小
AddressOfEntryPoint:程序入口点,是一个RVA,这个比较重要,在加壳时要用到
BaseOfCode:代码基址
BaseOfData:数据基址
ImageBase:指出文件的优先装入基址,如果恰好载入此基址,那么将不需要进行重定位,由于每个.exe系统都会分配固定大小的虚拟空间,所以一般.exe不需要重定位,每个.exe文件需要加载多个DLL文件,系统不可能会为每个DLL文件都载入到他们的默认基址处,所以DLL对于重定位依赖性很强. 一般EXE文件的默认优先装入地址被定为0x00400000,而DLL文件的默认优先装入地址被定为0x10000000。
SectionAlignment:区段在内存中的对齐大小,一般为0x1000
FileAlignment:区段在磁盘文件中的对齐大小,一般为0x200或者0x1000
SizeOfImage:装入内存后的总大小
SizeOfHeaders:注意这个字段,这个字段不能看他的名字想当然, 这个字段的意思是第一个区块的偏移地址(磁盘文件中).,并不是指向第一个区段表
Subsystem:子系统,
DataDirectory:这个是个非常重要的字段, 它由16个相同的IMAGE_DATA_DIRECTORY结构组成, IMAGE_DATA_DIRECTORY结构的定义很简单,它仅仅指出了某种数据块的位置和长度。我们需要重点关注的是导出表,输入表,资源表和重定位表,TLS表
IMAGE_DATA_DIRECTORY STRUCT
DWORD VirtualAddress ;数据的起始RVA
DWORD Size ; 数据块的长度
IMAGE_DATA_DIRECTORY ENDS
每个字段在winnt.h中已经给与定义,关键几个定义如下
IMAGE_DIRECTORY_ENTRY_EXPORT 导出表
IMAGE_DIRECTORY_ENTRY_IMPORT 导入表
IMAGE_DIRECTORY_ENTRY_RESOURCE 资源
IMAGE_DIRECTORY_ENTRY_BASERELOC 重定位表
IMAGE_DIRECTORY_ENTRY_TLS TLS表
如图
如图所示,数据目录表的每个成员占据8个字节,前四个字节表示VirtualAddress,(数据表的起始RVA),后四个字节代表SIZE(数据块的大小)
1.3区段表
typedef struct _IMAGE_SECTION_HEADER {
0x00 BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; 区段名字
union {
0x08 DWORD PhysicalAddress;不用关心,一般是下面的VirtualSize起作用
0x08 DWORD VirtualSize; 内存中占据的大小
} Misc;
0x0c DWORD VirtualAddress;RVA
0x10 DWORD SizeOfRawData;在文件中的大小
0x14 DWORD PointerToRawData;FileOffset
0x18 DWORD PointerToRelocations;
0x1c DWORD PointerToLinenumbers;
0x20 WORD NumberOfRelocations;
0x22 WORD NumberOfLinenumbers;
0x24 DWORD Characteristics;属性
};
Characteristics:重点说下这个, 其中的不同数据位代表了不同的属性,具体属性可以去winnt.h中查找,例如.text段的属性是包含可执行代码,代码可执行,可读取属性,想要添加属性可以用 | 来进行添加,去掉属性用 & 运算去除
2.工具编写
以上我们对PE结构有了大概的了解,现在我们对其进行详细遍历以及进行界面化的编写
2.1功能简介
我们预期实现PE头和区段信息浏览和修改,位置计算,数据目录中包括导入表,导出表,重定位,资源信息的浏览
2.2界面概览
这是工具的主界面,囊括了PE头一个大概信息,更多的信息需要在数据目录表和区段信息中查看修改
下图是区段表中详细信息,点击一个区段可以查看相应信息,区段标志做了多选框,这样可以很方便查看数据所代表的意思,这个给了我很好的启发,根据权值来判断相应的操作,而且很直观
下图是数据目录表中的详细资源信息
下图是位置计算器,可以对RVA VA OFFSET三者进行互相转换
2.3重要表的遍历思想
2.3.1导出表:
来看一下导出表的结构
struct _IMAGE_EXPORT_DIRECTORY {
0x00 DWORD Characteristics;
0x04 DWORD TimeDateStamp;
0x08 WORD MajorVersion;
0x0a WORD MinorVersion;
0x0c DWORD Name;
0x10 DWORD Base;
0x14 DWORD NumberOfFunctions;
0x18 DWORD NumberOfNames;
0x1c DWORD AddressOfFunctions;
0x20 DWORD AddressOfNames;
0x24 DWORD AddressOfNameOrdinals;
};
Base:基序号,也就是最小序号
NumberOfFunctions:导出函数总数目,这个数值并不能真正反映出导出这么多函数
NumberOfNames: 以名字导出的函数总数目,这就给与我们提示,还有以序号导出的函数
AddressOfFunctions:指向一个数组,这个数组保存了函数地址,也就是EAT
AddressOfNames:是一个指针数组,保存了各个导出函数名的地址,也就是ENT
AddressOfNameOrdinals:此数组保存了函数序号,姑且叫她EIT
EAT的导出顺序是按照序号大小进行依次导出的,(注意这个序号是减去基序号后的相对序号)如果存在这个序号则会有对应的EAT,否则EAT的值为null,而导出的ENT和EIT是一一对应的两个表格
遍历思想:根据以上知识我们来进行具体遍历,由于知道导出的最大函数数目,所以用for循环遍历比较合适,我们先获取EAT ENT EIT的指针
PDWORD pEAT=(PDWORD)((DWORD)lpImage+RVAToOffset(lpImage,pExprot->AddressOfFunctions));
PDWORD pENT=(PDWORD)((DWORD)lpImage+RVAToOffset(lpImage,pExprot->AddressOfNames));
PWORD pEIT=(PWORD)((DWORD)lpImage+RVAToOffset(lpImage,pExprot->AddressOfNameOrdinals));
// dwOrdinal的值可以认为是序号的索引,由于是EAT是按照序号大小先后导出的,如果EAT有值,说明存在此函数,然后从EIT中寻找是否有此序号,如果没有说明没有导出此函数的函数名,如果有说明有序号有地址依次遍历,代码如下
for (DWORD dwOrdinal = 0; dwOrdinal < pExprot->NumberOfFunctions; dwOrdinal++)
{
if (!pEAT[dwOrdinal]) //如果EAT的值为空则跳过
{
continue;
}
bool bFlag=FALSE; //设置标记位
for (DWORD dwIndex = 0; dwIndex < pExprot->NumberOfNames; dwIndex++)
{
if (dwOrdinal==pEIT[dwIndex])
{
PCHAR pszFunName=(PCHAR)((DWORD)lpImage+RVAToOffset(lpImage,pENT[dwIndex]));
printf("%04X 0x%p %s\r\n",pExprot->Base+dwOrdinal,pEAT[dwOrdinal],pszFunName);
bFlag=TRUE;
break;
}
}
if (!bFlag)
{
printf("%04X 0x%p %s\r\n",pExprot->Base+dwOrdinal,pEAT[dwOrdinal],"null");
}
}
}
下图是遍历结果
2.3.2导入表:
还是先看一下此表的结构体
struct _IMAGE_IMPORT_DESCRIPTOR {
0x00 union {
0x00 DWORD Characteristics;
0x00 PIMAGE_THUNK_DATA OriginalFirstThunk;
} u;
0x04 DWORD TimeDateStamp;
0x08 DWORD ForwarderChain;
0x0c DWORD Name;
0x10 PIMAGE_THUNK_DATA FirstThunk;
};
OriginalFirstThunk:指向输入名称表的(INT)的RVA
FirstThunk:指向输入地址表的RVA,如果没有载入到内存,他和OriginalFirstThunk:指向的内容一致,他们分别指向如下结构体的数组,且这个数组以一个空的IMAGE_THUNK_DATA结尾
typedef struct _IMAGE_THUNK_DATA {
union {
0x00 LPBYTE ForwarderString;
0x00 PDWORD Function;
0x00 DWORD Ordinal;
0x00 PIMAGE_IMPORT_BY_NAME AddressOfData;
} u1;
} IMAGE_THUNK_DATA,*PIMAGE_THUNK_DATA;
Ordinal:导入函数的序号,如果此值的最高位为1,则采用序号导入的方式,则低31位将呗看作函数序号,我们可以用宏IMAGE_SNAP_BY_ORDINAL32来判断此项是否为序号
AddressOfData:指向IMAGE_IMPORT_BY_NAME结构
typedef struct _IMAGE_IMPORT_BY_NAME
{
0x00 WORD Hint;导入函数的序号
0x02 BYTE Name[1];导入函数的名称
} IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;
遍历思想:我们并不知道到底导入了多少DLL,所以用while循环比较合适,如果IMAGE_IMPORT_DESCRIPTOR结构体的name字段无值,说明遍历结束,如果有值进入IMAGE_THUNK_DATA结构体进行判断,这是个联合体,同一时段只能有一个位段有值,前两个位段只是在载入到内存时才有值,所以排除判断,前门说我们有一个宏来进行判断序号,所以用if(不是序号)else(直接输出序号)来判断即可
下面给出伪代码,读者可以自己试着编写出具体代码
//假设pImport表示指向导入表的指针, pTHUNK表示指向IMAGE_THUNK_DATA32的指针
While(pImport->Name)
{
…….
while (pTHUNK->u1.Ordinal) //如果此值无效,第四个字段才有效,所以先判断此值
{
If(!IMAGE_SNAP_BY_ORDINAL32(pTHUNK->u1.Ordinal))
{
…
pTHUNK++;
continue;
}
Else
{
}
}
pImport++;
}
测试效果如下
2.3.3资源表:
struct _IMAGE_RESOURCE_DIRECTORY {
0x00 DWORD Characteristics;属性
0x04 DWORD TimeDateStamp;建立时间
0x08 WORD MajorVersion;主板本
0x0a WORD MinorVersion;自版本
0x0c WORD NumberOfNamedEntries;用字符串作为资源标识的条目个数
0x0e WORD NumberOfIdEntries;用ID作为资源标识的个数
};
这是资源表的结构体,资源在PE文件中以目录的结构存在,一般情况下这个目录分三层,从根目录开始依次为资源类型,目录资源ID与资源代码页,这三层都是IMAGE_RESOURCE_DIRECTORY结构为头部,并在其后紧随着一个IMAGE_RESOURCE_DIRECTORY_ENTRY的结构数组, IMAGE_RESOURCE_DIRECTORY主要是指出后面结构体数组的成员个数,而后面这个ENTRY结构数组的每个成员分别指向下一层目录结构或者是资源数据
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31;资源名偏移
DWORD NameIsString:1;资源名为字符串
} DUMMYSTRUCTNAME;
DWORD Name;资源类型
WORD Id;资源ID
} DUMMYUNIONNAME;
union {
DWORD OffsetToData;数据偏移地址
struct {
DWORD OffsetToDirectory:31;子目录偏移地址
DWORD DataIsDirectory:1;下一层是目录
} DUMMYSTRUCTNAME2;
} DUMMYUNIONNAME2;
}IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;
如果NameIsString为1, NameOffset指向IMAGE_RESOURCE_DIR_STRING_U
typedef struct _IMAGE_RESOURCE_DIR_STRING_U {
WORD Length;字符串长度
WCHAR NameString[ 1 ];字符串数组
} IMAGE_RESOURCE_DIR_STRING_U, *PIMAGE_RESOURCE_DIR_STRING_U;
经过三层遍历后,最后会到达IMAGE_RESOURCE_DATA_ENTRY
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData;资源数据的RVA
DWORD Size; 资源数据的长度
DWORD CodePage; 代码页
DWORD Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;
遍历思想:在第一层时, IMAGE_RESOURCE_DIRECTORY_ENTRY的name字段或者结构体有效,保存的是资源类型,第二层遍历时ID或者是联合体内的数据有效,第三层时Name字段有效,保存的是资源语言区域类型,第二个联合体同一时间只有一个字段有效,所以比较好判断
下面给出伪代码
//假设pRes1指向第一层IMAGE_RESOURCE_DIRECTORY
//第一层为目录遍历
DWORD dwCount1=pRes1->NumberOfIdEntries+pRes1->NumberOfNamedEntries;
For(DWORD i = 0; i < dwCount1; i++)
{
获取IMAGE_RESOURCE_DIRECTORY_ENTRY结构指针pEntry1
If(类型是字符串)
{
………
}
Else类型是Name字段
{
…………..
}
//接下来判断第二个联合体指向的是数据还是目录
If(如果为目录)
{
获取第二层IMAGE_RESOURCE_DIRECTORY 的指针pRes2,
If(ID是字符串)//注意这层是ID
{
}
Else Id字段指明
{
}
//接下来判断第二个联合体指向的是数据还是目录
If(是目录)
{
获取第三层IMAGE_RESOURCE_DIRECTORY 的指针pRes3
//这是最后一层
直接获取资源代码页的数据
}
Else是数据
{
}
}
Else是数据
{
…….
}
}
效果如下
2.3.4重定位表:
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress;需要重定位的起始RVA
DWORD SizeOfBlock; 本结构与TypeOffset总大小
// WORD TypeOffset[1];
} IMAGE_BASE_RELOCATION;
这个相对比较简单,需要说明的是VirtualAddress字段,这个字段更像前面所说的基址,由于SizeOfBlock字段指明了这个重定位表的大小,所以用它的值减去VirtualAddress和SizeOfBlock这两个字段所占大小,此时的大小将是TypeOffset数组的大小,这个结构体的定义如下
struct{
WORD Offset:12;重定位的偏移,
WORD Type:4;重定位类型
}
注意Offset这个字段,这个字段的数据加上刚才的VirtualAddress才是真正的RVA
遍历思想:我们并不知道有几个重定位区块,所以用while循环比较适合,这个比较简单,贴出伪代码
While(pBase->VirtualAddress)
{
获得需要重定位项的个数dwBaseOfNum
获得指向TypeOffset的结构体指针pOffset
for (DWORD i = 0; i < dwBaseOfNum; i++)
{
依次获取每个TypeOffset的类型,进而进行判断并修复
}
指向下个重定位位段
}
效果如下
到现在我们已经遍历了几个比较重要而且常用的目录表,其余的就是界面编写与数据修改部分,不在我们的讨论范围内
3:总结
开发过程中,以前学过的一些控件的用法有些模糊了,尤其是树控件的消息响应,都是从网上找到相关资料进行解决,开发本来就是个很细的工作,不要抱着侥幸心理,例如资源的遍历,要考虑全面才能解析各种变态PE。虽然名字叫PEEdit,但只能编辑保存少部分信息,更多的信息还不能进行保存,希望可以在后续版本中进行不断优化和增加功能,成为一个真正的PeEdit.
后记:
走到现在,非常感谢任老师的教学,许Sir和薛老师的课下指导
整个界面模仿了大名鼎鼎的LoadPE,书籍参考了黑客免杀攻防(任晓珲著). PeEdit.rar
[注意]传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!
上传的附件: