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.
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
memcpyinto live objects. If a C API must fill a buffer, that buffer is not anAnimal. - Build:
-D_GLIBCXX_ASSERTIONS, ASan/UBSan in CI. Optional-fno-rttiremoves typeinfo words; the function slots stay. - Binary triage:
objdump -son.data.rel.ro/.rodatalooking for 8-byte-aligned pointer runs into.text. Cross-check ctoradrp+add+strof those addresses into[x0]. - Runtime: gdb
x/4gx *(void**)objat 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, vptr0x2028, slots~ / speak / id - Dog vtable file VA
0x2050, vptr0x2060,idat[vptr,#16] = 0x08d8 - Ctor store:
str x0, [x19]withx0 = vtable+0x10(Itanium skip of top/typeinfo) - Repro: 8
As over vptr, SIGSEGV atldr 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