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