https://4zm.org/2024/12/25/a-simple-elf.html [ ] [ ] Skip to content logo The Ivory Tower A Simple ELF [ ] Initializing search * Home * Archive * Categories logo The Ivory Tower * Home * [ ] Archive Archive + 2024 * [ ] Categories Categories + Best Practices + Beyond the Basics + C++ + Code Review + Git + Leadership + Philosophy + Python + Shenanigans + Toolchains Table of contents * Life without libc * Life without PIE * Linker Scripts Back to index * Metadata + December 25, 2024 + in Toolchains + 12 min read A Simple ELF Let's write a simple program for Linux. How hard can it be? Well, simple is the opposite of complex, not of hard, and it is surprisingly hard to create something simple. What is left when we get rid of the complexity from the standard library, all the modern security features, debugging information, and error handling mechanisms? * * * Let's start with something complex: #include int main() { printf("Hello Simplicity!\n"); } Wait, what?! It doesn't look very complex, does it... Hmm, let's compile it and take a look: $ gcc -o hello hello.c $ ./hello Hello Simplicity! Still looks pretty simple, right? Wrong! While this might be familiar territory and easy to comprehend, the program is far from simple. Let's take a look behind the curtain. $ objdump -t hello hello: file format elf64-x86-64 SYMBOL TABLE: 0000000000000000 l df *ABS* 0000000000000000 Scrt1.o 000000000000038c l O .note.ABI-tag 0000000000000020 __abi_tag 0000000000000000 l df *ABS* 0000000000000000 crtstuff.c 0000000000001090 l F .text 0000000000000000 deregister_tm_clones 00000000000010c0 l F .text 0000000000000000 register_tm_clones 0000000000001100 l F .text 0000000000000000 __do_global_dtors_aux 0000000000004010 l O .bss 0000000000000001 completed.0 0000000000003dc0 l O .fini_array 0000000000000000 __do_global_dtors_aux_fini_array_entry 0000000000001140 l F .text 0000000000000000 frame_dummy 0000000000003db8 l O .init_array 0000000000000000 __frame_dummy_init_array_entry 0000000000000000 l df *ABS* 0000000000000000 hello.c 0000000000000000 l df *ABS* 0000000000000000 crtstuff.c 00000000000020f8 l O .eh_frame 0000000000000000 __FRAME_END__ 0000000000000000 l df *ABS* 0000000000000000 0000000000003dc8 l O .dynamic 0000000000000000 _DYNAMIC 0000000000002018 l .eh_frame_hdr 0000000000000000 __GNU_EH_FRAME_HDR 0000000000003fb8 l O .got 0000000000000000 _GLOBAL_OFFSET_TABLE_ 0000000000000000 F *UND* 0000000000000000 __libc_start_main@GLIBC_2.34 0000000000000000 w *UND* 0000000000000000 _ITM_deregisterTMCloneTable 0000000000004000 w .data 0000000000000000 data_start 0000000000000000 F *UND* 0000000000000000 puts@GLIBC_2.2.5 0000000000004010 g .data 0000000000000000 _edata 0000000000001168 g F .fini 0000000000000000 .hidden _fini 0000000000004000 g .data 0000000000000000 __data_start 0000000000000000 w *UND* 0000000000000000 __gmon_start__ 0000000000004008 g O .data 0000000000000000 .hidden __dso_handle 0000000000002000 g O .rodata 0000000000000004 _IO_stdin_used 0000000000004018 g .bss 0000000000000000 _end 0000000000001060 g F .text 0000000000000026 _start 0000000000004010 g .bss 0000000000000000 __bss_start 0000000000001149 g F .text 000000000000001e main 0000000000004010 g O .data 0000000000000000 .hidden __TMC_END__ 0000000000000000 w *UND* 0000000000000000 _ITM_registerTMCloneTable 0000000000000000 w F *UND* 0000000000000000 __cxa_finalize@GLIBC_2.2.5 0000000000001000 g F .init 0000000000000000 .hidden _init That's a lot of symbols! Actually, as far as symbol tables go, this one is quite modest. Any non-trivial program will have many more symbols, but still, what are they all for? We're just printing a string! We recognize our main function in the .text segment at address 0x1149. But where is the printf function? It turns out that for simple cases, where there is no formatting work required by printf, GCC optimizes the code and replaces it with the simpler puts@GLIBC_2.2.5 from libc. The address is all zeros since the symbol is undefined (*UND*). It will be resolved when the program is loaded together with the dynamic libc.so library as we run it. 0000000000001149 g F .text 000000000000001e main 0000000000000000 F *UND* 0000000000000000 puts@GLIBC_2.2.5 Let's keep digging. What sections are there in the program? The only data we have is the hardcoded string and its length. Surely we only need a .text section? Let's see what we got: $ objdump -h hello hello: file format elf64-x86-64 Sections: Idx Name Size VMA LMA File off Algn 0 .interp 0000001c 0000000000000318 0000000000000318 00000318 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 1 .note.gnu.property 00000030 0000000000000338 0000000000000338 00000338 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 2 .note.gnu.build-id 00000024 0000000000000368 0000000000000368 00000368 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 3 .note.ABI-tag 00000020 000000000000038c 000000000000038c 0000038c 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 4 .gnu.hash 00000024 00000000000003b0 00000000000003b0 000003b0 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 5 .dynsym 000000a8 00000000000003d8 00000000000003d8 000003d8 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 6 .dynstr 0000008d 0000000000000480 0000000000000480 00000480 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 7 .gnu.version 0000000e 000000000000050e 000000000000050e 0000050e 2**1 CONTENTS, ALLOC, LOAD, READONLY, DATA 8 .gnu.version_r 00000030 0000000000000520 0000000000000520 00000520 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 9 .rela.dyn 000000c0 0000000000000550 0000000000000550 00000550 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 10 .rela.plt 00000018 0000000000000610 0000000000000610 00000610 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 11 .init 0000001b 0000000000001000 0000000000001000 00001000 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 12 .plt 00000020 0000000000001020 0000000000001020 00001020 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 13 .plt.got 00000010 0000000000001040 0000000000001040 00001040 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 14 .plt.sec 00000010 0000000000001050 0000000000001050 00001050 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 15 .text 00000107 0000000000001060 0000000000001060 00001060 2**4 CONTENTS, ALLOC, LOAD, READONLY, CODE 16 .fini 0000000d 0000000000001168 0000000000001168 00001168 2**2 CONTENTS, ALLOC, LOAD, READONLY, CODE 17 .rodata 00000011 0000000000002000 0000000000002000 00002000 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 18 .eh_frame_hdr 00000034 0000000000002014 0000000000002014 00002014 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 19 .eh_frame 000000ac 0000000000002048 0000000000002048 00002048 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 20 .init_array 00000008 0000000000003db8 0000000000003db8 00002db8 2**3 CONTENTS, ALLOC, LOAD, DATA 21 .fini_array 00000008 0000000000003dc0 0000000000003dc0 00002dc0 2**3 CONTENTS, ALLOC, LOAD, DATA 22 .dynamic 000001f0 0000000000003dc8 0000000000003dc8 00002dc8 2**3 CONTENTS, ALLOC, LOAD, DATA 23 .got 00000048 0000000000003fb8 0000000000003fb8 00002fb8 2**3 CONTENTS, ALLOC, LOAD, DATA 24 .data 00000010 0000000000004000 0000000000004000 00003000 2**3 CONTENTS, ALLOC, LOAD, DATA 25 .bss 00000008 0000000000004010 0000000000004010 00003010 2**0 ALLOC 26 .comment 0000002b 0000000000000000 0000000000000000 00003010 2**0 CONTENTS, READONLY Ok, so that's definitely complex. It's not just a simple .text section. There are a LOT of them. This is too much to deal with right now. Where does the program even start? It starts with main, right? Wrong again! $ objdump -f hello hello: file format elf64-x86-64 architecture: i386:x86-64, flags 0x00000150: HAS_SYMS, DYNAMIC, D_PAGED start address 0x0000000000001060 The "start address" (also known as the entry point), is _start, not main. This mystery function at 0x1060 must call our main somehow, but where does it come from!? 0000000000001060 g F .text 0000000000000026 _start Let's start simplifying the program. As we peel off complexity, we will get a chance to focus on understanding a few things at a time. Life without libc A major source of complexity in our program comes from the standard libraries. They are used for printing the string and initializing the program. Let's get rid of them. Easy enough, just compile with: -nostdlib. Unfortunately, that means we no longer have access to the printf (or the puts) function. That's unfortunate since we still want to print "Hello Simplicity!". It also means we will lose the _start function. It is provided by the C runtime library (CRT) to perform some initialization (like clearing the .bss segment) and call our main function. Since we still need our main to be executed, we will have to do something about that. Fortunately, we can provide our own entry point with -Wl,-e, . We could specify main as our entry point directly, but that would mean treating it as void main() instead of int main(). The entry point doesn't return anything. I feel that changing the signature of main is one bridge too far; let's instead create our own void startup() function that calls main. For writing to stdout, we resort to the syscall assembly instruction. This instruction is how we ask the Linux kernel to do things for us. In this particular case, we would like to execute the write syscall to write a string to stdout (file descriptor = 1). Later on, we also want to call exit to terminate the process. When calling the syscall instruction, we pass the syscall number in the rax register and the arguments in registers rdi, rsi, and rdx. The write syscall has number 0x01 and the exit syscall has number 0x3c. These are their C signatures: ssize_t write(int fildes, const void *buf, size_t nbyte); void exit(int status); and this is our new program hello-syscall.c: int main() { volatile const char message[] = "Hello Simplicity!\n"; volatile const unsigned long length = sizeof(message) - 1; // write(1, message, length) asm volatile("mov $1, %%rax\n" // write syscall number (0x01) "mov $1, %%rdi\n" // Stdout file descriptor (0x01) "mov %0, %%rsi\n" // Message buffer "mov %1, %%rdx\n" // Buffer length "syscall" // Make the syscall : // No output operands : "r"(message), "r"(length) // Input operands : "%rax", "%rdi", "%rsi", "%rdx" // Clobbered registers ); return 0; } void startup() { volatile unsigned long status = main(); // exit(status) asm volatile("mov $0x3c, %%rax\n" // exit syscall number (0x3c) "mov %0, %%rdi\n" // exit status "syscall" // Make the syscall : // No output operands : "r"(status) // Input operands : "%rax", "%rdi" // Clobbered registers ); } In case you are wondering, the volatile keyword is required to prevent GCC from optimizing away the variables. And unsigned long is used instead of int to match the size of the r__ 64-bit registers. We build it like so: gcc -Wl,-entry=startup -nostdlib -o hello-nostd hello-syscall.c Is this really simpler than before? Well, yes! It might not be easier to understand unless you are accustomed to assembly language, syscalls, and custom entry points. But simple is not synonymous with easy. Simple is the opposite of complex. Complex things are intrinsically hard to understand, no matter how much you know. Simple things are only hard to understand until you have acquired the appropriate skills. Rich Hickey explains this eloquently in his 2011 talk "Simple Made Easy". Still not convinced that we have actually made the program simpler? Let's take a look at the symbols and sections: $ objdump -h -t hello-nostd Sections: Idx Name Size VMA LMA File off Algn 0 .interp 0000001c 0000000000000318 0000000000000318 00000318 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 1 .note.gnu.property 00000020 0000000000000338 0000000000000338 00000338 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 2 .note.gnu.build-id 00000024 0000000000000358 0000000000000358 00000358 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 3 .gnu.hash 0000001c 0000000000000380 0000000000000380 00000380 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 4 .dynsym 00000018 00000000000003a0 00000000000003a0 000003a0 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 5 .dynstr 00000001 00000000000003b8 00000000000003b8 000003b8 2**0 CONTENTS, ALLOC, LOAD, READONLY, DATA 6 .text 0000007f 0000000000001000 0000000000001000 00001000 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 7 .eh_frame_hdr 0000001c 0000000000002000 0000000000002000 00002000 2**2 CONTENTS, ALLOC, LOAD, READONLY, DATA 8 .eh_frame 00000058 0000000000002020 0000000000002020 00002020 2**3 CONTENTS, ALLOC, LOAD, READONLY, DATA 9 .dynamic 000000e0 0000000000003f20 0000000000003f20 00002f20 2**3 CONTENTS, ALLOC, LOAD, DATA 10 .comment 0000002b 0000000000000000 0000000000000000 00003000 2**0 CONTENTS, READONLY SYMBOL TABLE: 0000000000000000 l df *ABS* 0000000000000000 hello-syscall.c 0000000000000000 l df *ABS* 0000000000000000 0000000000003f20 l O .dynamic 0000000000000000 _DYNAMIC 0000000000002000 l .eh_frame_hdr 0000000000000000 __GNU_EH_FRAME_HDR 0000000000001050 g F .text 000000000000002f startup 0000000000004000 g .dynamic 0000000000000000 __bss_start 0000000000001000 g F .text 0000000000000050 main 0000000000004000 g .dynamic 0000000000000000 _edata 0000000000004000 g .dynamic 0000000000000000 _end There's still a lot going on here, but at least it now fits on one screen. As expected, objdump -f gives us a new start address: 0x1050. It's our startup function! Let's continue simplifying! Life without PIE For the last 20 years, your programs have been loaded into memory at random addresses as a security mitigation. ASLR (Address Space Layout Randomization) makes it harder to write exploits since the shellcode can't jump to hardcoded destinations. It also means jumps in your regular programs can't be hardcoded. By default, programs on modern systems are built as Position Independent Executables (PIE). Addresses are resolved when the program is loaded into memory. It's great for security, but it adds complexity. Let's get rid of it with: -no-pie. To further unclutter our assembly code, we turn off some more safety features with -fcf-protection=none and -fno-stack-protector. We also get rid of some metadata generation with -Wl,--build-id=none and some debugger-friendly stack unwinding info with -fno-unwind-tables and -fno-asynchronous-unwind-tables. gcc -no-pie \ -nostdlib \ -Wl,-e,startup \ -Wl,--build-id=none \ -fcf-protection=none \ -fno-stack-protector \ -fno-asynchronous-unwind-tables \ -fno-unwind-tables \ -o hello-nostd-nopie hello.c We are now down to this: $ objdump -h -t hello-nostd-nopie hello-nostd-nopie: file format elf64-x86-64 Sections: Idx Name Size VMA LMA File off Algn 0 .text 00000077 0000000000401000 0000000000401000 00001000 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .comment 0000002b 0000000000000000 0000000000000000 00001077 2**0 CONTENTS, READONLY SYMBOL TABLE: 0000000000000000 l df *ABS* 0000000000000000 hello-syscall.c 000000000040104c g F .text 000000000000002b startup 0000000000402000 g .text 0000000000000000 __bss_start 0000000000401000 g F .text 000000000000004c main 0000000000402000 g .text 0000000000000000 _edata 0000000000402000 g .text 0000000000000000 _end Did you notice how the symbol addresses changed with -no-pie? Before, they were relative, waiting for some offset to be added at load time. Now, they are absolute, and main will really be at 0x00401000. $ gdb hi (gdb) break main Breakpoint 1 at 0x401004 (gdb) run Breakpoint 1, 0x0000000000401004 in main () Phew! We are finally approaching something simple-ish. Now, our entire program even fits on one screen: $ objdump -d -M intel hello-nostd-nopie Disassembly of section .text: 0000000000401000
: 401000: 55 push rbp 401001: 48 89 e5 mov rbp,rsp 401004: 48 b8 48 65 6c 6c 6f movabs rax,0x6953206f6c6c6548 40100b: 20 53 69 40100e: 48 ba 6d 70 6c 69 63 movabs rdx,0x79746963696c706d 401015: 69 74 79 401018: 48 89 45 e0 mov QWORD PTR [rbp-0x20],rax 40101c: 48 89 55 e8 mov QWORD PTR [rbp-0x18],rdx 401020: 66 c7 45 f0 21 0a mov WORD PTR [rbp-0x10],0xa21 401026: c6 45 f2 00 mov BYTE PTR [rbp-0xe],0x0 40102a: 48 c7 45 d8 12 00 00 mov QWORD PTR [rbp-0x28],0x12 401031: 00 401032: 4c 8b 45 d8 mov r8,QWORD PTR [rbp-0x28] 401036: 48 8d 4d e0 lea rcx,[rbp-0x20] 40103a: 48 c7 c0 01 00 00 00 mov rax,0x1 401041: 48 c7 c7 01 00 00 00 mov rdi,0x1 401048: 48 89 ce mov rsi,rcx 40104b: 4c 89 c2 mov rdx,r8 40104e: 0f 05 syscall 401050: b8 00 00 00 00 mov eax,0x0 401055: 5d pop rbp 401056: c3 ret 0000000000401057 : 401057: 55 push rbp 401058: 48 89 e5 mov rbp,rsp 40105b: 48 83 ec 10 sub rsp,0x10 40105f: b8 00 00 00 00 mov eax,0x0 401064: e8 97 ff ff ff call 401000
401069: 48 98 cdqe 40106b: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax 40106f: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8] 401073: 48 c7 c0 3c 00 00 00 mov rax,0x3c 40107a: 48 89 d7 mov rdi,rdx 40107d: 0f 05 syscall 40107f: 90 nop 401080: c9 leave 401081: c3 ret You can see the startup function calling main, the two syscalls, and the "Hello Simplicity!" string hardcoded as a large number of ASCII values (being loaded onto the stack, relative to the stack base pointer rbp). There's not a lot of complexity left, at least not at this level. Our ELF is actually quite simple! But wait, there is more! Linker Scripts Where do the strange symbols (like __bss_start) come from? And who decides that our startup function should be loaded into memory at 0x0040104c? What if we want our code to live in the cool 0xc0d30000 address range? These things are specified in the linker script. Until now, we have been using the default one, which you can see with ld -verbose. It's very complex. Let's get rid of it. Our simple hello world application doesn't use any global variables. If it had, they would fall into three categories: * .rodata: Constants with values provided at compile time, like our hardcoded string. * .data: Non-const variables with values provided at compile time. * .bss: Uninitialized global variables. Let's complicate our program a tiny bit by introducing a symbol for each category. This will provide a more interesting linker script example. Here is the new program hello-data.c: const char message[] = "Hello Simplicity!\n"; // .rodata unsigned long length = sizeof(message) - 1; // .data unsigned long status; // .bss int main() { // write(1, message, length) asm volatile("mov $1, %%rax\n" // write syscall number (0x01) "mov $1, %%rdi\n" // Stdout file descriptor (0x01) "mov %0, %%rsi\n" // Message buffer "mov %1, %%rdx\n" // Buffer length "syscall" // Make the syscall : // No output operands : "r"(message), "r"(length) // Input operands : "%rax", "%rdi", "%rsi", "%rdx" // Clobbered registers ); return 0; } void startup() { status = main(); // exit(status) asm volatile("mov $0x3c, %%rax\n" // exit syscall number (0x3c) "mov %0, %%rdi\n" // exit status "syscall" // Make the syscall : // No output operands : "r"(status) // Input operands : "%rax", "%rdi" // Clobbered registers ); } Looking at the symbol table again, without using a custom linker script, we can see the globals in .data, .rodata and .bss respectively: 000000000040102f g F .text 000000000000002d startup 0000000000403010 g O .data 0000000000000008 length 0000000000402000 g O .rodata 000000000000000e message 0000000000401000 g F .text 000000000000002f main 0000000000403018 g O .bss 0000000000000008 status Now, let's create a simple and fun linker script (hello.ld) with a cool memory map and emojis in the section names: MEMORY { IRAM (rx) : ORIGIN = 0xC0DE0000, LENGTH = 0x1000 RAM (rw) : ORIGIN = 0xFEED0000, LENGTH = 0x1000 ROM (r) : ORIGIN = 0xDEAD0000, LENGTH = 0x1000 } SECTIONS { " .text" : { *(.text*) } > IRAM " .data" : { *(.data*) } > RAM " .bss" : { *(.bss*) } > RAM " .rodata" : { *(.rodata*) } > ROM /DISCARD/ : { *(.comment) } } ENTRY(startup) We use the same build options as before but add -T hello.ld to start using our linker script. This is the simple program in its final form: $ objdump -t -h hello-data hello-data: file format elf64-x86-64 Sections: Idx Name Size VMA LMA File off Algn 0 .text 0000005c 00000000c0de0000 00000000c0de0000 00001000 2**0 CONTENTS, ALLOC, LOAD, READONLY, CODE 1 .data 00000008 00000000feed0000 00000000feed0000 00003000 2**3 CONTENTS, ALLOC, LOAD, DATA 2 .bss 00000008 00000000feed0008 00000000feed0008 00003008 2**3 ALLOC 3 .rodata 00000013 00000000dead0000 00000000dead0000 00002000 2**4 CONTENTS, ALLOC, LOAD, READONLY, DATA SYMBOL TABLE: 0000000000000000 l df *ABS* 0000000000000000 hello-data.c 00000000c0de002f g F .text 000000000000002d startup 00000000feed0000 g O .data 0000000000000008 length 00000000dead0000 g O .rodata 0000000000000013 message 00000000c0de0000 g F .text 000000000000002f main 00000000feed0008 g O .bss 0000000000000008 status Isn't that absolutely adorable?! I've put some sample code over at github.com/4ZM/elf-shenanigans to reproduce the examples in this article. If you want to learn more about linker scripts (and why wouldn't you?!) this is an outstanding technical documentation: "c_Using_LD". If you want to explore more ridiculous things to do with section names, check out my other article: "ELF Shenanigans". * * * Copyright (c) 2024 Anders Sundman Made with Material for MkDocs