Symbol Tables: What Function Names Actually Are
In part 1 we read headers and sections. In part 2 we made a binary run. Both times we skipped over one question: what are function names in a binary, and where do they live?
The answer is a table. Two tables. And understanding the difference between them is the first thing that separates someone who can use nm from someone who can write their own symbol resolver.
This is Part 3: .symtab and .dynsym.
What a symbol actually is
When you write main in C, the compiler doesn’t know or care that it’s called main. It generates machine code at some offset and records a name in a table. That name, its address, its size, its type, all packed into a fixed-size struct. That struct is a symbol.
The binary contains two separate symbol tables, each serving a different purpose:
.symtab: the full symbol table. Every function, every global variable, every label. Used by debuggers,nmand tools like our elf-explorer. Stripped from release binaries..dynsym: the dynamic symbol table. Only symbols that can be referenced across shared library boundaries. Never stripped. The dynamic linker (ld.so) needs this at runtime.
If you run strip on a binary, .symtab disappears. .dynsym stays. That’s why nm on a stripped binary returns nothing useful but readelf --dyn-syms still works.
The struct
Every entry in both tables is an Elf64_Sym:
typedef struct { uint32_t st_name; // offset into a string table unsigned char st_info; // type + binding unsigned char st_other; // visibility uint16_t st_shndx; // which section (or SHN_UNDEF, SHN_ABS) uint64_t st_value; // address or offset uint64_t st_size; // size in bytes (0 if unknown)} Elf64_Sym;24 bytes. Every symbol, same layout. Same overlay trick as before, cast a pointer and iterate.
The fields that matter:
st_name: byte offset into the associated string table (.strtabfor.symtab,.dynstrfor.dynsym). Same pattern assh_namein Part 1.st_value: the virtual address of the symbol. For functions in a static binary, this is the actual address you’dcall. For shared libraries, it’s an offset from the load base.st_size: how many bytes the symbol spans. Useful for knowing where a function ends.st_info: two packed fields: the high 4 bits are binding (local, global, weak), the low 4 bits are type (function, object, etc)
Binding and type: the 4-bit encoding
st_info packs 2 values into one byte. To extract them
int bind = ELF64_ST_BIND(sym->st_info); // high 4 bitsint type = ELF64_ST_TYPE(sym->st_info); // low 4 bitsBindings:
| Value | Name | Meaning |
|---|---|---|
| 0 | STB_LOCAL | Not visible outside this object file |
| 1 | STB_GLOBAL | Visible to all linked objects |
| 2 | STB_WEAK | Like global, but can be overridden |
Types:
| Value | Name | Meaning |
|---|---|---|
| 0 | STT_NOTYPE | No type info |
| 1 | STT_OBJECT | Variable (data) |
| 2 | STT_FUNC | Function (code) |
| 3 | STT_SECTION | Section symbol |
| 4 | STT_FILE | Source file name |
Most of what you care about is STB_GLOBAL + STT_FUNC. That’s a function you can call. A STB_WEAK function is one the linker might replace with a different implementation, malloc in a libc is often weak so a custom allocator can override it.
String tables: the same chicken-and-egg
Just like section names in Part 1, symbol names are stored in a separate string table:
.symtabreferences.strtab.dynsymreferences.dynstrst_nameis a byte offset into the right table. Finding the string is the same three-lookup dance:
const char *get_symbol_name(const elf_file_t *elf, Elf64_Sym *sym_table, uint32_t strtab_idx, uint32_t st_name) { Elf64_Shdr *shdr_table = (Elf64_Shdr *)(elf->map + elf->ehdr->e_shoff); Elf64_Shdr *strtab = &shdr_table[strtab_idx]; const char *strtab_buf = (const char *)(elf->map + strtab->sh_offset);
if (st_name >= strtab->sh_size) return "<invalid>";
return strtab_buf + st_name;}The only difference from Part 1’s get_section_name is which string table we’re indexing into. The pattern is always the same: integer offset -> pointer into a blob -> null-terminated string.
How to find the string table for each symbol table
Here’s the part that confuses everyone. .symtab’s string table isn’t the next section over, it’s wherever sh_link points.
Each Elf64_Shdr has a sh_link field. For symbol table sections (SHT_SYMTAB and SHT_DYNSYM), sh_link is the index of the associated string table section. So:
// Find .symtabElf64_Shdr *symtab_hdr = find_section_by_type(elf, SHT_SYMTAB);
// sh_link tells us which section is .strtabconst char *strtab = (const char *)(elf->map + shdr_table[symtab_hdr->sh_link].sh_offset);
// Now iterate symbolsElf64_Sym *syms = (Elf64_Sym *)(elf->map + symtab_hdr->sh_offset);int count = symtab_hdr->sh_size / sizeof(Elf64_Sym);
for (int i = 0; i < count; i++) { printf("[%3d] %s\n", i, strtab + syms[i].st_name);}That’s the whole symbol resolver. Find the section by type, follow sh_link to the string table, cast and iterate.
readelf --symbols vs nm
Two tools, same data, different presentation. System binaries on Arch (and modern distros) are almost always stripped, so use a binary you compiled yourself, the elf-loader from Part 2:
$ readelf --symbols ./elf-loader | grep FUNC
20: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __libc_start_mai[...] 33: 00000000000013c8 2627 FUNC GLOBAL DEFAULT 12 load_elf 43: 0000000000001e0b 1283 FUNC GLOBAL DEFAULT 12 build_stack 46: 0000000000001160 38 FUNC GLOBAL DEFAULT 12 _start 49: 0000000000001259 367 FUNC GLOBAL DEFAULT 12 main$ nm ./elf-loader | head -10
0000000000001e0b T build_stack U close@GLIBC_2.2.50000000000002310 T _fini0000000000001000 T _init00000000000013c8 T load_elf0000000000001259 T main...readelf shows every field. nm shows just the address, type letter and name. The type letters map to the bindings and type above: T = text (code) + global, U = undefined (imported from another library), W = weak.
nm is faster to scan. readelf is what you want when you’re debugging or writing a parser.
SHN_UNDEF and SHN_ABS
Two special section index values show up in symbol tables:
SHN_UNDEF(0): the symbol isn’t defined in this binary. It’s imported. When you callprintf, your binary has a symbol entry for it withst_shndx = SHN_UNDEFandst_value = 0. The dynamic linker fills in the real address at load time.SHN_ABS(0xfff1): the symbol’s value is absolute, not relative to any section. Used for things like the entry point address or special linker symbols.
When you see U __libc_start_main in nm output, that’s SHN_UNDEF. The binary references the symbol but doesn’t provide it. The loader has to resolve it.
The full picture
Here’s where everything connects:
.symtab ──references──▶ .strtab (symbol names).dynsym ──references──▶ .dynstr (dynamic symbol names)
Each Elf64_Sym has: st_name ──offset──▶ string table ──▶ "main", "printf", ... st_value ──address──▶ where the code/data lives st_info ──encoding──▶ binding + type (global/function, etc.)Two tables, two string tables, one struct. The same pointer-arithmetic trick we’ve been using since Part 1.
Try it
Run nm and readelf --symbols on a binary you compiled yourself, not system binaries, those are stripped:
nm ./elf-loader | head -20readelf --symbols ./elf-loader | grep FUNC | head -10readelf --dyn-syms ./elf-loader | grep FUNC | head -10Notice the differences: --symbols shows everything (including symbols from the static libc that got linked in), while --dyn-syms shows only what’s needed for dynamic linking.
Now try it on a stripped binary, anything Arch ships in /usr/bin is stripped already:
nm /usr/bin/true # no symbolscp /usr/bin/true /tmp/true-strippedstrip /tmp/true-strippednm /tmp/true-stripped # nothing usefulreadelf --dyn-syms /tmp/true-stripped # still worksThat’s why .dynsym exists. The dynamic linker can’t be stripped out, the binary needs it to run.
Where this is going
- Part 4, relocations: How PIE binaries fix up addresses at load time, and what
SHN_UNDEFsymbols became after the dynamic linker runs. - Part 5, a mini-readelf: glue headers, sections, and symbols into one tool
Each one builds on the last. By part 5 you will have a complete picture of what’s inside an ELF binary, not because a tool told you, but because you built the tool.
Next reads
View all →3 Sept
Building a Custom ELF Loader from Scratch, in C
You type ./program and the kernel loads it. Here's how that works: we build a userspace ELF loader in ~300 lines of C that maps PT_LOAD segments, builds a stack by hand, and jumps to the entry point.
9 Sept
Mini-Readelf: Gluing It All Together
The capstone. Four parts of pieces, headers, sections, symbols, relocations, joined into one tool that reads any ELF. The only new mechanic is the sh_link chain: offset into a table that holds offsets into a table that holds strings.
5 Sept
Relocations: How PIE Binaries Fix Their Addresses
A PIE binary can't write final addresses because ASLR moves it. The linker leaves placeholders and the loader patches them after mapping. That's a relocation: R_X86_64_RELATIVE, GLOB_DAT and JUMP_SLOT.
Get posts by email
One email when I publish, not a drip, not weekly. Sign up and I'll only write when there's something new.
You won't get mail just for signing up. Unsubscribe any time.