创建EXE

自我隔离是开始花费很多时间和精力的事情的好时机。所以我决定做我一直想要的-编写自己的编译器。



现在他可以构建Hello World,但是在本文中,我不想谈论解析和编译器的内部结构,而是要讨论诸如exe文件的逐字节汇编之类的重要部分。



开始



想要剧透吗?我们的程序将是2048个字节。



通常,使用exe文件是研究或修改其结构。可执行文件本身是由编译器形成的,对于开发人员来说,此过程似乎有些神奇。



但是现在我们将尝试修复它!



要构建程序,我们需要任何HEX编辑器(我个人使用HxD)。



首先,让我们使用伪代码:



资源
func MessageBoxA(u32 handle, PChar text, PChar caption, u32 type) i32 ['user32.dll']
func ExitProcess(u32 code) ['kernel32.dll']

func main()
{
	MessageBoxA(0, 'Hello World!', 'MyApp', 64)
	ExitProcess(0)
}




前两行表示从WinAPI库导入的函数MessageBoxA函数显示一个包含我们的文本的对话框,ExitProcess通知系统程序结束。

单独考虑主要功能是没有意义的,因为它使用了上述功能。



DOS头



首先,我们需要生成正确的DOS标头,这是DOS程序的标头,不应影响Windows下exe的启动。



我注意到或多或少重要的字段,其余的字段都用零填充。



IMAGE_DOS_HEADER结构
Struct IMAGE_DOS_HEADER
{
     u16 e_magic	// 0x5A4D	"MZ"
     u16 e_cblp		// 0x0080	128
     u16 e_cp		// 0x0001	1
     u16 e_crlc
     u16 e_cparhdr	// 0x0004	4
     u16 e_minalloc	// 0x0010	16
     u16 e_maxalloc	// 0xFFFF	65535
     u16 e_ss
     u16 e_sp		// 0x0140	320
     u16 e_csum		
     u16 e_ip
     u16 e_cs
     u16 e_lfarlc	// 0x0040	64
     u16 e_ovno
     u16[4] e_res
     u16 e_oemid
     u16 e_oeminfo
     u16[10] e_res2
     u32 e_lfanew	// 0x0080	128
}




最重要的是,此标头包含e_magic字段,这意味着这是一个可执行文件,而e_lfanew则指示PE标头与文件开头的偏移量(在我们的文件中,此偏移量为0x80 = 128字节)。



太好了,现在我们知道了DOS Header结构,让我们将其写入文件中。



(1)RAW DOS标头(偏移量0x00000000)
4D 5A 80 00 01 00 00 00  04 00 10 00 FF FF 00 00
40 01 00 00 00 00 00 00  40 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 80 00 00 00










, , .



, (Offset) .



, 0x00000000, 64 (0x40 16- ), 0x00000040 ..

完成后,将写入前64个字节。现在您需要再添加64个,这就是所谓的DOS Stub(Stub)。从DOS下启动时,它必须通知用户该程序并非设计为在此模式下运行。



但是总的来说,这是一个小的DOS程序,它打印一行并退出该程序。

让我们将Stub写入文件并更详细地考虑它。



(2)RAW DOS存根(Offset 0x00000040)
0E 1F BA 0E 00 B4 09 CD  21 B8 01 4C CD 21 54 68
69 73 20 70 72 6F 67 72  61 6D 20 63 61 6E 6E 6F
74 20 62 65 20 72 75 6E  20 69 6E 20 44 4F 53 20
6D 6F 64 65 2E 0D 0A 24  00 00 00 00 00 00 00 00






现在是相同的代码,但以反汇编形式



Asm DOS存根
0000	push cs			;  Code Segment(CS) (    )
0001	pop ds			;   Data Segment(DS) = CS
0002	mov dx, 0x0E	;     DS+DX,      $( ) 
0005	mov ah, 0x09	;   ( )
0007	int 0x21		;    0x21
0009	mov ax, 0x4C01	;   0x4C (  ) 
						;     0x01 ()
000c	int 0x21		;    0x21
000e	"This program cannot be run in DOS mode.\x0D\x0A$" ;  




它的工作方式如下:首先,存根打印一行,表明该程序无法启动,然后以代码1退出程序。这与正常终止(代码0)不同。



我比较了gcc和delphi,存根代码可能略有不同(因编译器而异),但一般含义是相同的。



存根行以\ x0D \ x0D \ x0A $结尾也很有趣。出现这种现象的最可能的原因是,c ++默认情况下以文本模式打开文件。结果,字符\ x0A被替换为序列\ x0D \ x0A。结果,我们得到3个字节:2个回车符(0x0D)是没有意义的,1个换行符(0x0A)。在二进制模式下(std :: ios :: binary),不会发生此替换。



为了检查写入值的正确性,我将Far与ImpEx插件一起使用:







NT标头



在128(0x80)个字节之后,我们进入了NT标头(IMAGE_NT_HEADERS64),其中还包含PE标头(IMAGE_OPTIONAL_HEADER64)。尽管名称IMAGE_OPTIONAL_HEADER64是必需的,但对于x64和x86体系结构却有所不同。



IMAGE_NT_HEADERS64结构
Struct IMAGE_NT_HEADERS64
{
	u32 Signature	// 0x4550 "PE"
	
	Struct IMAGE_FILE_HEADER 
	{
		u16 Machine	// 0x8664  x86-64
		u16 NumberOfSections	// 0x03     
		u32 TimeDateStamp		//   
		u32 PointerToSymbolTable
		u32 NumberOfSymbols
		u16 SizeOfOptionalHeader //  IMAGE_OPTIONAL_HEADER64 ()
		u16 Characteristics	// 0x2F 
	}
	
	Struct IMAGE_OPTIONAL_HEADER64
	{
		u16 Magic	// 0x020B      PE64
		u8 MajorLinkerVersion
		u8 MinorLinkerVersion
		u32 SizeOfCode
		u32 SizeOfInitializedData
		u32 SizeOfUninitializedData	
		u32 AddressOfEntryPoint	// 0x1000 
		u32 BaseOfCode	// 0x1000 
		u64 ImageBase	// 0x400000 
		u32 SectionAlignment	// 0x1000 (4096 )
		u32 FileAlignment	// 0x200
		u16 MajorOperatingSystemVersion	// 0x05	Windows XP
		u16 MinorOperatingSystemVersion	// 0x02	Windows XP
		u16 MajorImageVersion
		u16 MinorImageVersion
		u16 MajorSubsystemVersion	// 0x05	Windows XP
		u16 MinorSubsystemVersion	// 0x02	Windows XP
		u32 Win32VersionValue
		u32 SizeOfImage	// 0x4000
		u32 SizeOfHeaders // 0x200 (512 )
		u32 CheckSum
		u16 Subsystem	// 0x02 (GUI)  0x03 (Console)
		u16 DllCharacteristics
		u64 SizeOfStackReserve	// 0x100000
		u64 SizeOfStackCommit	// 0x1000
		u64 SizeOfHeapReserve	// 0x100000
		u64 SizeOfHeapCommit	// 0x1000
		u32 LoaderFlags
		u32 NumberOfRvaAndSizes // 0x16 
		
		Struct IMAGE_DATA_DIRECTORY [16] 
		{
			u32 VirtualAddress
			u32 Size
		}
	}
}




让我们看看该结构中存储了什么:



说明IMAGE_NT_HEADERS64
Signature — PE



IMAGE_FILE_HEADER x86 x64.



Machine — x64

NumberOfSections — ( )

TimeDateStamp —

SizeOfOptionalHeader — IMAGE_OPTIONAL_HEADER64, IMAGE_OPTIONAL_HEADER32.



Characteristics — , , (EXECUTABLE_IMAGE) 2 RAM (LARGE_ADDRESS_AWARE), ( ) (RELOCS_STRIPPED | LINE_NUMS_STRIPPED | LOCAL_SYMS_STRIPPED).



SizeOfCode — ( .text)

SizeOfInitializedData — ( .rodata)

SizeOfUninitializedData — ( .bss)

BaseOfCode —

SectionAlignment —

FileAlignment —

SizeOfImage —

SizeOfHeaders — (IMAGE_DOS_HEADER, DOS Stub, IMAGE_NT_HEADERS64, IMAGE_SECTION_HEADER[IMAGE_FILE_HEADER.NumberOfSections]) FileAlignment

Subsystem — GUI Console

MajorOperatingSystemVersion, MinorOperatingSystemVersion, MajorSubsystemVersion, MinorSubsystemVersion — exe, . 5.2 Windows XP (x64).

SizeOfStackReserve — . 1 , 1. Rust , C++ .

SizeOfStackCommit — 4 . .

SizeOfHeapReserve — . 1 .

SizeOfHeapCommit — 4 . SizeOfStackCommit, .



IMAGE_DATA_DIRECTORY — . , , 16 . .



, , . :

Export(0) — . DLL. .



Import(1) — DLL. VirtualAddress = 0x3000 Size = 0xB8. , .



Resource(2) — (, , ..)

.



现在,我们已经了解了NT标头的组成,我们还将与其他标头类似地将其写入文件0x80。



(3)RAW NT标题(偏移量0x00000080)
50 45 00 00 64 86 03 00  F4 70 E8 5E 00 00 00 00
00 00 00 00 F0 00 2F 00  0B 02 00 00 3D 00 00 00
13 00 00 00 00 00 00 00  00 10 00 00 00 10 00 00
00 00 40 00 00 00 00 00  00 10 00 00 00 02 00 00
05 00 02 00 00 00 00 00  05 00 02 00 00 00 00 00
00 40 00 00 00 02 00 00  00 00 00 00 02 00 00 00
00 00 10 00 00 00 00 00  00 10 00 00 00 00 00 00
00 00 10 00 00 00 00 00  00 10 00 00 00 00 00 00
00 00 00 00 10 00 00 00  00 00 00 00 00 00 00 00
00 30 00 00 B8 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00




结果,我们得到了这种IMAGE_FILE_HEADER,IMAGE_OPTIONAL_HEADER64和IMAGE_DATA_DIRECTORY标头:















接下来,我们根据IMAGE_SECTION_HEADER结构描述应用程序的所有部分



IMAGE_SECTION_HEADER结构
Struct IMAGE_SECTION_HEADER
{
	i8[8] Name
	u32 VirtualSize
	u32 VirtualAddress
	u32 SizeOfRawData
	u32 PointerToRawData
	u32 PointerToRelocations
	u32 PointerToLinenumbers
	u16 NumberOfRelocations
	u16 NumberOfLinenumbers
	u32 Characteristics
}




IMAGE_SECTION_HEADER的描述
Name — 8 ,

VirtualSize —

VirtualAddress — SectionAlignment

SizeOfRawData — FileAlignment

PointerToRawData — FileAlignment

Characteristics — (, , , , .)



在本例中,我们将分为3个部分。



我不知道为什么虚拟地址(VA)从1000开始,而不是从头开始,但是我考虑过的所有编译器都这样做。结果,我们在SizeOfImage中编写的1000 + 3个部分* 1000(SectionAlignment)= 4000。这是我们程序在虚拟内存中的总大小。可能用于为内存中的程序分配空间。



 Name	| RAW Addr	| RAW Size	| VA	| VA Size | Attr
--------+---------------+---------------+-------+---------+--------
.text	| 200		| 200		| 1000	| 3D	  |   CER
.rdata	| 400		| 200		| 2000	| 13	  | I   R
.idata	| 600		| 200		| 3000	| B8	  | I   R


属性的解码:



I-初始化数据,

U-未初始化数据,

C-代码,未初始化数据,包含可执行的

E-执行代码,允许执行

R-读取代码,允许从

W-Write部分读取数据,允许将数据写入该部分



.text(.code)-存储可执行代码(程序本身),CE属性

.rdata(.rodata)-存储只读数据,例如常量,字符串等,IR属性

.data-存储可以读取和写入的数据,例如静态或全局变量。 IRW属性

.bss-存储未初始化的数据,例如静态或全局变量。另外,此部分通常具有零RAW大小和非零VA大小,因此它不会占用文件中的空间。URW

.idata属性-包含从其他库导入的函数的部分。IR属性



重要的一点是,各部分必须相互遵循。此外,在文件和内存中。至少当我任意更改其顺序时,程序停止运行。



现在我们知道程序将包含哪些部分,我们将它们写入文件。此处偏移量以8结尾,记录将从文件的中间开始。



(4)个RAW节(偏移量0x00000188)
                         2E 74 65 78 74 00 00 00
3D 00 00 00 00 10 00 00  00 02 00 00 00 02 00 00
00 00 00 00 00 00 00 00  00 00 00 00 20 00 00 60
2E 72 64 61 74 61 00 00  13 00 00 00 00 20 00 00
00 02 00 00 00 04 00 00  00 00 00 00 00 00 00 00
00 00 00 00 40 00 00 40  2E 69 64 61 74 61 00 00
B8 00 00 00 00 30 00 00  00 02 00 00 00 06 00 00
00 00 00 00 00 00 00 00  00 00 00 00 40 00 00 40






下一个条目地址将是00000200,它对应于PE-Header的SizeOfHeaders字段。如果我们再添加一个部分,并且该部分再加上40个字节,那么我们的标头将无法容纳512(0x200)个字节,并且必须使用512 + 40 = 552个字节(按FileAlignment对齐),即1024(0x400)个字节。从0x228(552)到地址0x400的所有内容都需要填充一些东西,当然最好是零。



让我们看一下Far中的一部分块:







接下来,我们将这些部分本身写入文件中,但是有一点细微差别。



从SizeOfHeaders示例中可以看到,我们不能只编写标题并继续进行下一部分。由于要记录一个标题,我们需要知道所有标题将花费多长时间。结果,我们需要预先计算需要多少空间,或者写入空(零)值,然后在写入所有标头之后,返回并写下其实际大小。



因此,程序要经过多次编译。例如,.rdata节位于.text节之后,尽管我们无法在.rdata中找到变量的虚拟地址,因为如果.text节增长超过0x1000(SectionAlignment)字节,它将占用范围的地址0x2000。因此,.rdata节将不再位于0x2000,而是位于0x3000。我们将需要返回并重新计算.rdata之前的.text部分中所有变量的地址。



但是在这种情况下,我已经计算了所有内容,因此我们将立即写下代码块。



。文字部分



ASM段.text
0000	push rbp
0001	mov rbp, rsp
0004	sub rsp, 0x20
0008	mov rcx, 0x0
000F	mov rdx, 0x402000
0016	mov r8, 0x40200D
001D	mov r9, 0x40
0024	call QWORD PTR [rip + 0x203E]
002A	mov rcx, 0x0
0031	call QWORD PTR [rip + 0x2061]
0037	add rsp, 0x20
003B	pop rbp
003C	ret




专门针对此程序,前3行与后3行完全一样,是可选的。

最后一个3甚至都不会执行,因为程序将在第二个调用函数处退出。



但是可以这样说,如果它不是主要功能,而是子功能,则应以这种方式完成。



但是,尽管不是必需的,但是在这种情况下,前3个是理想的。例如,如果我们不使用MessageBoxA,而是使用printf,那么如果没有这些行,我们将得到一个错误。



根据64位MSDN系统的调用约定,前4个参数在寄存器RCX,RDX,R8,R9中传递。如果它们适合那里,例如不是浮点数。其余的则通过堆栈。



从理论上讲,如果将2个参数传递给函数,则必须将它们传递给寄存器,并在堆栈中为它们保留两个位置,以便在必要时函数可以将寄存器压入堆栈。同样,我们不应期望这些寄存器以其原始状态返回给我们。



因此,printf函数的问题在于,如果仅向其传递1个参数,则它似乎仍将覆盖参数堆栈中的所有4个位置,尽管似乎只覆盖了其中的1个参数。



因此,如果您不希望程序表现异常,请在将至少1个参数传递给函数时,始终保留至少8个字节* 4个参数= 32(0x20)个字节。



考虑带有函数调用的代码块



MessageBoxA(0, 'Hello World!', 'MyApp', 64)
ExitProcess(0)


首先我们传递参数:



rcx = 0

rdx =字符串在内存ImageBase + Sections [“。Rdata”]中的绝对地址。VirtualAddress +从该部分开头开始的字符串偏移量,该字符串被读取为字节零

r8 =类似于先前的

r9 = 64(0x40)MB_ICONINFORMATION ,信息图标



然后是对MessageBoxA函数的调用,使用它并不是一切都那么简单。关键是编译器尝试使用最短的命令。指令大小越小,分别适合处理器高速缓存的指令越多,高速缓存未命中,重载就越少,并且程序速度越高。有关命令和处理器内部工作的更多信息,请参阅《 Intel 64和IA-32体系结构软件开发人员手册》。



我们可以在完整地址处调用该函数,但这至少要花(1个操作码+ 8个地址= 9个字节),而相对地址下,调用命令仅需要6个字节。



让我们仔细看看这个魔术:rip + 0x203E仅仅是在我们的偏移量指定的地址处进行的函数调用。



我向前看了一下,找出了我们需要的偏移量的地址。对于MessageBoxA,它是0x3068,对于ExitProcess,它是0x3098。



现在是时候将魔术变成科学了。每次操作码到达处理器时,它都会计算其长度并将其添加到当前指令地址(RIP)中。因此,当我们在一条指令内使用RIP时,此地址表示当前指令的末尾/下一条指令的开始。

对于第一个调用,偏移量将指示调用命令的末尾,即002A。不要忘记,该地址在内存中将位于偏移量Sections [“。Text”]中。0x1000。因此,我们通话的RIP为102A。我们需要MessageBoxA的地址为0x3068。考虑0x3068-0x102A = 0x203E对于第二个地址,所有内容都与0x1000 + 0x0037 = 0x1037,0x3098-0x1037 = 0x2061相同



我们在汇编器命令中看到的就是这些偏移量。



0024	call QWORD PTR [rip + 0x203E]
002A	mov rcx, 0x0
0031	call QWORD PTR [rip + 0x2061]
0037	add rsp, 0x20


让我们将.text部分写入文件,在地址0x400处添加零:



(5)RAW .text部分(偏移量0x00000200-0x00000400)
55 48 89 E5 48 83 EC 20  48 C7 C1 00 00 00 00 48
C7 C2 00 20 40 00 49 C7  C0 0D 20 40 00 49 C7 C1
40 00 00 00 FF 15 3E 20  00 00 48 C7 C1 00 00 00
00 FF 15 61 20 00 00 48  83 C4 20 5D C3 00 00 00
........
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00


4 . FileAlignment. 0x000003F0, 0x00000400, . 1024 , ! .




.Rdata部分



这也许是最简单的部分。我们将两行放在这里,将零添加到512字节。



.rdata
0400	"Hello World!\0"
040D	"MyApp\0"




(6)RAW .rdata部分(偏移量0x00000400-0x00000600)
48 65 6C 6C 6F 20 57 6F  72 6C 64 21 00 4D 79 41
70 70 00 00 00 00 00 00  00 00 00 00 00 00 00 00
........
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00




.idata部分



好了,这是最后一部分,它描述了从库中导入的函数。



等待我们的第一件事是新结构IMAGE_IMPORT_DESCRIPTOR



IMAGE_IMPORT_DESCRIPTOR结构
Struct IMAGE_IMPORT_DESCRIPTOR
{
	u32 OriginalFirstThunk (INT)
	u32 TimeDateStamp
	u32 ForwarderChain
	u32 Name
	u32 FirstThunk (IAT)
}




说明IMAGE_IMPORT_DESCRIPTOR
OriginalFirstThunk — , Import Name Table (INT)

Name — ,

FirstThunk — , Import Address Table (IAT)



首先,我们需要添加2个导入的库。召回:



func MessageBoxA(u32 handle, PChar text, PChar caption, u32 type) i32 ['user32.dll']
func ExitProcess(u32 code) ['kernel32.dll']


(7)原始IMAGE_IMPORT_DESCRIPTOR(偏移量0x00000600)
58 30 00 00 00 00 00 00  00 00 00 00 3C 30 00 00
68 30 00 00 88 30 00 00  00 00 00 00 00 00 00 00
48 30 00 00 98 30 00 00  00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 00 00




我们使用2个库,并说我们已经列出了它们。最后一个结构用零填充。



 INT	| Time	 | Forward  | Name   | IAT
--------+--------+----------+--------+--------
0x3058	| 0x0    | 0x0      | 0x303C | 0x3068
0x3088	| 0x0    | 0x0      | 0x3048 | 0x3098
0x0000	| 0x0    | 0x0      | 0x0000 | 0x0000


现在让我们添加库本身的名称:



图书馆名称
063	"user32.dll\0"
0648	"kernel32.dll\0"




(8)RAW库名称(偏移量0x0000063C)
                                     75 73 65 72
33 32 2E 64 6C 6C 00 00  6B 65 72 6E 65 6C 33 32
2E 64 6C 6C 00 00 00 00




接下来,让我们描述user32库:



(9)RAW user32.dll(偏移量0x00000658)
                         78 30 00 00 00 00 00 00 
00 00 00 00 00 00 00 00  78 30 00 00 00 00 00 00
00 00 00 00 00 00 00 00  00 00 4D 65 73 73 61 67 
65 42 6F 78 41 00 00 00




如果我们稍微看高一些,第一个库的“名称”字段指向0x303C,我们将看到在地址0x063C处有一个库“ user32.dll \ 0”。



提示,请记住,.idata部分对应于文件偏移量0x0600和内存偏移量0x3000。对于第一个库,INT为3058,这意味着在文件中它将偏移0x0658。在此地址,我们看到条目0x3078和第二个零。表示列表的末尾。 3078表示0x0678,这是RAW字符串



“ 00 00 4D 65 73 73 61 67 65 42 6F 78 41 00 00 00”



。前2个字节对​​我们来说无关紧要,并且等于零。然后有一行函数名称,以零结尾。也就是说,我们可以将其表示为“ \ 0 \ 0MessageBoxA \ 0”。



在这种情况下,IAT引用的结构类似于IAT表,但是在程序启动时仅将功能地址加载到其中。例如,内存中的第一个条目0x3068在文件中将具有一个非0x0668的值。将有系统加载的MessageBoxA函数的地址,我们将通过程序代码中的调用来引用该地址。



最后一个难题是kernel32。并且不要忘记在SectionAlignment中添加零。



(10)RAW kernel32.dll(偏移量0x00000688-0x00000800)
                         A8 30 00 00 00 00 00 00 
00 00 00 00 00 00 00 00  A8 30 00 00 00 00 00 00 
00 00 00 00 00 00 00 00  00 00 45 78 69 74 50 72 
6F 63 65 73 73 00 00 00  00 00 00 00 00 00 00 00 
........
00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00






我们检查了Far是否能够正确识别我们导入的功能:







太好了!一切都很好,所以现在我们的文件可以运行了。

击鼓…



决赛







恭喜,我们做到了!



该文件占用2 KB =头512字节+ 512字节的3个部分。



数字512(0x200)只不过是我们在程序标题中指定的FileAlignment。



另外:

如果您想更深入一点,可以替换题词“ Hello World!”。别忘了更改程序代码(.text节)中的行地址。内存中的地址为0x00402000,但是文件的反向字节顺序为00 20 4000。否则,查询会



变得有些复杂。将另一个MessageBox调用添加到代码中。为此,您将必须复制上一个呼叫并重新计算其中的相对地址(0x3068-RIP)。



结论



文章被弄得很皱,当然,它由3个单独的部分组成:标题,程序,导入表。



如果有人编译了自己的exe,那么我的工作就不会白费。



我正在考虑以类似的方式创建ELF文件,这样的文章会很有趣吗?)



链接:






All Articles