唐凌 发表于 2022-10-2 11:36:03

【UEFI】编写x64极小EFI程序


# 前言
前段时间有个群友想玩EFI,这个人我不好用准确的方式去描述,容易被当做人身攻击,懂的自然懂。
总之呢,他要一个最简EFI程序,那就开整吧。

# 构造文件头
EFI文件一般走PE文件标准,我们在构造文件的时候要搞出来以下内容:

1. DOS头
2. NT头
3. 节表

为了抠字节数,我们用汇编来构造。汇编器选用NASM,非常便携。

## 构造DOS头
DOS头只需要管`e_magic`和`e_lfanew`两个成员即可,前者填`MZ`这两个字符,后者填充NT头相对于映像基址的偏移即可,代码如下:
```Assembly
; DOS Header
        dw 'MZ'                                        ; e_magic
        dw 0                                        ; e_cblp
        dw 0                                        ; c_cp
        dw 0                                        ; e_crlc
        dw 0                                        ; e_cparhdr
        dw 0                                        ; e_minalloc
        dw 0                                        ; e_maxalloc
        dw 0                                        ; e_ss
        dw 0                                        ; e_sp
        dw 0                                        ; e_csum
        dw 0                                        ; e_ip
        dw 0                                        ; e_cs
        dw 0                                        ; e_lfarlc
        dw 0                                        ; e_ovno
        times 4 dw 0                        ; e_res
        dw 0                                        ; e_oemid
        dw 0                                        ; e_oeminfo
        times 10 dw 0                        ; 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个,即使我们一个都用不到。

综上所述,上代码:
```Assembly
; PE Header
pe_hdr:
        dw 'PE', 0                                ; Signature

; Image File Header
        dw 0x8664                                ; Machine
        dw 0x01                                        ; NumberOfSections
        dd 0                                        ; 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`,虽然你这么做会表示你很诚实,但是加载器可能会出于安全考虑而不予加载。

代码如下:
```Assembly
; 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语言表达就是:
```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。
我们就应该写这样的汇编代码:
```Assembly
mov rcx,qword         ; ConOut=SystemTable->ConOut
mov rdx,hello_text
call                         ; return ConOut->OutputString(ConOut,hello_text)
ret
```
然而这里存在问题。我们构造的代码目标是raw,因此要汇编器推算地址的时候不会计算映像偏移,因此`mov rdx,hello_text`指令会移动一个立即数过去,这个立即数就是`hello_text`这个label在文件中的偏移了。
因此我们要改变策略,必须使用相对地址。因此`mov rdx,hello_text`要改成`lea rdx,`。
最后要注意的是我们必须输出unicode字符串,因此要用NASM的unicode相关宏来描述我们要输出的字符串。
代码如下:
```Assembly
code:
        mov rcx,qword         ; ConOut=SystemTable->ConOut
        lea rdx,
        call                         ; return ConOut->OutputString(ConOut,hello_text)
        ret
       
hello_text:
        dw __?utf16?__("Hello UEFI!"),13,10,0
```

# 收尾
最后,由于前面为了让汇编器自动推算文件大小来填充文件头,需要在文件结尾声明常量:
```Assembly
sect_size equ $-code
code_size equ $-code
```
至此,最小EFI文件构造完毕。它的大小是:



嗯,411字节,还行。
最后再看看VMware里的运行效果:



他还说想要用C语言写的。我觉得为了缩短篇幅还是算了吧,反正再说了他又不会真的去玩。
另外我怀疑这货没有论坛账号,让他注册个账号再下载代码吧。

**** Hidden Message *****

唐凌 发表于 2022-10-10 03:05:27


修正一下,汇编代码还有一处地方可以做优化。
那就是`call`+`ret`指令可以优化为`jmp`指令。这样可以再减少一个字节的输出。

watermelon 发表于 2022-10-2 18:31:19

有点意思的.....;P

陈布衣 发表于 2022-10-3 08:57:20

好6——
(好奇为什么 efi 程序使用 pe 文件头呢.. 是不是和微软有蜜汁妥协

xiawan 发表于 2022-10-4 08:38:12

楼主大能,感谢感谢

啊喵~ 发表于 2022-10-5 10:22:25

看弹幕流什么时候来这个帖子回帖

Golden Blonde 发表于 2022-10-6 21:53:52

用NV的泄露证书给EFI文件签名,能否让你的BIN在开启了SB的真机上运行?

我只知道这证书泄露不久之后,就被微软的更新包给拉黑了。

0xAA55 发表于 2022-10-7 18:41:46

第一次知道 NASM 竟然还有 __?utf16?__ 这种东西

唐凌 发表于 2022-10-7 22:53:45

Golden Blonde 发表于 2022-10-6 21:53
用NV的泄露证书给EFI文件签名,能否让你的BIN在开启了SB的真机上运行?

我只知道这证书泄露不久之后,就被 ...

自己造个签名扔进自己机器上的数据库就行了。
如果想要在任意开启SB的机器上跑,需要签EV并且还要找微软给你签,因为一般固件的数据库里都有微软的签名。

Golden Blonde 发表于 2022-10-10 14:17:49

tangptr@126.com 发表于 2022-10-7 22:53
自己造个签名扔进自己机器上的数据库就行了。
如果想要在任意开启SB的机器上跑,需要签EV并且还要找微软 ...

懂了,就等哪天微软的签名漏了。

PS:注意一下8和9。8. Microsoft will not sign EFI submissions that use EFI_IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER. Instead, we recommend transitioning to EFI_IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER. This prevents unnecessary use of runtime EFI drivers.
9. Use of EFI Byte Code (EBC): Microsoft will not sign EFI submissions that are EBC-based submissions.

唐凌 发表于 2022-10-10 23:48:13

Golden Blonde 发表于 2022-10-10 14:17
懂了,就等哪天微软的签名漏了。

PS:注意一下8和9。

8的话确实没啥好办法,最多塞白名单。
9的话你见过几个EBC的玩意,编译器都难找。。。
页: [1]
查看完整版本: 【UEFI】编写x64极小EFI程序