windows PE文件结构及其加载机制

1. 概述

PE文件的全称是Portable Executable,意为可移植的可执行的文件,常见的EXE、DLL、OCX、SYS、COM都是PE文件,PE文件是微软Windows操作系统上的程序文件(可能是间接被执行,如DLL)。它是1993年Windows NT系统引入的新可执行文件格式,到现在已经经过20多年了。虽然使用PE作为可执行文件格式的Windows操作系统已经更换了很多版本,其结构的变化、新特性的增加、文件格式的转换,以及内核的重新定位等,都发生了翻天覆地的变化,硬件架构也从16位发展到现在的64位架构,而这些变化对PE格式的影响却不大。由于PE格式有较好的数据组织方式和数据管理算法,面对如此多的变化却能保持其设计的优雅。

众所周知,Windows NT继承自VAX® VMS®和UNIX。Windows NT的许多创建者在到Microsoft之前都曾为这些平台设计和编写程序。当他们设计Windows NT时,很自然会使用以前写过的和测试过的工具以尽快开始他们的新项目。这些工具产生的和使用的可执行文件和目标模块的格式被称为COFF(Common Object File Format的首字母,通用目标文件格式)。Microsoft编译器生成的OBJ文件就使用COFF格式。

PE文件之所以被称为“可移植”是因为Windows NT在各种平台(x86、MIPS®、Alpha等等)上的所有实现都使用同样的可执行文件格式。当然,像CPU指令的二进制编码之类的内容会有所不同。重要的是操作系统加载器和编程工具不需要针对遇到的每种新的CPU再完全重写。Windows NT及其以后版本,Windows 95及其以后版本和Windows CE一直到现在的win10都使用了这个相同的格式,所以说在很大程度上,这个目的达到了。对于64位的Windows,PE格式只是进行了很少的修改。这种新的格式被叫做PE32+。没有加入新的域,只有一个域被去除。剩下的改变只是一些域从32位扩展到了64位。在这种情况下,你能写出和32位与64位PE文件都能一起工作的代码。对于C++代码,Windows头文件的能力使这些改变很不明显。EXE和DLL文件之间的不同完全是语义上的。它们都使用完全相同的PE格式。仅有的区别是用了一个单个的位来指出这个文件应该被作为EXE还是一个DLL。甚至DLL文件的扩展名也是不固定的,一些具有完全不同的扩展名的文件也是DLL,比如.OCX控件和控制面板程序(.CPL文件)。

PE文件格式主要来自于UNIX操作系统所通用的COFF规范,同时为了保证与旧版本MS-DOS及Windows操作系统的兼容,也保留了MS-DOS中那熟悉的MZ头部。PE格式被公开在WINNT.H头文件中(非常零散)。在WINNT.H文件的中间有一个“Image Format”节。这个节以MS-DOS MZ格式和PE格式开头,后面才是新的PE格式。WINNT.H提供了PE文件使用的原始数据结构的定义。

2. PE文件分析相关的概念

在详细了解PE文件结构之前,我们需要先了解几个基本的概念。理解这些概念有利于我们更好地把握和分析PE中的数据结构。

2.1. 地址

PE中涉及的地址有四类,他们分别是:

  • 虚拟内存地址(VA)
  • 相对虚拟地址(RVA)
  • 文件偏移地址(FOA)
  • 特殊地址

2.1.1. 虚拟地址(Virtual Addresses)

由于Windows NT推出时程序是运行在保护模式下,用户的PE文件被操作系统加载进内存后,PE对应的进程支配了自己独立的4GB虚拟空间。在这个空间中定位的地址称为虚拟内存地址(Virual Address,VA)。到了现在系统运行在X64架构的硬件上(长模式),内存访问可以采用线性地址的方式,同时可访问的内存也突破了4GB的限制,但是独立的进程拥有独立的虚拟地址空间的内存管理机制还在沿用。在PE中,进程本身的VA被解释为:进程的基地址 + 相对虚拟内存地址。

2.1.2. 相对虚拟地址(Relative Virtual Addresses)

PE格式大量地使用所谓的RVA(相对虚拟地址)。一个RVA,亦即一个“Relative Virtual Addresses(相对虚拟地址)”,是在你不知道基地址时,被用来描述一个内存地址的。它是需要加上基地址才能获得线性地址的数值。基地址就是PE映象文件被装入内存的地址,并且可能会随着一次又一次的调用而变化。

在一个可执行文件中,有许多在内存中的地址必须被指定的位置。例如,当引用一个全局变量时就必须指定它的地址。PE文件可以被加载到进程地址空间的任何位置。虽然它们有一个首选加载地址,但你不能依赖于可执行文件真的会被加载到那个位置。因为这个原因,指定一个地址而不依赖于可执行文件的加载位置就很重要。

为了消除PE文件中对内存地址的硬编码,于是产生了RVA。一个RVA是在内存中相对于PE文件被加载的地址的一个偏移。例如,如果一个EXE文件被加载到地址0x400000,它的代码节位于地址0x401000处。那么代码节的RVA就是:

(目标地址) 0x401000 - (加载地址)0x400000 = (RVA)0x1000.

因为PE-文件中的各部分(各节)不需要像已载入的映象文件那样对齐,事情变得复杂起来。例如,文件中的各节常按照512(十六进制的0x200)字节边界对齐,而已载入的映象文件则可能按照4096(十六进制的0x1000)字节边界对齐。参见下面的“SectionAlignment(节对齐)”和“FileAlignment(文件对齐)”。

因此,为了在PE文件中找到一个特定RVA地址的信息,你得按照文件已被载入时的那样来计算偏移量,但要按照文件的偏移量来跳过。

试举一例,假若你已知道执行开始处位于RVA 0x1560地址处,并且想从那里开始的代码处反汇编。为了从文件中找到这个地址,你得先查明在RAM(内存)中各节是按照4096字节对齐的,并且“.code”节是从RVA 0x1000地址处开始,有16384字节长;然后你才知道RVA 0x1560地址位于此节的偏移量0x560处。你还要查明在文件中那节是按512字节边界对齐,且“.code”节在文件中从偏移量0x800处开始,然后你就知道在文件中代码的执行开始处就在0x800+0x560=0xd60字节处。

然后你反汇编它并发现访问一个变量的线性地址位于0x1051d0处。二进制文件的线性地址在装入时将被重定位,并常被假定使用的是优先载入地址。因为你已查明优先载入地址为0x100000,因此我们可开始处理RVA 0x51d0了。因数据节开始于RVA 0x5000处,且有2048字节长,所以它处于数据节中。又因数据节在文件中开始于偏移量0x4800处,所以该变量就可以在文件中的0x4800+0x51d0-0x5000=0x49d0处找到。

2.1.3. 文件偏移地址

文件偏移地址(File Offset Address,FOA)和内存无关,他是指某个位置距离文件头的偏移。

2.1.4. 特殊地址

在PE结构中海油一种特殊地址,其计算方法并不是从文件头算起,也不是从内存的某个位置的基地址算起,而是从特定的位置算起。这个地址在PE结构中很少见,如:在资源表里就出现过这样的地址。

2.2. 节(Section)

无论是结构化程序设计,还是面向对象程序设计,都是倡导程序和数据的独立性,因此,程序中的代码和数据通通常是分开存放的。为了保证程序执行的安全,保证内核的稳定,Windows操作系统通常对不同用途的数据设置不同的权限。比如:代码段中的字节码在程序运行的时候,一般不允许用户进行修改,数据段则允许在程序运行过程中读和写,常量只能读等。Windows操作系统在加载可执行程序时,会为这些具有不同属性的数据分别分配标记有不同属性的页面(当然,相同属性的数据可能会被放到同一个页面),以确保程序运行时的安全。正式基于这个原因,PE中才出现了所谓的节的概念。

节就是存放不同类型数据(比如代码、数据、常量、资源等)的地方,不同的节具有不同的访问权限。节是PE文件中存放代码和数据的基本单元。例如:一个目标文件中的所有代码可以组合成单个节,或者每个函数独占一格节(如果编译器允许)。增加节的数目会增加文件的开销,但是链接器在链接代码的时候就会有更大的选择余地。一个节中的所有原始数据必须被加载到连续的内存空间中。

从操作系统加载角度来看,节是相同属性数据的组合。与数据目录不同的是,尽管有些数据类型不同,分别属于不同的数据目录,但是由于其访问属性相同,便被归类到同一个节中。这个节最终可能会占用一个或多个页面;但无论有多少个,所有相关页面均会被赋予相同的页面属性。这些属性包括只读、只写、可读、可写等。

Windows操作系统在装载PE文件时会对相同和不同类型的节数据执行抛弃、合并、新增、复制等操作。这些不同的操作交叉组合导致了内存中的节和文件中的节会出现很大的不同。例如:”.data”的数据在磁盘中不存在,但是在内存中存在,而”.reloc”重定位表数据却恰恰相反。

2.3. 对齐(Alignment)

对齐这个概念并非只在PE文件中出现,许多文件格式都会有对齐的要求。有的对齐是为了美观,有的对齐则是为了效率。PE中规定了三类的对齐:数据在内存中的对齐、数据在文件中的对齐、资源文件中资源数据的对齐。

2.3.1. 内存对齐(SectionAligment)

PE 文件头里边的SectionAligment 定义了内存中区块的对齐值。由于Windows操作系统对内存属性的设置以页为单位,所以通常情况下,PE 文件被映射到内存中时,节在内存中的对齐单位必须至少是一个页的大小。对于32位的windows操作系统来说,这个值是4KB(1000h),而对64位操作系统来说,这个值就是8KB(2000h)。

2.3.2. 文件对齐(FileAligment)

PE 文件头里边的FileAligment 定义了磁盘区块的对齐值。每一个区块从对齐值的倍数的偏移位置开始存放。而区块的实际代码或数据的大小不一定刚好是这么多,所以在多余的地方一般以00h 来填充,这就是区块间的间隙。为了提高磁盘利用率,对齐单位通常小于内存,以一个物理扇区的大小作为对齐粒度,512字节,200h。

2.3.3. 资源数据对齐

资源字节码部分一般要求以双字(4字节)方式对齐。

3. PE文件结构

PE 文件格式被组织为一个线性的数据流,它由一个MS-DOS 头部开始,接着是一个是模式的程序残余以及一个PE 文件标志,这之后紧接着PE文件头和可选头部。这些之后是所有的段头部,段头部之后跟随着所有的段实体。文件的结束处是一些其它的区域,其中是一些混杂的信息,包括重分配信息、符号表信息、行号信息以及字串表数据。

如下图所示,PE文件结构被划分为四大部份,包括:DOS部分、PE头、节表、和节数据。

这里写图片描述

PE文件至少包含两个段(节),即数据段和代码段。Windows的应用程序PE文件格式有11个预定义段,这是对Windows应用程序所通用的。例如: .text 、.bss 、.rdata 、.data 、.pdata 和.debug 段,这些段并不是都是必须的,当然,也可以根据需要定义更多的段(比如一些加壳程序)。

在应用程序中最常出现的段有以下6种:

  • 执行代码段,通常 .text (Microsoft)或 CODE(Borland)命名;
  • 数据段,通常以 .data
    、.rdata 或 .bss(Microsoft)、DATA(Borland)命名;
  • 资源段,通常以 .rsrc命名;
  • 导出表,通常以 .edata命名;
  • 导入表,通常以 .idata命名;
  • 调试信息段,通常以 .debug命名;

PE文件一个方便的特点是磁盘上的数据结构和加载到内存中的数据结构是相同的。加载一个可执行文件到内存中 (例如,通过调用LoadLibrary)主要就是映射一个PE文件中的几个确定的区域到地址空间中。因此,一个数据结构比如IMAGE_NT_HEADERS (稍后我们会研究到)在磁盘上和在内存中是一样的。关键的一点是如果你知道怎么在一个PE文件中找到一些东西,当这个PE文件被加载到内存中后你几乎能找到相同的信息。

要注意到PE文件并不仅仅是被映射到内存中作为一个内存映射文件。代替的,Windows加载器分析这个PE文件并决定映射这个文件的哪些部分。当映射到内存中时文件中偏移位置较高的数据映射到较高的内存地址处。一个项目在磁盘文件中的偏移也许不同于它被加载到内存中时的偏移。然而,所有被表现出来的信息都允许你进行从磁盘文件偏移到内存偏移的转换 (参见下图)。

Windows装载器在装载的时候仅仅建立好虚拟地址和PE文件之间的映射关系,只有真正执行到某个内存页中的指令或访问某一页中的数据时,这个页才会被从磁盘提交到物理内存。但因为装载可执行文件时,有些数据在装入前会被预先处理(如需要重定位的代码),装入以后,数据之间的相对位置也可能发生改变。因此,一个节的偏移和大小在装入内存前后可能是完全不同的。

这里写图片描述

这里写图片描述

4. PE文件结构分析

4.1. PE文件准备

为了对PE文件结构进行更好的分析,我们首先准备一个例子,这次我们通过在WIN10 X64环境下使用VS2015编译生成的一个X64架构的release程序来进行分析。这个例子是一个在WIN10 X64下通过TLS实现反调试的小程序。程序清单如下:

#include <Windows.h>
#include <tchar.h>

#pragma comment(lib,"ntdll.lib")

extern "C" NTSTATUS NTAPI NtQueryInformationProcess(HANDLE hProcess, ULONG InfoClass, PVOID Buffer, ULONG Length, PULONG ReturnLength);

#define NtCurrentProcess() (HANDLE)-1


void NTAPI __stdcall TLS_CALLBACK(PVOID DllHandle, DWORD Reason, PVOID Reserved)
{
    if (IsDebuggerPresent())
    {
        MessageBoxA(NULL, "TLS_CALLBACK: Debugger Detected!", "TLS Callback", MB_OK);
//      ExitProcess(1);
    }
    else
    {
        MessageBoxA(NULL, "TLS_CALLBACK: No Debugger Present!...", "TLS Callback", MB_OK);
    }
}

void NTAPI __stdcall TLS_CALLBACK_2(PVOID DllHandle, DWORD Reason, PVOID Reserved)
{
    HANDLE DebugPort = NULL;
    if (!NtQueryInformationProcess(
        NtCurrentProcess(),
        7,          // ProcessDebugPort
        &DebugPort, // If debugger is present, it will be set to -1 | Otherwise, it is set to NULL
        sizeof(HANDLE),
        NULL))
    {
        if (DebugPort)
        {
            MessageBoxA(NULL, "TLS_CALLBACK2: Debugger detected!", "TLS callback", MB_ICONSTOP);
        }

        else
        {
            MessageBoxA(NULL, "TLS_CALLBACK2: No debugger detected", "TLS callback", MB_ICONINFORMATION);
        }
    }
}

//linker spec通知链接器PE文件要创建TLS目录,注意X86和X64的区别
#ifdef _M_IX86
#pragma comment (linker, "/INCLUDE:__tls_used")
#pragma comment (linker, "/INCLUDE:__tls_callback")
#else
#
  • 48
    点赞
  • 167
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值