ELF 스터디

ELF(Executable and Linkable Format)은 executable file, object file 등에 널리 사용되는 포맷이다. C 컴파일러로 컴파일한 .o 파일이나 링킹까지 마친 a.out 등이 ELF로 되어있다. 리눅스에서 밥먹듯이 사용하는데 내부 구조를 살펴본 적은 없어서 간단하게 공부해 보았다.

File Header

다른 파일 포맷과 마찬가지로 첫 부분은 파일 헤더가 차지한다. 32-bit 주소를 사용할 경우 52-byte, 64-bit 주소를 사용할 경우 64-byte를 차지한다. 다음은 42를 리턴하는 것 외에 아무것도 하지 않는 C 프로그램을 컴파일하고 ELF 파일 헤더를 확인하는 예시다.

$ cat a.c
int main() {
  return 42;
}
$ gcc a.c
$ readelf -h a.out
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              DYN (Shared object file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x1040
  Start of program headers:          64 (bytes into file)
  Start of section headers:          14608 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         13
  Size of section headers:           64 (bytes)
  Number of section headers:         29
  Section header string table index: 28

주요 필드 몇개만 살펴보자.

  • e_ident : 첫 16-byte를 차지하며 magic number, 주소가 32-bit인지 64-bit인지, endianness 등의 정보를 담고 있다. 뒤 7-byte는 패딩이며 0으로 채워져 있다.
  • e_entry : entry point의 주소 값이다. 즉, 이 프로세스가 실행되면 0x1040에 있는 명령부터 실행을 시작한다.
  • e_phoff : 후술할 program header table의 위치를 말한다. Program header table에는 program header가 e_phnum개 들어있고, 각각의 크기는 e_phentsize이다. 위의 예시에서 program header table은 64-byte에 위치하고 program header는 13개 있으며 각각 56-byte이다.
  • e_shoff : 마찬가지로 후술할 section header table의 위치를 말한다. Section header table에는 section header가 e_shnum개 들어있고, 각각의 크기는 e_shentsize이다. 위의 예시에서 section header table은 14608-byte에 위치하고 section header는 29개가 있으며 각각 64-byte이다.

Program Header

Program header는 파일을 런타임에 실행하는데 필요한 정보를 담고 있다. 예를 들어, “파일의 1000-Byte째에 있는 코드를 프로세스가 실행되었을때 가상 메모리의 어느 주소에 올려둬야하는지?” 같은 정보가 있겠다. 각 program header가 묘사하는 영역은 memory segment라고도 불린다. (section과 헷갈리지 않도록 주의) 다음은 아까 생성한 실행파일의 program header table이다.

$ readelf -l a.out
Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  PHDR           0x0000000000000040 0x0000000000000040 0x0000000000000040
                 0x00000000000002d8 0x00000000000002d8  R      0x8
  INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318
                 0x000000000000001c 0x000000000000001c  R      0x1
      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
  LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x00000000000005c8 0x00000000000005c8  R      0x1000
  LOAD           0x0000000000001000 0x0000000000001000 0x0000000000001000
                 0x00000000000001c5 0x00000000000001c5  R E    0x1000
  LOAD           0x0000000000002000 0x0000000000002000 0x0000000000002000
                 0x0000000000000130 0x0000000000000130  R      0x1000
  LOAD           0x0000000000002df0 0x0000000000003df0 0x0000000000003df0
                 0x0000000000000220 0x0000000000000228  RW     0x1000
  DYNAMIC        0x0000000000002e00 0x0000000000003e00 0x0000000000003e00
                 0x00000000000001c0 0x00000000000001c0  RW     0x8
  NOTE           0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  NOTE           0x0000000000000358 0x0000000000000358 0x0000000000000358
                 0x0000000000000044 0x0000000000000044  R      0x4
  GNU_PROPERTY   0x0000000000000338 0x0000000000000338 0x0000000000000338
                 0x0000000000000020 0x0000000000000020  R      0x8
  GNU_EH_FRAME   0x0000000000002004 0x0000000000002004 0x0000000000002004
                 0x000000000000003c 0x000000000000003c  R      0x4
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RW     0x10
  GNU_RELRO      0x0000000000002df0 0x0000000000003df0 0x0000000000003df0
                 0x0000000000000210 0x0000000000000210  R      0x1

첫 헤더는 program header table 자체를 하나의 segment로 묘사하고 있다. 파일의 0x40번째 바이트부터(p_offset) 0x2d8만큼의 바이트를(p_filesz) 가상 주소 공간의 0x40번째 바이트부터(p_vaddr) 0x2d8만큼의 메모리에(p_memsz)에 올리라는 정보를 담고 있다. 물리 주소도 있는데, 가상 메모리가 있는 시스템에서는 잘 쓰이지 않는다. 그 외 해당 segment가 가지는 속성(p_flags)과 요구되는 정렬(p_align)이 명시되어 있다.

두번째 헤더는 타입이 INTERP인데, 해당 segment의 내용을 보면 /lib64/ld-linux-x86-64.so.2라고 쓰여있다. ELF 파일로부터 프로세스를 실행하려면 지금 우리가 살펴보고 있는 file header, program header, section header 등을 잘 해석하여 가상 주소 공간을 꾸려주는 program interpreter가 필요한데, 무엇을 사용할지 명시해 주는 것이다. 나머지 헤더들도 비슷한 방식으로 해석해보면 된다.

Section Header

Section header는 program header와는 반대로 linking/relocation에 필요한 정보를 주로 담는다. Relocation에 관해서는 다른 포스팅에서 다뤄보도록 하겠다. 일단 아까 생성한 실행파일의 section header table을 살펴보자.

$ readelf -S a.out
Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .interp           PROGBITS         0000000000000318  00000318
       000000000000001c  0000000000000000   A       0     0     1
  [ 2] .note.gnu.propert NOTE             0000000000000338  00000338
       0000000000000020  0000000000000000   A       0     0     8
  [ 3] .note.gnu.build-i NOTE             0000000000000358  00000358
       0000000000000024  0000000000000000   A       0     0     4
  [ 4] .note.ABI-tag     NOTE             000000000000037c  0000037c
       0000000000000020  0000000000000000   A       0     0     4
  [ 5] .gnu.hash         GNU_HASH         00000000000003a0  000003a0
       0000000000000024  0000000000000000   A       6     0     8
  [ 6] .dynsym           DYNSYM           00000000000003c8  000003c8
       0000000000000090  0000000000000018   A       7     1     8
  [ 7] .dynstr           STRTAB           0000000000000458  00000458
       000000000000007d  0000000000000000   A       0     0     1
  [ 8] .gnu.version      VERSYM           00000000000004d6  000004d6
       000000000000000c  0000000000000002   A       6     0     2
  [ 9] .gnu.version_r    VERNEED          00000000000004e8  000004e8
       0000000000000020  0000000000000000   A       7     1     8
  [10] .rela.dyn         RELA             0000000000000508  00000508
       00000000000000c0  0000000000000018   A       6     0     8
  [11] .init             PROGBITS         0000000000001000  00001000
       000000000000001b  0000000000000000  AX       0     0     4
  [12] .plt              PROGBITS         0000000000001020  00001020
       0000000000000010  0000000000000010  AX       0     0     16
  [13] .plt.got          PROGBITS         0000000000001030  00001030
       0000000000000010  0000000000000010  AX       0     0     16
  [14] .text             PROGBITS         0000000000001040  00001040
       0000000000000175  0000000000000000  AX       0     0     16
  [15] .fini             PROGBITS         00000000000011b8  000011b8
       000000000000000d  0000000000000000  AX       0     0     4
  [16] .rodata           PROGBITS         0000000000002000  00002000
       0000000000000004  0000000000000004  AM       0     0     4
  [17] .eh_frame_hdr     PROGBITS         0000000000002004  00002004
       000000000000003c  0000000000000000   A       0     0     4
  [18] .eh_frame         PROGBITS         0000000000002040  00002040
       00000000000000f0  0000000000000000   A       0     0     8
  [19] .init_array       INIT_ARRAY       0000000000003df0  00002df0
       0000000000000008  0000000000000008  WA       0     0     8
  [20] .fini_array       FINI_ARRAY       0000000000003df8  00002df8
       0000000000000008  0000000000000008  WA       0     0     8
  [21] .dynamic          DYNAMIC          0000000000003e00  00002e00
       00000000000001c0  0000000000000010  WA       7     0     8
  [22] .got              PROGBITS         0000000000003fc0  00002fc0
       0000000000000040  0000000000000008  WA       0     0     8
  [23] .data             PROGBITS         0000000000004000  00003000
       0000000000000010  0000000000000000  WA       0     0     8
  [24] .bss              NOBITS           0000000000004010  00003010
       0000000000000008  0000000000000000  WA       0     0     1
  [25] .comment          PROGBITS         0000000000000000  00003010
       000000000000002a  0000000000000001  MS       0     0     1
  [26] .symtab           SYMTAB           0000000000000000  00003040
       00000000000005d0  0000000000000018          27    44     8
  [27] .strtab           STRTAB           0000000000000000  00003610
       00000000000001ed  0000000000000000           0     0     1
  [28] .shstrtab         STRTAB           0000000000000000  000037fd
       000000000000010c  0000000000000000           0     0     1

Program header와 마찬가지로 각 section의 파일 내에서의 위치와 크기, 메모리 상에서의 위치와 크기 등의 정보를 담고 있다.

파일의 각 바이트는 1) 0개 이상의 segment에 속하거나, 2) 0 또는 1개의 섹션에 속한다. 다시 말해 segment끼리는 겹칠 수 있지만 section들은 서로 겹치지 않는다. 또한 어느 곳에도 속하지 않는 바이트들도 있다. File header가 그렇고, alignment를 맞추기 위해 임의의 바이트로 채워진 공간도 있다.

ELF 가지고 놀기 - 크기 최소화

이해를 제대로 했는지 확인하기 위해 몇가지 task를 풀어보기로 했다. 첫번째는 실행가능하면서 파일 크기가 최소인 ELF 만들어보기다. 먼저, 앞서 작성했던 프로그램의 실행파일 크기를 살펴보자.

$ wc -n a.c
16464 a.out

별로 하는 것도 없는데 16KB가 넘는다. ELF에 대해 이해한 것에 따라서 전략을 정리해보자면,

  • 파일 헤더는 무조건 있어야 한다. 크기를 줄이기 위해서는 32-bit 주소를 사용하는 ELF가 좋다. 파일 헤더에 52-byte.
  • 일단 코드를 메모리에 올려야 실행이 가능할테니, LOAD 타입의 program header가 최소 하나는 필요하다. Program header 하나의 크기는 32-bit ELF 기준 32-byte. 다른 헤더는 없어도 적당히 기본값으로 실행되지 않을까?
  • Section header는 linking/relocation 정보를 주로 담으므로 런타임에는 필수적이지 않다. 없어도 됨.
  • 올바른 machine code가 필요. n-byte라고 가정.

위와 같이 ELF를 구성하면 52+32+n = 84+n 바이트가 나올 것이다. Machine code 길이는 어떻게 최소화 할 수 있을까? 일단 앞서 만든 프로그램의 코드를 확인해보자.

$ readelf -h a.out
...
  Entry point address:               0x1040
...
$ objdump -S a.out
...
0000000000001040 <_start>:
    1040:       f3 0f 1e fa             endbr64 
    1044:       31 ed                   xor    %ebp,%ebp
    1046:       49 89 d1                mov    %rdx,%r9
    1049:       5e                      pop    %rsi
    104a:       48 89 e2                mov    %rsp,%rdx
    104d:       48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
    1051:       50                      push   %rax
    1052:       54                      push   %rsp
    1053:       4c 8d 05 56 01 00 00    lea    0x156(%rip),%r8        # 11b0 <__libc_csu_fini>
    105a:       48 8d 0d df 00 00 00    lea    0xdf(%rip),%rcx        # 1140 <__libc_csu_init>
    1061:       48 8d 3d c1 00 00 00    lea    0xc1(%rip),%rdi        # 1129 <main>
    1068:       ff 15 72 2f 00 00       callq  *0x2f72(%rip)        # 3fe0 <__libc_start_main@GLIBC_2.2.5>
    106e:       f4                      hlt    
    106f:       90                      nop
...
0000000000001129 <main>:
    1129:       f3 0f 1e fa             endbr64 
    112d:       55                      push   %rbp
    112e:       48 89 e5                mov    %rsp,%rbp
    1131:       b8 2a 00 00 00          mov    $0x2a,%eax
    1136:       5d                      pop    %rbp
    1137:       c3                      retq   
    1138:       0f 1f 84 00 00 00 00    nopl   0x0(%rax,%rax,1)
    113f:       00
...

일단 entry point에 main 함수가 아닌 _start라는 생뚱맞은 함수가 있다. 사실 링커가 기본 entry point로 삼는 곳은 main이 아니라 _start이다. 그리고 C 컴파일러는 argc, argv 인자를 세팅하는 등의 코드를 몰래 삽입한다. 우리는 코드를 최소화 하는 것이 목적이므로, 이런 부분들을 모두 쳐내고 exit 시스템 콜로 프로세스를 올바르게 종료하는 machine code만 사용해야 한다. 어셈블리로 작성해보는건 어떨까?

$ cat a.S # 확장자를 대문자 S로 해야함. 소문자 s는 왜 안되는지 다른 포스팅에서...
#include <sys/syscall.h>
  .global _start
_start:
  mov $SYS_exit, %rax
  mov $42, %rdi
  syscall
$ gcc -nostdlib a.S
$ objdump -S a.out
...
0000000000001000 <_start>:
    1000:	48 c7 c0 3c 00 00 00 	mov    $0x3c,%rax
    1007:	48 c7 c7 2a 00 00 00 	mov    $0x2a,%rdi
    100e:	0f 05                	syscall
...
$ wc -c a.out
13536 a.out

코드는 줄었지만, segment나 section에는 큰 변화가 없기 때문에 실행 파일은 여전히 13KB가 넘는다. 그러므로 7+7+2=16-byte의 machine code 48 c7 c0 3c 00 00 00 48 c7 c7 2a 00 00 00 0f 05만 가져오고 ELF의 나머지 부분은 손수 만들어야 한다. 이론상 84+16=100 바이트의 ELF 파일을 얻을 수 있을 것이다.

ELF 바이너리 파일을 손수 만드는데는 hex 에디터를 사용하거나 elf.h에서 구조체를 가져와서 사용하는 등 여러가지 방법이 있을텐데, 어셈블러에 ELF 헤더 등을 제외하고 순수 바이너리만 출력하는 기능이 있어 사용해보기로 마음 먹었다. nasm을 사용한다면 -f bin 옵션을 주면 되고, 나는 AT&T 문법이 더 익숙하기 때문에 GNU assembler를 사용하였다.

$ cat b.S
$$: # nasm은 섹션 시작 주소를 $$ directive로 제공하는데 gas는 없음

ehdr:
  .byte 0x7f, 'E', 'L', 'F', 1, 1, 1, 0 # e_ident[0:8]
  .byte 0, 0, 0, 0, 0, 0, 0, 0 # e_ident[8:16]
  .word 2 # e_type
  .word 0x3e # e_machine
  .long 1 # e_version
  .long _start # e_entry
  .long phdr - $$ # e_phoff
  .long 0 # e_shoff
  .long 0 # e_flags
  .word ehdrsize # e_ehsize
  .word phdrsize # e_phentsize
  .word 1 # e_phnum
  .word 0 # e_shentsize
  .word 0 # e_shnum
  .word 0 # e_shstrndx
  .equ ehdrsize, . - ehdr

phdr:
  .long 1 # p_type
  .long 0 # p_offset
  .long $$ # p_vaddr
  .long $$ # p_paddr
  .long filesize # p_filesz
  .long filesize # p_memsz
  .long 5 # p_flags
  .long 0x1000 # p_align
  .equ phdrsize, . - phdr

_start:
  mov $60, %rax # SYS_exit = 60
  mov $42, %rdi # return code
  syscall

  .equ filesize, . - $$
$ as -o a.o b.S; ld --oformat binary -o a.out a.o
$ wc -c a.out
100 a.out
$ ./a.out; echo $?
42

100-byte짜리 ELF 파일을 만드는데 성공하였고, 실제로 실행도 잘된다!


여기서 더 줄여볼 수는 없을까? ELF 헤더와 프로그램 헤더는 고정이니까, 코드를 더 줄이는 것이 가장 해봄직하다. Syscall number는 32-bit만 사용하므로 rax 레지스터 대신 eax 레지스터를 사용하자. 그리고 4바이트 60을 직접 넣는 것보다 0으로 초기화 후 1바이트 60을 넣는 것이 코드가 1바이트 더 짧다. 또한, 프로세스 실행/종료만 잘 되면 된다 치고 exit code를 아예 주지 말자.

$ cat b.S
...
_start:
  xor %eax, %eax
  mov $60, %al
  syscall
...
$ as -o a.o b.S; ld --oformat binary -o a.out a.o
$ objdump -S a.o
0000000000000054 <_start>:
  54:	31 c0                	xor    %eax,%eax
  56:	b0 3c                	mov    $0x3c,%al
  58:	0f 05                	syscall
$ wc -c a.out
90 a.out

코드를 16-byte에서 6-byte로 줄였으므로 90-byte가 되었다. 이제 정말정말 끝일까?

ELF 헤더를 보면 7번째 ~ 16번째 바이트는 혹시 있을지 모르는 표준 변경을 위해 예약된 공간으로 현재는 사용되지 않는다. 비록 표준에는 0으로 set하라고 되어있지만 이곳에 코드를 넣어도 잘 돌 것이다. 한 번 해보자.

$ cat b.S
...
ehdr:
  .byte 0x7f, 'E', 'L', 'F', 1, 1, 1 # e_ident[0:7]
_start:
  xor %eax, %eax
  mov $60, %al # SYS_exit = 60
  syscall
  .byte 0, 0, 0 # e_ident[13:16]
  .word 2 # e_type
  .word 0x3e # e_machine
  .long 1 # e_version
  .long _start # e_entry
  .long phdr - $$ # e_phoff
  .long 0 # e_shoff
  .long 0 # e_flags
  .word ehdrsize # e_ehsize
  .word phdrsize # e_phentsize
  .word 1 # e_phnum
  .word 0 # e_shentsize
  .word 0 # e_shnum
  .word 0 # e_shstrndx
  .equ ehdrsize, . - ehdr

phdr:
  .long 1 # p_type
  .long 0 # p_offset
  .long $$ # p_vaddr
  .long $$ # p_paddr
  .long filesize # p_filesz
  .long filesize # p_memsz
  .long 5 # p_flags
  .long 0x1000 # p_align
  .equ phdrsize, . - phdr
...
$ as -o a.o b.S; ld --oformat binary -o a.out a.o
$ wc -c a.out
84 a.out

84-byte가 되었다. 이제는 코드를 위한 공간조차 없고 ELF 헤더 52-byte와 프로그램 헤더 32-byte 뿐이다. 하지만 아직도 끝이 아니다.

ELF 스펙을 읽어보면 ELF 헤더는 무조건 제일 처음에 위치해야 하지만, 프로그램 헤더가 꼭 ELF 헤더 다음에 나와야 된다고 명시되어 있지는 않다. 즉, 둘을 오버랩 시켜도 상관이 없다는 말이다. 아쉽게도 당장은 ELF 헤더의 suffix와 프로그램 헤더의 prefix가 일치하는 부분이 없다. 그러나 프로그램 인터프리터가 헤더의 각 필드들을 엄격하게 검사하지는 않기 때문에, 잘 조절하면 일치될 가능성이 있다.

공격적으로 “프로그램 헤더가 ELF 헤더에 완전히 들어갈 수 있다.”고 가정해보자. 프로그램 헤더가 20번째 바이트에서 시작하게 둔 후 (프로그램 헤더의 끝과 ELF 헤더의 끝이 일치하도록 4-byte씩 앞으로 당기면서 유효한 ELF 파일인지 체크해보자.

  • e_phoff = 20 : e_entry와 p_offset이 겹치게 되는데, 가상 주소 0x10000 이하의 영역은 예약되어 있어 프로그램을 올릴 수 없기 때문에 e_entry = p_offset >= 0x10000 = /proc/sys/vm/mmap_min_addr을 만족해야만 한다. p_offset이 파일 크기보다 커지므로 불가능하다.
  • e_phoff = 16 : e_type, e_machine과 p_type이 겹치게 되는데 e_machine은 0x3e여야 하고 p_type은 1이여야 하므로 불가능하다.
  • e_phoff = 12 : 이번에는 e_type, e_machine과 p_offset이 겹치게 되는데, e_machine이 0x3e로 강제되므로 이전과 마찬가지로 p_offset이 지나치게 커지기 때문에 불가능하다.
  • e_phoff = 8 : 여기서는 e_type, e_machine이 p_vaddr과 겹치므로 p_vaddr >= 0x3e0000가 강제된다. 그러면 entry point가 해당 주소보다는 커야하고, e_entry와 p_filesz가 겹치므로 p_filesz >= 0x3e0000도 만족해야 된다. 그리고 e_phoff와 p_memsz가 겹치므로 p_memsz == 12이다. 그런데 p_filesz가 p_memsz보다 큰 경우 할당된 메모리보다 많은 영역을 접근하기 떄문에 segfault가 발생한다. 그러므로 이 경우도 불가능.
  • e_phoff = 4 : p_type, p_offset, p_vaddr가 e_ident와 겹쳐서 ELF 헤더 입장에서는 이상한 값들이 들어오는데, 실행에는 큰 지장이 없다. e_type, e_machine과 p_paddr과 겹치게 되는데 p_paddr 값은 무시되므로 괜찮다. e_version과 p_filesz가 겹치는데 e_version 쪽이 무시되므로 괜찮다. 이 다음이 까다로운데, e_entry와 p_memsz가 겹치게 되므로 p_memsz가 p_filesz보다 큰 값을 갖게 된다. 프로그램 로드 시에 이 나머지 영역은 ELF 스펙에 따라 0으로 채워야하므로 쓰기 권한이 필요하다. 그런데 p_flags는 e_phoff와 겹치기 떄문에 4로 고정되어 READ만 가능하다. 그럼 어떻게 해야할까? p_filesz를 p_memsz와 같은 값을 주면 된다. p_filesz가 실제 파일 크기보다 커지지만, 실제로 접근을 하지는 않기 때문인지 어떠한 에러도 발생하지 않는다. 즉, 프로그램 헤더를 4바이트째에 위치시키면 ELF 헤더와 온전하게 겹칠 수 있다1.

위 내용을 반영해서 다시 작성한 프로그램이다. 다만, 코드가 들어있던 e_ident 부분을 프로그램 헤더가 차지한 관계로 코드는 다시 ELF 헤더 뒤로 옮겼다.

$ cat d.S
$$:
ehdr:
  .byte 0x7f, 'E', 'L', 'F' # e_ident
phdr:
  .long 1                   #           p_type
  .long 0                   #           p_offset
  .long $$                  #           p_vaddr
  .word 2                   # e_type    p_paddr
  .word 0x3e                # e_machine
  .long _start              # e_version p_filesz
  .long _start              # e_entry   p_memsz
  .long phdr - $$           # e_phoff   p_flags
  .long 0                   # e_shoff   p_align
  .equ phdrsize, . - phdr
  .long 0                   # e_flags
  .word ehdrsize            # e_ehsize
  .word phdrsize            # e_phentsize
  .word 1                   # e_phnum
  .word 0                   # e_shentsize
  .word 0                   # e_shnum
  .word 0                   # e_shstrndx
  .equ ehdrsize, . - ehdr
_start:
  xor %eax, %eax
  mov $60, %al
  syscall
  .equ filesize, . - $$
$ as -o a.o d.S; ld --oformat binary -o a.out a.o
$ wc -c a.out
58 a.out

58-byte 실행파일이 만들어졌다. 그렇지만 실행코드가 다시 밖으로 밀려나온게 영 아쉽다. 헤더를 다시 잘 살펴보면, e_shoff는 섹션 헤더가 없기 때문에 아무 값이나 줘도 될 것 같고 e_flags도 왠지 아무 값이나 줘도 잘 실행 될 것 같은 느낌이 있다. 코드를 그곳으로 옮겨보자. 필요 없는 변수들도 정리하자.

$ cat e.S
$$:
  .byte 0x7f, 'E', 'L', 'F' # e_ident
  .long 1                   #           p_type
  .long 0                   #           p_offset
  .long $$                  #           p_vaddr
  .word 2                   # e_type    p_paddr
  .word 0x3e                # e_machine
  .long _start              # e_version p_filesz
  .long _start              # e_entry   p_memsz
  .long 4                   # e_phoff   p_flags
_start:
  xor %eax, %eax            # e_shoff   p_align
  mov $60, %al
  syscall                   # e_flags
  .word 0
  .word 52                  # e_ehsize
  .word 32                  # e_phentsize
  .word 1                   # e_phnum
  .word 0                   # e_shentsize
  .word 0                   # e_shnum
  .word 0                   # e_shstrndx
$ as -o a.o e.S; ld --oformat binary -o a.out a.o
$ wc -c a.out
52 a.out

ELF 헤더 달랑 하나의 크기인 52-byte 실행파일이 만들어졌다. 헤더 크기보다 파일이 작으면 애초에 ELF 표준에 어긋나므로 이론상 한계라고 할 수 있겠다. 하지만 현실적으로는 필드 몇개가 비더라도 적당한 기본값으로 실행되기 때문에 뒤의 세 필드 e_shentsize, e_shnum, e_shstrndx를 없애도 무방하다. 또한 e_phnum도 2-byte 대신 1-byte만 줘도 된다. 최소한 프로그램 헤더가 하나 있다는 사실은 알려줘야 하기 때문에 e_phnum을 완전히 지울수는 없다. 최종본은 다음과 같다.

$ cat f.S
$$:
  .byte 0x7f, 'E', 'L', 'F' # e_ident
  .long 1                   #           p_type
  .long 0                   #           p_offset
  .long $$                  #           p_vaddr
  .word 2                   # e_type    p_paddr
  .word 0x3e                # e_machine
  .long _start              # e_version p_filesz
  .long _start              # e_entry   p_memsz
  .long 4                   # e_phoff   p_flags
_start:
  xor %eax, %eax            # e_shoff   p_align
  mov $60, %al
  syscall                   # e_flags
  .word 0
  .word 52                  # e_ehsize
  .word 32                  # e_phentsize
  .byte 1                   # e_phnum
$ as -o a.o f.S; ld --oformat binary -o a.out a.o
$ wc -c a.out
45 a.out
$ hd a.out
00000000  7f 45 4c 46 01 00 00 00  00 00 00 00 00 00 40 00  |.ELF..........@.|
00000010  02 00 3e 00 20 00 40 00  20 00 40 00 04 00 00 00  |..>. .@. .@.....|
00000020  31 c0 b0 3c 0f 05 00 00  34 00 20 00 01           |1..<....4. ..|
$ ./a.out &
[1] 3606376

45-byte다. 이게 무슨 짓인지 싶겠지만, 실행하면 리눅스가 pid까지 부여해주는 어엿한 실행파일이다. (…)

이렇게 마무리 지은 후 다른 사람이 한 것도 찾아봤는데 Brian Raiter가 소름 돋게 비슷한 과정으로 같은 사이즈까지 줄인 것을 볼 수 있었다2.

  1. 언급하지 않았지만, p_offset - p_vaddr은 p_align 값과 무관하게 4KB의 배수여야지만이 실행이 되었다. mmap으로 로드하기 떄문에 그런듯. 

  2. http://www.muppetlabs.com/~breadbox/software/tiny/teensy.html 

Written on April 22, 2021