Most Linux reversing starts with two tables: names that still exist, and bytes the loader will patch. This lab is one 20-line PIE and a complete readelf -s / readelf -r session on it, then the same session on a stripped copy. I am not summarizing ELF. I am dumping the files.

JUMP_SLOT relocation fills GOT then PLT jumps
Figure 1. R_AARCH64_JUMP_SLOT is the reloc that names puts. The GOT slot is the Offset column.

Lab binary

 1/* sym_lab.c — one local, one global, one libc import, one global object */
 2#include <stdio.h>
 3
 4int g_counter = 7;
 5
 6static int hidden_add(int a, int b)
 7{
 8    return a + b + g_counter;
 9}
10
11int main(void)
12{
13    int v = hidden_add(1, 2);
14    printf("v=%d\n", v);
15    return v;
16}
1cc -O0 -fPIE -pie -g -o sym_lab sym_lab.c
2cp -a sym_lab sym_lab.unstripped
3strip -o sym_lab.strip sym_lab
4file sym_lab.unstripped sym_lab.strip
5# both: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked
6# unstripped: not stripped     strip: stripped

readelf -h / -l / -S (where the tables live)

 1$ readelf -h sym_lab.unstripped | egrep 'Class|Type|Machine|Entry|Magic'
 2  Magic:   7f 45 4c 46 02 01 01 00 ...
 3  Class:                             ELF64
 4  Type:                              DYN (Shared object file)
 5  Machine:                           AArch64
 6  Entry point address:               0x6a0
 7
 8$ readelf -S sym_lab.unstripped | egrep 'symtab|dynsym|rela|plt|text|dynstr'
 9  [ 6] .dynsym           DYNSYM          00000000000003f0  000003f0
10  [ 7] .dynstr           STRTAB          00000000000004c8  000004c8
11  [10] .rela.dyn         RELA            00000000000005b0  000005b0
12  [11] .rela.plt         RELA            00000000000006c8  000006c8
13  [12] .plt              PROGBITS        00000000000006b0  000006b0
14  [14] .text             PROGBITS        00000000000007a0  000007a0
15  [28] .symtab           SYMTAB          0000000000000000  00001280
16  [29] .strtab           STRTAB          0000000000000000  00001490

.symtab has no runtime VA (offset only). Strip deletes .symtab / .strtab. .dynsym stays; the dynamic linker needs it.

1$ readelf -S sym_lab.strip | egrep 'symtab|dynsym'
2  [ 6] .dynsym           DYNSYM          00000000000003f0  000003f0
3# no .symtab line

readelf -s — full symbol dump, then the rows I keep

 1$ readelf -s sym_lab.unstripped
 2Symbol table '.dynsym' contains 8 entries:
 3   Num:    Value          Size Type    Bind   Vis      Ndx Name
 4     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
 5     1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.17 (2)
 6     2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.17 (2)
 7     3: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
 8     4: 00000000000007c8    72 FUNC    GLOBAL DEFAULT   14 main
 9     5: 0000000000001108     4 OBJECT  GLOBAL DEFAULT   23 g_counter
10     6: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterT...
11     7: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerT...
12
13Symbol table '.symtab' contains 44 entries:
14   Num:    Value          Size Type    Bind   Vis      Ndx Name
15    ...
16    18: 00000000000007a4    36 FUNC    LOCAL  DEFAULT   14 hidden_add
17    32: 00000000000007c8    72 FUNC    GLOBAL DEFAULT   14 main
18    33: 0000000000001108     4 OBJECT  GLOBAL DEFAULT   23 g_counter
19    40: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@@GLIBC_2.17

Rows that matter:

NameTableBindNdxMeaning
printf.dynsymGLOBALUNDimport; Value=0 until bind
mainbothGLOBAL14 (.text)exported from the PIE
g_counterbothGLOBAL23 (.data)object, size 4
hidden_add.symtab onlyLOCAL14strip kills this name
__gmon_start__.dynsymWEAK UNDUNDoptional; linker does not fail if missing
1$ readelf -s sym_lab.strip
2Symbol table '.dynsym' contains 8 entries:
3   ... same UND printf, GLOBAL main, GLOBAL g_counter ...
4# no .symtab, no hidden_add

nm is the short form of the same tables:

 1$ nm -C sym_lab.unstripped | egrep 'main|hidden|g_counter|printf'
 20000000000001108 D g_counter
 300000000000007a4 t hidden_add
 400000000000007c8 T main
 5                 U printf
 6
 7$ nm -D sym_lab.strip | egrep 'main|hidden|g_counter|printf'
 80000000000001108 D g_counter
 900000000000007c8 T main
10                 U printf
11# hidden_add gone from -D as well (it was never dynamic)

Local functions are the first thing strip steals. Imports and exported globals survive. That is why a stripped daemon still shows printf and not hidden_add.

readelf -r — full reloc dump

 1$ readelf -r sym_lab.unstripped
 2Relocation section '.rela.dyn' at offset 0x5b0 contains 6 entries:
 3  Offset          Info           Type                     Sym. Value    Sym. Name + Addend
 40000000000000fd8  000000000403 R_AARCH64_RELATIVE                        7c8
 50000000000000fe0  000000000403 R_AARCH64_RELATIVE                        7a0
 60000000000001108  000000000403 R_AARCH64_RELATIVE                        1108
 70000000000000fa8  000300000401 R_AARCH64_GLOB_DAT     0000000000000000 __gmon_start__ + 0
 80000000000000fb0  000200000401 R_AARCH64_GLOB_DAT     0000000000000000 __libc_start_main + 0
 90000000000000fb8  000100000401 R_AARCH64_GLOB_DAT     0000000000000000 printf + 0
10
11Relocation section '.rela.plt' at offset 0x6c8 contains 2 entries:
12  Offset          Info           Type                     Sym. Value    Sym. Name + Addend
130000000000000ff8  000100000402 R_AARCH64_JUMP_SLOT    0000000000000000 printf@GLIBC_2.17 + 0
140000000000001000  000200000402 R_AARCH64_JUMP_SLOT    0000000000000000 __libc_start_main@GLIBC_2.17 + 0

How I read one row, every time:

1Offset   = GOT / data slot the loader writes  (file VA; add bias at runtime)
2Type     = what kind of write
3Name     = which symbol (empty for RELATIVE)
4Addend   = for RELATIVE, the file VA being slid

Three types on this binary:

  1. R_AARCH64_RELATIVE*(bias+Offset) = bias + Addend. Used for internal pointers in a PIE (here Addend 0x7c8 is main). No name needed. Strip does not remove these.
  2. R_AARCH64_GLOB_DAT — fill a GOT cell with the symbol’s resolved address (function or object). Eager even under lazy bind, typically inside the RELRO window.
  3. R_AARCH64_JUMP_SLOT — the PLT GOT cell. Lazy unless BIND_NOW. This is the printf@plt slot. Same dance as the GOT/PLT lab.

x86_64 names are R_X86_64_RELATIVE, R_X86_64_GLOB_DAT, R_X86_64_JUMP_SLOT. Same columns.

readelf -r on the stripped copy is byte-identical for these sections. Relocs are not in .symtab.

1$ readelf -r sym_lab.strip | md5sum
2$ readelf -r sym_lab.unstripped | md5sum
3# same digest on this toolchain

readelf -d — who consumes those tables

 1$ readelf -d sym_lab.unstripped
 2Dynamic section at offset 0xe28 contains 24 entries:
 3  Tag        Type                         Name/Value
 4 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]
 5 0x000000000000000c (INIT)               0x6a8
 6 0x000000000000000d (FINI)               0x8b8
 7 0x0000000000000019 (INIT_ARRAY)         0xfd8
 8 0x000000000000001b (INIT_ARRAYSZ)       8 (bytes)
 9 0x0000000000000005 (STRTAB)             0x4c8
10 0x0000000000000006 (SYMTAB)             0x3f0     ; this is .dynsym, not .symtab
11 0x000000000000000a (STRSZ)              130 (bytes)
12 0x000000000000000b (SYMENT)             24 (bytes)
13 0x0000000000000015 (DEBUG)              0x0
14 0x0000000000000003 (PLTGOT)             0xfe8
15 0x0000000000000002 (PLTRELSZ)           48 (bytes)
16 0x0000000000000014 (PLTREL)             RELA
17 0x0000000000000017 (JMPREL)             0x6c8     ; .rela.plt
18 0x0000000000000007 (RELA)               0x5b0     ; .rela.dyn
19 0x0000000000000008 (RELASZ)             144 (bytes)
20 0x0000000000000009 (RELAENT)            24 (bytes)
21 0x000000006ffffffb (FLAGS_1)            Flags: PIE
22 0x000000006ffffffe (VERNEED)            0x590
23 0x000000006fffffff (VERNEEDNUM)         1
24 0x000000006ffffff0 (VERSYM)             0x560

SYMTAB in dynamic tags points at .dynsym. I have mixed that up with .symtab on stripped files and then wondered why readelf -s still printed printf.

No BIND_NOW here → lazy JUMP_SLOT. RELRO is Partial (see RELRO note).

objdump: printf is a reloc, not an immediate

 1$ objdump -d sym_lab.unstripped | sed -n '/<main>:/,/ret/p'
 200000000000007c8 <main>:
 3    7c8:  a9be7bfd   stp   x29, x30, [sp, #-32]!
 4    7cc:  910003fd   mov   x29, sp
 5    7d0:  52800020   mov   w0, #1
 6    7d4:  52800041   mov   w1, #2
 7    7d8:  97fffff3   bl    7a4 <hidden_add>     ; direct, same module
 8    7dc:  2a0003e1   mov   w1, w0
 9    7e0:  90000000   adrp  x0, 0
10    7e4:  91208000   add   x0, x0, #0x820       ; "v=%d\n"
11    7e8:  97ffffc2   bl    6d0 <printf@plt>     ; JUMP_SLOT, not libc
12    7ec:  52800120   mov   w0, #9               ; 1+2+7
13    7f0:  a8c27bfd   ldp   x29, x30, [sp], #32
14    7f4:  d65f03c0   ret

hidden_add is a bl with a relative immediate — no reloc. printf is a bl into .plt. After strip, objdump still labels printf@plt because .dynsym + .rela.plt survived. It labels hidden_add as <main-0x24> or a raw VA.

1$ objdump -d sym_lab.strip | sed -n '/<main>:/,+12p'
200000000000007c8 <main>:
3    7c8:  ...
4    7d8:  97fffff3   bl    7a4 <main-0x24>      ; was hidden_add
5    7e8:  97ffffc2   bl    6d0 <printf@plt>     ; name kept

That is the practical difference between the two symbol tables, in one diff.

gdb: UND becomes a libc VA

 1$ gdb -q ./sym_lab.unstripped
 2(gdb) set disable-randomization on
 3(gdb) break printf@plt
 4(gdb) run
 5Breakpoint 1, 0x0000aaaaaaab06d0 in printf@plt ()
 6(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8     ; JUMP_SLOT Offset 0xff8 + bias
 7$1 = 0x0000aaaaaaab06e0                       ; still PLT tail
 8(gdb) finish
 9v=10
10(gdb) p/x *(unsigned long *)0xaaaaaaab0ff8
11$2 = 0x0000fffff7e9c4a0                       ; libc printf  [slide REDACTED]
12(gdb) info symbol 0xfffff7e9c4a0
13printf in section .text of /lib/aarch64-linux-gnu/libc.so.6

g_counter is a RELATIVE / data object. gdb sees the slid address:

1(gdb) p &g_counter
2$3 = (int *) 0xaaaaaaab1108
3(gdb) p g_counter
4$4 = 7

File VA 0x1108 + bias 0xaaaaaaab0000 = $3. RELATIVE reloc did that, not .symtab.

Sanitized reproduction (crash only)

A planted strcpy into a 8-byte global, so the overflow is in .data next to g_counter. Crash / ASan, not a reloc rewrite.

1/* -DPLANT_BUG */
2void load_tag(const char *s)
3{
4    char tag[8];
5    strcpy(tag, s);
6    g_counter = tag[0];
7}
1$ cc -O0 -fPIE -pie -fsanitize=address -g -o sym_asan sym_lab.c
2$ ./sym_asan $(python3 -c 'print("A"*32)')
3==412==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
4WRITE of size 33 at ... thread T0
5    #0 strcpy
6    #1 load_tag sym_lab.c:6
7    #2 main
8  This frame has 1 object(s):
9    [32, 40) 'tag' (line 5) <== Memory access at offset 40

Without ASan, 32 As smash the frame and pc becomes 0x4141… the same way as the other labs. I do not smash a JUMP_SLOT here; the RELRO note already did the str into .got.plt.

What I file after this lab

  • .dynsym: UND printf, T main, D g_counter; no hidden_add
  • .symtab (unstripped only): t hidden_add at 0x7a4
  • .rela.plt: JUMP_SLOT printf at Offset 0xff8
  • .rela.dyn: RELATIVE addend 0x7c8 (main), GLOB_DAT for the UND names
  • Strip deletes .symtab, keeps relocs and .dynsym
  • Repro: strcpy 33 bytes into tag[8], ASan stack-buffer-overflow

Patch / detection

  • strip --strip-unneeded is fine for shipping; do not expect local names in incident work.
  • Audit imports from .dynsym UND / JUMP_SLOT, not from a wishful nm without -D.
  • PIE internals: treat every code pointer in .data as RELATIVE until readelf -r says otherwise. Screenshot VAs without bias are wrong.

Commands appendix

1readelf -h "$1" | egrep 'Class|Type|Machine|Entry'
2readelf -S "$1" | egrep 'dynsym|symtab|rela|plt'
3readelf -s "$1"
4readelf -r "$1"
5readelf -d "$1" | egrep 'NEEDED|SYMTAB|JMPREL|BIND_NOW|FLAGS_1'
6nm -D "$1"
7objdump -d "$1" | less +/<main>