前言
前段时间有个群友想玩EFI,这个人我不好用准确的方式去描述,容易被当做人身攻击,懂的自然懂。
总之呢,他要一个最简EFI程序,那就开整吧。
构造文件头
EFI文件一般走PE文件标准,我们在构造文件的时候要搞出来以下内容:
- DOS头
- NT头
- 节表
为了抠字节数,我们用汇编来构造。汇编器选用NASM,非常便携。
构造DOS头
DOS头只需要管e_magic
和e_lfanew
两个成员即可,前者填MZ
这两个字符,后者填充NT头相对于映像基址的偏移即可,代码如下:
; DOS Header
dw 'MZ' ; e_magic
dw 0 ; [UNUSED] e_cblp
dw 0 ; [UNUSED] c_cp
dw 0 ; [UNUSED] e_crlc
dw 0 ; [UNUSED] e_cparhdr
dw 0 ; [UNUSED] e_minalloc
dw 0 ; [UNUSED] e_maxalloc
dw 0 ; [UNUSED] e_ss
dw 0 ; [UNUSED] e_sp
dw 0 ; [UNUSED] e_csum
dw 0 ; [UNUSED] e_ip
dw 0 ; [UNUSED] e_cs
dw 0 ; [UNUSED] e_lfarlc
dw 0 ; [UNUSED] e_ovno
times 4 dw 0 ; [UNUSED] e_res
dw 0 ; [UNUSED] e_oemid
dw 0 ; [UNUSED] e_oeminfo
times 10 dw 0 ; [UNUSED] e_res2
dd pe_hdr ; e_lfanew
构造NT头
一般来说DOS头和NT头之间隔着一个DOS Stub用于存放在MSDOS下执行的代码。这里直接阉割了,让NT头紧贴DOS头。
NT头分三个部分:签名、文件头、可选头。
签名占4个字节,前两个字节是PE
,后两个字节为零。
文件头里值得填充的内容有:处理器类型、有几个节、可选头有多大、PE文件属性。
处理器类型填0x8664
,也就是IMAGE_FILE_MACHINE_AMD64
。这里顺便给大家看个图乐呵乐呵:
为了压缩文件,我们在PE文件里只留一个节即可。
可选头大小这一条我们通过汇编器的宏来推断即可。
至于PE文件属性,我们要标明它是可执行映像、并且能处理大于2GB内存的程序,所以填0x22,即IMAGE_FILE_EXECUTABLE_IMAGE|IMAGE_FILE_LARGE_ADDRESS_AWARE
。
可选头本身其实是不可选的,必须要填好。值得关注的填就行了,别的就全部清零。
首先是魔数,PE32+文件直接填0x20B
就好。
然后是代码大小,这玩意让汇编器去推算即可。
然后是入口点。这里要注意加载器会为了内存对齐而转移节的位置。因此不可以让汇编器推算入口点的地址。我们直接把代码放在节的开头上,因此指定页对齐粒度0x1000
即可。
至于代码基地址,填代码段的基地址就好。这里也填0x1000
。
代码的内存基地址,这里可以填零,但我选择照着链接器的默认习惯填0x140000000
。u1s1,PE加载器,尤其是EFI的PE加载器基本上不可能真的把映像加载到那里去。
然后是节对齐边界。这个东西嘛,虽然文档上说可以小于页边界,但是给不给加载就是另外一回事了。VMware的EFI固件不支持。
接着是文件对齐边界。虽然文档上说不可以小于512,实际上意义不大。一般不会闲着蛋疼真的去检查节表里的文件偏移是不是没对齐。我们用最小可能的1字节对齐,填0x1
。
然后是映像大小。这里占了两个页,因此填0x2000
。
文件头大小这块,让汇编器自己去推算。
重头是PE文件子系统这块,我们是写EFI应用程序,因此这里要填IMAGE_SUBSYSTEM_EFI_APPLICATION
,即0xA
。
而DLL属性这块,我们填以下内容进去:
IMAGE_DLLCHARACTERISTICS_HIGH_ENTROPY_VA
,映像支持高熵地址,其实就是随机地址的意思,但是同时这个地址很“混乱”。高熵地址通常是作为溢出攻击的最后一道保险用的。
IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE
,映像基址可以动态选择,意味着代码要支持重定向。如果不支持,那基本上EFI固件不可能加载我们的代码了,因为那个地址未必可用。但由于我们不想为了这个再去专门写个重定向表,因此就要避免在代码里引用绝对地址。一切地址引用全部以相对地址来实现。
IMAGE_DLLCHARACTERISTICS_NX_COMPAT
,映像与NX机制兼容,意味着数据区不可执行。笑死,我们最小PE没有数据区。
汇总起来,我们要填0x160
进去。
最后填数据目录数量,这个是定死的16个,即使我们一个都用不到。
综上所述,上代码:
; PE Header
pe_hdr:
dw 'PE', 0 ; Signature
; Image File Header
dw 0x8664 ; Machine
dw 0x01 ; NumberOfSections
dd 0 ; [UNUSED] TimeDateStamp
dd 0 ; PointerToSymbolTable
dd 0 ; NumberOfSymbols
dw opt_hdr_size ; SizeOfOptionalHeader
dw 0x22 ; Characteristics
; Optional Header, COFF Standard Fields
opt_hdr:
dw 0x020b ; Magic (PE32+)
db 0x00 ; MajorLinkerVersion
db 0x00 ; MinorLinkerVersion
dd code_size ; SizeOfCode
dd 0 ; SizeOfInitializedData
dd 0 ; SizeOfUninitializedData
dd 0x1000 ; AddressOfEntryPoint
dd 0x1000 ; BaseOfCode
; Optional Header, NT Additional Fields
dq 0x140000000 ; ImageBase
dd 0x1000 ; SectionAlignment
dd 0x1 ; FileAlignment
dw 0x00 ; MajorOperatingSystemVersion
dw 0 ; MinorOperatingSystemVersion
dw 0 ; MajorImageVersion
dw 0 ; MinorImageVersion
dw 0x00 ; MajorSubsystemVersion
dw 0 ; MinorSubsystemVersion
dd 0 ; Reserved1
dd 0x2000 ; SizeOfImage
dd hdr_size ; SizeOfHeaders
dd 0 ; CheckSum
dw 0x0A ; Subsystem (EFI APPLICATION)
dw 0x160 ; DllCharacteristics
dq 0x100000 ; SizeOfStackReserve
dq 0x1000 ; SizeOfStackCommit
dq 0x100000 ; SizeOfHeapReserve
dq 0x1000 ; SizeOfHeapCommit
dd 0 ; LoaderFlags
dd 0x10 ; NumberOfRvaAndSizes
; Optional Header, Data Directories
dd 0 ; Export, RVA
dd 0 ; Export, Size
dd 0 ; Import, RVA
dd 0 ; Import, Size
dd 0 ; Resource, RVA
dd 0 ; Resource, Size
dd 0 ; Exception, RVA
dd 0 ; Exception, Size
dd 0 ; Certificate, RVA
dd 0 ; Certificate, Size
dd 0 ; Base Relocation, RVA
dd 0 ; Base Relocation, Size
dd 0 ; Debug, RVA
dd 0 ; Debug, Size
dd 0 ; Architecture, RVA
dd 0 ; Architecture, Size
dd 0 ; Global Ptr, RVA
dd 0 ; Global Ptr, Size
dd 0 ; TLS, RVA
dd 0 ; TLS, Size
dd 0 ; Load Config, RVA
dd 0 ; Load Config, Size
dd 0 ; Bound Import, RVA
dd 0 ; Bound Import, Size
dd 0 ; IAT, RVA
dd 0 ; IAT, Size
dd 0 ; Delay Import Descriptor, RVA
dd 0 ; Delay Import Descriptor, Size
dd 0 ; CLR Runtime Header, RVA
dd 0 ; CLR Runtime Header, Size
dd 0 ; Reserved, RVA
dd 0 ; Reserved, Size
opt_hdr_size equ $-opt_hdr
构造节表
由于我们只留一个节,因此填充一个节头即可。
首先是节名,虽然你爱填啥填啥,但我们出于习惯,填个.text
进去。
节的大小让汇编器去推断。
节的内存基址偏移直接按最小对齐地址,填0x1000。
节的文件基址偏移让汇编器去推断。
至于节的属性,有以下几点:
IMAGE_SCN_CNT_CODE
,这个节包含代码。
IMAGE_SCN_MEM_EXECUTE
,这个节所在内存应该要可执行。
IMAGE_SCN_MEM_READ
,这个节所在的内存应该要可读。
建议不要包含IMAGE_SCN_CNT_INITIALIZED_DATA
,虽然你这么做会表示你很诚实,但是加载器可能会出于安全考虑而不予加载。
代码如下:
; Section Table
section_name db '.text' ; Name
times 8-($-section_name) db 0
dd sect_size ; VirtualSize
dd 4096 ; VirtualAddress
dd code_size ; SizeOfRawData
dd code ; PointerToRawData
dd 0 ; PointerToRelocations
dd 0 ; PointerToLinenumbers
dw 0 ; NumberOfRelocations
dw 0 ; NumberOfLinenumbers
dd 0x68000020 ; Characteristics
hdr_size equ $-$$
构造代码
文件头终于构造好了,现在到了构造代码的时候了。
我们构造的EFI程序用C语言表达就是:
#include <Uefi.h>
EFI_STATUS EfiMain(IN EFI_HANDLE ImageHandle,IN EFI_SYSTEM_TABLE *SystemTable)
{
return SystemTable->ConOut->OutputString(SystemTable->ConOut,L"Hello UEFI!\r\n");
}
根据微软x64调用约定,第一个参数是rcx
寄存器,第二个参数是rdx
寄存器,那么我们要引用SystemTable
参数中的ConOut->OutputString
函数。
那么根据EFI的文档推算,在x64下:
ConOut
成员在EFI_SYSTEM_TABLE
结构体中的偏移为64。
OutputString
成员相对EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL
的偏移是8。
我们就应该写这样的汇编代码:
mov rcx,qword [rdx+64] ; ConOut=SystemTable->ConOut
mov rdx,hello_text
call [rcx+8] ; return ConOut->OutputString(ConOut,hello_text)
ret
然而这里存在问题。我们构造的代码目标是raw,因此要汇编器推算地址的时候不会计算映像偏移,因此mov rdx,hello_text
指令会移动一个立即数过去,这个立即数就是hello_text
这个label在文件中的偏移了。
因此我们要改变策略,必须使用相对地址。因此mov rdx,hello_text
要改成lea rdx,[rel hello_text]
。
最后要注意的是我们必须输出unicode字符串,因此要用NASM的unicode相关宏来描述我们要输出的字符串。
代码如下:
code:
mov rcx,qword [rdx+64] ; ConOut=SystemTable->ConOut
lea rdx,[rel hello_text]
call [rcx+8] ; return ConOut->OutputString(ConOut,hello_text)
ret
hello_text:
dw __?utf16?__("Hello UEFI!"),13,10,0
收尾
最后,由于前面为了让汇编器自动推算文件大小来填充文件头,需要在文件结尾声明常量:
sect_size equ $-code
code_size equ $-code
至此,最小EFI文件构造完毕。它的大小是: