Skip to content

Case 74: Internal detail:: base class layout change leaks via public API

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS, Windows
Flags ABI break, API break
Detected ChangeKinds internal_type_leaks_via_public_api
Source files examples/case74_detail_base_class_changed/

Category: Internal-leak | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

mylib::detail::descriptor_base is an "internal" base class (matching the detail::-namespace convention used by oneDAL, oneTBB, and many Boost libraries) that the public mylib::knn_descriptor inherits from. v2 adds an int max_iter_ field to descriptor_base. From the library author's perspective this looks like a private change โ€” only a detail:: type was touched โ€” but it grows sizeof(knn_descriptor) and shifts neighbor_count_'s offset, because the base subobject it embeds by value grew. Any consumer allocating or embedding knn_descriptor (stack or heap) is broken without recompilation.

Old/new diff

v1.h v2.h
class descriptor_base { int class_count_; }; class descriptor_base { int class_count_; int max_iter_; }; (new field)
class knn_descriptor : public detail::descriptor_base { int neighbor_count_; }; (unchanged โ€” but neighbor_count_'s offset moves)

abicheck command

g++ -shared -fPIC -g v1.cpp -o libfoo_v1.so
g++ -shared -fPIC -g v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so \
  --header old=v1.h --header new=v2.h --ast-frontend clang

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: descriptor_base (32 -> 64 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.
- type_field_added: Field added: descriptor_base::max_iter_
  > New field shifts subsequent fields; old code reads wrong offsets for
    all fields after insertion point.
- type_size_changed: Size changed: knn_descriptor (64 -> 96 bits)
  Affected symbols: mylib_free_descriptor, mylib_make_descriptor
- type_field_offset_changed: Field offset changed:
  knn_descriptor::neighbor_count_ (32 -> 64 bits)
  Affected symbols: mylib_free_descriptor, mylib_make_descriptor
- struct_size_changed: Struct size changed: mylib::knn_descriptor (8 -> 12 bytes)
  > sizeof(T) changed in debug info; confirms layout break visible at
    binary level.
- struct_field_offset_changed: Field offset changed:
  mylib::knn_descriptor::neighbor_count_ (+4 -> +8)

Additions:
- func_added: New public function: get_max_iter

The public-facing symptom is the knn_descriptor findings (type_size_changed, type_field_offset_changed, struct_size_changed/struct_field_offset_changed) โ€” on this reproduction they carry the "Affected symbols" annotation pointing at the two factory/destroy functions, which is what makes the break visible to someone only skimming the public class, even though the header-diff root cause is the detail::descriptor_base change above it. abicheck also ships a dedicated internal_type_leaks_via_public_api synthetic finding that names this exact "internal base, public leak" pattern explicitly; it did not fire on this particular header-only (no build-integrated L3/L4/L5 evidence) reproduction โ€” treat the layout findings above as the reliable signal for this case rather than relying on that overlay alone.

Minimum evidence

min_evidence: L2 โ€” the public headers are what let abicheck resolve descriptor_base's fully-namespace-qualified spelling (mylib::detail::descriptor_base) and confirm it's reached by value from the public knn_descriptor; castxml is the documented default AST backend for this evidence layer, and clang (--ast-frontend clang) is a supported alternative frontend that recovers the same qualified-name and layout information used above.

Why abicheck catches it

abicheck's header/AST parser records each type's namespace-qualified name alongside DWARF's layout facts (size, member offsets); comparing descriptor_base's size and field list between versions surfaces the root change, and because knn_descriptor embeds descriptor_base by value (inheritance, not a pointer), the same layout shift is independently visible on the public class's own DWARF size/offset facts โ€” no source build integration required.

Runtime failure demonstration

Severity: CRITICAL

Scenario: compile app against v1, swap in v2 .so without recompile โ€” both a stack-allocated knn_descriptor and a heap-allocated one via the factory function.

# Build v1 and app
g++ -shared -fPIC -g v1.cpp -o libmylib.so
g++ -g app.cpp -L. -lmylib -Wl,-rpath,. -o app
./app
# โ†’ class_count   = 2 (expect 2)
# โ†’ neighbor_count = 5 (expect 5)
# โ†’ factory class_count = 2 (expect 2)

# Swap in v2 (no recompile)
g++ -shared -fPIC -g v2.cpp -o libmylib.so
./app
# โ†’ *** stack smashing detected ***: terminated

Why CRITICAL: the app stack-allocates knn_descriptor sized for v1's 8-byte object; v2's constructor, running inside the swapped-in library, writes a 12-byte object (the grown descriptor_base base subobject plus neighbor_count_ at its new offset) into that undersized stack slot, tripping the stack-protector canary and aborting the process.

Safe redesign

Treat detail:: as a private implementation surface that must not leak through the binary interface. Use the Pimpl idiom so internal layout changes never affect the public sizeof:

// Pimpl โ€” internal layout changes never affect the public sizeof.
class knn_descriptor {
public:
    knn_descriptor();
    ~knn_descriptor();
    int get_class_count() const;
private:
    struct impl;
    impl* p_;             // size is fixed at sizeof(void*)
};

Real-world example: this mirrors the oneDAL pattern โ€” oneapi::dal::detail::descriptor_base<Task> is documented as unstable, but its layout still leaks through public derived classes like oneapi::dal::knn::descriptor wherever pimpl isn't used.

Cross-tool comparison

abidw --out-file v1.xml libfoo_v1.so
abidw --out-file v2.xml libfoo_v2.so
abidiff v1.xml v2.xml

References


Source files

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

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