Skip to content

Case 172: Vtable Thunk Offset Changed (detected from a stripped binary)

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux
Flags ABI break
Detected ChangeKinds vtable_thunk_offset_changed
Source files examples/case172_vtable_thunk_offset_changed/

Category: C++ Layout | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

Derived multiply-inherits from Base1 (primary) and Base2 (secondary), overriding Base2::f2() behind a non-virtual this-adjusting thunk (_ZThn16_...f2Ev). v2 adds one field to Base1; Derived's method set and vtable slot count are completely unchanged, but Base1 grows from 16 to 24 bytes, so Base2's subobject moves from offset 16 to offset 24 within Derived. The thunk's baked-in adjustment must change to match (_ZThn16_ โ†’ _ZThn24_). Any consumer that performs static_cast<Base2*>(derived_ptr) (or an implicit upcast) computed that pointer adjustment as a compile-time constant baked into the caller's machine code โ€” a binary compiled against v1 still subtracts 16, but v2's real Base2 subobject starts at 24. Recompilation is mandatory; the mismatch is a deterministic crash, not a symbol-resolution error.

Old/new diff

v1.h v2.h
struct Base1 { int x; virtual int f1(); virtual ~Base1(); }; (16 bytes) struct Base1 { int x; double y; virtual int f1(); virtual ~Base1(); }; (24 bytes)
Base2 subobject in Derived @ offset 16 Base2 subobject in Derived @ offset 24
thunk: _ZThn16_...f2Ev thunk: _ZThn24_...f2Ev

abicheck command

g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -shared -fPIC -g v2.cpp -o libv2.so
strip --strip-debug libv1.so libv2.so
abicheck compare libv1.so libv2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- vtable_thunk_offset_changed: Vtable thunk offset changed for
  Derived::f2(): h:n16 -> h:n24
  > A base subobject moved; old binaries mis-adjust `this` on virtual
    dispatch, corrupting memory with no symbol error.
- vtable_thunk_offset_changed: Vtable thunk offset changed for
  Derived::~Derived(): h:n16 -> h:n24

Minimum evidence

min_evidence: L0 โ€” read directly from the _ZTh.../_ZTv.../_ZTc... thunk symbol names in .dynsym; no DWARF, no headers. The command above deliberately strips debug info (strip --strip-debug) to demonstrate that the finding survives even on a fully stripped binary โ€” case142 _vtable_slot_count_binary_only shows the slot-count signal (_ZTV growing), but here the primary vtable's size doesn't change at all; the thunk offset encoded in the symbol name is the only place the shift shows up.

Why abicheck catches it

Itanium mangles a non-virtual this-adjusting thunk's baked-in offset directly into its symbol name (_ZThn16_... vs. _ZThn24_...). abicheck parses that offset from both sides' .dynsym for each thunk whose target method persists across versions, and reports a change as vtable_thunk_offset_changed โ€” no DWARF or header evidence needed, since the fact lives entirely in the exported symbol table.

Runtime failure demonstration

Severity: CRITICAL โ€” segfault on a non-virtual upcast

Scenario: compile app against v1 (bakes in the offset-16 adjustment), swap in v2 .so without recompile.

# Build old library + app
g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -g app.cpp -I. -L. -lv1 -Wl,-rpath,. -o app
./app
# โ†’ f2() via Base2* = 20 (expected 20)

# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o libv2.so
cp libv2.so libv1.so
./app
# โ†’ Segmentation fault

Why CRITICAL: app.cpp performs static_cast<Base2*>(derived_ptr) โ€” the compiler resolves this at compile time using v1's offset (16). Against v2's binary, the real Base2 subobject is 8 bytes further along, so the computed pointer lands 8 bytes into Base1's own data instead. Dereferencing it as a Base2* reads those bytes as a vtable pointer and jumps through garbage โ€” a deterministic crash with no change to any function's name or signature.

Safe redesign

  • Avoid inserting or resizing data members ahead of a secondary polymorphic base in a published class โ€” it silently moves every subobject after it.
  • Prefer composition over multiple inheritance for classes whose layout is part of a stable ABI, or order bases so the ones most likely to grow are declared last (as secondary bases) โ€” the first non-virtual polymorphic base always becomes primary and stays at offset 0, so only bases declared after a growing one have their offset (and thunk adjustments) disturbed.

References

  • Itanium C++ ABI: non-virtual thunks
  • Related cases: case142_vtable_slot_count_binary_only, case173_vtt_slot_count_changed, case174_secondary_vtable_group_changed, case60_base_class_position_changed

Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.