软件工具准备
目标代码
先整理好我们要写入的代码,这样能够保证我们清楚知道要设置多少.text
节区的VirtualSize
大小,以及IMAGE_IMPORT_DESCRIPTOR ImportDescriptor
导入表的结构是什么样的。
#include
#include "windows.h"
int main()
{
MessageBoxExW(nullptr, L"shp666", L"shp666", 0, 0);
getchar();
}
sub rsp,38
xor eax,eax
lea r8,qword ptr ds:[140001034]
xor r9d,r9d
mov word ptr ss:[rsp+20],ax
lea rdx,qword ptr ds:[140001042]
xor ecx,ecx
call qword ptr ds:[<&MessageBoxExW>]
call qword ptr ds:[<&_fgetchar>]
xor eax,eax
add rsp,38
ret
48 83 EC 38 33 C0 4C 8D 05 27 00 00 00 45 33 C9 66 89 44 24 20 48 8D 15 26 00 00 00 33 C9 FF 15 5C 10 00 00 FF 15 76 10 00 00 33 C0 48 83 C4 38 C3 00
最后捋一下,我们在程序中使用了2个call
。
MessageBoxExW
函数,它来自user32.dll
动态链接库。_fgetchar
函数,它来自msvcrt.dll
库。所以我们需要两个IMAGE_IMPORT_DESCRIPTOP
结构来导入两个库,每个IMAGE_IMPORT_DESCRIPTOP
都只有一个导入函数。
ok,现在我们可以开始干一场了。
最终需要注意的是,在手工编写PE文件时,文件的编写不一定总是像我们写作一样从上向下写。也可能是先写下面,然后跳回来写上面。我会尽量用符合直觉的顺序编写,如果要进行跳跃式编写,我会提前说明。
010 Editor
,左上角点击文件
->新建
->十六进制文件
。.exe
文件。010 Editor
,上方菜单栏点击视图
->编辑方式
->十六进制
。IMAGE_DOS_HEADER
IMAGE_DOS_HEADER
一共64个字节,主要用来给DOS系统运行使用,但是不代表在非DOS系统使用时我们可以随意填写。
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;
我们只关注我们需要的字段。
e_magic
:2个字节,是一个固定值0x5A4D
,ASCII
的代表的值为MZ
。e_lfanew
:4个字节,保存着我们接下来要使用的IMAGE_NT_HEADERS
结构的文件偏移量。Shift + ctrl + i
,我们从0位置,插入64个字节。
填入我们刚才只关注的两个字段的值。因为windows的PE读入为小端序,所以0x5A4D
的16进制的表示方式为4D 5A
。在3Ch
处,我们写入e_lfanew
的值为40 00 00 00
,表示IMAGE_NT_HEADERS
的文件偏移量为0x40h
。
IMAGE_NT_HEADERS
PE文件结构中的NT头(也称为PE头)指的是Portable Executable(PE)文件的头部结构。该头部结构位于PE文件的起始位置,包含一些元数据和描述信息,用于指定PE文件的结构和属性。NT头是Windows操作系统用于加载和执行可执行文件的重要组成部分。
typedef struct _IMAGE_NT_HEADERS64 {
DWORD Signature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER64 OptionalHeader;
} IMAGE_NT_HEADERS64, *PIMAGE_NT_HEADERS64;
需要注意32位与64位的NT头结构不同,我这里只展示64位的NT头结构。
Signature
签名:4字节,标志PE文件格式的标志,通常为"PE\0\0"(ASCII码:50 45 00 00)。FileHeader
文件头:指定PE文件的基本属性和布局信息。包括机器类型、文件类型、文件创建日期和时间、可选头的大小等信息。OptionalHeader
可选头:指定PE文件的高级属性和布局信息。包括程序入口点、区块对齐方式、内存对齐方式、导出表、导入表、资源表等信息。先插入字节
Signature
我们要填入0x4550
,小端序下50 45 00 00
。
IMAGE_FILE_HEADER
typedef struct _IMAGE_FILE_HEADER {
WORD Machine;
WORD NumberOfSections;
DWORD TimeDateStamp;
DWORD PointerToSymbolTable;
DWORD NumberOfSymbols;
WORD SizeOfOptionalHeader;
WORD Characteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
主要用来描述PE文件的大致属性。
Machine: 这是一个数字,表示可执行文件的目标机器类型(CPU 架构),这个字段可以有很多值,但我们只对其中两个感兴趣,0x8864
forAMD64
和0x14c
for i386
。有关可能值的完整列表,您可以查看Microsoft 官方文档。
NumberOfSections:用来指出文件中存在的节区数量。
TimeDateStamp:Unix
类型的时间戳,文件创建时间,16进制表示。
PointerToSymbolTable和NumberOfSymbols:这两个字段保存 COFF 符号表的文件偏移量和该符号表中的条目数,但是它们被设置为0
表示不存在 COFF 符号表,这是因为 COFF 调试信息已弃用。(已弃用)
SizeOfOptionalHeader:NT头结构体最后一个成员为IMAGE_OPTIONAL_HEADER32/64结构体。SizeOfOptionalHeader用来指出它的长度。(IMAGE_OPTIONAL_HEADER32
的大小为0xE0
,IMAGE_OPTIONAL_HEADER64
的大小为0xF0
)
Characteristics:表示文件属性的标志,这些属性可以是文件可执行、文件是系统文件而不是用户程序,以及很多其他的东西。可以在Microsoft 官方文档中找到这些标志的完整列表。(标志位可以进行或运算
,如果要做一个64位可执行文件的话应该为: IMAGE_FILE_EXECUTABLE_IMAGE|IMAGE_FILE_LARGE_ADDRESS_
= 0x0022
)
Machine
,我们需要填写0x8664
。
NumberOfSections
,我们填写2
,我们只需要两个节区就可以完成这个程序。
TimeDateStamp
,我们指定0
就可以。
PointerToSymbolTable
和NumberOfSymbols
我们也指定0。
SizeOfOptionalHeader
,我们指定0xF0
。
Characteristics
,我们指定0x0022
。
IMAGE_OPTIONAL_HEADER64
typedef struct _IMAGE_OPTIONAL_HEADER64 {
// 大小为大小为0xF0
WORD Magic;
BYTE MajorLinkerVersion;
BYTE MinorLinkerVersion;
DWORD SizeOfCode;
DWORD SizeOfInitializedData;
DWORD SizeOfUninitializedData;
DWORD AddressOfEntryPoint;
DWORD BaseOfCode;
ULONGLONG ImageBase;
DWORD SectionAlignment;
DWORD FileAlignment;
WORD MajorOperatingSystemVersion;
WORD MinorOperatingSystemVersion;
WORD MajorImageVersion;
WORD MinorImageVersion;
WORD MajorSubsystemVersion;
WORD MinorSubsystemVersion;
DWORD Win32VersionValue;
DWORD SizeOfImage;
DWORD SizeOfHeaders;
DWORD CheckSum;
WORD Subsystem;
WORD DllCharacteristics;
ULONGLONG SizeOfStackReserve;
ULONGLONG SizeOfStackCommit;
ULONGLONG SizeOfHeapReserve;
ULONGLONG SizeOfHeapCommit;
DWORD LoaderFlags;
DWORD NumberOfRvaAndSizes;
IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];
} IMAGE_OPTIONAL_HEADER64, *PIMAGE_OPTIONAL_HEADER64;
**Magic:**标识图像文件状态的无符号整数。 最常见的数字是 0x10B,它将其标识为普通可执行文件。 0x107将其标识为 ROM 映像,0x20B将其标识为 PE32+ 可执行文件。
**MajorLinkerVersion和MinorLinkerVersion:**链接器主版本号;链接器次要版本号;
**SizeOfCode:**代码 (文本) 节(.text)
的大小;如果有多个节,则为所有代码节的总和。
**SizeOfInitializedData:**该字段保存初始化数据 (.data
) 部分的大小,或者如果有多个部分,则保存所有初始化数据部分的总和。
**SizeOfUninitializedData:**该字段保存未初始化数据 ( .bss
) 部分的大小,或者如果有多个部分,则为所有未初始化数据部分的总和。
**AddressOfEntryPoint(重要 程序EP):**文件加载到内存时入口点的RVA。文档指出,对于程序映像,此相对地址指向起始地址,对于设备驱动程序,它指向初始化函数。对于 DLL,入口点是可选的,在没有入口点的情况下,该AddressOfEntryPoint
字段设置为0
。
**ImageBase:**指出文件的优先装入地址。该字段保存图像加载到内存时的第一个字节的首选地址(首选基地址),该值必须是64K的倍数。由于像 ASLR 这样的内存保护以及许多其他原因,该字段指定的地址几乎从未被使用过,在这种情况下,PE 加载程序选择一个未使用的内存范围来加载图像,在将图像加载到该地址后加载器进入一个称为重定位的过程,它修复图像中的常量地址以使用新的图像库,有一个特殊的部分保存有关需要重定位时需要修复的地方的信息,该部分称为重定位部分( .reloc
)。
FileAlignment
.SectionAligment
此字段包含一个值,该值用于磁盘上的部分原始数据对齐(以字节为单位),如果部分中实际数据的大小小于该FileAlignment
值,则块的其余部分将用零填充以保持对齐边界。文档指出该值应该是 2 的幂,介于 512 和 64K 之间,如果该值SectionAlignment
小于体系结构的页面大小,则FileAlignment
和 的大小SectionAlignment
必须匹配。MajorOperatingSystemVersion
, MinorOperatingSystemVersion
, MajorImageVersion
, MinorImageVersion
, MajorSubsystemVersion
and MinorSubsystemVersion
*这些结构成员指定了所需操作系统的主版本号,所需操作系统的次版本号,镜像的主版本号,镜像的次版本号,主版本号子系统的版本号和子系统的次版本号。SectionAlignment
的倍数,因为在将Image加载到内存中时会使用该值。NX
兼容以及是否可以在运行时重新定位。我不知道它为什么被命名DLLCharacteristics
,它存在于普通的可执行映像文件中,并且它定义了可以应用于普通可执行文件的特征。可以在Microsoft 官方文档DLLCharacteristics
中找到可能的标志的完整列表。0
。DataDirectory
)的数组大小一般为16。IMAGE_DATA_DIRECTORY
。Magic
,我们写入0x20B
。
MajorLinkerVersion
、MinorLinkerVersion
、SizeOfCode
、SizeOfInitializedData
、SizeOfUninitializedData
我们全部填入0。
AddressOfEntryPoint
我们写入1000h
。
BaseOfCode
我们写入0。
ImageBase
我们写入140000000h
。
SectionAlignment
= 4096
。
FileAlignment
: 512
。
MajorOperatingSystemVersion、MinorOperatingSystemVersion、MajorImageVersion、MinorImageVersion
全部填入0。
MajorSubsystemVersion
: 6
。关于这个值为什么是6
,您可以查看这篇文档。我们的子系统为CONSOLE
根据文档最低为5.01
,默认为6.0
。
MinorSubsystemVersion
和Win32VersionValue
:0
SizeOfImage
= 3000h
。
SizeOfHeaders
= 200h
。
CheckSum
= 0。
Subsystem
= 3
,我们要使用控制台程序。
DllCharacteristics、SizeOfStackReserve、SizeOfStackCommit、SizeOfHeapReserve、SizeOfHeapCommit、LoaderFlags
= 0
NumberOfRvaAndSizes
= 16
,默认定义16。
IMAGE_DATA_DIRECTORY_ARRAY
我们只需要导入表,但是目前不能确定导入表的RVA,所以我们先全部填充0。但是后面我们将要返回来填写此字段。
写完这些值之后我们可以重新打开文件,就能看到编辑器已经可以读入正常的格式了。
IMAGE_SECTION_HEADER
首先说明节的概念,节是可执行文件的实际数据的容器,它们占据了 PE 文件中标头之后的其余部分,恰好在节标头之后。
常见的节区如下:
.text
:**包含程序的可执行代码。.data
:**包含初始化数据。.bss
:**包含未初始化的数据。.rdata
:**包含只读初始化数据。.edata
:**包含导出表。.idata
:**包含导入表。.reloc
:**包含图像重定位信息。.rsrc
:**包含程序使用的资源,包括图像、图标甚至嵌入式二进制文件。.tls
: ( T hread Local S torage) ,为程序的每一个执行线程提供存储。 节区头用来描述节的相关大小和地址信息。存放在可选头与节区之间的位置。
我们需要两个节区.text
来保存代码和字符常量。.rdata
节区来保存导入表相关结构。
typedef struct _IMAGE_SECTION_HEADER { // 大小为40d字节
BYTE Name[IMAGE_SIZEOF_SHORT_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;
节区头是一个数组,下面介绍成员的属性。
Name
: Section Header 的第一个字段,一个字节数组,其大小IMAGE_SIZEOF_SHORT_NAME
包含该节的名称。 具有一个节名不能超过 8 个字符的IMAGE_SIZEOF_SHORT_NAME
的值。8
对于更长的名称,官方文档提到了一种解决方法,即用字符串表中的偏移量填充此字段,但是可执行映像不使用字符串表,因此 8 个字符的限制适用于可执行映像。PhysicalAddress
or VirtualSize
: Aunion
为同一事物定义了多个名称,该字段包含该部分加载到内存时的总大小。VirtualAddress
*文档指出,对于可执行映像,该字段包含加载到内存中时相对于映像基址的部分的第一个字节的地址,对于目标文件,它包含应用重定位之前该部分的第一个字节的地址。SizeOfRawData
*此字段包含磁盘上该部分的大小,它必须是 的倍数IMAGE_OPTIONAL_HEADER.FileAlignment
。SizeOfRawData
并且VirtualSize
可以不同,我们将在后面的帖子中讨论其原因。PointerToRawData(重要)
*这个字段表示该节区在文件中的偏移量(文件偏移),即该节区的数据在PE文件中的实际位置。PointerToRawData字段是相对于PE文件起始位置的偏移量,而不是相对于节区表的偏移量。PointerToRelocations
:**指向该部分重定位条目开头的文件指针。它被设置0
为用于可执行文件。PointerToLineNumbers
:**指向该部分的 COFF 行号条目开头的文件指针。设置为0
因为 COFF 调试信息已弃用。NumberOfRelocations
:**该部分的重定位条目数,它设置0
为用于可执行映像。NumberOfLinenumbers
:**该部分的 COFF 行号条目数,设置为0
因为 COFF 调试信息已弃用。Characteristics
:**描述部分特征的标志。 下面开始写入数据。
从148h
处开始就是我们的节区头结构的开始。
.text
节区头下面的值我将用直接用16进制表示。
Name = 2E 74 65 78 74 00 00 00
ascii的值为.text
。
VirtualSize = 46 00 00 00
十进制为70,这个大小是由我们在节区的数据量来确定,我们这个操作码+字符常量的总占地大小为70,所以这里填写70。
VirtualAddress = 00 10 00 00
SizeOfRawData = 00 02 00 00
PointerToRawData = 00 02 00 00
PointerToRelocations、PointerToLineNumbers、NumberOfRelocations、NumberOfLinenumbers = 00 00 00 00 00 00 00 00 00 00 00 00
Characteristics = 20 00 00 60
主要是权限,我们来个可读可执行。
.rdata
节区头这块就不细说了,只说明三个值
VirtualSize
= 00 20 00 00
00 02 00 00
00 04 00 00
这些都是我们后面计算RWA、RVA非常需要的值。
.text
节区 .text
用来保存代码和字符常量,我们给它设置的起始地址为200h
,大小为200h
,所以我们要再进行插入
插入之后,我们先不进行数据填充。理由如下:
call xxxx
这里的xxxx
就是IAT的RVA
,但是我们现在还没有进行导入表数据填写,所以无法确定。所以我们写入200h个空字符后先不要去着急写入代码。后面我们返回来进行填写。
.rdata
节区 此节区也先进行填充,我们设置的起始地址为400h
大小为200h
。
到此为止,我们的文件结尾地址应该是如图所示。
我们需要在.rdata
节区编写我们的导入表结构,我们从400h
开始,要进行一个IMAGE_IMPORT_DESCRIPTOR数组的编写。
此数组有若干个IMAGE_IMPORTDESCRIPTOR
结构体构成,数组的大小并不是固定的,系统会根据最后一个空IMAGE_IMPORT_DESCRIPTOR
结构体来判断是否为数组的结束。
之前说过我们有两个DLL
文件需要导入,那我们就需要(2 + 1) * sizeof(IMAGE_IMPORT_DESCRIPTOR) =3 * 20(十进制) = 60
60个字节需要使用。
返回更新IMAGE_DATA_DIRECTORY Import的值
在编写IMAGE_IMPORT_DESCRIPTOR
结构体之前,我们需要设置一个值。我们现在可以确定导入表的文件偏移了,为400h
。那么IMAGE_DATA_DIRECTORY Import
的值应该是400h
的RVA
,2000h+400h-400h = 2000h
。大小为60d
也就是3ch
。
还记得
IMAGE_DATA_DIRECTORY Import
吗?在可选头阶段,我们没有填写导入表的RVA,因为当时我们无法确定节区、地址、大小。
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics;
DWORD OriginalFirstThunk;
} DUMMYUNIONNAME;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Name;
DWORD FirstThunk;
} IMAGE_IMPORT_DESCRIPTOR;
typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR;
OriginalFirstThunk
: ILT/INT
的 RVA。TimeDateStamp
:**一个时间日期戳,如果未绑定则初始设置为如果绑定则0
设置为-1
。-1
并且 DLL 的实时日期戳可以在相应的绑定导入目录表中找到IMAGE_BOUND_IMPORT_DESCRIPTOR
。ForwarderChain
:**第一个转发器链引用的索引。Name
:**包含导入 DLL 名称的 ASCII 字符串的 RVA。FirstThunk
: IAT
的 RVA。 这个结构中我们只需要关注OriginalFirstThunk
、Name
、FirstThunk
这三个成员的值就够了。这三个成员的值都是RVA,相对虚拟地址。
为了得到它们的RVA,我们需要先给它们找到一个地址存放它们的数据。
我们先将我们需要的DLL
与DLL
中的函数写入.rdata
节区,只有确定了这些值的地址我们才能填写IMAGE_IMPORT_DESCRIPTOR
结构体。
IMAGE_IMPORT_DESCRIPTOR
数组的大小为60,400h+60d = 43ch
,我们从43Ch
地址处开始填入。
00 00 4D 65 73 73 61 67 65 42 6F 78 45 78 57 00
55 53 45 52 33 32 2E 64 6C 6C 00 00 6D 73 76 63
72 74 2E 64 6C 6C 00 00 00 5F 66 67 65 74 63 68
61 72 00 00
OriginalFirstThunk
为Import Name Table
的RVA,那么我们需要找一个位置设置INT
的值。INT
的值是DLL导入函数的名称的RVA,也就是MessageBoxExW
的RVA。计算公式为0x2000 + 0x43C - 0x400 = 0x203c
。INT的值为0x203c
,我们写到0x470
处。OriginalFirstThunk
的值为0x470
的RVA,计算后为0x2000 + 0x470 - 0x400 = 0x2070
TimeDateStamp、ForwarderChain
我们设置0就可以了。Name
的值是我们写入.rdata
的USER32.dll
的字符串地址44ch
的RVA,计算一下2000h+400h-44ch = 204ch
,所以此处我们填写4C 20 00 00
FirstThunk
的取值逻辑和OriginalFirstThunk
比较相似,我们需要先找到一个地址来存放IAT
,然后再计算此IAT
的RVA给到FirstThunk
。我们选择480h
处作为IAT
的文件偏移。IAT
的值我们可以设置为与INT
的相等值,也就是为0x203c
。FirstThunk
的值为480h
的RVA,计算公式2000h+400h-480h = 2080h
接下来按同样的方法来写msvcrt.dll
的导入表。
首先把代码放入0x200
的地址偏移处
48 83 EC 38 33 C0 4C 8D 05 27 00 00 00 45 33 C9 66 89 44 24 20 48 8D 15 26 00 00 00 33 C9 FF 15 00 00 00 00 FF 15 00 00 00 00 33 C0 48 83 C4 38 C3 00
这段代码可以看到FF15(call)
指令后面都是0
,是因为我们还未确定call调用的地址。下面我们会进行计算,但首先,我们先写入这些数据。
在x64
程序中,call
指令后面需要填写IAT的RVA。有一个计算公式
call xxxx
xxxx = IAT的VA - call指令的VA - call的长度
首先IAT
的VA
是ImageBase + RVA
= 140000000h + 2080h = 140002080h
。
call指令本身的地址
为0x14000101E
,call的长度
为固定值6。
xxxx = 140002080h - 0x14000101E - 6 = 0x105C
_fgetchar
的地址用同样的方法计算,结果为0x1076
更新我们的代码
48 83 EC 38 33 C0 4C 8D 05 27 00 00 00 45 33 C9 66 89 44 24 20 48 8D 15 26 00 00 00 33 C9 FF 15 5C 10 00 00 FF 15 76 10 00 00 33 C0 48 83 C4 38 C3 00
现在我们已经可以运行程序,只是弹窗没有字符串,接下来我们添加字符串。
在520h
的文件偏移处,写入两个宽字符串。
lea
指令后面要写入字符串的RVA,和上面的call指令找地址的公式差不多。
我这里直接给出计算过程。
140002120h - 140001006h - 7h = 1113h
14000212eh - 140001015h - 7h = 1112h
再次更新我们的代码
48 83 EC 38 33 C0 4C 8D 05 13 11 00 00 45 33 C9 66 89 44 24 20 48 8D 15 12 11 00 00 33 C9 FF 15 5C 10 00 00 FF 15 76 10 00 00 33 C0 48 83 C4 38 C3 00
从0手工构造64位PE并手工进行加壳
A dive into the PE file format - PE file structure - Part 4: Data Directories, Section Headers and Sections
第13章:PE文件格式(2) – IAT、INT