用010Editor解析PE头

首先打开模板

1712839823644

然后按 Alt + 4 即可

1712839924887

效果如图

PE头

1712832201352

以上是参考图

1.DOS部分

1
2
3
4
5
6
7
typedef struct _IMAE_DOS_HEADER//  偏移, 意义
{
WORD e_magic; // 0x00, 'MZ'(0x5A4D)标识
......中间成员为兼容16位操作系统,可修改可忽略......
LONG e_lfanew; // 0x3C, PE头的起始地址,默认0xB0处
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

1.WORD e_magic(0x00处)
● 对应PE文件的开头,是PE文件DOS头的标识符”MZ”→0x5A4D
○ 对应Winnt.h头文件中宏定义: #define IMAGE_DOS_SIGNATURE 0x4D5A // MZ
2.LONG e_lfanew(0x3C处)
● 对应PE文件0x3C处指向NT头在文件中的偏移(默认0xB0),即32位及以上系统文件头在文件中真正的偏移

2.NT头

1712832042886

“NT” 在这里是 “New Technology” 的缩写,完整的单词是 “New Technology”。 它们的内核相比之前的 MS-DOS 和 Windows 95/98 等系统有了重大的改进和升级。

文件头结构体

IMAGE_FILE_HEADER FileHeader

  • MAGE_FILE_HEADER:描述磁盘上PE文件的相关信息。
  • 定位文件头地址:DOS头中的e_lfanew+4(位于NT头标识的地址+0x4)
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
// 文件头结构体: _IMAGE_FILE_HEADER
typedef struct _IMAGE_FILE_HEADER{
WORD Machine; // +0x00, 指定程序的运行平台,勿改
WORD NumberOfSections; // +0x02, PE中的节/块(section)数量,勿改
DWORD TimeDateStamp; // +0x04, 时间戳:链接器填写的文件生成时间
DWORD PointerToSymbolTable; // +0x08, 指向符号表的地址(主要用于调试)
DWORD NumberOfSymbols; // +0x0C, 符号表中符号个数(同上)
WORD SizeOfOptionalHeader; // +0x10, IMAGE_OPTIONAL_HEADER32选项头结构大小,勿改
WORD Characteristics; // +0x12, 文件属性,勿改
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

//* 字段1:Machine 表CPU的类型, 定义在windows.h中,常用类型
32位:#define IMAGE_FILE_MACHINE_I386, 0x014c // Intel 386, x86
64位:#define IMAGE_FILE_MACHINE_AMD64, 0x8664 // AMD64(KB), x64
//* 字段2:NumberOfSections 表PE中的节(section)数量:
节表紧跟在IMAGE_NT_HEADERS后面,此字段决定了节表中元素的个数,即节的个数
遍历节表经验:根据此处的个数拿对应的节表数据
//* 字段6:SizeOfOptionalHeader 表IMAGE_OPTIONAL_HEADER32 结构大小
定位节表位置=选项头地址+选项头大小
//* 字段7: Characteristics 表文件属性,EXE默认0100,DLL默认210Eh,或运算组合设置。
#define IMAGE_FILE_RELOCS_STRIPPED     0x0001 // 文件中不存在重定位信息
#define IMAGE_FILE_EXECUTABLE_IMAGE    0x0002 // 文件可执行
#define IMAGE_FILE_LINE_NUMS_STRIPPED   0x0004 // 文件中不存在行信息
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED   0x0008 // 文件中不存在符号信息
#define IMAGE_FILE_AGGRESIVE_WS_TRIM    0x0010 // 调整工作集
#define IMAGE_FILE_LARGE_ADDRESS_AWARE   0x0020 // 程序能处理大于2G的地址
#define IMAGE_FILE_BYTES_REVERSED_LO    0x0080 // 小尾方式
#define IMAGE_FILE_32BIT_MACHINE      0x0100 // 只在32位平台上运行
#define IMAGE_FILE_DEBUG_STRIPPED     0x0200 // 不包含调试信息
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // 不能从可移动盘运行
#define IMAGE_FILE_NET_RUN_FROM_SWAP    0x0800 // 不能从网络运行
#define IMAGE_FILE_SYSTEM          0x1000 // 系统文件(如驱动程序),不能直接运行
#define IMAGE_FILE_DLL            0x2000 // 是一个dll文件
#define IMAGE_FILE_UP_SYSTEM_ONLY      0x4000 // 文件不能在多处理器计算机上运行
#define IMAGE_FILE_BYTES_REVERSED_HI    0x8000 // 大尾方式

选项头结构体

IMAGE_OPTIONAL_HEADER(区分32位和64位)

  • IMAGE_FILE_HEADER::以供操作系统加载PE文件使用,必选。
  • 定位选项头地址:DOS头中的e_lfanew+4+0x14(文件头大小)。
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
// 32位选项头结构体:_IMAGE_OPTIONAL_HEADER
typedef struct _IMAGE_OPTIONAL_HEADER
{
WORD Magic; //* PE标志字:32位(0x10B),64位(0x20B)
BYTE MajorLinkerVersion; // 主链接器版本号
BYTE MinorLinkerVersion; // 副链接器版本号
DWORD SizeOfCode; // 代码所占空间大小(代码节大小)
DWORD SizeOfInitializedData; // 已初始化数据所占空间大小
DWORD SizeOfUninitializedData; // 未初始化数据所占空间大小
DWORD AddressOfEntryPoint; //* 程序执行入口RVA,(w)(Win)mainCRTStartup:即0D首次断下来的自进程地址,也就是我们常说的OEP
DWORD BaseOfCode; // 代码段基址
DWORD BaseOfData; // 数据段基址
//BaseOfCode和BaseOfData是操作系统不依赖的
DWORD ImageBase; //* (建议)内存加载基址,(模块基址,PE映射到内存的基地址)exe默认0x400000,dll默认0x10000000,主模块一般能被满足,但是Dll一般不能满足
DWORD SectionAlignment; //* 节区数据在内存中的对齐值,一定是4的倍数,一般是0x1000(4096=4K)
DWORD FileAlignment; //* 节区数据在文件中的对齐值,一般是0x200(磁盘扇区大小512)
WORD MajorOperatingSystemVersion; // 要求操作系统最低版本号的主版本号
WORD MinorOperatingSystemVersion; // 要求操作系统最低版本号的副版本号
WORD MajorImageVersion; // 可运行于操作系统的主版本号
WORD MinorImageVersion; // 可运行于操作系统的次版本号
WORD MajorSubsystemVersion; // 主子系统版本号:不可修改
WORD MinorSubsystemVersion; // 副子系统版本号
DWORD Win32VersionValue; // 版本号:不被病毒利用的话一般为0,XP中不可修改
DWORD SizeOfImage; //* PE文件在进程内存中的总大小,与SectionAlignment对齐
DWORD SizeOfHeaders; //* PE文件头部在文件中的按照文件对齐后的总大小(所有头 + 节表)
DWORD CheckSum; // 对文件做校验,判断文件是否被修改:3环无用,MapFileAndCheckSum获取 驱动会检查这个值
WORD Subsystem; // 子系统,与连接选项/system相关:1=驱动程序,2=图形界面,3=控制台/Dll
WORD DllCharacteristics; // 文件特性(不是只是Dll)
DWORD SizeOfStackReserve; // 初始化时保留的栈大小
DWORD SizeOfStackCommit; // 初始化时实际提交的栈大小
DWORD SizeOfHeapReserve; // 初始化时保留的堆大小
DWORD SizeOfHeapCommit; // 初始化时实际提交的堆大小
DWORD LoaderFlags; // 已废弃,与调试有关,默认为 0
DWORD NumberOfRvaAndSizes; // 下边数据目录的项数,此字段自Windows NT发布以来,一直是16
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];// 数据目录表
} IMAGE_OPTIONAL_HEADER32, * PIMAGE_OPTIONAL_HEADER32;

//* 字段6:AddressOfEntryPoint 表 程序入口RVA,即OEP:
EOP:程序入口点,壳相关概念
OEP:原本的程序入口点(实际为偏移,+模块基址=实际入口点)
EP: 被加工后的入口点
//* 字段9:ImageBase 表 模块加载基地址,exe默认0x400000,dll默认0x10000000
建议装载地址:exe映射加载到内存中的首地址= PE 0处,即实例句柄hInstance
一般而言,exe文件可遵从装载地址建议,但dll文件无法满足
//* 尾字段:DataDirectory 表 数据目录表,用来定义多种不通用处的数据块。
存储了PE中各个表的位置,详情参考IMAGE_DIRECTORY_ENTRY...系列宏

WORD DllCharacteristics;一些介绍

  1. IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE(0x0040):指示DLL支持ASLR(地址空间布局随机化),使得每次加载时的地址空间布局都不同,增加了系统的安全性。
  2. IMAGE_DLLCHARACTERISTICS_NX_COMPAT(0x0100):指示DLL支持DEP(数据执行保护),这样操作系统就可以防止恶意代码在内存中执行。
  3. IMAGE_DLLCHARACTERISTICS_NO_SEH(0x0400):指示DLL没有安全异常处理器(SEH),这意味着该DLL可能不支持异常处理。
  4. IMAGE_DLLCHARACTERISTICS_TERMINAL_SERVER_AWARE(0x8000):指示DLL是Terminal Server Aware,即它能够识别并适应终端服务器环境。
  5. IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA(0x0020):指示DLL需要使用高熵虚拟地址(High Entropy VA),这是一种更高级别的ASLR。
  6. IMAGE_DLLCHARACTERISTICS_APPCONTAINER(0x1000):指示DLL运行在AppContainer环境中,这是Windows 8及更新版本引入的一种应用沙箱机制。

节表重点成员–数据目录 IMAGE_DATA_DIRECTORY

  • 数据目录用来描述PE中各个表的位置及大小信息,重点表:导出表、导入表、重定位表、资源表。
1
2
3
4
5
// 数据目录 _IMAGE_DATA_DIRECTORY结构体
typedef struct _IMAGE_DATA_DIRECTORY {
DWORD VirtualAddress; /**指向某个数据的相对虚拟地址 RAV 偏移0x00**/
DWORD Size; /**某个数据块的大小 偏移0x04**/
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
  • 参数1VirtualAddress指定了数据块的相对虚拟地址(RVA),因为当exe在处理导入表的时候,已经映射进进程内存了,取值RVA更方便。

  • Size则指定了该数据块的大小,有时并不是该类型数据的总大小,可能只是该类型数据一个数据项的大小。这两个成员(主要是VirtualAddress)成为了定位各种表的关键,所以一定要知道每个数组元素所指向的数据块类型,以下表格就是它的对应关系:

1712833456689

图: IMAGE_DATA_DIRECTORY数组元素项图析

  • IMP(导入表):导入表用来描述模块调用的API列表,位于PE数据目录中的第二项即DataDirectory[1],其中记录了导入表的地址和大小,VirtualAddress指向IMAGE_IMPORT_DESCRIPTOR 结构体数组,这个结构体数组中的每个元素对应着一个dll文件,以全0作为最后一个元素结尾。程序产生调用会生成CALL指令,两大问题及解决思路如下:

    • 1.地址存放问题:出于运行环境等因素考虑,导入函数的地址不能为固定地址。所以在exe中保存导入函数的相关信息,系统和链接器对其进行约定:链接器在生成exe的时候,为所有调用API的地方填写一个间接地址,当程序运行起来后,相应地址则会被写入真正API的地址,此区域即为IAT表(导入地址表);
    • 2.exe如何存储导入dll及其函数信息:dll与函数是一对多的关系,原则上应该设计为多方填写1方信息的数据关系,但考虑到数据较多的情况,遍历不便。反过来设计为1方存储多方信息的数据结构,虽然会造成插入删除的不方便,但是考虑到exe加载dll的实际场景,无插入删除需求,所以应该设计为后者结构更贴合遍历查询需求。
1
2
3
4
5
6
7
8
9
10
11
// IMAGE_IMPORT_DESCRIPTOR 导入表结构,以全0(20个0)结尾
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 作用1:导入表结束标志
DWORD OriginalFirstThunk; //* 作用2:*RVA指向一个结构体数组(INT表)
};
DWORD TimeDateStamp; // 时间戳
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name; //* RVA指向以0结尾的dll名字
DWORD FirstThunk; //* RVA指向一个结构体数组(IAT表,DataDirectory[12]项)
} IMAGE_IMPORT_DESCRIPTOR, *PIMAGE_IMPORT_DESCRIPTOR;
  • IAT(导入地址表):IMP中的FirstThunk指向IAT表。
  • INT(导入名称表):IMP中的OriginalFirstThunk指向INT表,也是DataDirectory[12]项。
  • PE加载前,IAT和INT都指向_IMAGE_IMPORT_BY_NAME结构体
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/ IMAGE_THUNK_DATA结构体汇总只有一个联合体,
// 一般用四字节的AddressOfData来获取IMAGE_IMPORT_BY_NAME的地址。
// 四字节解析,看最高位[31]:
// 1表序号导入,低word为导入函数的序号值;
// 0表RVA,指向_IMAGE_IMPORT_BY_NAME;
typedef struct _IMAGE_THUNK_DATA32 {
union {
PBYTE ForwarderString; // PBYTE 指向一个转向者字符串的RVA;
PDWORD Function; // PDWORD 被输入的函数的内存地址;
DWORD Ordinal; // *被输入的API的序数值
PIMAGE_IMPORT_BY_NAME AddressOfData; //*RVA 指向IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;


// IMAGE_IMPORT_BY_NAME有两个成员:1.序号;2.函数名。
typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; // 可能为0,编译器决定,如果不为0,是函数在导出表中的索引
BYTE Name[1];// 函数名称以0结尾,由于不知道到底多长,所以干脆只给出第一个字符,找到0结束
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;
  • PE加载后,IAT有变:加载后的IAT每一项存储的是所对应的导入函数地址。

1712833632524

3.节表

(1)节表总概

  • 节表:描述PE文件与内存之间的映射关系,由一系列的IMAGE_SECTION_HEADER结构排列而成,每个结构用来描述一个节(每个节占用0x28B),说明PE文件的指定内容拷贝至内存的哪个位置、拷贝大小及内存属性的设置。结构的排列顺序和它们描述的节在文件中的排列顺序是一致的。全部有效结构的最后以一个空的IMAGE_SECTION_HEADER结构作为结束,所以节表中总的IMAGE_SECTION_HEADER结构数量等于节的数量加一。节表总是被存放在紧接在PE文件头的地方。
  • 节表大小 = FileHeader.NumberOfSections(节数量)* IMAGE_SECTION_HEADER 结构体。

IMAGE_SECTION_HEADER 结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// IMAGE_SECTION_HEADER 节表结构体,大小40B
typedef struct _IMAGE_SECTION_HEADER {
BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 节表名称:描述性字段
// 下方4个字段:从文件S1处开始,拷贝S2大小的数据,到内存S3处,有效数据占用内存S4大小
union {
DWORD PhysicalAddress;
DWORD VirtualSize; // S4:内存大小
} Misc;
DWORD VirtualAddress; // S3:内存地址:基于模块基址
DWORD SizeOfRawData; // S2:文件大小
DWORD PointerToRawData; // S1:文件偏移
DWORD PointerToRelocations; // 无用
DWORD PointerToLinenumbers; // 无用
WORD NumberOfRelocations; // 无用
WORD NumberOfLinenumbers; // 无用
DWORD Characteristics; // 节属性,取值IMAGE_SCN_...系列宏
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

一句话解释就是:

在文件偏移为 DWORD PointerToRawData 处映射 DWORD SizeOfRawData 大小的内容到内存,映射到的内存地址是 DWORD VirtualAddress ,所占的内存大小为 DWORD VirtualSize

下面是Characteristics的属性介绍

1712833168537

  • 节表置于选项头之后,节表首地址 计算方法:

    • (1)选项头的地址 + 选项头的大小;
    • (2) e_lfanew+4+0x14(文件头大小)+0xE0(32位选项头大小)。

Dump:

我们知道,一个可执行程序从硬盘加载到内存到运行,需要一个拉伸的过程,那么我们可以反过来,能否可以在运行起来的内存里面,还原出一个exe?

这样的一个操作就叫做dump

步骤就是:

PE头拷贝下来,然后节按照节表的定义的区域,拷下来

值得注意的是:

我们最好使用OD,X96dbg先将程序运行到OEP,然后再Dump,这是为什么呢?

原因就是:程序一旦跑起来,程序如果有全局变量,那么就有可能全局变量被赋值,一旦被赋值,如果程序启动时有检测全局变量是否为初始值,不是就退出程序的话,那么Dump下来的程序就是不可用的

以下记录一次Dump之旅,主角:扫雷

首先用x96dbg打开扫雷,运行到oep,然后分析

1713356633659

选中PE头,然后按下 CTRL C 复制,然后打开一个新的文件,再按下CTRL B粘贴

1713356746297

然后拷贝节表

接下来是节:

1713356952203

选中的那一段翻译过来就是,在内存中偏移为0x1000处的地址拷贝0x3A56个字节(这是实际大小,没有对齐),复制到文件偏移为0x400的地址,粘贴0x3c00个字节

但是由于是dump,我们便要按照对齐后的大小,即复制粘贴0x3c00大小的数据

节表注入

新增节:

需要这几个步骤:

  1. 节表个数加1
  2. 节表添加一项
  3. 添加节数据
  4. 修改SizeOfImage

举例子:

目标:把一个exe塞进节表

过程:

首先修改节表个数

1713405246615

这里改为4(原来是3)

然后添加节表

1713405270367

  1. 这里要注意的是,新加的PointrtToRawData也就是文件偏移就等于上一个节表的PointrtToRawData+SizeOfRawData
  2. SizeOfRawData要与FileAlignment对齐,例如我这个的例子是0x200
  3. 新加的VirtualAddress是等于上一个节表的VirtualAddress加上VirtualSize与SectionAlignment对齐后的值

之前我这里犯了一个错误,添加完数据后,我想反正0x1000也是0x200的倍数,不如直接让SizeOfRawData和0x1000对齐得了,结果反而出错了,推测是从文件映射到内存根本没那么多数据,系统找不到,因此就无法启动

再继续添加节数据:

直接复制粘贴exe的数据进去即可:
1713405575265

修改SizeOfImage

新的SizeOfImage = VirtualAddress + VirtualSize与SectionAlignment对齐后的值

但是值得一提的是,新增节可能不会奏效,因为可能在节表那一段没有足够多的空间给我塞下一个新的节表,有可能存储着其他重要数据,而他们是不能够覆盖的

用PETools可以添加节:

1713441180278

扩展最后一个节:

  1. 修改节表
  2. 添加节数据
  3. 修改SizeOfImage

1.修改节表

1713431156375

例如我想在原来节的基础上再添加0x3000的数据,首先将两个参数修改一下

就直接先在文件的末尾添加0x3000个字节的0x00,然后再从这个表里复制全部内容到原来的文件末尾处即可

1713431335313

完成添加

1713431410600

1713431400347

导入表:

导出表是给别人用的函数的清单,相当于饭店提供的菜单

导入表相当于客人点菜选择的菜品单。上面记录了exe要使用的dll和dll中的函数

数据目录第一项伟导出表,第二项表为导入表

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

定位导入表:

和之前的好多表一样,VirtualAddress 指向多个一样的导入表结构。

最后 sizeOf ( IMAGE_IMPORT_DESCRIPTOR) 个 0 代表导入表结束

导入表的union只用第二个 OriginalFirstThunk,时间戳 TimeDateStamp 记录了当前程序编译生成的时间

Name 是指向 dll 名的 RVA,只记录了一个 dll 名,所以导入表会有若干张,顺序无缝存放,一张导入表就是一个要使用的 dll,以全0作为结尾

OriginalFirstThunk(导入表结构第一个成员)指向一张叫 INT 的表(import name table,导入名称表),存的是若干个 IMAGE_THUNK_DATA 结构,最后以0结尾作结束标志。

FirstThunk(导入表结构最后一个成员)也指向一张表,IAT表(import address table,导入地址表),找到这张表有两种方式,一种就是通过导入表这里找到,第二种就是通过数据目录表,倒数第三个项就是指向的 IAT 表,IMAGE_DIRECTORY_ENTRY_IAT,也是以0结束。在文件加载前,这两个表存的内容完全一致,都是存储的IMAGE_THUNK_DATA 结构(IMAGE_THUNK_DATA32)(详见下下图)。注意虽然一致,但 INT 表和 IAT 表是两块不同的空间,分别记录的都是程序中使用到的 dll 函数,不使用的 dll 函数不会记录。

PE文件加载前:
202310191910

这里要注意的是,NTDLL和Kernel32会提前将函数地址放入IAT

上文提到的 IMAGE_THUNK_DATA 结构其实只是一个四字节的 union,如下图。

在加载前,IMAGE_THUNK_DATA 只存一个 RVA,这个 RVA 指向上图的IMAGE_IMPORT_BY_NAME 结构(具体结构格式在下图也有)

在这个结构中,BYTE Name[1]; 使用的技巧是所谓的 “结构体变长数组”,这种技巧通常用于存储可变长度的数据。尽管声明为 BYTE Name[1];,实际上这个字段可以容纳一个以 null 终止的字符串,而字符串的长度可以是任意的。

这是因为在PE文件的导入表中,函数名称并不是固定长度的,每个函数名称的长度都不同。因此,为了有效地存储这些不定长度的字符串,使用了变长数组的技巧。结构体只定义了一个字节的数组,但在实际使用时,根据函数名称的长度动态分配所需的内存,然后将函数名称存储在这个内存块中,以 null 终止字符串。

这种方法允许节省内存,因为不需要为每个结构分配固定大小的缓冲区以容纳字符串,而可以根据实际需要进行动态分配。在C/C++中,这种技巧在很多情况下用于处理可变长度的数据,例如字符串数组,以提高内存使用效率。在实际使用时,程序员通常会动态分配足够的内存以存储实际的字符串,然后将字符串内容复制到该内存中,以确保字符串正确存储和 null 终止。

在加载后,IAT 表变为存储函数的地址了,所以才有文章最前面提到的,在调用 MassageBox 文件加载前call间接寻址找的是一个字符串,而文件加载后这个间接寻址变为了函数的真正地址。而 INT 表不变。

IAT 表在文件加载完成后系统会调用 GetProcAddress() 函数,就是做的我们前面写过的根据导出表函数序号或函数名字找到函数地址的功能。系统会循环 INT 表,根据表内的名字或序号调用 GetProcAddr() 得到地址,依次添加到 IAT 表中。

注意 IMAGE_IMPORT_BY_NAME 中的 Hint 不是导出序号,而是当前这个函数在导出表函数地址表中的索引。但基本没用,所以不一定是准确的,可以全部为0。而Name并不是一字节,而是以’\0’结尾的不定长字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_THUNK_DATA32 {						
union {
PBYTE ForwarderString;
PDWORD Function;
DWORD Ordinal; //序号
PIMAGE_IMPORT_BY_NAME AddressOfData;//指向IMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedef IMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;


typedef struct _IMAGE_IMPORT_BY_NAME {
WORD Hint; //可能为空,编译器决定 如果不为空 是函数在导出表中的索引
BYTE Name[1]; //函数名称,以0结尾
} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME;

哎,被一个基础的指针指向搞迷糊了,一个指针类型指向一个结构,取了星才是这个结构本身。

注意,IMAGE_IMPORT_BY_NAME 中的 Name 并不只有 1 个字节,而是一直遍历到元素为 ‘\0’ 为止,OriginalFirstThunk 和 FirstThunk 的遍历 具体详见下图(这俩的遍历都一样的,其实都是遍历的 IMAGE_THUNK_DATA)

202310191913

202310191914

下面就是定位导入表的过程:

第一层循环遍历所有导入表,以0结尾

第二层循环先处理OriginalFirstThunk,其中的表项IMAGE_THUNK_DATA32就当做一个DWORD 四字节数据处理即可,为0则表结束

表项IMAGE_THUNK_DATA32 有可能是个序号,也有可能是个偏移,根据图中说明来处理。

从测试的结果来看,INT表就算是没有,貌似也不会咋样,但是IAT表是一定要有的
因为就算INT表没有东西,也可以从IAT表拿Dll的名字

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
while(取出导入表一项,且不为全0)
{
if(Name==NULL)
{
//退出循环,停止加载导入表
break;
}
LoadLibrary(Name);

if(FirstThunk==NULL)
{
break;
}

导入表名称地址 = OriginalFirstThunk; //INT地址
if(OriginalFirstThunk==NULL) //如果INT为空,则转为拿IAT
{
导入名称表地址=FirstThunk;
}

while(从导入名称表获取一个名称地址)
{
if(名称地址==NULL)
{
//结束遍历
break;
}

if(名称地址最高位为1)
{
//序号导入
序号 = 名称地址 & 0xffff;
导入函数地址=GetProcAddress(序号);
}
else
{
导入函数地址 = GetProcAddress(名称地址);
}

导入函数地址:填入 IAT对应项;
}
}

绑定导入表:

(比较新的Windows版本基本废弃使用绑定导入表了 )

1.关于绑定导入
一般情况下,在程序加载前IAT表和INT表中的内容相同,都是程序引用的dll中的函数的函数名或序号;
加载完成后IAT表中将替换为函数的真正地址;

如果dll文件被更新了,那绑定导入表的时间戳和dll文件的时间戳就不一样了,内容肯定变了,就需要修正序号函数名称函数地址表,绑定导入表就这作用

但在加载前IAT表中直接写绝对地址是可以实现的;
加载前在IAT表中保存绝对地址的优点:启动程序快;
在启动程序时需要:申请4gb内存空间、贴exe、贴dll、将IAT表修复为地址等等;
如果直接用绝对地址,则省去了修复IAT表的操作;
缺点:
dll重定位时,如果dll没能占据自身ImageBase处的地址,则需要修复绝对地址;
dll被修改时,dll被修改,IAT表中对应的函数地址可能被改,需要修复函数地址;

例如windows提供的一些程序就使用了这种方式,比如记事本;
这种方式称为“绑定导入“;

2.如何判断绑定导入
在导入表中结构中有个属性:TimeDateStamp;
该属性表示时间戳;
如果值为0则表示当前的dll的函数没有被绑定,在程序加载时会调用系统函数获取函数地址;
如果值为-1则表示当前的dll的函数已经绑定,而且绑定的时间存在另外一张表里;那张表就是绑定导入表;

3.绑定导入表
PE加载EXE相关的DLL时,首先会根据IMAGE_IMPORT_DESCRIPTOR结构中的TimeDateStamp来判断是否要重新计算IAT表中的地址。
TimeDateStamp == 0 未绑定
TimeDateStamp == -1 已绑定 真正的绑定时间为IMAGE_BOUND_IMPORT_DESCRIPTOR的TimeDateStamp

绑定导入表位于数据目录的第12项;
绑定导入表的结构:

1
2
3
4
5
typedef struct _IMAGE_BOUND_IMPORT_DESCRIPTOR {
DWORD TimeDateStamp;
WORD OffsetModuleName;
WORD NumberOfModuleForwarderRefs; // Array of zero or more IMAGE_BOUND_FORWARDER_REF follows
} IMAGE_BOUND_IMPORT_DESCRIPTOR, *PIMAGE_BOUND_IMPORT_DESCRIPTOR;

TimeDateStamp ->时间戳;用来判断是否和绑定的dll是同一个版本;也就是看时间戳和dll的pe头中的时间戳是否一样;
OffsetModuleName ->dll的名字;注意保存的既不是RVA也不是FOA;
dll的名字计算公式为:第一个DESCRIPTOR的值+OffsetModuleName;
NumberOfModuleForwarderRefs ->当前dll另外依赖的dll数量;因为dll也可能依赖dll;

绑定导入表结构后面紧跟的并不一定是下一个绑定导入表;
如果NumberOfModuleForwarderRefs为N则还有N个另外的结构;
该结构也是用来描述dll的;
结构如下:

1
2
3
4
5
typedef struct _IMAGE_BOUND_FORWARDER_REF {
DWORD TimeDateStamp;
WORD OffsetModuleName;
WORD Reserved;
} IMAGE_BOUND_FORWARDER_REF, *PIMAGE_BOUND_FORWARDER_REF;

前两个属性和绑定导入表意义一样;
第三个属性Reserved为保留字段没鸟用
绑定导入表的结构图:

202310211146

注意:
当IMAGE_BOUND_IMPORT_DESCRIPTOR结构中的TimeDateStamp与DLL文件标准PE头中的TimeDateStamp值不相符时,
或者DLL需要重新定位的时候,就会重新计算IAT中的值.

导入表注入

凡是记录在导入表的dll,都会被系统加载到进程

步骤很简单:

1.找到一片宽阔的区域(没有存数据的区域)

2.把位于可选头存导入表的地址(注意时ROV)改到找到的没有存数据的区域

3.将原来的导入表复制到新的区域,

4.加上自己的导入表数据

按照下面这个来

202310191910

有个问题就是如果我用扫雷,发现注入不成功,原因未知
(已解决,属性要改下,最好每个节都给最高权限就不会有这些问题)

LoadPE

已经写好了,放在E盘

[C_LoadPe.rar](E:\viusal studio document\科锐\PE\LoadPe\C_LoadPe.rar)

(前提:没有TLS表)

主要就是解决 导入表修复问题

因为LoadLibrary只是简单的把文件映射到内存,IAT表此时还是和INT表是一个东西,并没有填上真正的地址

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
70
71
72
73
74
75
#include <iostream>
#include <windows.h>
#include "Stretch_Data.h"
using namespace std;


int main()
{


Stretch m_stretch;
m_stretch.GetContext();
LPVOID PTR = VirtualAlloc((LPVOID)0x400000, 0x30000, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

//拷贝DOS头 NT头 节表
memcpy(PTR, m_stretch.Stretch_Pointer, sizeof(IMAGE_DOS_HEADER) + sizeof(IMAGE_NT_HEADERS) + m_stretch.PFile_Header->SizeOfOptionalHeader + m_stretch.PFile_Header->NumberOfSections * sizeof(IMAGE_SECTION_HEADER));

//拷贝各种节
LPVOID Temp_Ptr = m_stretch.Stretch_Pointer;
for (int i = 0; i < m_stretch.PFile_Header->NumberOfSections; i++)
{
memcpy((LPVOID)((DWORD)PTR + m_stretch.Section[i].VirtualAddress), (LPVOID)((DWORD)Temp_Ptr+m_stretch.Section[i].PointerToRawData), m_stretch.Section[i].Misc.VirtualSize);

}

cout << "拷贝结束!" << endl;


//修复导入表
for (int i = 0; i < m_stretch.Improt_Table_Num; i++)
{
PIMAGE_THUNK_DATA m_ptr = (IMAGE_THUNK_DATA*)((DWORD)PTR + (DWORD)m_stretch.Import_Table[i].OriginalFirstThunk);
INT COUNT = 0;
while (1)
{
DWORD Number = 0;
CHAR* Func_Name = 0;
CHAR* Dll_Name;
Dll_Name = (CHAR*)((DWORD)PTR + m_stretch.Import_Table[i].Name);
if (m_ptr->u1.Ordinal & 0x80000000)
{
Number = m_ptr->u1.Ordinal;
HMODULE handle = LoadLibrary(Dll_Name);
DWORD FuncAddr = (DWORD)GetProcAddress(handle, (char*)Number);
DWORD* IAT = (DWORD*)(m_stretch.Import_Table[i].FirstThunk + (DWORD)PTR + COUNT * 4);
*IAT = FuncAddr;
}
else
{
Func_Name = (CHAR*)((DWORD)PTR + (DWORD)m_ptr->u1.AddressOfData + 0x2);
HMODULE handle = LoadLibrary(Dll_Name);
DWORD FuncAddr = (DWORD)GetProcAddress(handle, (char*)Func_Name);
DWORD* IAT = (DWORD*)(m_stretch.Import_Table[i].FirstThunk + (DWORD)PTR + COUNT * 4);
*IAT = FuncAddr;

}
COUNT++;
m_ptr = (PIMAGE_THUNK_DATA)((DWORD)m_ptr + sizeof(IMAGE_THUNK_DATA));
if (m_ptr->u1.AddressOfData == NULL)
break;
}

}

__asm
{
mov eax,0x411023
jmp eax
}



return 0;

}

导出表:

按照序号导出的时候:

导出表存的不是下面这个形式:

索引 地址
1 0x11223344
2 0x44556677
3 0x11223366

导入表是没有索引的,直接存地址:

0x11223344
0x11223344
0x55661122

那没有索引咋定位呢?回存一个base

例如第一个编码是0x1000,第二个是0x1001,第三个是0x1002

那么base就是0x1000,然后按照顺序存,例如0x1000就存在第0个,0x1002就存在第二个

当然如果编码分别是0x1000,0x2000,0x3000

那么base仍然是0x1000,序号为0x2000的与0x1000的中间会隔着0x1000的空数据

这就是空间换时间

如果是名称导出,系统自动给名称编个序号,本质上也是根据序号去找

数据目录结构

1
2
3
4
typedef struct IMAGE_DIRECTIRY_ENTRY_EXPORT {
DWORD VirtualAddress; //RVA地址
DWORD Size; //该数据所占的大小。可以修改(函数转发的判断条件)
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;

1714032068741

IMAGE_EXPORT_DIRECTORY

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct _IMAGE_EXPORT_DIRECTORY {
+0 DWORD Characteristics; // 属性,可修改
+4 DWORD TimeDateStamp; // 时间,可修改
+8 WORD MajorVersion; // 版本,可修改
+a WORD MinorVersion; // 版本,可修改
+c DWORD Name; // 库名称,可修改
+10 DWORD Base; // 序号基址
+14 DWORD NumberOfFunctions; // 导出函数个数
+18 DWORD NumberOfNames; // 导出名称表个数
+1c DWORD AddressOfFunctions; // 导出函数地址表 RVA
+20 DWORD AddressOfNames; // 导出函数名称表 RVA
+24 DWORD AddressOfNameOrdinals; // 导出函数序号表 RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

1714032104841

AddressOfFunctions 存的是导出函数的地址

AddressOfNames 指的是按照名字导出的函数的个数

AddressOfNameOrdinals 指的是按照名字导出的序号表,序号个数和NumberOfNames是一致的

那么不按序号导出的情况时,序号个数不是和NumberOfNames相等吗?难道不按序号导出就不配拥有序号?

当然不是,序号还是会有的,去AddressOfFunctions去找,对应的索引加上Base就是对应的序号值

a. 导出函数地址表:

  • 该表中元素宽度为4个字节
  • 该表中存储所有导出函数的地址
  • 该表中个数由NumberOfFunctions决定
  • 该表项中的值是RVA,需要加上ImageBase才是函数真正的地址。

b. 导出函数名称表:

  • 该表中的元素宽度为4个字节。
  • 该表中存储所有以名字导出函数的名字的RVA(以 /0结尾的字符串)
  • 导出名称表是排序过的,有利于折半查找。(ASCLL码排序)

c. 导出函数序号表:

  • 该表中的元素是2个字节
  • 该表中存储的是对应函数地址表的下标。
  • 该表中个数由导出序号的最大值 - 最小值 决定,没有的序号内容以 NULL 填充(所以会造成如果序号值相差过大,会影响Dll的体积)

(三)为什么分成3张表?

  1. 函数导出的个数与函数名个数未必一样,所以需要将导出函数地址和导出函数名称表分开。
  2. 函数地址表并不是一定大于函数名称表的。因为一个相同的函数可能存在多个不同的名字。

模拟GetProcAddress

重要性:加壳的代码很容易被人dump,因此为了防御都会想办法抹除导入表,内存和文件中都没有。

但是程序运行起来的时候,总要加载所需DLL,攻击者会在GetProcAddress下断点,将每次的数据都保存下来,从而恢复导入表。

但是,如果这个函数以及内部完全由自己实现的话,攻击者就不得不看代码进行还原。

反Dump:目前很多防止Dump都使用了API模拟,不调用API,将IAT表填上自己模拟的API地址。

原理:根据模块句柄和函数的名称或则序号来获取该API所在的地址。

(一)步骤:

  1. 定位到导出名称表
  2. 获取导出名称表个数
  3. 遍历比较字符串
  4. 获取对应的序号
  5. 获取序号对应的地址,(从地址表中获取)

(二)函数转发处理

判断转发函数:判断获取到的函数地址表地址是否在 导入名称表 + 导出表Size (导出表)范围内

1714033738638

处理流程:

  1. 拆解字符串。LoadLibaray(DLL名称)
  2. 调用自己,获取名字的地址。

导出表应用

(一)IAT表反向查询

VA(内存中的call地址) => RVA(转换成基于当前模块的偏移地址) => 遍历模块() =>遍历导出表 => 遍历导出地址表,判断RVA是否命中 =>遍历序号表 =>从名称表获取函数名称

不用考虑函数转发问题,因为函数转发的地址会命中另一个模块

静态反汇编只能看导入表,动态调试才能查看导出表

(二)IDA显示函数名称

​ 通过导入表反向查询函数名称,如果将PE文件中导入函数名称表中的地址按照计划调换位置,然后动态运行时再通过内存访问换回来,那么IDA作为静态调试只能通过PE中记录的函数名称去导入表信息遍历,这样只能获取错误的API。

​ IAT被填入了错误的API地址,而IDA进行IAT反向查询,实际上显示的是正确的,但是运行时程序会调用修改后的API。

​ 可以选用参数个数相同,但是功能不同的API,攻击者使用IDA反汇编看到的汇编代码调用了一个API,但实际上程序的调用了是另一个API,起到了混淆的作用。

​ 这样实际上就是对IDA做反汇编对抗,IDA没有办法检查API的正确性。

重定位表

在数据目录表的第五个(从0开始算)

简介:

定义:记录需要绝对地址修正的表,大多数绝对地址如果imagebase变化的话就无法使用,需要修正程序所调用的那些绝对地址。

  • 修正方法:需要重定位的地址 + 偏移(当前基址 - PE的基址)

  • 开了随机基址的程序才需要重定位,而DLL通常都有重定位表,因为不一定能够加载到DLL指定的ImageBase上。

为什么需要重定位呢?不是已经有导出表了,里面就有函数的偏移呀

因为导出表里面记录的偏移,也就是AddressOfFunctions,不包含dll里面不导出的函数地址,仅仅只有导出函数的地址,如果没有重定位表,那么这些地址将得不到修正,在dll也就无法调用这些不导出的函数,会影响Dll的正常功能

修的应该是那些绝对地址的,重定位表会指出需要修改的机器码的地址,然后修改即可,会改变机器码

OS如何判定是否重定位?

  • 先查看随机地址标志,标志开启,地址重定位
  • 再查看数据目录项 5 是否位NULL,不为NULL,基址重定位。

重定位表结构

重定位表描述待修复的值所在的地方,这个值是一个RVA。数据目录处的Size字段有用,是重定位表的总大小。

重定位表位于数据目录第3项。

1
2
3
4
5
typedef struct _IMAGE_BASE_RELOCATION {
DWORD VirtualAddress; //重定位数据所在页的RVA
DWORD SizeOfBlock; //当前页中重定位数据块的总大小
// WORD TypeOffset[1]; //重定位项数组
} IMAGE_BASE_RELOCATION;
  • VirtualAddress

    • 这个虚拟地址是一组重定位数据的开始RVA地址,只有重定位项的有效数据加上这个值才是重定位数据真正的RVA地址
  • SizeOfBlock

    • 它是当前重定位块的总大小,因为VirtualAddress和SizeOfBlock都是4字节的,所以(SizeOfBlock - 8)才是该块所有重定位项的大小,(SizeOfBlock - 8) / 2就是该块所有重定位项的数目。
  • TypeOffset[1]

    • 重定位项在该结构中没有体现出来,他的位置是紧挨着这个结构的,可以把他当作一个数组,宽度为2字节。表示该地址处有一个地址需要进行重定位
    • 每一个重定位项分为两个部分:高4位和低12位
      • 高4位表示了重定位数据的类型(0x00没有任何作用仅仅用作数据填充,为了4字节对齐,说明这个地址不需要修正。0x03表示这个数据是重定位数据,需要修正。0x0A出现在64位程序中,也是需要修正的地址),
      • 低12位就是重定位数据相对于VirtualAddress的偏移,也就是上面所说的有效数据。之所以是12位,是因为12位的大小足够表示该块中的所有地址(每一个数据块表示一个页中的所有重定位数据,一个页的大小位0x1000)。

修正方法:被重定位处原来的地址 + 偏移(当前基址 - PE的基址):(VA - ImageBase) + NewImageBase

重定位表只是记录了修哪里,以及怎么修,并不会记录修多少,因为既然是随机基址,那么基址的值就不固定,必须每次软件起来才能确定。

1714049885702

image.png

注意:.reloc: 的节一般用于存储重定位表,但是不作为定位重定位表的依据,应使用数据目录定位。

1714054461924

这个讲得不清不楚的,但是还是要说一下,并不是因为IAT表里面的地址需要被修正,而是调用某函数的时候,会先要FF 15 Call到IAT表,然后再Call IAT表里面真正的函数地址

1714054793352

所以 FF 15 Call的 IAT表的地址需要被修正

学完壳,再回来看看,感觉好奇怪

1714057549608

TLS表

TLS: Thread local storage 线程局部存储

显式TLS:

用Windows提供的API

  1. TlsAlloc:
    • 函数原型:DWORD TlsAlloc(void);
    • 功能:分配一个新的 TLS 索引,该索引可用于在当前进程的所有线程中访问线程局部存储。
    • 返回值:如果成功,则返回一个 TLS 索引;如果失败,则返回 TLS_OUT_OF_INDEXES (-1)。
    • 示例用法:通常在程序初始化时调用 TlsAlloc 来分配一个 TLS 索引,然后将该索引保存在全局变量中以备后续使用。
  2. TlsSetValue:
    • 函数原型:BOOL TlsSetValue(DWORD dwTlsIndex, LPVOID lpTlsValue);
    • 功能:将数据存储到当前线程的指定 TLS 索引处。
    • 参数:
      • dwTlsIndex:要设置值的 TLS 索引。
      • lpTlsValue:要存储的数据指针。
    • 返回值:如果成功,则返回非零值;如果失败,则返回零。
    • 示例用法:在当前线程中调用 TlsSetValue 来存储线程私有数据。
  3. TlsGetValue:
    • 函数原型:LPVOID TlsGetValue(DWORD dwTlsIndex);
    • 功能:从当前线程的指定 TLS 索引处获取数据。
    • 参数:
      • dwTlsIndex:要获取值的 TLS 索引。
    • 返回值:如果成功,则返回存储在 TLS 索引处的数据指针;如果失败,则返回 NULL
    • 示例用法:在当前线程中调用 TlsGetValue 来获取之前存储的线程私有数据。
  4. TlsFree:
    • 函数原型:BOOL TlsFree(DWORD dwTlsIndex);
    • 功能:释放指定的 TLS 索引。
    • 参数:
      • dwTlsIndex:要释放的 TLS 索引。
    • 返回值:如果成功,则返回非零值;如果失败,则返回零。
    • 示例用法:在程序退出或不再需要某个 TLS 索引时调用 TlsFree 来释放该索引。

代码如下:

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
#include <windows.h>
#include <iostream>
#include <thread>
using namespace std;

void ThreadFunc()
{
DWORD dwVal=(DWORD)TlsGetValue(0);
cout << hex << dwVal << endl;
TlsSetValue(1, (LPVOID)0x1111111111);
}

int main()
{

TlsSetValue(0,(LPVOID)0x12345678);


thread t(ThreadFunc);
t.join();

DWORD dwVal = (DWORD)TlsGetValue(0);
cout << hex << dwVal << endl;
system("pause");


}




/*在 Windows 中,TLS 索引在第一次调用 TlsSetValue 函数时会自动分配。因此,尽管你在代码中没有显式调用 TlsAlloc 函数来分配 TLS 索引,但在调用 TlsSetValue(0, (LPVOID)0x12345678); 时,系统会自动为 TLS 索引分配一个值为 0 的 TLS 索引,并将值 0x12345678 存储到该索引处。

所以,即使你没有显式地调用 TlsAlloc,仍然可以使用 TlsSetValue 和 TlsGetValue 来操作 TLS。但是显式地调用 TlsAlloc 可以提供更明确的控制和可读性,特别是在需要多个 TLS 索引时。*/

1714136965265

从运行的结果可以知道,主线程调用的TlsSetValue的值不能被子线程读到,同理,子线程设的值也不会被主线程读到

我们来看看,TlsSetValue是怎么干活的

[ebp+0x8]是第二个参数,[ebp+0xC]是第一个参数

1714138082207

发现把eax,也就是第二个参数,丢进了dword ptr[edi+esi*4+0xE10],edi是fs:[0x18] (即TEB)

用Windbg查出来,+0xE10存的是TlsSlots

1714138441720

发现是一个DWORD的,有64个元素的数组

TLS是用来存啥的?如果是TlsSlots是DWORD数组,那可以存字符串吗

在 Windows 中,TLS 可以用来存储各种类型的数据,包括整数、指针、结构体等。TLS 通常被用于存储线程特定的数据或者线程需要独立访问的数据,比如线程相关的配置信息、状态信息、线程私有的全局变量等。

(超过四字节就存地址呗 )

TlsAlloc 函数的作用是在 TLS (Thread Local Storage) 索引数组中找到一个空闲的位置,并返回该位置的索引值。

隐式TLS:

变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <windows.h>
#include <iostream>
#include <thread>
using namespace std;


__declspec(thread) DWORD g_dwVal = 0x12345678;
void ThreadFunc()
{
cout << g_dwVal << endl;
}


int main()
{
cout << g_dwVal << endl;
g_dwVal = 0x111111;
thread t(ThreadFunc);
t.join();
system("pause");
return 0;

}

1714140178291

发现主线程是可以读取的,但是不能改变

为啥会这样呢,我们用汇编看看怎么个事

对应着这个

g_dwVal = 0x111111;

1714140398129

1714141124330

大概长这样,随便画的

1714141343861

0x104是一个定死的值,具体为啥,没说

TLS表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct _IMAGE_TLS_DIRECTORY32 {
DWORD StartAddressOfRawData; // 隐式TLS变量初始化值存储范围的 起始地址(VA)
DWORD EndAddressOfRawData; // 隐式TLS变量初始化值存储范围的 结束地址(VA)
DWORD AddressOfIndex; // 索引 (上面的_tls_index)
DWORD AddressOfCallBacks; // 回调函数的地址(可以填多个,每个都会调用)
DWORD SizeOfZeroFill; //
union {
DWORD Characteristics;
struct {
DWORD Reserved0 : 20;
DWORD Alignment : 4;
DWORD Reserved1 : 8;
} DUMMYSTRUCTNAME;
} DUMMYUNIONNAME;
} IMAGE_TLS_DIRECTORY32;

PE头里面的 TLS 表,使用隐式TLS的时候才存在该表,数据目录第 9(下标) 项

StartAddressOfRawDataEndAddressOfRawData 决定一个范围,创建线程的时候拷贝该范围数据到TEB结构体中。

1
__declspec(thread) DWORD g_dwVal = 0x12345678;

加了一句这个,编译器就会在PE中留一块内存,专门存放这玩意

当进程启动后,就会拷贝到TEB,然后自己去TEB里面拿,TEB是每个线程独有的

这样就能保证每个线程都只能操控自己的变量

总结就是: tls就是把pe的tls数据复制到线程结构体里,没了

TLS_Callback TLS回调函数

TLS_Callback

TLS_Callback(Thread-local storage,线程本地存储)是WindowsAPI下的一个回调函数,它会在程序进程开始,结束以及线程开始,结束的时候调用。

TLS在逆向中

TLS_Callback在逆向中有一个特点: 先于main函数执行/在main结束后执行。因此也经常被用作反调试,或者是藏匿变量修改,以防止动态和静态分析。

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include <stdio.h>
#include <Windows.h>

#ifdef _WIN64
#pragma comment (linker, "/INCLUDE:_tls_used")
#pragma comment (linker, "/INCLUDE:thread_callback_base")

#else
#pragma comment (linker, "/INCLUDE:__tls_used") //创建TLS表
#pragma comment (linker, "/INCLUDE:_thread_callback_base")
#endif

void generate_str(char* ptr, DWORD reason)
{
switch (reason) {
case DLL_PROCESS_ATTACH:
strcat_s(ptr ,200, "DLL_PROCESS_ATTACH\n");
break;
case DLL_PROCESS_DETACH:
strcat_s(ptr, 200, "DLL_PROCESS_DETACH\n");
break;
case DLL_THREAD_ATTACH:
strcat_s(ptr, 200, "DLL_THREAD_ATTACH\n");
break;
case DLL_THREAD_DETACH:
strcat_s(ptr, 200, "DLL_THREAD_DETACH\n");
break;
}
}

// TLS回调
void NTAPI tls_callback_1(PVOID DllHandle, DWORD Reason, PVOID Reserved)
{
HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
char ptr[256]{ 0 };
strcpy_s(ptr, " TLS Callback1: ");
generate_str(ptr, Reason);
WriteConsoleA(hStdout, ptr, strlen(ptr), NULL, NULL);
}

// TLS回调
void NTAPI tls_callback_2(PVOID DllHandle, DWORD Reason, PVOID Reserved)
{
HANDLE hStdout = GetStdHandle(STD_OUTPUT_HANDLE);
char ptr[256]{ 0 };
strcpy_s(ptr, " TLS Callback2: ");
generate_str(ptr, Reason);
WriteConsoleA(hStdout, ptr, strlen(ptr), NULL, NULL);
}

#ifdef _WIN64
#pragma const_seg(".CRT$XLF")
EXTERN_C const
#else
#pragma data_seg(".CRT$XLF")
EXTERN_C
#endif
PIMAGE_TLS_CALLBACK thread_callback_base[] = { tls_callback_1, tls_callback_2, 0 };
#ifdef _WIN64
#pragma const_seg()
#else
#pragma data_seg()
#endif //_WIN64

#define OK_PRINT_FLAG(s) ("\033[1;40;32m[+]\033[0m "##s)
#define ERR_PRINT_FLAG(s) ("\033[1;40;31m[-]\033[0m "##s)

// 线程回调
DWORD WINAPI thread_func(
_In_ LPVOID lpParameter
)
{
printf(OK_PRINT_FLAG("Enter thread func...\n"));
Sleep(5000);
printf(OK_PRINT_FLAG("Leave thread func...\n"));
return 0;
}

int main(void)
{
printf(OK_PRINT_FLAG("Enter main...\n"));

HANDLE hThread = CreateThread(NULL, 0, thread_func, NULL, 0, NULL);
WaitForSingleObject(hThread, INFINITE);

printf(OK_PRINT_FLAG("Leave main...\n"));
return 0;
}

如何跟踪 Tls回调函数??

我们跟踪到Tls表

根据前面提到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct _IMAGE_TLS_DIRECTORY32 {
DWORD StartAddressOfRawData; // 隐式TLS变量初始化值存储范围的 起始地址(VA)
DWORD EndAddressOfRawData; // 隐式TLS变量初始化值存储范围的 结束地址(VA)
DWORD AddressOfIndex; // 索引 (上面的_tls_index)
DWORD AddressOfCallBacks; // 回调函数的地址(可以填多个,每个都会调用)
DWORD SizeOfZeroFill; //
union {
DWORD Characteristics;
struct {
DWORD Reserved0 : 20;
DWORD Alignment : 4;
DWORD Reserved1 : 8;
} DUMMYSTRUCTNAME;
} DUMMYUNIONNAME;
} IMAGE_TLS_DIRECTORY32;


//对我们来说,有用的就前面四个

对应的AddressOfCallBacks为0x417720,这是一个(VA)地址,我们直接用IDA去跟

1714270322100

果然发现了回调函数数组

1714270398892

资源表:

解析资源表

这是一个嵌套的结构

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
70
//资源目录头
typedef struct _IMAGE_RESOURCE_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
WORD NumberOfNamedEntries; // 以字符串命名的资源的个数
WORD NumberOfIdEntries; // 以ID命名的资源的个数
// IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[]; 柔性数组,个数 = NumberOfNamedEntries + NumberOfIdEntries
} IMAGE_RESOURCE_DIRECTORY, *PIMAGE_RESOURCE_DIRECTORY;

//资源的命名方式:1. 宏定义ID 2. 字符串命名
//当进行到第三层,IMAGE_RESOURCE_DIRECTORY_ENTRY DirectoryEntries[]中的第一个Union是一个代码页,我做实验发现是0x804





//资源目录
IMAGE_RESOURCE_DIRECTORY_ENTRY
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31; // 节内偏移,指向
DWORD NameIsString:1; //1:说明ID是字符串。
} DUMMYSTRUCTNAME;
DWORD Name; //
WORD Id; //
} DUMMYUNIONNAME;
//在这个union DWORD中最高位为1,那么就是说明ID是字符串,NameOffset指向字符串的偏移,否则,说明ID不是字符串,是一个数组,DWORD本身就是资源的ID值
//其中指向的字符串的结构为 _IMAGE_RESOURCE_DIRECTORY_STRING
//1. Name,这个字段拥有多个不同的含义:当IRDE位于第一层目录时,Name表示资源的类型;当IRDE位于第二层目录时,Name表示资源的名称;当IRDE位于第三层目录时,Name表示代码页的编号。此外,还需要先判断Name的最高位是0还是1,如果是0则表示当做一个值来使用,如果是1,则表示低31位当做指针来使用;





union {
DWORD OffsetToData;
struct {
DWORD OffsetToDirectory:31;
DWORD DataIsDirectory:1; //1. 目录项,低位节内偏移。 0.数据,是一个文件,按照IMAGE_RESOURCE_DATA_ENTRY结构体解析
} DUMMYSTRUCTNAME2;
} DUMMYUNIONNAME2;
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

//第二个union是用来判断是文件还是文件夹,也是看最高位,如果最高位为1,则为文件夹,那么偏移的地址的结构就是_IMAGE_RESOURCE_DIRECTORY。如果最高位为0,则是一个文件,那么按照_IMAGE_RESOURCE_DATA_ENTRY结构进行解析






//Pasc字符串
typedef struct _IMAGE_RESOURCE_DIRECTORY_STRING {
WORD Length;
CHAR NameString[ 1 ];
} IMAGE_RESOURCE_DIRECTORY_STRING, *PIMAGE_RESOURCE_DIRECTORY_STRING;

//@[comment("MVI_tracked")]
typedef struct _IMAGE_RESOURCE_DATA_ENTRY {
DWORD OffsetToData; // 位置:RVA
DWORD Size; // 资源的大小
DWORD CodePage;
DWORD Reserved;
} IMAGE_RESOURCE_DATA_ENTRY, *PIMAGE_RESOURCE_DATA_ENTRY;


//前面四个元素都是没啥用的

我们来试着解析一波资源表:

1714273648235

就以这个程序为例子:

1714273752238

从9A00开始,就是资源表

前面0xC个字节对我们来说不重要

NumberOfNamedEntries;在这里是0,NumberOfIdEntries在这里是7

接下来是柔性数组,是_IMAGE_RESOURCE_DIRECTORY_ENTRY结构

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
IMAGE_RESOURCE_DIRECTORY_ENTRY
typedef struct _IMAGE_RESOURCE_DIRECTORY_ENTRY {
union {
struct {
DWORD NameOffset:31; // 节内偏移,指向
DWORD NameIsString:1; //1:说明ID是字符串。
} DUMMYSTRUCTNAME;
DWORD Name; //
WORD Id; //
} DUMMYUNIONNAME;
//在这个union DWORD中最高位为1,那么就是说明ID是字符串,NameOffset指向字符串的偏移,否则,说明ID不是字符串,是一个数组,DWORD本身就是资源的ID值
//其中指向的字符串的结构为 _IMAGE_RESOURCE_DIRECTORY_STRING





union {
DWORD OffsetToData;
struct {
DWORD OffsetToDirectory:31;
DWORD DataIsDirectory:1; //1. 目录项,低位节内偏移。 0.数据,是一个文件,按照IMAGE_RESOURCE_DATA_ENTRY结构体解析
} DUMMYSTRUCTNAME2;
} DUMMYUNIONNAME2;
} IMAGE_RESOURCE_DIRECTORY_ENTRY, *PIMAGE_RESOURCE_DIRECTORY_ENTRY;

//第二个union是用来判断是文件还是文件夹,也是看最高位,如果最高位为1,则为文件夹,那么偏移的地址的结构就是_IMAGE_RESOURCE_DIRECTORY。如果最高位为0,则是一个文件,那么按照_IMAGE_RESOURCE_DATA_ENTRY结构进行解析

//当进行到第三层,是一个代码页

这个结构体总共是两个DWORD的大小,第一个DWORD最高位不为1,则该DWORD就是代表ID值,否则就是代表字符串的偏移

我们可以看到,图片里共有七个这样的结构,他们的ID分别是3 4 5 6 9 E 0x18,这个其实是资源的种类,可以用VS查看下

1714274254650

随便拿一个ID为3的,即RT_ICON
_IMAGE_RESOURCE_DIRECTORY_ENTRY结构中解析出ID号为3,最高位为1,是一个文件夹,要按照 _IMAGE_RESOURCE_DIRECTORY进行解析

解析到RT_MENU的结果(unicode编码)

1714276000712