2021-08-31学习笔记00
请注意,本文编写于 455 天前,最后修改于 204 天前,其中某些信息可能已经过时。

目录


1. PE文件结构

2021年8月22日

1.1 什么是可执行文件?

可执行文件 (executable file) 指的是可以由操作系统进行加载执行的文件。

可执行文件的格式:

Windows平台:
	PE(Portable Executable)文件结构

Linux平台:
	ELF(Executable and Linking Format)文件结构

哪些领域会用到PE文件格式:

<1> 病毒与反病毒

<2> 外挂与反外挂

<3> 加壳与脱壳(保护与破解)

<4> 无源码修改功能、软件汉化等等

1.2 如何识别PE文件?

<1> PE文件的特征(PE指纹)

分别打开.exe  .dll  .sys 等文件,观察特征前2个字节。

特征满足如下条件:看前两个字节是不是4D 5A,然后读取3C的位置,找到PE头所在的位置

<2> 不要仅仅通过文件的后缀名来认定PE文件

1.3 PE文件的整体结构

2. PE文件的两种状态

IMAGE_DOS_HEADER(64) IMAGE_OPTIONAL_HEADER32)(224)
IMAGE_FILE_HEADER(20) IMAGE_SECTION_HEADER)(40)

2.1 DOS

IMAGE_DOS_HEADER(64) 4行

2.1.1 IMAGE_DOS_HEADER

大小是64字节,是确定的大小

c
typedef struct _IMAGE_DOS_HEADER {
    WORD e_magic;      // Magic number    用于判断文件类型,可执行文件中是MZ
    WORD e_cblp;       // Bytes on last page of file 多少扇区,一个扇区512
    WORD e_cp;         // Pages in file              最后一个扇区的大小
    WORD e_crlc;       // Relocations
    WORD e_cparhdr;    // Size of header in paragraphs
    WORD e_minalloc;   // Minimum extra paragraphs needed
    WORD e_maxalloc;   // Maximum extra paragraphs needed
    WORD e_ss;         // Inital (relative) SS value
    WORD e_sp;         // Inital SP value
    WORD e_csum;       // Checksum
    WORD e_ip;         // Inital IP value
    WORD e_cs;         // Inital (relative) CS value
    WORD e_lfarlc;     // File address of relocation table
    WORD e_ovno;       // Overlay number
    WORD e_res[4];     // Reserved words
    WORD e_oemid;      // OEM identifier (for e_oeminfo)
    WORD e_oeminfo;    // OEM information; e_oemid specific
    WORD e_res2[10];   // Reserved words
    LONG e_lfanew;     // File address of new exe headers
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

2.1.2 DOS Stub

大小是不确定,因为这块数据是给链接器用的,这块数据可以所以修改
那么如果获得这块数据的大小呢?
结构体IMAGE_DOS_HEADER的最后一个4字节成员到PE头之间的数据就是DOS块

2.2 PE头

2.2.1 PE标志

IMAGE_NT_HEADERS

c
typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;                         // A 4-byte signature identifying the file as a PE image. The bytes are "PE\0\0".
    IMAGE_FILE_HEADER FileHeader;            // An IMAGE_FILE_HEADER structure that specifies the file header.
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;  // An IMAGE_OPTIONAL_HEADER structure that specifies the optional file header.
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32

Signature
类似DOS头的 e_magic 字段,值为0x00004550,ASCII码为“PE\0\0” ,对应宏IMAGE_NT_SIGNATURE

2.2.2 IMAGE_FILE_HEADER

用于存储PE文件的基本信息
共20字节

c
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;                  // 运行平台,常见8664和014C
WORD NumberOfSections;         // <重要> 文件的区段数量
DWORD TimeDateStamp;           // 文件创建时间(随便改)
DWORD PointerToSymbolTable;    // 符号表地址 pdb (随便改)
DWORD NumberOfSymbols;         // 符号表数量    (随便改)
WORD SizeOfOptionalHeader;     // <重要> IMAGE_OPTIONAL_HEADER文件大小
WORD Characteristics;          // 文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER

通过修改SizeOfOptionalHeader值可以改变IMAGE_OPTIONAL_HEADER大小

2.2.3 IMAGE_OPTIONAL_HEADER32

扩展PE头,存储着PE文件装载的信息,变长

c
typedef struct _IMAGE_OPTIONAL_HEADER {
    WORD Magic;                        // 文件状态
    BYTE MajorLinkerVersion;           // The major version number of the linker
    BYTE MinorLinkerVersion;           //The minor version number of the linker
    DOWRD SizeOfCode;                  // 代码区段的总大小
    DWORD SizeOfInitializedData;       // 已初始化数据的总大小
    DOWRD SizeOfUninitializedData;     // 未初始化数据的总大小
    DWORD AddressOfEntryPoint;         // 指向入口点函数的指针,相对于基地址,PE下数两行半
    DWORD BaseOfCode;                  // 指向代码区段开始的指针,相对于基地址
    DWORD BaseOfData;                  // 指向数据区段开始的指针,相对于基地址,
    DWORD ImageBase;                   // 载入到内存时该image文件第一个字节的地址即基地址,这个值是64K bytes的整数倍。
    DWORD SectionAlignment;            // 映射到内存中段的对齐方式,以字节为单位。该值必须大于或等于FileAlignment成员
    DWORD FileAlignment;               // 在磁盘中的段的对齐方式,以字节为单位。
    WORD MajorOperatingSystemVersion;  // 子系统版本
    WORD MinorOperatingSystemVersion;  // The minor version number of the required operating system
    WORD MajorImageVersion;            // The major version number of the image
    WORD MinorImageVersion;            // The minor version number of the imgae
    WORD MajorSubsystemVersion;        // The major version number of the subsystem
    WORD MinorSubsystemVersion;        // The minor version number of the subsystem
    DWORD Win32VersionValue;           // This member is reserved and must be 0
    DWORD SizeOfImage;                 // 载入内存后image的大小
    DWORD SizeOfHeaders;               // The combined size of the items
    DWORD CheckSum;                    // image映像校验和
    WORD Subsystem;                    // The subsystem required to run this image
    WORD DllCharacteristics;           // The DLL characteristics of the image
    DWORD SizeOfStackReserve;          // 初始化堆栈大小
    DWORD SizeOfStackCommit;           // 初始化实际提交堆栈大小
    DWORD SizeOfHeapReserve;           // The number of bytes to reserve for the local heap
    DWORD SizeOfHeapCommit;            // The number of bytes to commit for the loacl heap
    DWORD LoaderFlags;                 // This member is obsolete
    DWORD NumberOfRvaAndSizes;         // The number of directory entries in the remainder of the optional header
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];    //数组,数据目录,表数据
} IMAGE_OPTIONAL_HEADER, *PIMAGE_OPTIONAL_HEADER;

紧跟着就是节表

2.3 节表

每个节表大小为40字节

2.3.1 IMAGE_SECTION_HEADER

描述文件的数据如何映射到内存

c
typedef struct _IMAGE_SECTION_HEADER {
    BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // 节表名 8个字节

    union {
        DWORD PhysicalAddress;
        DWORD VirtualSize;     // 内存大小
    } Misc;
    DWORD VirtualAddress;      // 内存地址
    DWORD SizeOfRawData;       // 块在磁盘中所占的大小
    DWORD PointerToRawData;    // 在文件中的偏移量
    DWORD PointerToRelocations;    // 调试信息
    DWORD PointerToLinenumbers;
    WORD NumberOfRelocations;
    WORD NumberOfLinenumbers;
    DWORD Characteristics;     // 内存属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

三个节表意味数据有三个部分

扩展PE头IMAGE_OPTIONAL_HEADERSizeOfHeaders是头的大小,DOS头 + PE头 + 节表 然后按照所谓的文件对齐以后的大小

什么叫文件对齐?
假设DOS头 + PE头 + 节表加完之后是302字节,那么SizeOfHeaders一定不是302,可选PE头中还有个成员FileAlignment通常情况下这个值可能是0x200或者0x1000,假设当前FileAlignment的值是200,那么SizeOfHeaders的值一定是FileAlignment的整数倍,也就是400

可以看到确实如我们所说的,那么剩余的空间会干什么的?都是填0,所以我们可以利用起来

从最开始4D5A的位置一直到400都是头的大小

然后紧接着就是第一个节的数据了,节表里有几个成员就意味着在这个PE文件有几个节

2.4 节数据

节数据的大小也是根据文件对齐的方式的,就意味着很可能有空闲的地方是填0的

2.4.1 PE磁盘文件与内存映像结构图

头在文件及内存中是一样的,节数据不一样

在文件中,节数据是从200的整数倍开始的

在内存中,可以发现大不是从200的整数倍开始的,为什么?原因是因为它遵循的内存对齐方式

什么是内存对齐?
可选PE头中还有个成员SectionAlignment内存对齐值,这个值和文件对齐值可能一样,也可能不一样

这就是差异,所以一个PE文件在磁盘中和内存中是存在差异的

所以PE文件的两种状态说的就是这个,在硬盘中是没有经过拉伸的,而在内存中是经过拉伸的

3. DOS头属性说明

c
typedef struct _IMAGE_DOS_HEADER {      // DOS .EXE header
    WORD   e_magic;                     	// Magic number
    WORD   e_cblp;                      	// Bytes on last page of file
    WORD   e_cp;                        	// Pages in file
    WORD   e_crlc;                      	// Relocations
    WORD   e_cparhdr;                   	// Size of header in paragraphs
    WORD   e_minalloc;                  	// Minimum extra paragraphs needed
    WORD   e_maxalloc;                  	// Maximum extra paragraphs needed
    WORD   e_ss;                        	// Initial (relative) SS value
    WORD   e_sp;                        	// Initial SP value
    WORD   e_csum;                      	// Checksum
    WORD   e_ip;                        	// Initial IP value
    WORD   e_cs;                        	// Initial (relative) CS value
    WORD   e_lfarlc;                    	// File address of relocation table
    WORD   e_ovno;                      	// Overlay number
    WORD   e_res[4];                    	// Reserved words
    WORD   e_oemid;                     	// OEM identifier (for e_oeminfo)
    WORD   e_oeminfo;                   	// OEM information; e_oemid specific
    WORD   e_res2[10];                  	// Reserved words
    LONG    e_lfanew;                    	// File address of new exe header
  } IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;

这个结构体中,只有第一个和最后一个是有用的,其余对我们来讲是没用的,所以可以随意修改,不会影响程序的正常运行

第一个用于判断是不是MZ头,最后一个指向PE头开始的位置

如果要修改最后一个,就要调整PE头所有数据。

接着DOS块的数据我们也可以随意修改

简单来说

  1. IMAGE_DOS_HEADER中只有第一个和最后一个不能动

  2. Dos块可以随意修改

4. 标准PE头属性说明

4.1 PE头

typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;				            //PE标识
    IMAGE_FILE_HEADER FileHeader;			    //标准PE头
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;		//扩展PE头
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;

4.1.1 PE标识

PE标识不能破坏,操作系统在启动一个程序的时候会检测这个标识。

4.1.2 标准PE头

c
typedef struct _IMAGE_FILE_HEADER {
*   WORD    Machine;		        //可以运行在什么样的CPU上   任意:0    Intel 386以及后续:014C   x64:8664
*   WORD    NumberOfSections;	    //表示节的数量
*   DWORD   TimeDateStamp;	        //编译器填写的时间戳 与文件属性里面(创建时间、修改时间)无关
    DWORD   PointerToSymbolTable;	//调试相关
    DWORD   NumberOfSymbols;	    //调试相关
*   WORD    SizeOfOptionalHeader;	//可选PE头的大小(32位PE文件:0xE0  64位PE文件:0xF0)
*   WORD    Characteristics;	    //文件属性
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;

文件属性:IMAGE_FILE_HEADER.Characteristics

十六进制:010F
二进制:0000 0001 0000 1111

数据位是1的找到对应的含义:

5. 扩展PE头属性说明

5.1 PE头

32位程序和64位程序会有差异
32位

c
typedef struct _IMAGE_NT_HEADERS {
    DWORD Signature;                       //4字节
    IMAGE_FILE_HEADER FileHeader;          //20字节
    IMAGE_OPTIONAL_HEADER32 OptionalHeader;//没有修改的话224字节
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32

64位

c
typedef struct _IMAGE_NT_HEADERS64 {
    DWORD Signature;                        //4字节
    IMAGE_FILE_HEADER FileHeader;           //20字节
    IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;

5.2 扩展PE头

c
typedef struct _IMAGE_OPTIONAL_HEADER {
*   WORD    Magic;		                    //PE32:10B     PE32+:20B    //用于识别32位程序还是64位程序
    BYTE    MajorLinkerVersion;		        //链接器版本号
    BYTE    MinorLinkerVersion;		        //链接器版本号
    DWORD   SizeOfCode;		                //所有代码节的总和   文件对齐后的大小  编译器填的  没用
    DWORD   SizeOfInitializedData;	        //包含所有已经初始化数据的节的总大小  文件对齐后的大小 编译器填的  没用
    DWORD   SizeOfUninitializedData;	    //包含未初始化数据的节的总大小 文件对齐后的大小 编译器填的  没用
*   DWORD   AddressOfEntryPoint;	        //程序入口,需要配合内存镜像基址来看,这个值决定我们的程序从哪里开始跑
    DWORD   BaseOfCode;		                //代码开始的基址,编译器填的   没用
    DWORD   BaseOfData;		                //数据开始的基址,编译器填的   没用
*   DWORD   ImageBase;		                //内存镜像基址,每个进程都有自己的虚拟内存,而内存镜像基址记录,PE文件在内存中从哪里开始展开
*   DWORD   SectionAlignment;		        //内存对齐
*   DWORD   FileAlignment;		            //文件对齐
    WORD    MajorOperatingSystemVersion;	//标识操作系统版本号 主版本号
    WORD    MinorOperatingSystemVersion;	//标识操作系统版本号 次版本号
    WORD    MajorImageVersion;		        //PE文件自身的版本号
    WORD    MinorImageVersion;		        //PE文件自身的版本号
    WORD    MajorSubsystemVersion;	        //运行所需子系统版本号
    WORD    MinorSubsystemVersion;	        //运行所需子系统版本号
    DWORD   Win32VersionValue;	            //子系统版本的值,必须为0
*   DWORD   SizeOfImage;		            //内存中整个PE文件的映射的尺寸,可比实际的值大,必须是SectionAlignment的整数倍
*   DWORD   SizeOfHeaders;		            //所有头+节表按照文件对齐后的大小,否则加载会出错
*   DWORD   CheckSum;		                //校验和,一些系统文件有要求.用来判断文件是否被修改.
    WORD    Subsystem;		                //子系统	驱动程序(1)  图形界面(2)  控制台、DLL(3)
    WORD    DllCharacteristics;		        //文件特性 不是针对DLL文件的
    DWORD   SizeOfStackReserve;	            //初始化时保留的栈大小
    DWORD   SizeOfStackCommit;	            //初始化时实际提交的大小
    DWORD   SizeOfHeapReserve;	            //初始化时保留的堆大小
    DWORD   SizeOfHeapCommit;	            //初始化时实践提交的大小
    DWORD   LoaderFlags;		            //调试相关
    DWORD   NumberOfRvaAndSizes;	        //目录项数目
    IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32;

AddressOfEntryPoint说明
AddressOfEntryPoint在扩展头16字节的位置

这个0000739D地址是相对于ImageBase的地址,也就是说程序从ImageBase开始的地方加上AddressOfEntryPoint
假设ImageBase10000000,那么程序真正开始的地址是:1000739D

那么如果我们要对程序加密的话首要就是把程序的入口藏起来,后续会说

SizeOfImage
内存中整个PE文件的映射的尺寸,这个值一定是内存对齐SectionAlignment的整数倍

SizeOfHeaders
所有头+节表按照文件对齐后的大小,否则加载会出错

头后紧跟着节,在内存头的大小和磁盘中头的大小是一样的,但是需要内存对齐之后,才是节的数据

CheckSum
校验和,一些系统文件有要求.用来判断文件是否被修改,不是所有文件都有,通常会在.sys等驱动文件有

这个值是 PE文件2个字节2个字节相加,不管溢出,把所有文件加完之后最终的结果在加上文件的长度得到的值就是CheckSum
操作系统提供了现成的函数计算这个值

IMAGE_OPTIONAL_HEADER.DllCharacteristics
按位来算的

6. PE节表

PE文件中有几个节,每个节从什么地方开始,每个节里面的属性是什么,都是由节表决定的。

所有节相关的重要的特性都在节表中描述

PE文件在文件和内存中是两种状态,主要的差异体现在,节与节中间的空白区域大小是不一样的,但并不是所有都是这样的。

6.1 节表数据结构说明

c
# define IMAGE_SIZEOF_SHORT_NAME 8
typedef struct _IMAGE_SECTION_HEADER {
*   BYTE    Name[IMAGE_SIZEOF_SHORT_NAME];	//ASCII字符串 可自定义  只截取8个 可以8个字节都是名字
*   union {				                    //Misc  双字 是该节在没有对齐前的真实尺寸,该值可以不准确
           DWORD   PhysicalAddress;		    // 真实长度,这两个值是一个联合结构,可以使用其中的任何一个
           DWORD     VirtualSize;	        //一般是取后一个
    } Misc;
*   DWORD   VirtualAddress;		            //在内存中的偏移地址,加上ImageBase才是在内存中的真正地址
*   DWORD   SizeOfRawData;		            //节在文件中对齐后的尺寸
*   DWORD   PointerToRawData;		        //节区在文件中的偏移
    DWORD   PointerToRelocations;		    //调试相关
    DWORD   PointerToLinenumbers;
    WORD    NumberOfRelocations;
    WORD    NumberOfLinenumbers;
*   DWORD   Characteristics;		        //节的属性
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

SizeOfRawData  和 联合体VirtualSize的值 的关系
联合体的大小可能大于 SizeOfRawData 。可能等于 也可能小于
没有初始化的变量在文件中是不分配内存的 ,如果没有初始化的全局变量特别多的话,联合体的值可能会SizeOfRawData 大

他们谁大 按谁的来

VirtualSize
实际的大小,有可能比VirtualSize大,也有可能小

比如写代码的时候有两种全局变量,一种是有初始值的,一种是没有初始值的,假设我当前存储的全局变量全是有初始值的,那么VirtualSize一定小于SizeOfRawData
如果我当前代码中有很多是没有初始值的全局变量,这种节有个特点,这个节在文件中是不给他分配内存的,当时当前的节一旦要放在内存的时候,就必须要给这些变量初始化值。这个时候
VirtualSize大于SizeOfRawData。那么最终会按谁的大小来展开呢?他们谁大 按谁的来
PointerToRawData
当前节从文件哪里开始

SizeOfRawData
当前节文件中对齐后的大小

Characteristics
节的属性

十六进制:C0000040
二进制:1100 0000 0000 0000 0000 0000 0100 0000
每个位有不同含义

7. RVA与FOA的转换

如果想改变一个全局变量的初始值,该怎么做?
如果我知道这个全局变量的地址,改如何修改其初始值?
如果全局变量有初始值,那么在PE文件中是一定存在的

面临的问题

文件中和内存中的对齐方式不一样,数据的位置也就不一样

7.1 RVA到FOA的转换:

FOA (File Address 文件地址)
VA (Virtual Address 虚拟地址)
RVA (Relative virtual Address) 相对(ImageBase)虚拟地址 例如 0x1000. 虚拟地址0x00401000的RVA就是0x1000. RVA = 虚拟地址-ImageBase

<1> 得到RVA的值:内存地址 - ImageBase

<2> 判断RVA是否位于PE头中,如果是:FOA == RVA,PE头没有拉伸,文件和内存是一样的

<3> 判断RVA位于哪个节:

RVA >= 节.VirtualAddress
RVA <= 节.VirtualAddress + 当前节内存对齐后的大小

    差值 = RVA - 节.VirtualAddress;

<4> FOA = 节.PointerToRawData + 差值;

c
	PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)m_pFileBuff;
	PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)((LPBYTE)m_pFileBuff + pDosHeader->e_lfanew);
	PIMAGE_FILE_HEADER pFileHeader = (PIMAGE_FILE_HEADER)((DWORD)pNtHeader + sizeof(pNtHeader->Signature));
	PIMAGE_OPTIONAL_HEADER pOptionalHeader = (PIMAGE_OPTIONAL_HEADER)((DWORD)pNtHeader + sizeof(pNtHeader->Signature) + sizeof(pNtHeader->FileHeader));
	PIMAGE_SECTION_HEADER pSectionHeader = (PIMAGE_SECTION_HEADER)IMAGE_FIRST_SECTION(pNtHeader);

		dwVa = dwRva + pOptionalHeader->ImageBase;
		if (dwRva < pSectionHeader->VirtualAddress)
		{
			dwFoa = dwRva;
		}
		else
		{
			for (int i = 0; i < pFileHeader->NumberOfSections; i++)
			{
				if (dwRva >= pSectionHeader->VirtualAddress && dwRva < pSectionHeader->VirtualAddress + pSectionHeader->SizeOfRawData)
				{

					dwFoa = dwRva - pSectionHeader->VirtualAddress + pSectionHeader->PointerToRawData;
					break;
				}
				pSectionHeader++;
			}
		}
	}

8. 扩大节

为什么要扩大节?

我们可以在任意空白区添加自己的代码,但如果添加的代码比较多,空白区不够怎么办?

扩大哪一个节呢?

扩大第一个节行不行?可以但是后边所有的节都会受影响,所以修改第一个节,向后推多少就要修后边的节

所以扩大最后一个节合适

8.1 扩大节的步骤:

<1> 分配一块新的空间,大小为S

<2> 将最后一个节的SizeOfRawData和VirtualSize改成N

N  = (SizeOfRawData或者VirtualSize 内存对齐后的值) + S

<3> 修改SizeOfImage大小

在文件尾添加指定大小的数据,我这里插入了4096

接下来就是要修改了
SizeOfRawDataVirtualSize显然是SizeOfRawData

所以我们把他改成00 90 00 00因为我们加了0x1000

接着修改SizeOfImage大小

得先按内存对齐方式存放,但是我们这里它自己就是内存对齐的,所以这个我们就不用管,
直接SizeOfImage + 0x1000即可

但还得注意一点,就是这个节有没有可执行的属性,如果没有就修改节属性

9. 新增节

9.1 新增节的步骤:

<1> 判断是否有足够的空间,可以添加一个节表,如果没有空白位置.自己需要给扩展头扩大.并且自己修正节的偏移.

<2> 在节表中新增一个成员.

<3> 修改PE头中节的数量.

<4> 修改sizeOfImage的大小.

<5> 再原有数据的最后,新增一个节的数据(内存对齐的整数倍).

<6> 修正新增节表的属性.

1. 添加节表
首先找个程序

可以看到这个空间足够我们添加新节表了

复制一个过来改下名字

2. 修改PE头中节的数量
接着改一下PE头中节的数量,原来是9个改成10个

3.修正节表中的偏移
我们新增了一个节表.那么我们就要为这个节表指明内存中开始展开的位置. 文件中展开的位置. 以及节数据的大小.

对应的三个成员分别是:

节.VirtualAddress

节.SizeOfRawData

节.PointerToRawData

节.VirtualAddress
在内存中展开的位置
首先第一个成员. 节.virtuallAddress .我们按照文件对齐.与上一个节表对齐存放即可.

上一个节表对齐后的展开位置为 0x001F000 那么我们就修改为 0x0020000

节.SizeOfRawData
这个成员就是节数据按照文件对齐后的大小.取决于我们给这个节添加多少数据.我们可以在PE文件后面添加 0x1000个字节

节.PointerToRawData
最后修改的就是节在文件中哪里展开的. 这个我们需要看上一个节的文件偏移.以及节数据大小. 算出来的.

假设上一个节 偏移位置为10. 那么节数据为100. 那么节数据就是从10 ~ 100都是上一个节. 我们的节展开就要从100位置展开.
例如下图:

上一个节开始位置是0x9800 节数据对齐后的大小是0x600 他俩相加则是 0x9E00. 所以我们的偏移位置在0x9E00开始.
另外节数据按照文件对齐后的大小我们改成0x1000

4.修改扩展头中PE的镜像大小 SizeofImage
我们新增了0x1000节数据大小.那么我们的镜像大小也要加0x1000大小进行映射.注意.要按照内存对齐.

我们的原镜像大小已经按照内存对齐的方式存放了. 就是0x20000. 那么我们加了0x1000的数据就是 0x21000大小

可以运行

总结

  1. 一个节表0x28个字节.在最后一个节表位置添加.如果SizeofHeaders 有足够空间的情况下.

  2. 修改文件头中节表个数,文件.SectionNumber = 原有节个数 + 你新增节的个数

  3. 修改节属性:

  • 节.VirtuallAddress 内存中展开的位置.按照内存对齐,可以参照上一个节.virtuallAddress位置进行修改.

  • 节.SizeofRawData 节数据按照文件对齐后的大小.
    节.SizeofRawData = 你添加的节数据大小,按照文件对齐存放,例如添加了0x1000.那么大小就是0x1000

  • 节.PointerToRawData 文件中的偏移.
    节.PointerToRawData = 上一个节.PointerToRawData + 上一个节.SizeofRawData.

  1. 修改扩展头SizeofImage PE镜像大小.
    扩展头.SizeofImage = 内存对齐(原SizeofImage值 + 你行增节数据大小 按照内存对齐)

  2. 修正新增节表的属性

10. 合并节

10.1 合并节的步骤:

  1. 修改文件头节表个数

  2. 修改节表中的属性

     节.sIzeofRawData 节数据对齐后的大小.
  3. 修改扩展头中PE镜像大小 SizeofImage

  4. 将合并节的属性改为包含所有合并节的属性

1.修改文件头中节表个数

2. 修改节.SizeofRawData 节数据对齐后的大小

上一个节.SizeofRawData = 文件对齐(上一个节.SizeofRawData + AAA.SizeofRawData)

原来节数据对齐后的大小是0x600, .aaaaa节数据对齐后的大小是0x1000,所以修改上一个节.SizeofRawData 为0x1600

3. 修改扩展头的PE镜像大小SizeofImage

原先是21000,合并了0x1000数据大小所以改为22000

4. 将合并节的属性改为包含所有合并节的属性
由于两个节的属性一致,所以不用动

11. 导出表

一个可执行程序是由一个PE文件组成的?
答案: 不是,一个可执行程序,准确的讲是由一堆的PE文件组成的。
可执行程序运行时,会依赖DLL,比如Kerner32.dlluser32.dll等。

扩展头里有一个结构体数组称之为数据目录,里面有16项成员

每个成员的内容是一样的

c
typedef struct _IMAGE_DATA_DIRECTORY {
    DWORD   VirtualAddress;                        虚拟地址
    DWORD   Size;                                  大小
} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY;
Size成员.保存的是导出表中以及导出表子表中的所有成员大小

这个结构存储的是导出表在哪里,以及导出表有多大.

其中数据目录每一项都是保存着不同的表
例如第一项就是导出表. 记录了导出表的虚拟地址以及大小

c
# define IMAGE_DIRECTORY_ENTRY_EXPORT          0   // Export Directory
# define IMAGE_DIRECTORY_ENTRY_IMPORT          1   // Import Directory
# define IMAGE_DIRECTORY_ENTRY_RESOURCE        2   // Resource Directory
# define IMAGE_DIRECTORY_ENTRY_EXCEPTION       3   // Exception Directory
# define IMAGE_DIRECTORY_ENTRY_SECURITY        4   // Security Directory
# define IMAGE_DIRECTORY_ENTRY_BASERELOC       5   // Base Relocation Table
# define IMAGE_DIRECTORY_ENTRY_DEBUG           6   // Debug Directory
//      IMAGE_DIRECTORY_ENTRY_COPYRIGHT       7   // (X86 usage)
# define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE    7   // Architecture Specific Data
# define IMAGE_DIRECTORY_ENTRY_GLOBALPTR       8   // RVA of GP
# define IMAGE_DIRECTORY_ENTRY_TLS             9   // TLS Directory
# define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG    10   // Load Configuration Directory
# define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT   11   // Bound Import Directory in headers
# define IMAGE_DIRECTORY_ENTRY_IAT            12   // Import Address Table
# define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT   13   // Delay Load Import Descriptors
# define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14   // COM Runtime descriptor

因为结构体记录的是导出表的RVA, 所以我们需要转换为FOA 去PE文件中查看.

RVA 判断在那个节
RVA >= 节.VirtualAddress
RVA <= 节.VirtualAddress + 节.SizeOfRawData

RVA - 节.VirtuallAddress == 差值偏移

FOA == 差值偏移+ 节.PointerToRawData


在数据目录中得出导出表
RVA == 0x92c70
大小 == DC14

RVA >= 节.VirtualAddress && RVA < (节.VirtualAddress + 节.SizeofRawData)

得出在.rdata节中
rdata节中.虚拟地址 == 0x80000
rdata节中.文件偏移 == 0x65000

FOA = 0x92C70 - 0x80000 + 0x65000 = 0x77C70

文件偏移0x77C70就是导出表

导出表结构

c
typedef struct _IMAGE_EXPORT_DIRECTORY {
    DWORD   Characteristics;		// 未使用
    DWORD   TimeDateStamp;		    // 时间戳
    WORD    MajorVersion;			// 未使用
    WORD    MinorVersion;			// 未使用
*   DWORD   Name;			        // 指向该导出表文件名字符串
*   DWORD   Base;			        // 导出函数起始序号
*   DWORD   NumberOfFunctions;		// 所有导出函数的个数
*   DWORD   NumberOfNames;		    // 以函数名字导出的函数个数
*   DWORD   AddressOfFunctions;     // 导出函数地址表RVA
*   DWORD   AddressOfNames;         // 导出函数名称表RVA
*   DWORD   AddressOfNameOrdinals;  // 导出函数序号表RVA
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

1. Name成员解析

0x96B5E是一个RVA 所以我们要进行FOA转换

FOA == 0x96B5E - 0x80000 + 0x65000 = 0x7BB5E

是当前dll的名字

2. Base成员解析
导出函数起始序号

导出函数的序号起始位置,DLL导出的函数如果给序号了,那么就从这个序号开始

3.NumberOfFunctions 以及 NumberOfNmaes

  • NumberOfFunctions:函数导出总个数
  • NumberOfNmaes:函数以名字导出的个数


所有函数导出是 647个函数. 名字导出是 647个函数,此外函数是可以通过序号导出的。

如果有按照序号导出,那么以函数名导出的个数就会跟所有函数导出个数不一样的。

如何算出未导出的函数是多少个呢?

所有导出函数个数 - 以名字导出的个数 = 以序号导出或未导出的个数

4. 导出表子表

  1. AddressOfFunctions; // 导出函数地址表RVA
  2. AddressOfNames; // 导出函数名称表RVA
  3. AddressOfNameOrdinals; // 导出函数序号表RVA

都是RVA需要转成FOA
AddressOfFunctions : 0x77C98
AddressOfNames : 0x795B4
AddressOfNameOrdinals : 0x7AED0

4.1 导出函数地址表
AddressOfFunctions

函数地址表指向一个偏移,这个偏移存放了函数所有导出个数的函数的地址

例如所有导出函数有2个,那么函数地址表中就有2项
每个占4个字节,存放的是函数地址的RVA偏移。

例如第一项 RVA偏移为 0x01FA10 函数地址偏移 + ImageBase 就是函数地址

如果按照序号导出,
1 3 4 5导出了4个函数,在导入表中我们的函数地址表中的地址会有5个。
原因就是会按照序号顺序用0填充不存在的序号:
1 2 3 4 5 虽然第二项并没有,但是也会给我们导出


4.2 函数名称表
也是存储的名称RVA,4个字节存储一个. 存储的大小跟导出表的以函数名字导出个数这个成员来决定的

以名称导出函数的个数 例如为10 那么函数名称表就可以存储10个RVA. 每一个为4个字节

里面的RVA指向了当前导出函数的函数名称.
根据上面算出的FOA去看看

表中存储的都是RVA,如果在内存中我们直接RVA + 当前PE的ImageBase就可以看到函数导出的名称
但我们先在文件中找一找,比如第一个0x96BCA
FOA = 0x96BCA - 0x80000 + 0x65000 = 0x7BBCA

FOA位置为0x7BBCA在文件中就保存这导出函数的名称

注意: 函数名称表保存的并不是函数名称,而是指向函数名称的RVA偏移.
还有RVA偏移是按照字母排序的,并不是按照导出的时候函数的顺序进行排序的。

例如:
EXPORT
SUB
ADD
MUL
导出三个函数:第一项就为 ADD,因为按照字母排序A在前边,后面依次类推

4.3函数序号表
DLL导出函数的时候,会有序号进行导出。
但并不是说如果按照名字导出,名称表中有,序号表中就没有。

序号表里有几个成员,看名字表里有几个,假设名字表里有3个成员,那序号表也是3个成员,换句话来讲序号表是给名称表的使用的

序号表是两个字节,存储序号
AddressOfNameOrdinals : 0x7AED0

c
FARPROC GetProcAddress(
    HMODULE hModule,  // DLL模块句柄
    LPCSTR lpProcName // 函数名
);

这个函数就是遍历PE文件中导出表进行返回的,那么他是如何实现的.如何通过名字查找函数地址或者如何通过序号进行查找函数地址的?

首先我们要分成三张表,函数地址表中序号开始的位置是导出表成员Base指定的,假设为0开始。

首先GetProcAddress 如果按照名称查找的话,会先去遍历函数名称表,比如我们要获取Sub的地址,遍历函数名称表的时候找到了Sub ,是函数名称表的第3个索引,

然后拿着这个索引去序号表中进行查找对比,在序号表中查到了对比成功.序号表中第3项的值跟这个索引一样的,所以就拿序号表的序号,去函数地址表中获取函数地址。

序号为0,那么他就在函数地址表中找到了第0项, 当函数地址进行返回。(并不是直接返回,加上了当前DLL模块的ImageBase才返回的,所以为什么需要DLL模块地址)

以上就是GetProcAddress的名字查找的实现流程

如果是序号来查找的话,比如我们寻找14序号,他会先根据导出表中Base成员属性,将表的起始位置进行一次定义.

例如上面我们找的14序号并不存在,但是他会先看看Base起始位置是多少,假设为13. 那么我们函数地址表中 0索引 相当于 13 、 1索引相当于 14 、 2索引相当于15了依次类推。

这样我们虽然说寻找14,但是根据Base起始位置的指定,那么也会寻找到对应函数地址。

总结

  1. 遍历函数名称表 得出索引

  2. 当前索引,去序号表中查找,如果有则取出当前序号表的序号,当做函数地址表的下标

  3. 得出下标. 返回函数地址 (RVA +ImageBase)

12. 导入表

一个进程是一组PE文件构成的,PE文件需要依赖哪些模块,以及依赖这些模块中的哪些函数,这个就是导入表需要做的。

  1. 确定PE依赖哪个模块
  2. 确定PE依赖的哪个函数
  3. 确定函数地址。

导入表位置: 在扩展头中有一个数据目录结构体,第二项保存的就是导入表的RVA以及大小。

RVA 判断在那个节
RVA >= 节.VirtualAddress
RVA <= 节.VirtualAddress + 节.SizeOfRawData

RVA - 节.VirtuallAddress == 差值偏移

FOA == 差值偏移 + 节.PointerToRawData

EXE文件通常没有导出表,而图中导入表的 RVA 是 0x01B1C8 位于节idata中. 虚拟地址: 0x1B000 文件偏移: 0x8200

转换为 FOA = 0x1B1C8 - 0x1B000 + 0x8200 = 0x83C8

导出表和导入表是截然不同的,因为PE以来的模块很多。

导入表结构

c
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk;	//RVA 指向IMAGE_THUNK_DATA结构数组
    };
    DWORD   TimeDateStamp;	        //时间戳
    DWORD   ForwarderChain;
    DWORD   Name;		            //RVA,指向dll名字,该名字已0结尾
    DWORD   FirstThunk;		        //RVA,指向IMAGE_THUNK_DATA结构数组
} IMAGE_IMPORT_DESCRIPTOR;

导入表大小为十进制的20个字节, 16进制的 0x14,如果以16进制为一行,则是一行零4个字节

导入表跟导出表不同,导出表只有一个,里面有子表记录

导入表结束位置是20个字节的连续为0的数据。 也就是导入表最后一项都为0的时候就说明这一个导入表结束了。

12.1 确定依赖模块的名字

Name 保存的是RVA,指向dll名字,该名字已0结尾
先转成FOA,0x86A6

看到导入表依赖的模块名字就是 VCRUNTIME140D.dll 带有D结尾的.dll说明是调试DLL. 140是编译器版本。

我们查看的这个Name属性描述的就是 VCRUNTIME140D.dll 这个模块的信息了,如果想看其它依赖的模块就需要查看下一张导入表。

接着看下一个:

转成FOA:0x8972

依次查看全部即可。

12.2 确定依赖的函数

我们根据Name成员,确定了导入表依赖的DLL的名字,那么我们导入表怎么确定依赖了那些函数呢?

c
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
    union {
        DWORD   Characteristics;
        DWORD   OriginalFirstThunk;	//RVA 指向IMAGE_THUNK_DATA结构数组
    };
    DWORD   TimeDateStamp;	        //时间戳
    DWORD   ForwarderChain;
    DWORD   Name;		            //RVA,指向dll名字,该名字已0结尾
    DWORD   FirstThunk;		        //RVA,指向IMAGE_THUNK_DATA结构数组
} IMAGE_IMPORT_DESCRIPTOR;

第一个成员指向了一个INT表,最后一个成员指向了一个 IAT表。

INT:: 导入名称表 Improt Name Table

IAT:: 导入地址表 Improt Address Table

两张表是一样的,但是所在位置是不一样的名字也不一样.一个叫做 INT 一个叫做IAT,不过通过这两个其中一个都可以找到我们当前PE依赖哪些函数

c
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;

结构体大小:4个字节,他是一个联合体,找最大的。

里面有4个成员,为当前的4个字节起了四个名字,真正有用的是下面两个,也就是说有的时候需要用第三个成员,有的时候需要用第四个成员,而第四个成员是指向一个 IMAGE_IMPORT_BY_NAME的结构的RVA

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

无论是第一个成员还是最后一个成员都能确定当前依赖的dll函数有哪些

第一个成员(INT表)找:
INT表是4个字节,最后0结尾。INT表有多少个成员,就是说依赖的这个dll就有多少个函数。

转成 FOA:0x84B0

看这个表的4个字节,最高位为1那么就是函数的导出序号,去掉最高位,就是函数的序号

如果最高位不是1,那么找的就是一个RVA ,一个指向IMAGE_IMPROT_BY_NAME的结构。


观察最高位,看来都不是序号导出的,所以还得接着找。以第一个0x1B458为例,依然需要先转成FOA:0x8658

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

高位两个字节,是函数在导出表中的导出索引,后面就是以0结尾的函数名称了。
我们通常不用这个值。

总结来说: 不管是INT表还是IAT表,主要看其高位值,高位为1,那么去掉高位,就是函数的序号。
高位为0,指向一个结构,这个结构保存了函数的导出序号以及函数名称。

IMAGE_IMPROT_BY_NAME结构体中的 HINT 如果不是空,那么这个序号(索引)就是导出表的函数地址表的索引,我们可以直接拿着这个索引去导出表中获取函数地址。

12.3 确定函数地址

在程序中,调用一个DLL的函数,那么就会生成一个间接Call

假设我们调用MessageBox,那么一编译的时候,编译器就会在导入表里生成一个20个字节的结构体,这个结构体会表述我要用到的这个dll的名字

c
int main()
{
	MessageBox(0, 0, 0, 0);
	return 0;
}

可以看到call后边跟的不是一个具体的地址,属于间接call

我们去这个地址看看:

这个地址才是真正的MessageBox的地址,换句话说当我们在自己的代码里调用其他模块的时候,编译器首先会给我们生成间接call,会把函数地址存到一个表里。

导入表中,最后一个成员IAT表,就是上面所说的表,函数地址表。

不是所INT表和IAT边是一样的吗?不都是存储结构体吗?

PE文件加载前:

PE文件加载后:

没有运行之前,在文件中存储的时候,无论是INT还是IAT都能找到我们要用的函数,IAT中存储的并不是函数的地址,
当我们的程序真正要执行的时候,这个表就会发生变化,IAT表里存储的不再与INT表一样,而是存储真正的函数地址。

那IAT是怎么知道要存什么地址呢?

PE加载前存储的是结构体,通过这个结构体,就能找到导出表函数的名字,能找到函数名字,就可以根据名字得到对应的函数地址,然后放到IAT表里。

所以我们Dump之后为什么要修复导入表就是这个原因,因为pe在加载前和加载后是不一样的。

总结
导入表大小为20个字节,十六进制0x14 ,一行零4个字节。

1.导入表重要成员有三个INT表,Name表,IAT表

PE加载前:

INT表和IAT表相同,根据INT或者IAT表的高位,高位为1,去掉高位就是函数序号,高位为0,就是一个RVA偏移,指向函数名称表。

函数名称表:
HINT 当前函数在导出函数地址表中的索引

Name 当前函数的名称

PE加载后:
INT表不变,IAT表变成了存储函数地址的地址表。

  1. Name 民称表,指向DLL名称文件名,是一个RVA。

  2. INT表和IAT表的RVA,用于定位INT IAT表位置,这个表存储的才是数据。

13. 重定位表

13.1 什么是重定位表

重定位就是修正偏移:
假设有一个地址0x405678 ,Imagebase是0x400000,那么RVA就是0x5678. 如果Imagebase变成0x300000, 那么修正之后就是 ImageBase + RVA = 0x300000+0x5678 = 0x305678

一个EXE文件由多个PE文件组成。

exe文件启动的基址 (ImageBase) 是0x40000, 假设调用三个DLL A、 B、 C

A.DLL 在EXE展开的基址位置是0x10000000


问题来了,B.DLL 展开的位置也是0x1000000 A、B两个DLL位置展开地方是一样的,怎么办也不能覆盖掉A啊,
这个时候操作系统在展开的时候就会修正。会给B.DLL换个位置进行展开。

完美吗?未必,这里还存在一些问题,PE文件中有很多RVA ,RVA是相对于ImageBase的偏移的。
如果PE文件中都是RVA还好,但有情况除外

c
# include <stdio.h>
int x;
int main()
{
	x= 0x11;
	return 0;
}

查看反汇编:

全局变量在赋值的的时候,地址是一个固定的计算好的值,不是RVA。


ImageBase + RVA的值. 直接编译到二进制当中了

假设A编译的全局变量的地址是0x1012345,A展开的位置是0x1000000,全局变量地址是正确的.
但是如果B编译的时候地址也是0x1012345,但是模块基址加载不一样,就出问题了

找到问题,解决就简单了,我们只需要改掉这个地址就行了,那么我们就需要一张表,这张表里能够记录这个地址是需要修改的


这张表就是重定位表,所以重定位表平时是没有意义的,只有在模块在内存中展开的时候没有占住自己想占的位置时有意义。

有了重定位表,就不用担心ImageBase没有占住位置。

重定位表的位置
数据目录项的第6个结构,就是重定位表

重定位表的结构

typedef struct _IMAGE_BASE_RELOCATION {
    DWORD   VirtualAddress;
    DWORD   SizeOfBlock;    //存储的值以字节为单位,字节多大,表示了一个重定位快有多大
} IMAGE_BASE_RELOCATION;
typedef IMAGE_BASE_RELOCATION ,* PIMAGE_BASE_RELOCATION;

看着很简单,但实际上非常复杂

假设VirtualAddressx,设SizeofBlockY,一个格子为1个字节

可以看到两个成员分别占用了4个字节,

SizeOfBlock以字节为单位,代表重定位的块有多大。 一个PE文件有很多地方需要进行重定位
假如SizeOfBlock存的是16那么重定位表的大小就是如下图所示:

然后紧接着是第二个重定位块,如果SzieofBlock大小为20个字节:

接着是第三块重定位块,又是这样的结构体,以此类推,直到有连续的八个字节的0

这样设计的原因:

假如我们需要修正地址801234 801235 801236 这种修正的地址有10000个。

每个地址有4个字节的,那么 4 * 10000 = 40000个字节, 也就是要准备一张4万个字节的表,也就是说需要修正的地址越多,这张表就越大,约占空间,那么这么设计肯定是不好的。
能不能然它变得更简单呢?

我们发现一个规律,要修正的表的偏移都很近 1234 1235 ....

那么我们可不可以这样,我们把800000取出来, 用两个字节存储1234 另外两个地址存储1235,不用准备四个字节的空间了,小的偏移我们两个字节存储,这样的话我们的表的字节就会缩小一半。

VirtuallAddress 就是存储了800000,也就是基址

SizeofBlock就是下面的偏移有多大,要修正的偏移是 VirtualAddress + sizeofBlock下面的值

重定位表,是按照一个物理页(4kb)进行存储的。也就是一个4kb内存,有一个重定位块, 一个重定位表只管自己当前的物理页的重定位。

一个重定位表的记录偏移的大小是2个字节,也就是16位,而记录偏移的大小是由SizeofBlock决定的。

但是我们记录偏移的位置,12位就够了,高4位.挪作他用,只有高4位为3的时候才会进行重定位(基址 + 偏移)

真正修复的位置 virtualaddress + (高四位为3 ? 低12位偏移 : 不用管)

也就是高四位为3 virtualaddress + 低12位偏移 就等于真正要修复的RVA,
例如 36b0 高位为3 低12位就是6b0 要修复的RVA = virtualaddress + 6b0再加上当前DLL的ImageBase才是真正要修复的虚拟地址(VA)。

总结

  1. 重定位表有两个成员:
  • VirtuallAddress :记录了当前物理页需要进行重定位的起始地址.
  • sizeofBlock :记录了重定位表多大,去掉8个字节(重定位表大小) 下面都是记录了重定位表需要重定位的偏移
  1. 偏移是2个字节存储,12位存储偏移,高4位存储是否进行重定位,高4位为3则需要进行重定位,virtualaddress + 低12位 就是要修正的RVA偏移,再加上当前DLL的ImageBase才是真正要修复的虚拟地址(VA)。

参考资料

本文作者:Na1r

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!