感觉只学手操不学基础知识还是不行,趁着暑假赶紧补一补, 跟着这篇学的

从一次PE编辑实现弹窗开始

堆理论知识之前先搞一波实操:在一个exe程序启动之前实现一个弹窗功能。

添加区段

这里就用线程的LordPE来实现吧,我们先拷贝一份_LordPE_fix - 副本,打开后显示出这样的界面

这便是该程序的启动界面,我们要做的就是在这个界面之前添加一个弹窗。

我们用LordPE打开之前拷贝的副本(没错,我改我自己),显示的便是拷贝文件的一些基本信息

重点关注以下信息:

区段数目 NumberOfSections 0004
入口点 EntryPoint 0x00004340
镜像基址 ImageBase 0x00040000
镜像大小 SizeOfImages 0x00036000

点击区段,显示出区段表,正好有4项

名称后面分别是:

VOffset VSize ROffset Rsize Flags
映射入内存后的虚拟地址 映射入内存后的虚拟地址长度 文件中的位置 文件中的长度 区段属性

右击最下面的区段后点击添加区段,发现多了一个NewSec区段。

之后右击修改新区段在虚拟和物理磁盘中的大小以及标志,把不需要的取消勾选。

修改后的区段表如下:

我们发现区段数已经增加了,我们单机保存即可,虽然添加了一个区段,但是文件的实际大小却没有改变

我们尝试运行副本

发现无法运行,这是因为我们添加了一个区段后,这是因为我们刚才只给了该文件一个区段,但是却没有对应的文件,因此会出现报错,无法被windows系统加载,于是我们选择打开16进制编辑器。

用16进制编辑器修改文件

这里选择HXD_Hex_32来完成。

用编辑器打开后发现末尾地址是0x32FFF

通过简单计算, 正好是我们添加的区段在文件中的地址起始点,我们在末尾添加512个字节(用16进制00填充),发现文件变大了

此时再次打开副本发现可以打开。

TIPS:新版的windows可能改过PE文件加载过程,如果win版本较低的话这里可能双击仍然无法正常运行,这时可以用LordPE继续修改,把镜像基址0x00036000加0x1000变成0x00037000,win10下他自动改好了

修改汇编添加弹出对话框功能

拖入OD发现停在了EntryPoint处

此时对应的是虚拟地址,该地址的计算方法为:虚拟地址 = 镜像基址 +入口点

由于我们要实现的功能是在程序启动前实现弹窗,于是我们考虑把入口点设置为之前添加的NewSec区段映射入内存后的起始地址。

再次拖入OD,发现入口点发生改变,变成了00436000。

按Ctrl+G定位到地址00436020,选中部分字节后按空格编辑,选择ASCII码直接输入字符串HelloWorld!!,发现上方的汇编语言也跟着改变了。

这是因为OD把输入的HelloWorld!! 和 Hello 两串字符当成了汇编代码进行显示,这当然没办法执行。

之后我们从入口点00436000开始插入以下汇编代码

1
2
3
4
5
push 0
push 00436030
push 00436020
push 0
call MessageBoxA

发现录入汇编后,在注释的位置便可以自动解析出对应地址我们之前输入的ASCII码。

之后我们右击->复制到可执行文件->选择->保存文件并覆盖原文件,我们就把改好的汇编代码添加到了原来的LordPE副本文件。此时我们点击运行该文件,发现出现了弹窗

但是PE文件本体却没有执行,这是因为我们修改了入口点,并且添加的汇编代码没有跳转到原来的代码起始点的指令,因此在弹窗之后原来的文件是无法打开的。

于是我们在此修改汇编,在弹窗指令后加入一个跳转指令

我们对其进行调试,发现在执行call语句弹窗后,我们点击确定执行jmp跳转指令,程序便运行到之前的入口点处了。

此时我们再次覆盖文件后,发现程序便可以再弹窗后正常打开了。

小结

本次操作成功实现了在一个可执行文件之前添加一个弹窗并且成功跳转到原入口点处的功能。

这种应用有很多实际意义,比如

  1. 病毒技术,反病毒技术,反反病毒(免杀)技术
  2. 软件保护技术,如加壳、脱壳技术

如果我们刚刚在本体前加的不是弹对话框,而是其他的一些文件操作函数,比如遍历磁盘并删除文件,这就是一款具有破坏性的病毒了,对于反病毒技术,如何分离病毒代码与宿主程序,或者修复被破坏的程序,这也需要用到PE文件结构的知识。

PE文件结构概述

PE文件格式是Portable Executable的缩写,即可移植的执行体。windows操作系统下,所有的可执行文件都是PE格式,如exe文件,dll文件,sys文件,ocx文件等。

PE结构不是一个单纯的结构,一个PE文件由若干个结构集合所构成,不同的结构有不同的用处。

PE文件格式是一种对文件组织管理的方式

这里的数据指的是广义上的数据,因为无论是代码,数据还是资源,其实初始状态都是数据。

一个栗子(用RadASM编写一个简单程序)

打开RadASM创建工程,直接编写汇编代码即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
	.386  //用到的汇编指令的指令集是.386
.model flat, stdcall //flat表示使用的是内存的平坦模式,stdcall是函数调用的一种方式
option casemap:none //casemap:none就是不区分大小写

//调用头文件和链接库
include windows.inc //一些常量
include kernel32.inc //kernel32.dll中的一些系统函数
include user32.inc //user32.dll的一些函数
includelib kernel32.lib
includelib user32.lib

//定义字符串
.data
szCaption db 'hello', 0 //db是字节的意思,定义hello字符串,汇编中win32用,'\0'进行结尾
szText db 'hello world!', 0

//代码部分
.code
start: //代码从标号开始执行,下面的end start也就是说标号是start
push 0
lea eax, szCaption
push eax
lea eax, szText
push eax
push 0
call MessageBox //一段简单的弹窗代码,弹出文本szCaption和szText
push 0
call ExitProcess
end start

编译后连接,工程文件夹中出现demo.exe文件,执行后就是我们刚才编写的弹窗程序

在WinHex中对比可执行文件在文件和内存中的差距

用winhex打开demo.exe,之后执行demo.exe,在工具栏中打开RAM,找到demo#6164下的demo.exe打开

分别为文件中和内存中的demo.exe:

对比发现

文件中的demo.exe是从00000000开始的,但是内存中的demo.exe却是从00400000开始的。

此外

190到220这一段数据是可以对应上的,但同时也发现

文件中的600开始和内存中的2000开始发现调用的dll是一样的,可以对应上,但是前面的部分数据却对应不上。

用PEView查看可执行文件的结构

用PEView打开demo.exe

右边显示的和之前在winhex中的一样,分别是偏移Offset,数据Raw Data和对应的值Value(以字符的方式显示,如果无法解析则用.代替)

然后我们点击第一个IMAGE_DOS_HEADER

注意箭头的位置,我们发现之前整个程序从00000000开始的16进制字节分别是4D 5A,但是这里却倒过来了变成了5A4D,这个玩意叫做字节序。

此外,signature的Data 00004550我们找到文件中相对应的位置000000B0,发现是50,45
也是倒序的,这个玩意也是因为字节序导致的。

再看左边:

  • IMAGE_DOS_HEADER:dos头

  • MS-DOS Stub Program:DOS存根

  • Signature:PE文件的标识

  • IMAGE_FILE_HEADER:文件头

  • IMAGE_OPTIONAL_HEADER:可选头(但不是可以不选的那种,是必须有的,只是其中某些东西只需要占位,不需要有具体数据)

  • IMAGE_SECTION_HEADER:节区,给出了三种数据在文件和在内存中的位置

    • .text:代码
    • .rdata: 只读数据
    • .data:数据
  • SECTION:有三个,分别为.text,.rdata,.data,是真正的数据

文件中的数据不会变化,但是在映射到内存中后一些相对位置就变了

DOS头

DOS头是PE文件结构的第一个头,用来保持对DOS系统的兼容,并且用于定位真正的PE头。

因为DOS操作系统已经不存在了,DOS头现在的作用也就是保持一个兼容给出一个提示,没什么太大作用。

DOS头在winnt.h头文件中的定义如下(该文件头大小为40h,64d)

我们关注的主要是两个属性:e_magic 和 e_lfanew

TIPS:WORD是双字节,LONG是4字节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct _IMAGE_DOS_HEADER {		
WORD e_magic; //00h EXE标志MZ,MZ是一个人名的缩写
WORD e_cblp; //02h 最后(部分)页中的字节数
WORD e_cp; //04h 文件中的全部和部分页数
WORD e_crlc; //06h 重定位表中的指针数
WORD e_cparhdr; //08h 头部尺寸,以段落为单位
WORD e_minalloc; //0Ah 所需的最小附加段
WORD e_maxalloc; //0Ch 所需的最大附加段
WORD e_ss; //0Eh 初始的SS值(相对偏移量)
WORD e_sp; //10h 初始的SP值
WORD e_csum; //12h 校验和
WORD e_ip; //14h 初始的IP值
WORD e_cs; //16h 初始的CS值
WORD e_lfarlc; //18h 重定位表的字节偏移量
WORD e_ovno; //1Ah 覆盖号
WORD e_res[4]; //1Ch 保留字
WORD e_oemid; //24h EM标识符(相对e_oeminfo )
WORD e_oeminfo; //26h OEM信息; e_oemid specific
WORD e_res2[10]; //28h 保留字
LONG e_lfanew; //3Ch PE头相对于文件的偏移地址
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
#define IMAGE_DOS_SIGNATURE 0x4D5A //MZ

判断一个文件是否为PE文件

用16进制编辑器打开一个文件,就那之前编写的demo.exe来做例子吧。

由于文件头大小为40h,我们只关注前4行:

之前的DOS头的定义,我们知道,demo.exe的e_magic(前两个字节)是4D5A,即对应文本MZ,此外我们关注的

e_lfanew是B0 00 00 00 ,但是由于这玩意是小端序存储,所以PE头相对于文件的实际偏移为00 00 00 B0,因此我们找到000000B0处,

发现从这里开始正好是50 45 00 00对应PE..,而00000040到000000B0之间的这一段数据是没有用的。

因此判断一个文件是否为PE文件的步骤为

  1. 观察其前2字节是否为4D 5A(MZ)
  2. 找到e_lfanew(3C到3F的小端序)
  3. 根据e_lfanew找到地址,观察其前4字节是否为PE00

找到了PE的话一般都是PE文件了

不是所有MZ开头的都是可执行文件,但是所有可执行文件都是MZ开头

intel架构的cpu存储数据都是小端序,大端序存储一般在其他cpu架构或者网络传输数据时使用

网络上传输数据一般都是大尾方式

写个程序计算DOS_HEADER大小

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <windows.h>
using namespace std;
int main()
{
printf("%d %x\r\n", sizeof(IMAGE_DOS_HEADER), sizeof(IMAGE_DOS_HEADER));
return 0;
}

输出结果为

即base10下为64,base16下为40。

一个简单实验

为了证明之前所说的在00000040到000000B0之间的数据是没用的,可以用16进制编辑器把这一段数据填充为00,然后尝试打开demo.exe。

填充后重新打开demo.exe,发现依然可以弹窗

同时,为了证明除了e_magic和e_lfanew这两个属性,其他属性都是没用的,我们也可以把除了这两个属性之外的属性全部填充为00,再次尝试运行demo.exe。

发现仍然可以正常运行

好,那么我们刚才填充为00的这些属性到底有啥用呢,为什么要留着他们呢?

我们把00000000到000000B0之间的数据拷贝下来粘贴进一个新文件,保存为dos.bin

然后我们用IDApro打开这个dos.bin文件。

这一块代码实际上是在编译-连接的时候自动添加进来的一个程序,被称为DOS存根

读一下汇编,它的作用就是输出”This program cannot be run in DOS mode.”这个字符串,然后功能吗是4C01,即退出程序。

小节

  1. 可执行文件开始的两个字节是MZ
  2. 学会找到e_lfanew偏移位置
  3. 学会逐字节对应DOS头的每个属性

文件头及编程分析

文件头定义

通过前面介绍的DOS头(IMAGE_DOS_HEADER)中的e_lfanew属性,我们可以找到真正的PE头(IMAGE_NT_HEADERS),IMAGE_NT_HEADERS的定义如下

1
2
3
4
5
6
7
#ifdef _WIN64
typedef IMAGE_NT_HEADERS64 IMAGE_NT_HEADERS;
typedef PIMAGE_NT_HEADERS64 PIMAGE_NT_HEADERS;
#else
typedef IMAGE_NT_HEADERS32 IMAGE_NT_HEADERS;
typedef PIMAGE_NT_HEADERS32 PIMAGE_NT_HEADERS;
#endif

我们发现这个HEADER加了个S,说明他不是单个头组成的,是由多个头组成的。

1
2
3
4
5
6
7
8
9
10
11
typedef struct _IMAGE_NT_HEADERS64 {    // 64位版本
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;

typedef struct _IMAGE_NT_HEADERS { // 32位版本
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

这里重点介绍32位的(32和64位的只有属性宽度等不同)。

1
2
3
4
5
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE标识符,即之前提到的PE00
IMAGE_FILE_HEADER FileHeader; // 文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

文件头结构体用于判断其是exe文件还是dll文件,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 该结构体可以用于判断文件是exe文件还是dll文件 
// 14h 20d
struct _IMAGE_FILE_HEADER {
WORD Machine; // 0x04 运行平台
WORD NumberOfSections; // 0x06 PE中节的数量,最大96个节
DWORD TimeDateStamp; // 0x08 文件创建日期和时间,编译器创建此文件时的时间戳
DWORD PointerToSymbolTable; // 0x0C 指向符号表(用于调试)
DWORD NumberOfSymbols; // 0x10 符号表中符号个数(用于调试)
WORD SizeOfOptionalHeader; // 0x14 可选头IMAGE_OPTIONAL_HEADER结构体的长度 32位是E0 64位是F0
WORD Characteristics; // 0x16 文件的属性 exe是010f dll是210e
}IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

#define IMAGE_SIZEOF_FILE_HEADER 20

重点关注Machine,NumberOfSections,SizeOfOptionalHeader和Characteristics

IMAGE_FILE_HEADER.MACHINE的常用取值:

1
2
#define lMAGE_FILE_MACHINE_1386         0x014c // Intel 386
#define lMAGE_FILE_MACHINE_IA64 0x0200 // Intel 64

IMAGE_FILE_HEADER.Characteristics的常用属性:

1
2
3
4
5
6
7
#define IMAGE_FILE_RELOCS_STRIPPED			0x0001 // Relocation info stripped from file.没有重定位
#define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // File is executable (i.e. no unresolved externel references).表示是可执行的
#define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // Line nunbers stripped from file.跳过行号
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // Local symbols stripped from file跳过符号
#define IMAGE_FILE_32BIT_MACHINE 0x0100 // 32 bit word machine是32位机器
#define IMAGE_FILE_SYSTEM 0x1000 // System File.是系统文件
#define IMAGE_FILE_DLL 0x2000 // File is a DLL.是DLL文件

16进制窗口对照

用16进制编辑器打开之前的demo.exe

通过之前的分析我们知道真正的PE头是在00B0处开始的,我们找到相应的位置

前面4个字节是signature标识符PE00,接下来分别是:

Machine,长度为2字节,值为01 4C,即Intel 386,为32位平台。

0x014c为Intel 386,0x0200为Intel 64

NumberOfSections,2字节,由于小端序的原因,值为00 03,也就是3个节(区段)。

TimeDateStamp,4字节,值为F8 22 DD 61,61DD22F8代表文件编译的时间,源代码编译完后生成的是obj文件,再经过连接才生成了exe文件,这个时间戳就是给obj文件使用的。

PointerToSymbolTable,NumberOfSymbols,都是4字节,值也都是00,用于调试,不用管。

SizeOfOptionalHeader,2字节,由于小端序,值为00 E0, 说明他是32位文件 。

32位是E0,64位是F0

Characteristics,2字节,由于小端序,值为 01 0F,说明他是exe文件。

exe是010f,dll是210e

另外,010F = 1 + 2 + 4 + 8 + 0100,根据Characteristics的常用属性可以知道

  • 它没有重定位的数
  • 它是一个可执行文件
  • 没有行号
  • 没有本地符号
  • 在32位机器上运行

文件解析

这里使用编程实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#include <stdio.h>
#include <windows.h>
using namespace std;

#define FILENAME L"C:\\Users\\Misranduil\\Desktop\\demo.exe"
//解析DOS头
void PrintDosHdr(PIMAGE_DOS_HEADER pImgDosHdr)
{
printf("IMAGE_DOS_HEADER:\r\n");
/*
typedef struct _IMAGE_DOS_HEADER {
WORD e_magic;
WORD e_cblp;
WORD e_cp;
WORD e_crlc;
WORD e_cparhdr;
WORD e_minalloc;
WORD e_maxalloc;
WORD e_ss;
WORD e_sp;
WORD e_csum;
WORD e_ip;
WORD e_cs;
WORD e_lfarlc;
WORD e_ovno;
WORD e_res[4];
WORD e_oemid;
WORD e_oeminfo;
WORD e_res2[10];
LONG e_lfanew;
} IMAGE_DOS_HEADER,*PIMAGE_DOS_HEADER;
*/
//逐个解析就好了,这里只解析e_magic 和 e_lfanew
printf("e_magic:%04X(%c%c)\r\n", pImgDosHdr -> e_magic, *(char*)pImgDosHdr, *((char*)pImgDosHdr + 1));
printf("e_res[4]:");
for (int i = 0; i < 4; ++i)
{
printf("%02X ", pImgDosHdr -> e_res[i]);
}
printf("\r\n");
printf("e_lfanew:%08X\r\n", pImgDosHdr -> e_lfanew);
}
//解析NT头
void PrintNtHdr(PIMAGE_NT_HEADERS pImgNtHdrs)
{
printf("IMAGE_NT_HEADERS:\r\n");
printf("Signature:%08X(%s)\r\n", pImgNtHdrs -> Signature, pImgNtHdrs);
}
int main()
{
// 打开文件
HANDLE hFile = CreateFile(FILENAME, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
// 创建文件内核映射对象
HANDLE hMap = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
// 将文件映射入内存
LPVOID lpBase = MapViewOfFile(hMap, FILE_MAP_READ, 0, 0, 0);
PIMAGE_DOS_HEADER pImgDosHdr = (PIMAGE_DOS_HEADER) lpBase;
PIMAGE_NT_HEADERS32 pImgNtHdr = (PIMAGE_NT_HEADERS) ((DWORD)lpBase + (DWORD)pImgDosHdr -> e_lfanew);
PrintDosHdr(pImgDosHdr);
PrintNtHdr(pImgNtHdr);

// 释放文件映射
UnmapViewOfFile(lpBase);
// 光比文件内核映射对象
CloseHandle(hMap);
// 关闭文件
CloseHandle(hFile);
return 0;
}

输入大概为(莫得Microsoft Visual Studio,直接网页截图了)

可选头

在第三节中我们了解到PE头是多个头的组合,其定义为

1
2
3
4
5
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; // PE标识符,即之前提到的PE00
IMAGE_FILE_HEADER FileHeader; // 文件头
IMAGE_OPTIONAL_HEADER32 OptionalHeader; // 可选头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

我们已经介绍了文件头,这一节我们来介绍可选头。

可选头是IMAGE_OPTIONAL_HEADER,它是PE头部中重要的头部,虽然被称为可选头,但是并不是它说的可有可无,而是指该结构体中的部分数据在不同的文件中是不同的。

可选头定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 32位头的大小是e0h, 224d

typedef struct _IMAGE_OPTIONAL_HEADER {
//
// Standard fields. 标准字段
//

WORD Magic; // 0x18 魔术字 107h = ROM Image 10Bh = EXE(32位) Image 20Bh = PE32+(64位)
BYTE MajorLinkerVersion; // 0x1A 连接器主版本号(对执行没有任何影响)
BYTE MinorLinkerVersion; // 0x1B 连接器次版本号(对执行没有任何影响)
DWORD SizeOfCode; // 0x1C 所有含代码的节的大小(按照文件对齐,判断某节是否含代码,使用节属性是否包含TNA
// (GE_scu_cwr_coE属性判断,而不是通过IMAGE_sCN_CNT_EXECUTE)
DWORD SizeOfInitializedData; // 0x20 所有含有初始化数据的节的大小
DWORD SizeOfUninitializedData; // 0x24 所有含未初始化数据的节的大小(被定义为未初始化,不占用文件空间,加载入内存后为其分配空间)
DWORD AddressOfEntryPoint; // 0x28 程序执行入口RVA(距离PE加载后地址的距离,对于病毒和加密程序,都会修改该值,从而获得程序的控制权,对于DLL如果没有入口函数,那么是0,对于驱动该值是初始化的函数的地址)
DWORD BaseOfCode; // 0x2C 代码的节的起始RVA(一般情况下跟在PE头部的后面)
DWORD BaseOfData; // 0x30 数据的节的起始RVA

//
// NT additional fields. NT系统增加的字段
//
DWORD ImageBase; // 0x34 程序的建议装载地址 (如果被占用了就不能装载在这个地方了)
DWORD SectionAlignment; // 0x38 内存中的节的对齐值 32位0x1000 64位0x2000
DWORD FileAlignment; // 0x3C 文件中的节的对齐值 0x1000或者0x200
WORD MajorOperatingSystemVersion; // 0x40 操作系统主版本号
WORD MinorOperatingSystemVersion; // 0x42 操作系统次版本号
WORD MajorImageVersion; // 0x44 该PE的主版本号
WORD MinorImageVersion; // 0x46 该PE的次版本号
WORD MajorSubsystemVersion; // 0x48 所需子系统的主版本号
WORD MinorSubsystemVersion; // 0x4A 所需子系统的次版本号
DWORD Win32VersionValue; //0x4C 未使用,必须为0
DWORD SizeOfImage; // 0x50 内存中的整个PE文件映像大小(按照内存对齐)
DWORD SizeOfHeaders; // 0x54 所有头+节表的大小
DWORD CheckSum; // 0x58 校验和(一般exe文件为0,而dll和sys文件则必须是正确的值)
WORD Subsystem; // 0x5C 文件子系统
WORD DllCharacteristics; // 0x5E DLL文件特性
DWORD SizeOfStackReserve; // 0x60 初始化时保留的栈大小(默认1M)
DWORD SizeOfStackCommit; // 0x64 初始化时实际提交的钱大小(默认4k)
DWORD SizeOfHeapReserve; // 0x68 初始化时保留的堆大小(默认1M)
DWORD SizeOfHeapCommit; // 0x6C 初始化时实际提交的堆大小(默认4K)
DWORD LoaderFlags; // 0x70 加载标志一般为0
DWORD NumberOfRvaAndSizes; // 0x74 数据目录的数效量(就是下面那个数组的大小)
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES]; // 0x78 数据目录数组
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

RVA是相对起始地址,后面会提到

建议装载地址:

1657185138122

如图为一个虚拟地址内存,比如我们在低2G装载一个exe文件,而加载的dll文件A,B的起始地址重合了,那么就系统会自动给已经被占用的dll文件重新分配。

对齐:32位默认对齐值4K=4096=0x1000,64位默认8K。是内存分页的一个对齐值,大概意思就是比如:A班有50人,坐在一间教师里,B班只有两个人,但是也要坐在相同大的教室里。

在16进制编辑器中查看可选头

由于之前的知识,我们知道00B0开始是PE头,长度为20字节,于是我们可以知道可选头即为选中部分。

  • Magic是0x010B,表示exe文件

  • 要求最低的主版本号0x05,辅版本号0x0C

  • 代码大小是0x00002000

  • 包含的初始化数据大小是00004000

  • 包含的未初始化数据大小是0

  • 程序入口地址是00001000

  • 代码起始地址是00001000

  • 数据的起始地址是00001000

  • 建议装载地址是00004000

  • ……

后面还有一堆就不列举了。

节表解析与地址转换

在PE文件中经常会用到三种地址,分别是

  • VA (Virtual Address): 虚拟地址
  • RVA (Relatvie Virtual Address)∶ 相对虚拟地址
  • FOA (File Offset Address): 文件偏移地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//Section header format.

//此处偏移是按照每个IMAGE_SECTION_HEADER开始的(大小为28h,40d)

#define IMAGE_SIZEOF_SHORT_NAME 8 //(节的长度最长为8)

typedef struct_IMAGE_SECTION_HEADER
{
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 000h节名称
union
{
DWORD PhysicalAddress;
DWORD VirtualSize // 0008h 节区的尺寸
}Misc;
DWORD VirtualAddress; // 000ch 节区的起始RVA地址
DWORD SizeOfRawData; // 0x10 在文件中对齐后的尺寸
DWORD PointerToRawData; // 0x14 该节在文件中的起始偏移
DWORD PointerToRelocations; // 0x18 在OBJ文件中使用
DWORD PointerToLinenumbers; // 0x1C 行号表的位置(调试用)
WORD NumberOfRelocations; // 0x20 在OBJ文件中使用
WORD NumberOfLinenumbers; // 0x24 行号表中行号的数量
DWORD Characteristics; // 0x28 节的属性
}IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;
#define IMAGE_SIZEOF_SECTION_HEADER

用LordPE解析节表

我们用LordPE载入之前写的demo.exe并打开节表

名称(Name) 只是一个标识,可有可无,对数据没有影响,有些软件保护会把这个名称擦除、改名或者随机化
在内存中的节区长度(VSize) 是没有对齐的,在结构体中是union里的VirtualSize
节区的起始RVA地址(VOffset) 由于

所以起始RVA分别是1000,2000,3000,是结构体里的VirtualAddress |
| 在文件中的尺寸(Rsize) | 这个是对齐的,对应结构体中的SizeOfRawData |
| 在文件中的起始偏移(ROffset) | 由于
所以起始文件偏移为200,400,600 |

再打开编辑区段中的标志

右下角就是结构体中的Characteristics,这个值的得出方法为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// Section characteristics.

// IMAGE_SCN_TYPE_REG 0x00000000 // Reserved.
// IMAGE_SCN_TYPE_DSECT 0x00000001 // Reserved.
// IMAGE_SCN_TYPE_NOLOAD 0x00000002 // Reserved.
// IMAGE_SCN_TYPE_GROUP 0x00000004 // Reserved.
#define IMAGE_SCN_TYPE_NO_PAD 0x00000008 // Reserved.
// IMAGE_SCN_TYPE_COPY 0x00000008 // Reserved.

#define IMAGE_SCH_CNI_CODE 0x00000020 // Section contains code.
#define IMAGE_SCN CNINITIALIZED_DATA 0x00000040 // Seccion contains
initialized data.
#define IAGE_SCN_CNT_UNINITIALIZED_DATAun 0x00000080 // Section contains
initialized data.
#define IHAGE SCN_INK_OTHER 0x00000100 // Reserved.
#define IMAGE SCN_LNK_INEO 0x00000200 // Section contains
comzents or some other type of information.
// IMAGE SCN TYPE_OVER 0x00000400 // Reserved.
#define IMAGE SCN_LNK_REMOVE 0x00000800 // Seccion contents will
not become part of image.
#define IMAGE_SCN_LNK_COMDAT 0x00001000 // Section contents comdat
// 0x00002000 // Reserved.
// IMAGE_SCN_HEM_PROTECTED - Obsolete 0x00004000
#define IMAGE_SCN_NO_DEFER_SPEC_EXC 0x00004000 // Reset speculative
exceptions handling bits in the TLB entries for chis section.
#define IMAGE SCN GPREL 0x00008000 // Section content can be
accessed relative to GP
#define IMAGE_SCN_MEM_FARDATA 0x00008000
// IMAGE_SCN_MEM_SYSHEAP -Obsolete 0x00010000
#define IMAGE_SCN_MEM_PURGEABLE 0x00020000
#define IMAGE_SCN_MEM_16BIT 0x00020000
#define IMAGE_SCN_MEM_LOCKER 0x00030000
#define IMAGE_SCN_MEM_PRELOAD 0x00080000

#define IMAGE SCN ALIGN 1BYTES 0x00100000 //
#define IMAGE _SCN ALIGN 2BYTES 0x00200000 //
#define IMAGE SCN ALIGN 4BYTES 0x00300000 //
#define IMAGE SCN ALIGN SBYTES 0x00400000 //
#define IMAGE_SCN ALIGN 16BYTES 0x00500000 // Default alignment if no
others are specified.
#define IMAGE SCN ALIGN 32BYTES 0x00600000 //
#define IMAGE SCN ALIGN 64BYTES 0x00700000 //
#define IMAGE_SCN ALIGN 128BYTES 0x00800000 //
#define IMAGE SCN ALIGN 256BYTES 0x00900000 //
#define IMAGE SCN ALIGN 512BYTES 0x00A00000 //
#define IMAGE SCN ALIGN 1024BYTES 0x00B00000 //
#define IMAGE SCN ALIGN 2048BYTES 0x00C00000 //
#define IMAGE_SCN ALIGN 4096BYTES 0x00D00000 //
#define IMAGE_SCN_ALIGN_8192BYTES 0x00E00000 //
//Unused 0x00F00000
#define IMAGE_SCN_ALIGN_MASK 0x00F00000
#define IMAGE_SCN_LNK_NRELOC_OVFL 0x01000000 // Section contains
extended relocations.
#define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 // Section can be discarded.
#define IMAGE_SCN_MEM_NOT_CACHED 0x04000000 // Section is not cachable.
#define IMAGE_SCN_MEM_NOT_PAGED 0x08000000 // Section 1s not pageable.
#define IMAGE_SCN_MEM_SHARED 0x10000000 // Section is shareable.
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // Section is executable.
#define IMAGE_SCN_MEM_REXE 0x40000000 // Section is readable.
#define IMAGE_SCN_MEM_WRITE 0x80000000 //Section is writeable

这些属性值相组合得出的就是最终的属性值,比如,当前值为

而对应的属性分别为:可执行,可读,包含代码,正好和上图能对应上。

用OD查看内存布局

用OD打开demo.exe,按M按钮打开内存布局

发现PE头的装载地址是0x00400000,后面跟着3个节。同时发现虚拟地址VA = 相对虚拟地址RVA + 装载地址。

字节转换

比如我们要找字符串Hello World!在文件中的偏移地址FOA

我们发现该字符串的虚拟地址VA为0x00403006

同时我们也知道每个节的VA,RVA,FOA。那么由此计算该字符串的FOA有两种方法。

方法一

  1. 计算内存中的虚拟地址VA - 装载地址 = 相对起始地址RVA ,即0x00403006 - 0x00400000 = 0x00003006

  2. 找到RVA对应的节,由于.data的RVA为0x00003000,长度为0x00001000,所以该字符串对应的节为.data

  3. 计算.data的RVA和FOA的差值,即0x00003000 - 0x00000600 = 0x00002800(注意是16进制计算)

  4. 用该字符串的RVA减去该差值得到其FOA,即0x00003006 - 0x00002800 = 0x00000806。

方法二

  1. 计算内存中的虚拟地址VA - 装载地址 = 相对起始地址RVA ,即0x00403006 - 0x00400000 = 0x00003006
  2. 找到RVA对应的节,由于.data的RVA为0x00003000,长度为0x00001000,所以该字符串对应的节为.data
  3. 计算RVA在.data节内的偏移,即0x00003006 - 0x00003000 = 0x00000006
  4. 节内偏移加上该节的起始FOA为该字符串的起始FOA,即0x00000006 + 0x00000800 = 0x00000806

用16进制编辑器打开demo.exe,跳转到0x00000806地址。

发现正是字符串hello world!的起始位置。

用LordPE中的位置计算器验证,在RVA处输入3006,点击执行

发现结果也完全正确。

计算exe文件用VA来计算是可以的,但是如果是dll文件可能结果就不对了,因此一般用RVA来验算。

文件解析和之前一样,把结构体中的每个属性输出一遍即可。

添加节

添加节可以是一种软件保护措施,比如把可执行代码写入一个甚至两个节中打到保护的目的。

添加节的一般步骤

  1. 增加节表项
  2. 修正文件的映像长度
  3. 修正一个节的数量
  4. 增加文件的节数据
    即:IMAGE_OPTIONAL_HEADER.SizeOfImage;
    IMAGE_FILE_HEADER.NumberOfSections;

用16进制编辑器增加节表项

用16进制编辑器打开demo.exe,找到节的位置(.text,.data,.rdata就是开始的地方)

所以我们从0x220处开始添加节,长度为40字节。

根据上一节学到的节表构成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//Section header format.

//此处偏移是按照每个IMAGE_SECTION_HEADER开始的(大小为28h,40d)

#define IMAGE_SIZEOF_SHORT_NAME 8 //(节的长度最长为8)

typedef struct_IMAGE_SECTION_HEADER
{
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 000h节名称
union
{
DWORD PhysicalAddress;
DWORD VirtualSize // 0008h 节区的尺寸
}Misc;
DWORD VirtualAddress; // 000ch 节区的起始RVA地址
DWORD SizeOfRawData; // 0x10 在文件中对齐后的尺寸
DWORD PointerToRawData; // 0x14 该节在文件中的起始偏移
DWORD PointerToRelocations; // 0x18 在OBJ文件中使用
DWORD PointerToLinenumbers; // 0x1C 行号表的位置(调试用)
WORD NumberOfRelocations; // 0x20 在OBJ文件中使用
WORD NumberOfLinenumbers; // 0x24 行号表中行号的数量
DWORD Characteristics; // 0x28 节的属性
}IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;
#define IMAGE_SIZEOF_SECTION_HEADER

我们可以依次添加我们想要的属性,先添加第一个属性:节名称吧,注意第一个字符是0x2E对应字符.

第二个是节的大小,根据内存的对齐写0x1000即可,注意小端序。

第三个是起始位置,根据上一个节.data的起始位置和大小分别为0x3000和0x13以及对齐为0x1000

我们知道添加节的起始位置为0x3000 + 0x1000 = 0x4000

第四个是文件中的尺寸,根据对齐应该是0x200

第五个是文件中的起始位置,由于上一个.data节的起始位置为0x800,对齐为0x200,所以添加节的文件起始位置为0x0A00(16进制)

接下来的两个4字节和两个2字节是没用的,不填即可。

于是还剩最后一个Characteristic属性

0xE0000060,即包含代码和初始化数据,可读,可写,可执行。

修正文件的节数量和映像长度

先复习一下NT头,也就是文件头和可选头,要想修改节表数量和映像长度,我们只要找到文件头中的NumberOfSections和可选头中的`SizeOfImage即可

先改节数量

然后是映像长度,可以用C32ASM中的查看->PE信息,在侧边栏中直接找到SizeOfImage属性并定位

也可以用长度来推,稍微有些麻烦,找到之后,原来的大小为0x4000,我们由于增加了0x1000,改成0x5000即可

再在末尾插入512个00或者90(90对应的汇编是nop,同时文件对齐为0x200 = 512d)

保存后,文件仍可以正常运行

用PELord验证一下

区段数目,镜像大小,以及新添加的.zym节,都显示了出来。

导入表分析

PE文件结构是Windows操作系统用来管理可执行文件的一种格式。通过前面的学习,我们已经对PE文件格式有了一定的掌握,比如对于一个可执行的EXE文件来说,在被操作系统装载时,会被装载到哪个虚拟地址,装载后会从哪个虚拟地址开始执行;PE文件会按照数据的属性进行分节,每个节装载入内存的什么位置,每个节具有哪些属性等。
PE文件不只是有头部,它还有一些PE体来作为可执行文件运行的支撑部分。

一个问题

我们之前写过一个弹出对话框的程序

弹出对话框是因为我们用了一个API函数MessageBox,但其实这个API并不在我们写的程序中,

我们只是调用了它,而这个代码到底在哪里呢?

用OD分析MessageBox的调用过程

用OD打开demo.exe

一路F7到401012处,再按一次F7,发现跳转到了401024处

再按一次回车,发现就是MessageBoxA实现的代码地址处

再返回上一步

这个FF25是个操作码,后面的是一个小端序的内存0x00402008,我们查看该地址处存的是啥

发现是0x76660C90,这个地址正好是MessageBoxA对应的地址

也就是说,当需要调用一个API的时候,会先call到一个jmp语句(调表),然后jmp语句中保存的地址就是真正要跳转的地址,而这个保存的地址是由编译器和连接器来写入的,而真正的地址是由windows系统通过装载器来完成的。

我们打开内存模块,由上图我们知道该API的地址开头为76,据此找到user32的大致的虚拟内存地址

导入表获取

导入表的定位:通过IMAGE_OPTIONAL_HEADER.DataDirectory的第二项获取。

我们用PElord打开demo.exe,并打开目录,第二项就是导入表(Import table)

再点后面的三个点,查看导入表具体导入了哪些API

发现正好有我们使用过的kernel32.dll中的ExitProcess函数和user32.dll中的MessageBoxA函数

导入表具体字段分析

导入表定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
typedef struct _IMAGE_IMPORT_DESCRIPTOR
{
union
{
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA,指向的是一个INT(Import Name Table),即表中所有API函数的RVA(这个RVA一般指向的是API函数的名字)
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 0 if not bound
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time of DLL bound to (Old BIND)
// 实际上可忽略
DWORD ForwarderChain; // -1 if no forwards, 可忽略
DWORD Name; // 保存的是一个RVA,RVA指向的内容是一个dll名, 0指示结束, 不再继续遍历了
DWORD FirstThunk; // RVA,在内存中指向的是一个IAT(Import Address Table)表,保存的是所有函数的VA(地址),但是在文件中指向的是一个INT表,和OriginalFirstThunk相同。0也能指示结束, 不再继续遍历了
}IMAGE_IMPORT_DESCRIPTOR;

注意:FirstThunk在内存中指向的是一个IAT(Import Address Table)表,保存的是所有函数的VA(地址),但是在文件中指向的是一个INT表,和OriginalFirstThunk相同,即在内存中会把这个表替换成真正函数的地址

具体区别如下图:

文件中:

可以发现OriginalFirstThunk,FirstThunk均指向的是INT,有可能是一个RVA,也有可能是一个数值

内存中:

可以发现FirstThunk指向的是真正的函数入口地址。

由上图我们发现FirstThunk指向IMAGE_THUNK_DATA32结构体,有可能是一个RVA,有可能是一个数值,这是因为其构成是一联合体(union)

1
2
3
4
5
6
7
8
9
10
typedef struct _IMAGE_THUNK_DATA32
{
union
{
DWORD ForwarderString; // PBYTE
DWORD Function; // PDWORD
DWORD Ordinal;
DWORD AddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;

这个联合体(union) 中有4个字段,但是他所占的空间是其中最大的类型的空间(DWORD, 4字节)而不是4个字段之和(4 * 4 = 16)

如果他的值的最高位是1的话,那么他的低16位是导入的序号

而如果他的值最高位不是1的话,那么这个值指向的值是导入函数的名称,这个名称在 _IMAGE_IMPORT_BY_NAME这个结构体里。

1
2
3
4
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint;
CHAR Name[1];
} IMAGE_IMPORT_BY_NAME,*PIMAGE_IMPORT_BY_NAME;

这里再介绍一下序号导入:

我们用PElord打开一个较复杂的exe文件,找到导入表

发现再Thunk值不是1的时候,后面的API名称一栏就是以字符串形式出现的API名称,但是如果最高位是8(在16进制下的0x8转化成2进制就是1000,也就是说其最高位是1),后面的API名称就是序号。

文件解析

用C32ASM打开demo.exe,在查看->PE信息中找到IMAGE_DIRECTORY_ENTRY_IMPORT(导入表)

其RVA为0x2010,大小为0x3C也就是60,我们把它转化成FOA

发现其在文件中的地址FOA为0x610,我们跳转到该地址

前60个字节就是导入表,根据前面的导入表定义

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct _IMAGE_IMPORT_DESCRIPTOR
{
union
{
DWORD Characteristics;
DWORD OriginalFirstThunk;
}DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;

4C 20 00 00

OriginalFirstThunk,是一个RVA值,指向的是一个INT(IMAGE_THUNK_DATA),这个值转化成FOA是0x64C

由于INT中的值都是DWORD型也就是4字节,这个值是一个RVA,其值为0x205C,我们再把这个RVA再转化为FOA,得到文件中的地址为0x65C,这个位置正好是ExitProcess

6A 20 00 00

中间的TimeDateStampForwarderChain都是0,直接跳过,接下来是Name,0x206A转化出来的FOA是0x66A

是kernel32.dll

00 20 00 00

即FitstThunk,在文件中指向的也是是IMAGE_THUNK_DATA,0x2000转化出来是0x600

这个值也是一个RVA,其值为0x205C,发现和前面的4C 20 00 00一样,都是ExitProcess函数

86 20 00 00

0x2086转化成FOA是0x686

是user32.dll

剩下的导入表全是00。

内存解析

用OD打开demo.exe,导入表的RVA为0x2010,加上装载地址,把RVA转化成VA也就是0x402010

前面是一样的,我们直接看00 20 00 00,也就是FirstThunk的位置,转化为VA为0x402000

我们发现这个值已经不是0x205C了,而是0x76394100,直接跳转到该地址处

发现就是ExitUserProcess这个函数在内存中的地址而不是一个字符串了,这就是导入表在文件和在内存中的区别。