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.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.