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.
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.
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.
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...@.....
e0 10 → 0x10e0.Decode it by hand once and it's yours forever:
7f 45 4c 46 — the magic, \x7fELF. This is what file checks.02 01 01 — 64-bit, little-endian, ELF version 1. Little-endian matters for reading everything else: bytes are stored least-significant first.0300 = type ET_DYN. Surprise #1: your executable has the same type as a shared library. That's because it's a PIE (position-independent executable) — the kernel loads it at a random base address, exactly like a .so.3e00 = machine 0x3E, x86-64.e010 0000... = entry point 0x10e0 (read backwards: little-endian). Note this is not main — more on that in Step 7.40 = program headers start at offset 64 (immediately after this header). Offset 0x28: 4037 = 0x3740 = section headers start at offset 14144 — the very end of the file. That layout is typical: runtime table up front, link-time table at the back..shstrtab).Cross-check with readelf -h cat — it says exactly what you just decoded.
readelf -lW catThirteen 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 offset | Virt addr | Size | Perms | Contents |
|---|---|---|---|---|
| 0x0000 | 0x0000 | 0x728 | R | ELF header + dynamic-linking metadata |
| 0x1000 | 0x1000 | 0x2b5 | R-X | all code (.text, PLT, .init) |
| 0x2000 | 0x2000 | 0xec | R | read-only data ("HAHAH!" lives here) |
| 0x2d98 | 0x3d98 | 0x278 file / 0x280 mem | RW | writable data, GOT, .bss |
Three insights hiding in this table:
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.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..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.
readelf -SW cat31 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.
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.
.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,.rodataand 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.
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:
A (SHF_ALLOC) flag in readelf -SW cat — A means "allocate memory for me". Sections 1–26 in your binary have it; the last four (.comment, .symtab, .strtab, .shstrtab) don't — and their Address column reads 0: no virtual address exists for them.readelf -lW cat — any section that appears under no LOAD segment is not part of the running process..comment, .symtab, .strtab, .shstrtab, and the entire section header table — is ~3.8 KB, 24% of your file that never becomes memory."With me at execution time" actually has three answers, not two — the middle one is the subtlety most articles skip:
| When it's consumed | Parts of your binary | In memory while running? |
|---|---|---|
| Link time only static linker ld, debuggers | .symtab .strtab .shstrtab .comment .debug_* + section headers | No — no A flag, Address 0, beyond the last LOAD |
Load timeld-linux, milliseconds before main | .interp .dynamic .dynsym .dynstr .gnu.hash .gnu.version* .rela.dyn .rela.plt | Yes, 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 .fini | Yes — the R-X / R / RW LOADs; this is your living program |
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.
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_...
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.
.dynamic: the dynamic linker's instruction sheet readelf -d catThis is a key-value array ld-linux walks at startup. The entries in yours tell a complete story:
NEEDED: libc.so.6 — load this library first. (One entry per dependency; ldd cat is essentially this plus recursion.)SYMTAB/STRTAB/GNU_HASH — where my imports and their names are, plus a hash table so lookups aren't linear scans.RELA/JMPREL — where the relocations are (Step 6).FLAGS: BIND_NOW, FLAGS_1: NOW PIE — resolve all symbols at startup rather than lazily. Your compiler enabled full RELRO by default: eager binding means the GOT can then be made read-only. Worth knowing that both modes exist — older/differently-configured binaries bind lazily.INIT, INIT_ARRAY — constructors to run before main (this is how __attribute__((constructor)) and C++ static initializers work).puts("HAHAH!") finds libcputs, 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.
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.
LD_PRELOAD hijacks (preloaded library wins the symbol lookup, its address lands in the GOT), and it's what RELRO protects.
main: what happens before your codeThe 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 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.
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.LD_DEBUG=reloc,symbols ./cat cat.c 2>&1 | grep puts — see the exact symbol lookup and GOT write you traced above happen live.puts in a .so, LD_PRELOAD it, and confirm your version runs — you now understand mechanically why that works.readelf -x .rodata cat — find HAHAH! at vaddr 0x2004 and match it to the lea 0xdd3(%rip),%rdi # 2004 in main.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.