Een beknopte handleiding voor het maken van extreem kleine ELF-executables voor Linux

"Er staat iets op," zei ze met een frons, "maar het is echt minuscuul."
— [Dave Barry, "The Columnist's Caper"]

Als je een programmeur bent die klaar is met software bloat, dan is dit het perfecte tegengif.

Dit document onderzoekt methoden om overtollige bytes uit eenvoudige programma's te persen. Het praktische doel is om enkele interne werkingen van het ELF-bestandsformaat en het Linux-besturingssysteem te beschrijven, terwijl je leert hoe je extreem kleine ELF-executables maakt.

Let op:

  • De informatie en voorbeelden zijn grotendeels specifiek voor ELF-executables op een Linux-platform met een Intel x86-architectuur. Veel informatie is waarschijnlijk toepasbaar op andere ELF-gebaseerde Unix-systemen, maar dit is niet met zekerheid te zeggen.
  • Als je niet bekend bent met assembly-code, kunnen delen van dit document lastig te volgen zijn. De gebruikte assembly-code is geschreven met Nasm (zie http://www.nasm.us/).

De basis: Een simpel C-programma

Om te beginnen hebben we een programma nodig. Hoe eenvoudiger, hoe beter. We maken een programma dat niets anders doet dan een getal teruggeven aan het besturingssysteem. Aangezien 0 en 1 al bezet zijn (door true en false), gebruiken we het getal 42.

Versie 1:

/* tiny.c */
int main(void) { return 42; }

We compileren en testen dit als volgt:

$ gcc -Wall tiny.c
$ ./a.out ; echo $?
42

Hoe groot is het bestand?

$ wc -c a.out
3998 a.out

Dit is naar huidige maatstaven klein, maar het kan zeker kleiner.

Strippen en optimaliseren

De eerste stap is het strippen van het executable:

$ gcc -Wall -s tiny.c
$ ./a.out ; echo $?
42
$ wc -c a.out
2632 a.out

Vervolgens proberen we optimalisatie:

$ gcc -Wall -s -O3 tiny.c
$ wc -c a.out
2616 a.out

Dit helpt nauwelijks, wat logisch is: er is simpelweg bijna niets om te optimaliseren.

Overstap naar Assembly

Om verder te gaan, moeten we C achter ons laten en assembly gebruiken om de overhead van C-programma's te vermijden. In assembly moet de functie de accumulator (eax) op 42 zetten en vervolgens terugkeren.

Versie 2:

; tiny.asm
BITS 32
GLOBAL main
SECTION .text
main:
    mov     eax, 42
    ret

Bouwen en testen:

$ nasm -f elf tiny.asm
$ gcc -Wall -s tiny.o
$ ./a.out ; echo $?
42
$ wc -c a.out
2604 a.out

We hebben slechts twaalf bytes bespaard. Het probleem is dat we nog steeds de overhead van de main()-interface hebben. De linker voegt een interface naar het OS toe die main() aanroept.

De _start routine omzeilen

Het standaard startpunt dat de linker gebruikt is het symbool start. Wanneer we met gcc linken, wordt automatisch een start-routine opgenomen die onder andere argc en argv instelt en dan main() aanroept. Laten we onze eigen _start definiëren.

Aangepaste assembly:

; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
    mov     eax, 42
    ret

Als we dit proberen te compileren met gcc, krijgen we een foutmelding over een dubbele definitie van _start. We moeten gcc vertellen dat hij de standaard startup-bestanden niet moet gebruiken met de optie -nostartfiles.

$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
$ ./a.out ; echo $?
Segmentation fault
139

Het programma crasht. Waarom? Omdat we start behandelden als een C-functie en probeerden eruit terug te keren (ret). start is echter geen functie, maar een symbool voor het startpunt. Er staat geen retouradres op de stack; er staan alleen argc, argv en envp.

Gebruik van _exit

Om correct af te sluiten, moet start de functie exit() aanroepen. We omzeilen de standaard bibliotheek-shutdowncode en gaan direct naar de shutdown-verwerking van het OS.

Versie 3:

; tiny.asm
BITS 32
EXTERN _exit
GLOBAL _start
SECTION .text
_start:
    push    dword 42
    call    _exit

Bouwen en testen:

$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostartfiles tiny.o
$ ./a.out ; echo $?
42
$ wc -c a.out
1340 a.out

De grootte is bijna gehalveerd.

Systeemoproepen (System Calls)

We kunnen nog verder gaan door de standaard bibliotheken volledig te verwijderen met -nostdlib. Echter, _exit() is een bibliotheekfunctie. Om zonder bibliotheken af te sluiten, moeten we een systeemoproep doen.

Onder Linux gebeurt dit via de instructie int 0x80.

  • eax bevat het nummer van de systeemoproep.
  • ebx bevat het eerste argument.
  • ecx, edx, esi, edi bevatten eventuele volgende argumenten.

In /usr/include/asm/unistd.h zien we dat de exit systeemoproep nummer 1 is.

Versie 4:

; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
    mov     eax, 1
    mov     ebx, 42
    int     0x80

Bouwen en testen:

$ nasm -f elf tiny.asm
$ gcc -Wall -s -nostdlib tiny.o
$ ./a.out ; echo $?
42
$ wc -c a.out
372 a.out

Optimalisatie van instructies en de linker

We kunnen de instructies verder verkorten. In plaats van mov eax, 1 (5 bytes), kunnen we xor eax, eax gevolgd door inc eax gebruiken. In plaats van mov ebx, 42 (5 bytes), kunnen we mov bl, 42 gebruiken (2 bytes), omdat het OS alleen de laagste byte gebruikt.

Daarnaast kunnen we gcc vervangen door de linker ld om extra overhead te vermijden.

Geoptimaliseerde assembly:

; tiny.asm
BITS 32
GLOBAL _start
SECTION .text
_start:
    xor     eax, eax
    inc     eax
    mov     bl, 42
    int     0x80

Bouwen en testen:

$ nasm -f elf tiny.asm
$ ld -s tiny.o
$ ./a.out ; echo $?
42
$ wc -c a.out
368 a.out

Analyse van het ELF-formaat

Ons programma is nu slechts 7 bytes lang, maar het bestand is 368 bytes. Wat zit er in? Met objdump -x a.out zien we verschillende secties, waaronder een .comment sectie van 28 bytes waarin "The Netwide Assembler 0.398" staat.

Om echt klein te gaan, moeten we het ELF-formaat begrijpen. Een ELF-bestand bestaat uit:

  1. ELF Header (52 bytes): Bevat de magic-number signature (7F 45 4C 46), architectuur, startadres, en locaties van de tabellen.
  2. Program Header Table: Beschrijft hoe delen van het bestand in het geheugen geladen moeten worden. Wordt gebruikt door de programmalader.
  3. Section Header Table: Beschrijft waar delen van het programma in het bestand staan. Wordt gebruikt door de compiler en linker.

Voor een executable is de Section Header Table optioneel, maar standaardtools voegen deze bijna altijd toe. Om deze te verwijderen, moeten we het bestand handmatig construeren.

Handmatige constructie van een ELF-bestand

Met Nasm kunnen we een plat binair bestand (-f bin) maken. We definiëren zelf de ELF-header en de Program Header Table.

Handmatige ELF-structuur:

BITS 32
org     0x08048000
ehdr:                                                 ; Elf32_Ehdr
db      0x7F, "ELF", 1, 1, 1, 0         ;   e_ident
times 8 db      0
dw      2                               ;   e_type
dw      3                               ;   e_machine
dd      1                               ;   e_version
dd      _start                          ;   e_entry
dd      phdr - $$                       ;   e_phoff
dd      0                               ;   e_shoff
dd      0                               ;   e_flags
dw      ehdrsize                        ;   e_ehsize
dw      phdrsize                        ;   e_phentsize
dw      1                               ;   e_phnum
dw      0                               ;   e_shentsize
dw      0                               ;   e_shnum
dw      0                               ;   e_shstrndx
ehdrsize      equ     $ - ehdr
phdr:                                                 ; Elf32_Phdr
dd      1                               ;   p_type
dd      0                               ;   p_offset
dd      $$                              ;   p_vaddr
dd      $$                              ;   p_paddr
dd      filesize                        ;   p_filesz
dd      filesize                        ;   p_memsz
dd      5                               ;   p_flags
dd      0x1000                          ;   p_align
phdrsize      equ     $ - phdr
_start:
    mov     bl, 42
    xor     eax, eax
    inc     eax
    int     0x80
filesize      equ     $ - $$

Bouwen en testen:

$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
91 a.out

Extreme overlap-technieken

We kunnen de grootte verder reduceren door delen van het bestand te laten overlappen.

Code in de ELF-header

De identificatievelden in de ELF-header bevatten padding die door het OS wordt genegeerd. We kunnen onze code hierin plaatsen. Resultaat: 84 bytes.

Overlap van de Program Header Table

De laatste bytes van de ELF-header lijken op de eerste bytes van de Program Header Table. Door deze te laten overlappen, besparen we meer ruimte. Resultaat: 76 bytes.

Maximale manipulatie van velden

Door te onderzoeken welke velden Linux echt controleert, kunnen we nog meer overlap creëren. Veldjes zoals eversion, eflags en p_paddr worden grotendeels genegeerd. Door een niet-standaard laadadres te kiezen (bijv. 0x00200000), kunnen we de headers nog sterker laten samenvallen. Resultaat: 64 bytes.

De absolute limiet: Volledige embedding

Het is mogelijk om zowel de Program Header Table als het programma zelf volledig in de ELF-header te embedden. We passen pflags aan naar 4 (alleen leesbaar) en verhogen pfilesz zodat de lader niet hoeft te schrijven naar read-only geheugen.

Versie: Volledig embedded

BITS 32
org     0x00010000
db      0x7F, "ELF"             ; e_ident
dd      1                       ; p_type
dd      0                       ; p_offset
dd      $$                      ; p_vaddr
dw      2                       ; e_type        ; p_paddr
dw      3                       ; e_machine
dd      _start                  ; e_version     ; p_filesz
dd      _start                  ; e_entry       ; p_memsz
dd      4                       ; e_phoff       ; p_flags
_start:
    mov     bl, 42              ; e_shoff       ; p_align
    xor     eax, eax
    inc     eax                 ; e_flags
    int     0x80
db      0
dw      0x34                    ; e_ehsize
dw      0x20                    ; e_phentsize
dw      1                       ; e_phnum
dw      0                       ; e_shentsize
dw      0                       ; e_shnum
dw      0                       ; e_shstrndx
filesize      equ     $ - $$

Resultaat: 52 bytes.

De laatste truc

Als een bestand niet precies de grootte van een volledige ELF-header heeft, vult Linux de ontbrekende bytes aan met nullen. We kunnen dus de laatste zeven nullen weglaten.

De finale versie:

BITS 32
org     0x00010000
db      0x7F, "ELF"             ; e_ident
dd      1                       ; p_type
dd      0                       ; p_offset
dd      $$                      ; p_vaddr
dw      2                       ; e_type        ; p_paddr
dw      3                       ; e_machine
dd      _start                  ; e_version     ; p_filesz
dd      _start                  ; e_entry       ; p_memsz
dd      4                       ; e_phoff       ; p_flags
_start:
    mov     bl, 42              ; e_shoff       ; p_align
    xor     eax, eax
    inc     eax                 ; e_flags
    int     0x80
db      0
dw      0x34                    ; e_ehsize
dw      0x20                    ; e_phentsize
db      1                       ; e_phnum
filesize      equ     $ - $$

Eindresultaat:

$ nasm -f bin -o a.out tiny.asm
$ chmod +x a.out
$ ./a.out ; echo $?
42
$ wc -c a.out
45 a.out

Dit bestand van 45 bytes is minder dan een achtste van de kleinste ELF-executable die met standaardtools gemaakt kan worden, en minder dan een vijftigste van een programma geschreven in pure C. Elke byte is nu verantwoord en gerechtvaardigd.