Reverse Engineering Part 1

What Your Compiler Actually Made

0x4142 0x4142 6 min read #reverse-engineering#elf#objdump

Pulling apart the ELF file gcc builds from hello world: its header, sections, symbols, and a first look at main in assembly.

The smallest C program worth compiling produces a file of about 16 KB. The source is five lines. So what’s in the rest? This part answers that with four tools (file, readelf, nm and objdump) and no assembly knowledge at all. By the end you’ll know where your code, your string and your function names live inside the binary, and you’ll have seen main as the CPU sees it.

Everything here runs inside the lab image. If you haven’t set it up yet, part 0 has the one command you need.

The program

Here’s the whole thing, in part-01/hello.c:

#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}

The lab builds it with these flags:

Terminal window
gcc -O0 -g -fno-pie -no-pie -o hello hello.c
  • -O0 turns optimization off, so the assembly follows the source line by line instead of being rearranged.
  • -g adds debug information, which gdb will use from part 2 on.
  • -fno-pie -no-pie builds a position-dependent executable: it always loads at the same address, so the addresses you see in these posts are the addresses you’ll see in yours.

These flags make the first arc easier to follow. They’re not how real software ships, and part 11 drops them to see what optimized, stripped code looks like.

Four stages

“Compiling” is really four programs run back to back:

  1. Preprocess. cpp pastes in stdio.h and expands macros. The output is still C.
  2. Compile. cc1 turns that C into assembly, a text file of instructions.
  3. Assemble. as turns the assembly into machine code in an object file, hello.o.
  4. Link. ld glues hello.o to the C runtime’s startup code and records that hello needs the C library at runtime.

gcc -S stops after stage 2 and prints the assembly, which lets you see the middle of the pipeline. Step through the frames:

Step 1 of 3: the source

From C to a binary
$ cat hello.c
#include <stdio.h>
int main(void) {
printf("Hello, world!\n");
return 0;
}

Step 2 of 3: the compiler's assembly output

From C to a binary
$ gcc -O0 -fno-pie -masm=intel -fno-asynchronous-unwind-tables -S -o - hello.c
.file "hello.c"
.intel_syntax noprefix
.text
.section .rodata
.LC0:
.string "Hello, world!"
.text
.globl main
.type main, @function
main:
endbr64
push rbp
mov rbp, rsp
mov edi, OFFSET FLAT:.LC0
call puts
mov eax, 0
pop rbp
ret
.size main, .-main
.ident "GCC: (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:

Step 3 of 3: the finished binary

From C to a binary
$ file hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c2389cb890c4ae64ef9b748ff4d79b84c6798306, for GNU/Linux 3.2.0, with debug_info, not stripped

Don’t try to read the assembly in frame 2 yet. Just notice that the string "Hello, world!" and the name main are both still there as text. Frame 3 is the end of the pipeline: an actual executable.

What is this file?

file looks at the first bytes of a file and tells you what it is:

file: what did gcc produce?
$ file hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c2389cb890c4ae64ef9b748ff4d79b84c6798306, for GNU/Linux 3.2.0, with debug_info, not stripped

That’s one long line (scroll the block sideways), so here’s what matters in it:

  • ELF is the Executable and Linkable Format, the file format Linux uses for executables, object files and shared libraries.
  • 64-bit and x86-64: built for 64-bit Intel and AMD processors.
  • LSB stands for least significant byte first, also known as little-endian: multi-byte numbers are stored with their lowest byte first. This will matter a lot in part 3, when you read raw memory.
  • executable, and not “pie executable”, because of -no-pie.
  • dynamically linked: printf isn’t inside this file. It gets loaded from the C library when the program starts.
  • with debug_info, not stripped: the file still carries the names of its functions and the debug info from -g. Binaries you download usually have both removed, and part 11 deals with that.

The ELF header

Every ELF file starts with a 64-byte header that tells the operating system how to load it. readelf -h prints it:

readelf: the ELF header
$ readelf -h hello
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: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x401050
Start of program headers: 64 (bytes into file)
Start of section headers: 14560 (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: 37
Section header string table index: 36

Four lines are worth reading closely:

  • Magic. The first four bytes are always 7f 45 4c 46: the byte 0x7f followed by the ASCII letters E, L, F. This is how file knew what it was looking at. The next two bytes are the class (02, 64-bit) and the data encoding (01, little-endian), which readelf spells out on the next two lines.
  • Type. EXEC, a fixed-address executable. A PIE would say DYN.
  • Machine. x86-64.
  • Entry point address. 0x401050 is where the CPU starts executing.

That entry point is not main. Keep the number in mind for the symbols section below: it belongs to a function you never wrote, which sets up the process and then calls main for you.

Sections

The linker arranges the file into sections, named chunks that each hold one kind of thing. There are a lot of them:

readelf: sections
$ readelf -S hello
There are 37 section headers, starting at offset 0x38e0:
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 0000000000400318 00000318
000000000000001c 0000000000000000 A 0 0 1
[ 2] .note.gnu.pr[...] NOTE 0000000000400338 00000338
0000000000000030 0000000000000000 A 0 0 8
[ 3] .note.gnu.bu[...] NOTE 0000000000400368 00000368
0000000000000024 0000000000000000 A 0 0 4
[ 4] .note.ABI-tag NOTE 000000000040038c 0000038c
0000000000000020 0000000000000000 A 0 0 4
[ 5] .gnu.hash GNU_HASH 00000000004003b0 000003b0
000000000000001c 0000000000000000 A 6 0 8
[ 6] .dynsym DYNSYM 00000000004003d0 000003d0
0000000000000060 0000000000000018 A 7 1 8
[ 7] .dynstr STRTAB 0000000000400430 00000430
0000000000000048 0000000000000000 A 0 0 1
[ 8] .gnu.version VERSYM 0000000000400478 00000478
0000000000000008 0000000000000002 A 6 0 2
[ 9] .gnu.version_r VERNEED 0000000000400480 00000480
0000000000000030 0000000000000000 A 7 1 8
[10] .rela.dyn RELA 00000000004004b0 000004b0
0000000000000030 0000000000000018 A 6 0 8
[11] .rela.plt RELA 00000000004004e0 000004e0
0000000000000018 0000000000000018 AI 6 24 8
[12] .init PROGBITS 0000000000401000 00001000
000000000000001b 0000000000000000 AX 0 0 4
[13] .plt PROGBITS 0000000000401020 00001020
0000000000000020 0000000000000010 AX 0 0 16
[14] .plt.sec PROGBITS 0000000000401040 00001040
0000000000000010 0000000000000010 AX 0 0 16
[15] .text PROGBITS 0000000000401050 00001050
00000000000000ff 0000000000000000 AX 0 0 16
[16] .fini PROGBITS 0000000000401150 00001150
000000000000000d 0000000000000000 AX 0 0 4
[17] .rodata PROGBITS 0000000000402000 00002000
0000000000000012 0000000000000000 A 0 0 4
[18] .eh_frame_hdr PROGBITS 0000000000402014 00002014
0000000000000034 0000000000000000 A 0 0 4
[19] .eh_frame PROGBITS 0000000000402048 00002048
00000000000000a4 0000000000000000 A 0 0 8
[20] .init_array INIT_ARRAY 0000000000403df8 00002df8
0000000000000008 0000000000000008 WA 0 0 8
[21] .fini_array FINI_ARRAY 0000000000403e00 00002e00
0000000000000008 0000000000000008 WA 0 0 8
[22] .dynamic DYNAMIC 0000000000403e08 00002e08
00000000000001d0 0000000000000010 WA 7 0 8
[23] .got PROGBITS 0000000000403fd8 00002fd8
0000000000000010 0000000000000008 WA 0 0 8
[24] .got.plt PROGBITS 0000000000403fe8 00002fe8
0000000000000020 0000000000000008 WA 0 0 8
[25] .data PROGBITS 0000000000404008 00003008
0000000000000010 0000000000000000 WA 0 0 8
[26] .bss NOBITS 0000000000404018 00003018
0000000000000008 0000000000000000 WA 0 0 1
[27] .comment PROGBITS 0000000000000000 00003018
000000000000002d 0000000000000001 MS 0 0 1
[28] .debug_aranges PROGBITS 0000000000000000 00003045
0000000000000030 0000000000000000 0 0 1
[29] .debug_info PROGBITS 0000000000000000 00003075
000000000000008c 0000000000000000 0 0 1
[30] .debug_abbrev PROGBITS 0000000000000000 00003101
0000000000000045 0000000000000000 0 0 1
[31] .debug_line PROGBITS 0000000000000000 00003146
0000000000000056 0000000000000000 0 0 1
[32] .debug_str PROGBITS 0000000000000000 0000319c
00000000000000e6 0000000000000001 MS 0 0 1
[33] .debug_line_str PROGBITS 0000000000000000 00003282
000000000000001d 0000000000000001 MS 0 0 1
[34] .symtab SYMTAB 0000000000000000 000032a0
0000000000000330 0000000000000018 35 18 8
[35] .strtab STRTAB 0000000000000000 000035d0
00000000000001a1 0000000000000000 0 0 1
[36] .shstrtab STRTAB 0000000000000000 00003771
000000000000016f 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)

Thirty-six sections for a five-line program (entry [ 0] is an empty placeholder that every ELF file has). Most are bookkeeping for the linker, the loader and the debugger, and you can ignore them for now. Four are worth knowing by name:

  • .text holds the machine code. Its flags are AX: allocated in memory when the program runs, and executable. It starts at 0x401050, the entry point from the header.
  • .rodata holds read-only data, like string literals. It’s at 0x402000 and only 0x12 (18) bytes long: 4 bytes the C runtime puts there, followed by "Hello, world!" and its terminating zero byte. So the string lives at 0x402004. Remember that address.
  • .data holds global variables that have an initial value. Flags WA: allocated and writable.
  • .bss holds global variables that start out as zero. Its type is NOBITS: it takes no space in the file, and the loader just hands the program zeroed memory.

hello.c has no globals, yet .data and .bss aren’t empty. That’s the C runtime’s startup code again, which the linker added. Every .debug_* section comes from -g.

Symbols

A symbol is a name with an address attached. nm lists them:

nm: symbols
$ nm hello
0000000000403e08 d _DYNAMIC
0000000000403fe8 d _GLOBAL_OFFSET_TABLE_
0000000000402000 R _IO_stdin_used
00000000004020e8 r __FRAME_END__
0000000000402014 r __GNU_EH_FRAME_HDR
0000000000404018 D __TMC_END__
000000000040038c r __abi_tag
0000000000404018 B __bss_start
0000000000404008 D __data_start
0000000000401100 t __do_global_dtors_aux
0000000000403e00 d __do_global_dtors_aux_fini_array_entry
0000000000404010 D __dso_handle
0000000000403df8 d __frame_dummy_init_array_entry
w __gmon_start__
U __libc_start_main@GLIBC_2.34
0000000000401080 T _dl_relocate_static_pie
0000000000404018 D _edata
0000000000404020 B _end
0000000000401150 T _fini
0000000000401000 T _init
0000000000401050 T _start
0000000000404018 b completed.0
0000000000404008 W data_start
0000000000401090 t deregister_tm_clones
0000000000401130 t frame_dummy
0000000000401136 T main
U puts@GLIBC_2.2.5
00000000004010c0 t register_tm_clones

The letter in the middle column is the symbol’s type: T is code (usually in .text; _init and _fini live in their own small code sections), D is initialized data, B is .bss, R is read-only data. Lowercase means the symbol is local to this file. Three lines are the point:

  • 0000000000401136 T main: your function, in .text, at 0x401136.
  • 0000000000401050 T _start: the entry point from the ELF header. _start is the startup code that eventually calls main.
  • U puts@GLIBC_2.2.5: U means undefined. The binary uses puts but doesn’t contain it, and there’s no address because it won’t have one until the C library is loaded at runtime. Part 8 shows how that address gets filled in.

Wait: puts? The source calls printf. There’s no printf anywhere in the list. gcc noticed that printf("Hello, world!\n") has no format specifiers and ends in a newline, and swapped it for the cheaper puts("Hello, world!"), which adds the newline itself. That’s even with optimizations off. It’s your first sign that a binary isn’t a literal translation of its source, and the compiler will take bigger liberties than this once optimization is on.

First look at assembly

Finally, objdump -d disassembles the machine code: it turns the bytes in .text back into readable instructions. Here’s main:

objdump: main in assembly
$ objdump -d -M intel --no-show-raw-insn --disassembler-color=on --disassemble=main hello
hello: file format elf64-x86-64
Disassembly of section .init:
Disassembly of section .plt:
Disassembly of section .plt.sec:
Disassembly of section .text:
0000000000401136 <main>:
401136: endbr64
40113a: push rbp
40113b: mov rbp,rsp
40113e: mov edi,0x402004
401143: call 401040 <puts@plt>
401148: mov eax,0x0
40114d: pop rbp
40114e: ret
Disassembly of section .fini:

The empty “Disassembly of section” headers are objdump announcing sections it skipped because we asked for main only. Each remaining line is one instruction: its address, then the instruction itself.

You don’t need to know what any of these instructions do yet. That’s part 2. For now, look at the shape, and you can already match most of it to things you’ve seen in this post:

  • push rbp / mov rbp,rsp at the top and pop rbp at the bottom are the function’s prologue and epilogue, setting up and tearing down its stack frame. Nearly every function at -O0 starts and ends this way. (endbr64 is a security marker for the CPU, and you can skip it.)
  • mov edi,0x402004 loads 0x402004, the address of "Hello, world!" in .rodata, into a register.
  • call 401040 <puts@plt> calls puts, which gets that address as its argument.
  • mov eax,0x0 is return 0: the return value goes in eax.
  • ret returns to whoever called main: the C runtime code that _start hands off to.

Five lines of C, eight instructions, and you can already point at where the string comes from and where the 0 goes.

Recap and next

  • gcc runs four stages (preprocess, compile, assemble, link) and produces an ELF file, whose header tells the OS how to load it and where to start.
  • Code lives in .text, string literals in .rodata, globals in .data and .bss, and symbols tie names like main to addresses in them.
  • The binary isn’t a literal copy of your source: execution starts at _start, not main, and printf quietly became puts.

Next up, part 2 (Registers and your first instructions): we open gdb, step through main one instruction at a time, and learn what mov, rbp and eax actually are.