第一次用十六进制编辑器打开一个.exe文件时,看到开头的"MZ"两个字母,我以为是某个程序员的签名。后来才知道这是PE文件的身份证——每个Windows可执行程序都带着这个标记开始它的征程。PE文件就像个精心设计的俄罗斯套娃,外层是DOS头,往里拆才能见到真正的PE头。
用010 Editor打开一个简单的HelloWorld.exe,从偏移0x0开始的前64字节就是DOS头。关键字段是最后4字节的e_lfanew,它指向PE头的起始位置。我习惯用快捷键Ctrl+G直接跳转到这个偏移量,那里必定藏着"PE\0\0"的魔法标记。这个设计保留了DOS时代的兼容性,就像现代建筑保留着古代的地基。
理解PE文件最烧脑的就是地址转换。文件躺在磁盘上是按文件偏移地址(FOA)排列的,加载到内存后则按相对虚拟地址(RVA)和虚拟地址(VA)组织。三者的关系就像:
手动计算时记住这个公式:VA = ImageBase + RVA。在32位程序里,ImageBase默认是0x400000,就像所有程序都约定从同一个虚拟起点开始布局。用PEditor查看节表时,会看到每个节的PointerToRawData(FOA)和VirtualAddress(RVA),它们之间的转换需要对齐值(SectionAlignment和FileAlignment)参与计算。
PE头就像程序的体检报告,前20个字节的FileHeader记录着基础信息。用WinHex查看Characteristics字段时,0x010F表示这是个可执行的32位程序,而DLL会显示0x210E。这里有个坑:某些病毒会修改这个标记来伪装文件类型,我在分析恶意软件时就遇到过把EXE改成DOC的案例。
OptionalHeader才是真正的精华区,它告诉加载器如何布置内存。关键字段包括:
特别要注意DataDirectory数组,它用16个结构体指向各种关键数据表。逆向时最常打交道的是第2个导入表和第13个导入地址表(IAT)。有次调试时发现程序崩溃,最后查出是病毒修改了DataDirectory的Size字段,导致系统加载器误判表结构。
用PEditor查看这些数据时,推荐开启"Follow Pointers"功能。比如查看导入表时,它会自动解析RVA到文件偏移,省去手工计算的麻烦。对于初学者,建议先用PEditor这类工具生成报告,再对照十六进制数据验证理解。
节表就像城市规划图,.text节放代码,.data节放变量,.rsrc节放图标等资源。每个节表条目有20字节,记录着节的名称、内存大小、文件大小等属性。有趣的是节名可以自定义,有些加壳程序会创建奇怪的节名如"UPX0"来干扰分析。
分析节表时最实用的技巧是地址定位。假设某变量的FOA是0x410,通过以下步骤定位:
实际操作中会遇到各种边界情况。有次分析加壳程序,发现节表的VirtualSize被设为0xFFFFFFFF,这是典型的内存拉伸技巧。后来用x64dbg动态调试,才确认实际内存占用远小于这个值。
导入表是PE文件最精妙的设计之一,它建立了静态编译和动态加载的"双桥"机制。用PEditor打开导入表目录项,会看到RVA和Size两个字段。这里的RVA指向一个IMAGE_IMPORT_DESCRIPTOR数组,每个DLL对应一个描述符。
分析HelloWorld.exe的导入表时,发现它依赖user32.dll和kernel32.dll。每个描述符包含:
调试时有个重要现象:程序加载前,INT和IAT都指向函数名;加载后,IAT会被系统替换为实际函数地址。这就是所谓的"桥2"动态链接过程。用OllyDBG调试时可以清晰看到这个变化过程。
手动解析导入表的步骤:
遇到过最棘手的情况是导入表加密。某次分析勒索软件时,发现其导入表在运行时才解密重建。最后通过内存转储配合API监控才还原出原始调用关系。
静态分析就像看建筑图纸,动态调试则是实地考察。用OllyDBG加载HelloWorld.exe时,第一件事是确认入口点是否匹配PE头的AddressOfEntryPoint。加壳程序通常会修改这个值跳转到解压代码。
调试调用MessageBox的代码时,观察到五个push加一个call的典型调用约定。E8开头的call指令采用相对地址,计算方法是:目标地址 = 下条指令地址 + 操作数。例如E8 08000000表示跳转到当前EIP+8的位置。
单步执行时F7和F8的区别很关键:
修改字符串的技巧也值得掌握。在OD中Ctrl+G跳转到数据段,直接修改Unicode字符串时要注意结尾的null字符。有次修改后程序崩溃,就是因为漏掉了最后的00字节。
正常的PE文件就像规整的集装箱货轮,而恶意程序往往像改装过的海盗船。常见异常特征包括:
检测工具如PEiD通过特征码识别已知加壳器。但现代恶意程序更多使用:
分析这类样本时,我通常会:
曾经遇到一个样本,其导入表只有LoadLibrary和GetProcAddress两个函数,却在运行时加载了二十多个DLL。通过钩取这两个API,最终还原出完整的函数调用树。