Mastering ELF with your own cat

Everything's collected. Here's the full walkthrough — every number below comes from your cat binary, and every command is one you can rerun yourself.

/src/elf2/cat  ·  ELF64  ·  x86-64  ·  PIE

Your program is 9 lines of C that imports open, read, write, puts from libc. That makes it the ideal specimen: small enough to account for every byte, real enough to contain the whole ELF machinery.

The one mental model to hold onto: an ELF file is one blob of bytes with two tables of contents pointing into it. The program headers are the runtime view (what the kernel and ld-linux read to run it). The section headers are the link-time view (what the linker and debuggers read). Neither "contains" the data — both just describe ranges of the same bytes. Everything below is exploring those two views.
the file: one blob ELF header (64 B) program headers dyn metadata code (.text, PLT) data / GOT section headers Runtime view program headers read by kernel + ld-linux coarse segments, mmap'd required to run Link-time view section headers read by linker + debuggers fine-grained sections optional at runtime
Two tables of contents, one blob of bytes. Neither view "owns" the data.

1The ELF header: 64 bytes at offset 0

xxd -l 64 cat
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0300 3e00 0100 0000 e010 0000 0000 0000  ..>.............
00000020: 4000 0000 0000 0000 4037 0000 0000 0000  @.......@7......
00000030: 0000 0000 4000 3800 0d00 4000 1f00 1e00  ....@.8...@.....
magic class endianness EI version e_type e_machine e_version e_entry e_phoff e_shoff sizes & counts padding / flags
0x00
7f454c46 020101 000000000000000000
0x10
03003e00 01000000 e010000000000000
0x20
4000000000000000 4037000000000000
0x30
00000000 400038000d0040001f001e00
Every field of your header, byte by byte. Multi-byte values read backwards (little-endian): e0 10 → 0x10e0.

Decode it by hand once and it's yours forever:

Cross-check with readelf -h cat — it says exactly what you just decoded.

2Program headers: what actually runs readelf -lW cat

Thirteen entries, but only a few kinds matter:

The four LOAD segments are the whole runtime story — they're the only thing that becomes memory:

File offsetVirt addrSizePermsContents
0x00000x00000x728RELF header + dynamic-linking metadata
0x10000x10000x2b5R-Xall code (.text, PLT, .init)
0x20000x20000xecRread-only data ("HAHAH!" lives here)
0x2d980x3d980x278 file / 0x280 memRWwritable data, GOT, .bss
FILE (offsets) MEMORY (virtual addresses) R hdrs + metadata R-X code R rodata RW data + GOT section headers @0x3740 0x0000 0x1000 0x2000 0x2d98 R R-X R RW .bss = zeros, no file bytes base+0x0000 base+0x1000 base+0x2000 base+0x3d98 +0x278…0x280 mmap offset ≠ vaddr, but ≡ mod 0x1000 0x2d98 → 0x3d98 (both end in d98) 4 LOAD segments become 4 mapped regions — sections are nowhere in this picture
The loader's entire job: mmap four ranges of the file, then zero-fill the .bss tail.

Three insights hiding in this table:

  1. Permissions are why segments exist. mmap works per page with per-page protections, so the file is grouped into page-aligned chunks by W/X needs. Code is never writable, data is never executable.
  2. File offset ≠ virtual address, but they're congruent mod 0x1000. Look at the last row: offset 0x2d98 maps to address 0x3d98. The loader can still mmap it because both have the same position within a page (0xd98). This trick lets the file skip padding — your binary would be bigger if offsets had to equal addresses.
  3. MemSiz > FileSiz is .bss. The last segment occupies 0x278 bytes in the file but 0x280 in memory. Those extra 8 zero-filled bytes are your uninitialized globals (here, libc runtime flags like completed.0). Zeros aren't stored — they're materialized at load time. This is why a program with char big[100000000]; still produces a tiny file.

The non-LOAD entries are annotations pointing inside the LOADs: INTERP names /lib64/ld-linux-x86-64.so.2 (the dynamic linker the kernel launches to do the real work), DYNAMIC tells that linker where its instructions are, GNU_STACK RW (note: no X) makes the stack non-executable, and GNU_RELRO marks a region — including the GOT — to be re-protected read-only after relocation, which is an exploit mitigation.

3Section headers: the linker's fine-grained map readelf -SW cat

31 sections slice the same bytes into semantic units: .text (your code, 0x1c5 bytes), .rodata (0xb bytes — almost entirely "HAHAH!\0"), .dynsym/.dynstr (imports), .got (pointer slots), .bss (type NOBITS — a section with no file bytes at all, matching the MemSiz>FileSiz you saw above).

The section-to-segment mapping at the bottom of readelf -lW output shows the two views reconciled: segment 03 (the R-X LOAD) = .init .plt .plt.got .plt.sec .text .fini, and so on.

The key practical fact: sections are optional at runtime. Try it:
cp cat /tmp/cat-stripped && strip /tmp/cat-stripped && /tmp/cat-stripped cat.c

It runs fine, and readelf -S shows .symtab/.strtab gone. The kernel and dynamic linker only ever read program headers and the DYNAMIC data. Malware often deletes the entire section header table to break naive analysis tools; readelf -l still works on such files.

QYour question: if sections are "for linking", how can .text run?

"Sections comprise all information needed for linking a target object file in order to build a working executable" — then how is it possible that .text, .rodata and others are sections? How do I know which parts are with me at execution time, and which parts are only linking time? — your question, quoting Intezer's ELF 101 article

The paradox dissolves once you notice what that sentence is actually about: the section view — the header table — not the bytes. A section header doesn't contain code; it describes a byte range and gives it a name and a purpose for the linker. .text-the-bytes are absolutely needed at runtime — but they reach memory because a LOAD segment covers that range. .text-the-header is pure link/debug metadata. Same bytes, two descriptions; delete the description and the bytes still run — that's exactly what your strip experiment in Step 3 proved.

Where the "sections are for linking" sentence really comes from: a relocatable object file. Run gcc -c cat.c; readelf -l cat.o — there are no program headers at all. Sections are the static linker's input units: it merges the .text of every .o, resolves relocations between them, then emits segments as the output units for the kernel. An executable keeps its section headers only as a courtesy to debuggers and tools.

The rule: a byte exists at execution time if and only if some PT_LOAD segment covers its file offset. Sections are just labels over those bytes — a label can point inside a LOAD (mapped) or outside it (link-time only). Three ways to check, from quick to ground truth:
covered by a PT_LOAD → exists at runtime no PT_LOAD → link/debug only R meta R-X code R rodata RW data dead at runtime 0x0000 0x1000 0x2000 0x2d98 0x3010 0x3f00 .comment .symtab .strtab .shstrtab + section header table 3.8 KB — 24% of the file gaps = alignment padding  ·  widths not to scale
Your 16,128-byte file: every byte past offset 0x3010 is invisible to the running process.

"With me at execution time" actually has three answers, not two — the middle one is the subtlety most articles skip:

When it's consumedParts of your binaryIn memory while running?
Link time only
static linker ld, debuggers
.symtab .strtab .shstrtab .comment .debug_* + section headersNo — no A flag, Address 0, beyond the last LOAD
Load time
ld-linux, milliseconds before main
.interp .dynamic .dynsym .dynstr .gnu.hash .gnu.version* .rela.dyn .rela.pltYes, mapped (first R LOAD) — but only read once during startup; idle baggage afterwards
Run time
the CPU, every moment
.text .plt* .rodata .data .bss .got .init .finiYes — the R-X / R / RW LOADs; this is your living program
LINK TIME gcc/ld on your dev box .symtab · reloc'd .o sections not with you — strippable LOAD TIME ld-linux, before main runs .dynamic .dynsym .rela.* mapped, read once, then idle RUN TIME the CPU, whole process life .text .rodata .data .bss .got with you every instruction
One lifetime, three consumers: what "needed for linking" vs "with me at execution" really means.

And you can watch the ground truth live — ask the kernel what it actually mapped:

gdb -batch -ex starti -ex 'info proc mappings' ./cat

The mapped ranges of cat match the four LOAD segments — nothing more. No region corresponds to .symtab or the section headers; the running process has simply never heard of them.

4String tables: how ELF stores every name

Names in ELF are never inline — they're byte offsets into a string table. Here's .dynstr raw (file offset 0x4e0):

0070 7574 7300 5f5f 7374 6163 6b5f ...   .puts.__stack_...
\0 puts\0 __stack_chk_fail\0 read\0 open\0 libc.so.6\0 … GLIBC_2.4\0 … 0 1 6 19 24 72 .dynstr — NUL-terminated strings back to back; everything else refers to them by offset

It's just NUL-terminated strings back to back, starting with a NUL. "puts" is at offset 1, "read" at 19, "libc.so.6" at 72. When any other structure needs a name, it stores that offset. This is why you saw readable symbol names when you opened cat in the editor — string tables are the only human-readable part of the file. There are three of them: .dynstr (runtime symbols), .strtab (debug symbols, strippable), .shstrtab (section names). Try readelf -p .dynstr cat to see it decoded.

5.dynamic: the dynamic linker's instruction sheet readelf -d cat

This is a key-value array ld-linux walks at startup. The entries in yours tell a complete story:

6Symbols, relocations, and the PLT/GOT: how puts("HAHAH!") finds libc

This is the crown jewel — trace it end to end in your binary.

The problem: your code calls puts, but nobody knows where libc will land in memory until runtime (ASLR). The solution is one indirection through a table of pointers (the GOT — Global Offset Table) that ld-linux fills in at startup.
main (.text) 1231: call 1090 <puts@plt> puts@plt (.plt.sec) 1090: endbr64 1094: jmp *0x3fb0(GOT) GOT slot 0x3fb0 on disk: 0x1030 at runtime: &puts read-only after RELRO libc.so.6 puts: ... real code ... ld-linux (at startup) R_X86_64_JUMP_SLOT: look up puts, write its address into 0x3fb0 call jmp * lands in libc writes real address (BIND_NOW) One indirection solves ASLR — and it's exactly what LD_PRELOAD hijacks and RELRO protects.
The complete journey of puts("HAHAH!"), from your call site to libc.

1. The import list. readelf --dyn-syms cat shows 11 entries in .dynsym, all UND (undefined) — value 0, "I need this from somewhere." Note puts@GLIBC_2.2.5: the version suffix comes from .gnu.version/.gnu.version_r and pins which historical ABI of puts you need.

2. The fix-up list. readelf -r cat shows the relocations — each one says "write the address of symbol X into slot Y":

.rela.plt:
000000003fb0  R_X86_64_JUMP_SLOT  puts@GLIBC_2.2.5 + 0

"Put the runtime address of puts at address 0x3fb0" — a GOT slot. You'll also see R_X86_64_RELATIVE entries with no symbol at all: those just say "write base_address + addend here", and they exist purely because this is a PIE — even the binary's own internal pointers need adjusting once the random base is known.

3. The call site. From the disassembly of main:

1231:  call   1090 <puts@plt>

Your code never calls libc directly — it calls a local trampoline in .plt.sec:

0000000000001090 <puts@plt>:
    1090:  endbr64
    1094:  jmp    *0x2f16(%rip)        # 3fb0

One instruction: jump through whatever pointer is stored at 0x3fb0 — exactly the slot the relocation targets. ld-linux looks up puts in libc's .dynsym, writes the real address into 0x3fb0 at startup (because of BIND_NOW), and every call thereafter is a single indirect jump.

4. Proof from the raw bytes. Here's the GOT on disk (file offset 0x2f98 = vaddr 0x3f98):

00002fa8: ........ 3010 0000 0000 0000    ← slot 0x3fb0 (puts) = 0x1030
00002fb8: 4010 ... 5010 ...              ← write=0x1040, stack_chk=0x1050 ...

The slots don't contain libc addresses (impossible — file's not running yet); they contain 0x1030, 0x1040… — addresses of stubs back in .plt that would trigger lazy on-demand resolution. That's the fallback path; with BIND_NOW it's dead weight that gets overwritten at startup before main runs. Also note the first GOT entry, 0x3da8 — the address of .dynamic itself.

This GOT/PLT mechanism is the thing to internalize: it's how all dynamic linking works, it's what LD_PRELOAD hijacks (preloaded library wins the symbol lookup, its address lands in the GOT), and it's what RELRO protects.

7Entry point vs main: what happens before your code

The header said entry = 0x10e0, but main is at 0x11c9. Entry is _start (see objdump -d cat around 0x10e0): a tiny stub that sets up registers per the ABI and calls __libc_start_main(main, argc, argv, ...) — which initializes libc, runs the INIT_ARRAY constructors, calls your main, and turns its return value into exit(). So the real startup chain is:

kernel mmaps LOADs + INTERP ld-linux libc, relocs, RELRO _start @ 0x10e0 __libc_start_main init libc, ctors main @ 0x11c9 entry point (e_entry) is _start — your main is the fifth stop, not the first

kernel loads your LOADs + ld-linux (from INTERP) → ld-linux loads libc.so.6 (from NEEDED), applies all relocations, makes RELRO regions read-only → jumps to _start → __libc_start_main → main.

One bonus in your disassembly of main: mov %fs:0x28,%rax / mov %rax,-0x8(%rbp) — the stack canary being installed (that's why __stack_chk_fail is in your import list), because your 1024-byte buf makes GCC nervous. And open(argv[1], 0) — that literal 0 is O_RDONLY.

★Exercises to cement it

  1. Break the magic: printf '\x00' | dd of=/tmp/cat2 bs=1 seek=4 conv=notrunc after copying cat — byte 4 is the class byte; the kernel will refuse with "Exec format error". Then try corrupting byte 0x18 (entry point) — it'll load but crash. You learn which fields the kernel actually validates.
  2. Watch the linker work: LD_DEBUG=reloc,symbols ./cat cat.c 2>&1 | grep puts — see the exact symbol lookup and GOT write you traced above happen live.
  3. Hijack the GOT: write a puts in a .so, LD_PRELOAD it, and confirm your version runs — you now understand mechanically why that works.
  4. Read a section raw: readelf -x .rodata cat — find HAHAH! at vaddr 0x2004 and match it to the lea 0xdd3(%rip),%rdi # 2004 in main.
  5. Compare views: run readelf -h on /usr/lib/x86_64-linux-gnu/libc.so.6 and on a .o file (gcc -c cat.c) — same format, but the .o has type REL, no program headers at all, and unresolved relocations in .text. Seeing all three file types (REL → DYN-executable → DYN-library) completes the picture.