1886 1799 bytes (~3.51 sectors), OVMF
-87 bytes by removing . and .. from cluster 2 and reorganizing the sector.
Started off with @набиячлэвэлиь's answer and golfed it down to the point where it's now a single NASM file building the image from scratch. Outputs "Done" and enters an infinite loop.
Hexdump:
00000000: 0000 0000 0000 0000 0000 0000 0201 0100 ................
00000010: 0110 0004 0000 0100 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000000f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000100: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000110: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000120: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000130: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000140: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000150: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000160: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000170: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000180: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000190: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000001f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000200: 0000 00ff 0f00 0000 0000 0000 0000 0000 ................
00000210: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000220: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000230: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000240: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000250: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000260: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000270: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000280: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000290: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000002f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000300: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000310: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000320: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000330: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000340: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000350: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000360: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000370: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000380: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000390: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000003f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000400: 4546 4920 2020 2020 2020 2010 0000 0000 EFI .....
00000410: 0000 0000 0000 0000 0000 0200 0000 0000 ................
00000420: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000430: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000440: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000450: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000460: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000470: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000480: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000490: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000004a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000004b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000004c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000004d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000004e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000004f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000500: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000510: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000520: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000530: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000540: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000550: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000560: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000570: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000580: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000590: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005a0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005b0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005d0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005e0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000005f0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000600: 4d5a 0000 0000 0000 0000 0000 0000 0000 MZ..............
00000610: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000620: 424f 4f54 2020 2020 2020 2010 0000 0000 BOOT .....
00000630: 0000 0000 0000 0000 0000 0200 4000 0000 ............@...
00000640: 5045 0000 6486 0100 0000 0000 0000 0000 PE..d...........
00000650: 0000 0000 7000 2e22 0b02 0000 0000 0000 ....p.."........
00000660: 4100 0000 0000 0000 0010 0000 0010 0000 A...............
00000670: 0000 0040 0100 0000 0100 0000 0100 0000 ...@............
00000680: 4200 0000 0000 0000 0000 0000 0000 0000 B...............
00000690: 0040 0000 f000 0000 0000 0000 0a00 0000 .@..............
000006a0: 424f 4f54 5836 3420 4546 4920 0000 0000 BOOTX64 EFI ....
000006b0: 0000 0000 0000 0000 0000 0200 0002 0000 ................
000006c0: 0000 0000 0000 0000 0000 0000 0000 0000 ................
000006d0: 1a00 0000 0010 0000 1a00 0000 f000 0000 ................
000006e0: 0000 0000 0000 0000 0000 0000 2000 0000 ............ ...
000006f0: 488b 4a40 488d 1505 0000 00ff 5108 ebfe H.J@H.......Q...
00000700: 4400 6f00 6e00 65 D.o.n.e
Boot:
$ qemu-system-x86_64 -bios OVMF.fd -nographic -drive file=disk.bin,format=raw
BdsDxe: failed to load Boot0001 "UEFI QEMU DVD-ROM QM00003 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Secondary,Master,0x0): Not Found
BdsDxe: loading Boot0002 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
BdsDxe: starting Boot0002 "UEFI QEMU HARDDISK QM00001 " from PciRoot(0x0)/Pci(0x1,0x1)/Ata(Primary,Master,0x0)
Done
There's way too much to explain in a single answer, so here's a .zip of everything in my working directory, and here's the final .asm:
[bits 64]
; Creates a non-partitioned disk image with no MBR/GPT, just FAT12.
; (UEFI specs req. implementations to support FAT12, FAT16, and FAT32 ESP's,
; although there might be smaller partition formats for certain
; implementations).
; FAT12 appears to be optimal.
; Resources:
; https://wiki.osdev.org/FAT
; https://wiki.osdev.org/UEFI
; https://wiki.archlinux.org/title/EFI_system_partition
; https://en.wikipedia.org/wiki/Design_of_the_FAT_file_system
; https://uefi.org/sites/default/files/resources/UEFI_Spec_2_10_Aug29.pdf
; https://archive.vn/w01DO#selection-265.0-265.44
; https://wiki.osdev.org/PE
; https://learn.microsoft.com/en-us/windows/win32/debug/pe-format
; https://johv.dk/blog/bare-metal-assembly-tutorial
; https://github.com/tianocore/edk2
; https://retrage.github.io/2019/12/05/debugging-ovmf-en.html
; https://edk2-docs.gitbook.io/edk-ii-build-specification/
; https://uefi.org/sites/default/files/resources/PI_Spec_1_6.pdf
; As long as OVMF boots this, we're good.
; Layout:
; - Reserved sectors (1 boot sector, everything else opt.)
; - FAT region (1 FAT * 1 sector per fat = 1 sector)
; - Root directory region (1 sector)
; - Data region (1 sector)
; Now, in a golfing view: whats the absolute smallest this image can be?
; - The minimum SectorSize recognized by OVMF is 512 bytes.
; - I have tried merging the FAT into the boot sector, and certain forensics
; tools can read the image just fine, but OVMF can't as it requires that
; ReservedSectors != 0.
; - OVMF requires a full, dedicated directory for the root directory, and will
; only read it right where it should be, after the FAT. This puts the total to
; 3 sectors so far.
; - It does not look like directories can point to cluster 0, meaning we need
; at least 1 sector for data-- now 4 sectors.
; A lot of the boot sector is intended for old BIOS bootloader code, but
; because we're using UEFI, we can fill most of it with zeros.
boot_sector:
times 11 db 0x00 ; Ignored by OVMF
; DOS 2.0 BPB
; OVMF requires a sector size within the range [512, 4096].
; See edk2/FatPkg/EnhancedFatDxe/Fat.h:63 for more info.
dw 512 ; Bytes per sector (512, optimal value)
; Need at least one sector per cluster (duh)
; Volume->FirstClusterLba is fixed to be directly after the root directory
; sector(s), so there's no weird bug that you could get by setting this to
; zero or -1. See edk2/FatPkg/EnhancedFatDxe/Init.c:316 for more info.
db 1 ; Sectors per cluster (1, optimal value)
; Can't merge sectors as if ReservedSectors == 0, OVMF returns
; EFI_UNSUPPORTED. This is due to a check in the code that assumes this is
; not a FAT volume if ReservedSectors is 0.
; See edk2/FatPkg/EnhancedFatDxe/Init.c:250 for more info.
dw 1 ; Reserved sectors (1, optimal value)
; Just like ReservedSectors, OVMF returns EFI_UNSUPPORTED if NumFats is
; zero. See edk2/FatPkg/EnhancedFatDxe/Init.c:250 for more info.
db 1 ; # of FATs (1, optimal value)
; OVMF returns EFI_UNSUPPORTED if RootEntries is zero and the volume is
; not FAT32. See edk2/FatPkg/EnhancedFatDxe/Init.c:285 for more info.
; Furthermore, OVMF assumes that the root directory entries will take up a
; fixed number of sectors, so any value from 1 to 16 inclusive will equal
; a single full sector. See edk2/FatPkg/EnhancedFatDxe/Init.c:312 for more
; info.
; 16 entries * 32 bytes per entry = 512 bytes, 1 sector.
dw 16 ; # of root directory entries (16, optimal value)
; If Sectors and LargeSectors (which we are not using) are both zero, OVMF
; returns EFI_UNSUPPORTED. See edk2/FatPkg/EnhancedFatDxe/Init.c:250 for
; more info.
; OVMF will, understandably, fail to properly read the volume if this is
; not just the actual value that it should be.
dw 4 ; Total logical sectors (4)
db 0 ; Ignored by OVMF
; If SectorsPerFat is zero, OVMF assumes that this volume is FAT32. FAT32
; requires a whole lot more sectors of random information, so we can't use
; it here.
; See edk2/FatPkg/EnhancedFatDxe/Init.c:239 for more info.
dw 1 ; Sectors per FAT (1, optimal value)
times 512 - ($ - boot_sector) db 0 ; Boot code + BIOS signature
; Sector 2: The File Allocation Table
; Since we're using FAT12, things get a little difficult in NASM.
; Two entries are 24 bits = 3 bytes, but each entry is stored a little
; awkwardly. See Wikipedia for more info.
; 0b111111111111000000000000
; Split
; 0b11111111 0b11110000 0b00000000
; Reverse byte order (little endian)
; 0b00000000 0b11110000 0b11111111
; Table index 0 is the FAT ID (equal to BPB_Media with high bits 1), and index
; 1 is the end-of-chain indicator (0xFFF)
; However, setting them both to zero results in no difference to OVMF.
file_allocation_table:
; FAT ID (0), EOC (0), Cluster 2 (EOC)
db 0x00, 0x00, 0x00, 0xff, 0x0f, 0x00
; Fill rest of table with zero bytes
times 512 - ($ - file_allocation_table) db 0
; Sector 3: Root directory region
; Each entry is 32 or 64 bytes, an 8.3 format chunk followed by an optional
; long file name chunk. Here we will just use 8.3.
; There is only one file in the root directory-- EFI.
; (/EFI/BOOT/BOOTX64.EFI)
root_dir_region:
db "EFI " ; 8.3 file name (11 chars, space padded)
db 0x10 ; Attributes (DIRECTORY)
times 14 db 0 ; Ignored to boot by OVMF
dw 2 ; Low 16 bits first cluster number (cluster 2)
times 4 db 0 ; Ignored to boot by OVMF
; Fill rest of region with zero bytes
times 512 - ($ - root_dir_region) db 0
; Since indices 0 and 1 of the FAT are reserved, the first cluster is really
; cluster 2. Using some tricks, we can store all of the data we need in this
; single cluster.
; This cluster is:
; - The contents of /EFI/ (a listing of directories and files,
; including BOOT)
; - The contents of /EFI/BOOT/ (a listing of directories and files,
; including BOOTX64.EFI)
; - The contents of BOOTX64.EFI (the file to be booted)
cluster_2:
; Once OVMF reads an entry with a zeroed first byte, it stops reading.
; Due to how much of a pain OVMF is, the best way to golf this is to
; squeeze in the entries where they fit in the EFI and hope for the best.
; MZ header
; Looks like it's required, even though parts of edk2 are able to handle PE
; executables that don't have an MZ header.
; EDK II Build Spec. doesn't say much other than "All EFI images must be
; formatted PE32/PE32+/COFF." The UEFI specs seem to require the MS-DOS
; stub.
; There's also extensive support for Terse Executable images (see UEFI
; PI specs section 15), but it appears that won't work here either.
mz_hdr:
db "MZ"
times 30 db 0
; ./BOOT/, but also MZ header part 2.
; Here a bit of a hack is employed to save a sector:
; If we say that BOOT occupies cluster 2, we can put BOOTX64.EFI right
; in this same sector as it refers back to this.
db "BOOT " ; 8.3 file name (11 chars, space padded)
db 0x10 ; Attributes (DIRECTORY)
times 14 db 0 ; Ignored to boot by OVMF
dw 2 ; Low 16 bits of first cluster number (cluster 2)
; edk2/BaseTools/Source/C/Common/BasePeCoff.c for some details on how
; e_lfanew is read
dd pe_hdr - cluster_2 ; e_lfanew
; PE header
pe_hdr:
dd "PE" ; mMagic (0x00004550)
dw 0x8664 ; mMachine (IMAGE_FILE_MACHINE_AMD64)
dw 1 ; mNumberOfSections (1)
dd 0 ; mTimeDateStamp (unused)
dd 0 ; mPointerToSymbolTable (unused)
dd 0 ; mNumberOfSymbols (unused)
dw popt_hdr_len ; mSizeOfOptionalHeader (required to be correct value)
dw 0x222e ; mCharacteristics (IMAGE_...)
; PE optional (required by OVMF) header
; Setting popt_hdr_len to zero results in RETURN_UNSUPPORTED, so there
; doesn't seem to be any way that the code section header can be merged
; with the optional header.
pe_opt_hdr:
dw 0x20B ; mMagic (PE32+ 64 bit)
db 0 ; mMajorLinkerVersion (unused)
db 0 ; mMinorLinkerVersion (unused)
dd 0 ; mSizeOfCode (unused)
db "A", 0, 0, 0 ; mSizeOfInitializedData (used to keep FAT entries going)
dd 0 ; mSizeOfUninitializedData (unused)
dd 0x1000 ; mAddressOfEntryPoint (0x1000)
dd 0x1000 ; mBaseOfCode (0x1000)
dq 0x140000000 ; mImageBase (OVMF ignores this, but with PIC who cares)
dd 0x1 ; mSectionAlignment (1 byte)
dd 0x1 ; mFileAlignment (1 byte)
db "B", 0 ; mMajorOperatingSystemVersion (used to keep FAT going)
dw 0 ; mMinorOperatingSystemVersion (unused)
dw 0 ; mMajorImageVersion (unused)
dw 0 ; mMinorImageVersion (unused)
dw 0 ; mMajorSubsystemVersion (unused)
dw 0 ; mMinorSubsystemVersion (unused)
dd 0 ; mWin32VersionValue (unused)
dd 0x4000 ; mSizeOfImage (must be weirdly large)
dd all_hdrs_len ; mSizeOfHeaders
dd 0 ; mCheckSum (unused)
dw 0xa ; mSubsystem (EFI)
dw 0 ; mDllCharacteristics (unused)
db "BOOTX64 " ; mSizeOfStackReserve (entry BOOTX64.EFI)
db "EFI", 0x20, 0, 0, 0, 0 ; mSizeOfStackCommit (BOOTX64 name + attr)
db 0, 0, 0, 0, 0, 0, 0, 0 ; mSizeOfHeapReserve (BOOTX64 unused)
db 0, 0, 2, 0, 0, 2, 0, 0 ; mSizeOfHeapCommit (BOOTX64 unused, clst, sze)
dd 0 ; mLoaderFlags (unused)
dd 0 ; mNumberOfRvaAndSizes (unused)
popt_hdr_len: equ $ - pe_opt_hdr
; Code section (only section)
code_sec_hdr:
times 8 db 0 ; mName (unused)
dd code_sec_len ; mVirtualSize (small as possible)
dd 0x1000 ; mVirtualAddress (0x1000)
dd code_sec_len ; mSizeOfRawData (small as possible)
dd code_sec-cluster_2 ; mPointerToRawData
dd 0 ; mPointerToRelocations (unused)
dd 0 ; mPointerToLinenumbers (unused)
dw 0 ; mNumberOfRelocations (unused)
dw 0 ; mNumberOfLinenumbers (unused)
dd 0x20 ; mCharacteristics (rx)
all_hdrs_len: equ $ - mz_hdr
; OVMF REFUSES to load sections with mPointerToRawData before mSizeOfHeaders,
; and falsifying mSizeOfHeaders results in the EFI file just not loading.
; Overall the PE loader seems pretty robust; see
; edk2/MdePkg/Library/BasePeCoff.c for more info.
code_sec:
; Output "Done" and loop forever.
; In RDX: EFI_SYSTEM_TABLE*
; Get EFI_SYSTEM_TABLE->ConOut (EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL*)
mov rcx, [rdx+64]
; Get EFI_SIMPLE_TEXT_OUTPUT_PROTOCOL->OutputString()
; <inlined into call directive>
; OutputString(rcx this, rdx string)
; `this` is already in RCX
lea rdx, [rel str]
; 32 bytes of shadow space is supposed to be allocated, but OVMF runs
; without it.
call [rcx+8] ; Call print
; jmp $
db 0xeb, 0xfe
; "Done" + NUL in UTF-16LE
; echo -ne "Done\0" | iconv -f UTF-8 -t UTF-16LE |
; hexdump -e ' 1/1 "0x%02x, " ' -v
; The last three bytes are 0x00, and since we are at the end of file we can
; let OVMF fill in the rest.
str: db 0x44, 0x00, 0x6f, 0x00, 0x6e, 0x00, 0x65
code_sec_len: equ ($ - code_sec) + 3
; OVMF fills the rest of the sector with zero bytes, meaning the smaller
; the .efi file is, the smaller the image is.
At this point, I think this is pretty close to the lower bound for OVMF without using any major bugs or exploits.