Stripped C++ still leaves a table of function pointers per class. The reversing job is to find that table, name the slots, and prove a call site loads [vptr+16]. This lab is a two-class hierarchy I compile myself: Animal / Dog, virtual destructor plus speak() and id(). No production binary, no RTTI-stripping rabbit hole beyond what g++ -O0 actually emits.

AArch64 frame at a virtual call
Figure 1. Frame I expect at the virtual call: x29/x30 pair, this in x0, vptr load, then blr.

Lab binary

 1/* vtable_lab.cpp — toy, no heap games in the happy path */
 2#include <cstdio>
 3#include <cstdint>
 4
 5struct Animal {
 6    int tag;
 7    Animal() : tag(1) {}
 8    virtual ~Animal() { std::puts("~Animal"); }
 9    virtual void speak() { std::puts("animal"); }
10    virtual int  id()    { return 0xA1; }
11};
12
13struct Dog : Animal {
14    int bark;
15    Dog() : bark(2) {}
16    ~Dog() override { std::puts("~Dog"); }
17    void speak() override { std::puts("woof"); }
18    int  id() override    { return 0xD0; }
19};
20
21int main(void) {
22    Dog d;
23    Animal *p = &d;
24    p->speak();
25    return p->id();
26}
1# aarch64 box used for the dumps below; x86_64 twin at the end of the note
2g++ -O0 -fPIE -pie -g -o vtable_lab vtable_lab.cpp
3file vtable_lab
4# vtable_lab: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped

Itanium-style vtable (what libstdc++ emits here): the vptr stored in the object points at slot 0, which is the first virtual function. Two 8-byte words before that slot are offset-to-top and the RTTI pointer. I dump the whole 5-word window so the ctor math is visible.

File-level vtable dump

nm still has the names because this is a lab build. On a stripped sample the same bytes live in .rodata / .data.rel.ro; only the labels vanish.

 1$ nm -C vtable_lab | egrep 'vtable for|typeinfo'
 20000000000002018 D vtable for Animal
 30000000000002050 D vtable for Dog
 40000000000002078 D typeinfo for Animal
 50000000000002090 D typeinfo for Dog
 6
 7$ readelf -S vtable_lab | egrep 'rodata|data.rel.ro|\.data '
 8  [16] .rodata           PROGBITS  00000000000008c0  000008c0
 9  [21] .data.rel.ro      PROGBITS  0000000000001fd8  00000fd8
10  [23] .data             PROGBITS  0000000000002108  00001108

On this toolchain the vtables landed in .data (writable until RELRO). Full RELRO later remaps .data.rel.ro; I still dump them as the compiler laid them out.

 1$ objdump -s -j .data vtable_lab
 2Contents of section .data:
 3 2108 00000000 00000000 00000000 00000000  ................
 4 ...
 5# vtable for Animal @ 0x2018, shown with readelf so endianness is explicit:
 6
 7$ readelf -x .data vtable_lab | sed -n '/2010/,+12p'
 8  0x00002010 00000000 00000000 00000000 00000000 ................
 9  0x00002020 88200000 00000000 b0070000 00000000 . ..............
10  0x00002030 e0070000 00000000 14080000 00000000 ................
11
12$ nm vtable_lab | egrep '2018|07b0|07e0|0814'
13# closer: I dump with gdb-friendly x/ after load. File VAs:
14
15$ objdump -d vtable_lab | egrep '<_ZN6AnimalD|_ZN6Animal5speak|_ZN6Animal2id'
1600000000000007b0 <_ZN6AnimalD2Ev>:     ; Animal::~Animal()
1700000000000007e0 <_ZN6Animal5speakEv>:
180000000000000814 <_ZN6Animal2idEv>:

Reconstructed Animal vtable (little-endian aarch64, 8-byte slots):

1VA 0x2018  offset-to-top     = 0
2VA 0x2020  typeinfo          = 0x2088   ; "typeinfo for Animal"
3VA 0x2028  slot0  ~Animal    = 0x07b0   ; vptr points HERE at runtime
4VA 0x2030  slot1  speak      = 0x07e0
5VA 0x2038  slot2  id         = 0x0814   ; [vptr,#16]

Dog’s table is the same shape, different IMPs:

 1$ nm -C vtable_lab | grep 'Dog::'
 20000000000000850 T Dog::~Dog()
 300000000000008a4 T Dog::speak()
 400000000000008d8 T Dog::id()
 5
 6# Dog vtable @ 0x2050
 7#  0x2050  offset-to-top = 0
 8#  0x2058  typeinfo for Dog
 9#  0x2060  slot0  ~Dog     = 0x0850     ; vptr
10#  0x2068  slot1  speak    = 0x08a4
11#  0x2070  slot2  id       = 0x08d8     ; [vptr,#16]

That is the artifact. Everything after this is proving the ctor writes that pointer and main calls through slot 2.

Ctor writes the vptr

Dog::Dog runs Animal::Animal first, then overwrites the vptr with Dog’s table. I want the store, not the C++ story.

 1$ objdump -d vtable_lab | sed -n '/<_ZN3DogC2Ev>/,/ret/p'
 2000000000000090c <_ZN3DogC2Ev>:
 3    90c:  a9be7bfd   stp   x29, x30, [sp, #-0x20]!
 4    910:  910003fd   mov   x29, sp
 5    914:  f9000bf3   str   x19, [sp, #0x10]
 6    918:  aa0003f3   mov   x19, x0              ; this
 7    91c:  97ffffxx   bl    7xx <_ZN6AnimalC2Ev> ; parent ctor
 8    920:  90000080   adrp  x0, 2000
 9    924:  91018000   add   x0, x0, #0x60        ; 0x2060 = Dog slot0
10    928:  f9000260   str   x0, [x19]            ; this->vptr = &Dog::vtable[0]
11    92c:  52800042   mov   w2, #0x2
12    930:  b9000e62   str   w2, [x19, #0xc]      ; this->bark = 2
13    934:  f9400bf3   ldr   x19, [sp, #0x10]
14    938:  a8c27bfd   ldp   x29, x30, [sp], #32
15    93c:  d65f03c0   ret

Animal::Animal did the same store with 0x2028. After the derived ctor returns, the object’s first qword is 0x2060 (plus load bias). tag sits at this+8, bark at this+0xc — layout I confirm with pahole or just the stores.

Parent ctor, trimmed:

 10000000000000780 <_ZN6AnimalC2Ev>:
 2    780:  a9bf7bfd   stp   x29, x30, [sp, #-16]!
 3    784:  910003fd   mov   x29, sp
 4    788:  90000081   adrp  x1, 2000
 5    78c:  9100a021   add   x1, x1, #0x28        ; 0x2028 = Animal slot0
 6    790:  f9000001   str   x1, [x0]             ; this->vptr
 7    794:  52800021   mov   w1, #0x1
 8    798:  b9000801   str   w1, [x0, #8]         ; this->tag
 9    79c:  a8c17bfd   ldp   x29, x30, [sp], #16
10    7a0:  d65f03c0   ret

Two stores, two tables. If a write-up shows a single vptr write in a derived ctor, they compiled with a different ABI or inlined the parent.

Call through [x0,#16]

main calls id() virtually. After the vptr load, slot 2 is sixteen bytes in.

 10000000000000940 <main>:
 2    940:  a9be7bfd   stp   x29, x30, [sp, #-0x20]!
 3    944:  910003fd   mov   x29, sp
 4    948:  910043e0   add   x0, sp, #0x10        ; &d  (Dog on stack)
 5    94c:  97ffffb0   bl    90c <_ZN3DogC1Ev>
 6    950:  910043e0   add   x0, sp, #0x10        ; this
 7    954:  f9400008   ldr   x8, [x0]             ; vptr
 8    958:  f9400508   ldr   x8, [x8, #8]         ; slot1 speak
 9    95c:  d63f0100   blr   x8
10    960:  910043e0   add   x0, sp, #0x10
11    964:  f9400008   ldr   x8, [x0]             ; vptr again
12    968:  f9400908   ldr   x8, [x8, #16]        ; slot2 id   ← the hunt
13    96c:  d63f0100   blr   x8
14    970:  2a0003e1   mov   w1, w0               ; return value
15    ...

speak is [vptr,#8], id is [vptr,#16]. Destructor calls from ~Dog / complete-object dtor use [vptr,#0]. When I only have a stripped blob I still name slots by offset: +0 dtor-ish, +8, +16. Then I read the IMP at each slot and rename.

x86_64 of the same id() call, for the other side of the notebook:

1# cc -O0  x86_64
2  4011c2:  mov    rax, QWORD PTR [rbp-0x10]   ; this
3  4011c6:  mov    rax, QWORD PTR [rax]        ; vptr
4  4011c9:  mov    rax, QWORD PTR [rax+0x10]   ; slot2
5  4011cd:  mov    rdi, QWORD PTR [rbp-0x10]
6  4011d1:  call   rax

Same offset. Different register (rdi = this), same table.

gdb: print the vptr, dump four slots

set disable-randomization on so the numbers in this note repeat. Production traces get the slide [REDACTED].

 1$ gdb -q ./vtable_lab
 2(gdb) set disable-randomization on
 3(gdb) break main
 4(gdb) run
 5Breakpoint 1, main () at vtable_lab.cpp:24
 6
 7(gdb) break *main+0x28          ; ldr x8, [x0] before speak
 8(gdb) continue
 9(gdb) p/x $x0
10$1 = 0x0000ffffffffe2c0         ; &d on stack  [REDACTED high bits in real ASLR]
11(gdb) x/4gx $x0
120xffffffffe2c0:  0x0000aaaaaaab2060    0x0000000200000001
130xffffffffe2d0:  0x0000000000000000    0x0000000000000000
14# qword0 = vptr → Dog slot0
15# qword1 low  = tag=1, bark=2  (two ints)
16
17(gdb) x/4gx 0xaaaaaaab2060
180xaaaaaaab2060:  0x0000aaaaaaab0850    0x0000aaaaaaab08a4
190xaaaaaaab2070:  0x0000aaaaaaab08d8    0x0000000000000000
20# [0] ~Dog   [8] speak   [16] id
21
22(gdb) info symbol 0xaaaaaaab08d8
23Dog::id() in section .text of /home/[REDACTED]/vtable_lab
24
25(gdb) x/2i $pc
26=> 0xaaaaaaab0964:  ldr  x8, [x0]
27   0xaaaaaaab0968:  ldr  x8, [x8, #16]
28(gdb) stepi
29(gdb) stepi
30(gdb) p/x $x8
31$2 = 0x0000aaaaaaab08d8         ; Dog::id
32(gdb) finish
33(gdb) p $x0
34$3 = 208                        ; 0xD0, Dog::id

That is the proof: object vptr, four GOT-sized slots, call through #16 lands in Dog::id, return 0xD0. Animal’s table at 0xaaaaaaab2028 is still sitting in the image; nothing in main uses it after the derived ctor.

Peek at the two header words behind the vptr, because people confuse “vtable symbol” with “vptr value”:

1(gdb) x/6gx 0xaaaaaaab2050
20xaaaaaaab2050:  0x0000000000000000    0x0000aaaaaaab2090  ; top, typeinfo
30xaaaaaaab2060:  0x0000aaaaaaab0850    0x0000aaaaaaab08a4  ; ← vptr
40xaaaaaaab2070:  0x0000aaaaaaab08d8    0x0000000000000000
5(gdb) printf "%s\n", (char*)0xaaaaaaab2090
6# typeinfo name is mangled; abi::__cxa_demangle in a nicer session.
7# readelf -p .rodata | grep Dog  → "3Dog" / "6Animal"

Sanitized reproduction (crash only)

I plant a 8-byte overflow into the stack Dog so the vptr becomes 0x4141414141414141. Next virtual call dies. This is a crash, not a vtable-hijack recipe.

1/* extra lab path, compiled in with -DPLANT_BUG */
2void smash(Animal *p) {
3    char *raw = reinterpret_cast<char *>(p);
4    for (int i = 0; i < 8; i++)
5        raw[i] = 'A';           /* clobber vptr only */
6    p->id();                    /* load [vptr,#16] of 0x4141… */
7}
 1$ g++ -O0 -fPIE -pie -DPLANT_BUG -g -o vtable_crash vtable_lab.cpp
 2$ gdb -q ./vtable_crash
 3(gdb) run
 4Program received signal SIGSEGV, Segmentation fault.
 50x0000aaaaaaab0968 in main ()
 6(gdb) x/i $pc
 7=> 0xaaaaaaab0968:  ldr  x8, [x8, #16]
 8(gdb) p/x $x8
 9$1 = 0x4141414141414141
10(gdb) p/x $x0
11$2 = 0xffffffffe2c0
12(gdb) x/gx $x0
130xffffffffe2c0:  0x4141414141414141

ASan on the same smash, when I instead overflow a neighbor buffer into d:

 1$ g++ -O0 -fsanitize=address -g -o vtable_asan vtable_lab.cpp
 2$ ./vtable_asan
 3=================================================================
 4==4120==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
 5WRITE of size 1 at 0x... thread T0
 6    #0 smash vtable_lab.cpp:31
 7    #1 main vtable_lab.cpp:27
 8Address 0x... is located in stack of thread T0 at offset 32 in frame
 9    main
10  This frame has 1 object(s):
11    [32, 48) 'd' (line 24) <== Memory access at offset 32
12HINT: this is a stack-buffer-overflow, not a typed C++ bug.

I am not publishing a fake vtable, a system slot, or a heap spray. The lab stops at: vptr clobber → SIGSEGV on [x8,#16], and ASan names the stack object.

Patch / detection

  • Source: keep virtual calls; do not memcpy into live objects. If a C API must fill a buffer, that buffer is not an Animal.
  • Build: -D_GLIBCXX_ASSERTIONS, ASan/UBSan in CI. Optional -fno-rtti removes typeinfo words; the function slots stay.
  • Binary triage: objdump -s on .data.rel.ro / .rodata looking for 8-byte-aligned pointer runs into .text. Cross-check ctor adrp+add+str of those addresses into [x0].
  • Runtime: gdb x/4gx *(void**)obj at any virtual call. If slot 2 is not in an r-x mapping, stop and dump maps.

RELRO note: Full RELRO makes the table bytes read-only after load. That does not stop a vptr smash inside the object; it only stops rewriting the table itself. I still record RELRO in the same pass as the vtable dump — see the GOT lab for the BIND_NOW check.

What I file after this lab

  • Animal vtable file VA 0x2018, vptr 0x2028, slots ~ / speak / id
  • Dog vtable file VA 0x2050, vptr 0x2060, id at [vptr,#16] = 0x08d8
  • Ctor store: str x0, [x19] with x0 = vtable+0x10 (Itanium skip of top/typeinfo)
  • Repro: 8 As over vptr, SIGSEGV at ldr x8, [x8, #16]
  • Fix: do not write through a char* into a live polymorphic object

Commands appendix

1g++ -O0 -fPIE -pie -g -o vtable_lab vtable_lab.cpp
2nm -C vtable_lab | egrep 'vtable for|::'
3readelf -x .data vtable_lab
4objdump -d vtable_lab | less +/_ZN3DogC2Ev
5gdb -q ./vtable_lab -ex 'set disable-randomization on' -ex 'b main' -ex 'r'
6# inside gdb:  x/4gx $x0   ;   x/4gx *(void**)$x0