Case 18: Dependency ABI Leak¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux |
| Flags | ABI break, Bad practice |
Detected ChangeKinds |
type_size_changed |
| Source files | examples/case18_dependency_leak/ |
Category: Type Layout | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
libfoo's own exported symbol interface is identical between v1 and v2 โ
same function names, same signatures, same public header prototypes. The
break is transitive: foo.h includes thirdparty.h, which defines
ThirdPartyHandle โ a type owned by a third-party dependency, not by
libfoo โ and that type's layout grows from 4 bytes to 8 bytes between v1
and v2. A caller compiled against v1 allocates the 4-byte layout; the v2
.so reads/writes an 8-byte layout through the same pointer, corrupting
adjacent memory. nm/readelf on the two .so files show no difference
at all โ only the (transitively included) header changed.
Old/new diff¶
| thirdparty_v1.h | thirdparty_v2.h |
|---|---|
typedef struct { int x; } ThirdPartyHandle; (4 bytes) |
typedef struct { int x; int y; } ThirdPartyHandle; (8 bytes) |
foo.h's own prototypes (process, get_value) are byte-for-byte
unchanged between v1 and v2 โ the leak is entirely in the included
third-party type.
abicheck command¶
gcc -shared -fPIC -g libfoo_v1.c -I. -o libfoo_v1.so
gcc -shared -fPIC -g libfoo_v2.c -I. -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_size_changed: Size changed: ThirdPartyHandle (32 -> 64 bits)
> Old code allocates or copies the type with the old size; heap/stack
corruption, out-of-bounds access.
Affected symbols: get_value, process
Additions:
- type_field_added_compatible: Field added: ThirdPartyHandle::y
> Field appended without changing existing offsets; old code works
but won't initialize the new field.
Minimum evidence¶
min_evidence: L1 โ the two .so files were built with -g, and DWARF's
DW_TAG_structure_type entry for ThirdPartyHandle records its full byte
size and member offsets even though the type is only reached transitively
through an included third-party header, not declared directly in
libfoo's own sources. No public-header/AST evidence is required to catch
this specific size change โ but note that DWARF only carries the type if the
library isn't built with debug info stripped; many distro packages strip it,
which is why this class of bug is easy to miss in practice.
Why abicheck catches it¶
libfoo's exported function signatures never change, so a symbol-only (L0)
diff sees nothing. What changes is a type reachable from those functions'
parameters โ abicheck's DWARF-based type differ walks the full type graph
referenced by exported symbols, not just the symbols' own declarations, so
ThirdPartyHandle's size change is caught as a consequence of appearing in
process()'s and get_value()'s parameter types, regardless of which
header (or which project) originally defined it.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: app allocates ThirdPartyHandle with v1's 4-byte layout,
calls process() from v2 which writes h->y at offset 4 โ past the
caller's allocation.
# Build libfoo v1 + app
gcc -shared -fPIC -g libfoo_v1.c -I. -o libfoo.so
gcc -g app.c -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# โ sizeof(ThirdPartyHandle) = 4
# โ before process: h.x=42 canary=0x5AFE5AFE
# โ process: x=42
# โ after process: h.x=42 canary=0x5AFE5AFE
# Swap in libfoo v2 (no recompile; writes to h->y = offset 4, the canary's location)
gcc -shared -fPIC -g libfoo_v2.c -I. -o libfoo.so
./app
# โ sizeof(ThirdPartyHandle) = 4
# โ before process: h.x=42 canary=0x5AFE5AFE
# โ process: x=42
# โ process: wrote y=0xBADC0DE at offset 4
# โ after process: h.x=42 canary=0xBADC0DE โ CORRUPTED
# โ CORRUPTION: v2 read/wrote past ThirdPartyHandle boundary!
echo "exit: $?" # โ 1
Why CRITICAL: the library's exported symbol table looks identical in
both scenarios โ nm and readelf show the same function names. Yet the
v2 library writes 4 bytes past the caller's struct allocation because a
third-party type it exposes in its public header grew silently. Heap
corruption or information leakage follows.
Safe redesign¶
Never expose implementation-detail or third-party types in public API headers. Use the Pimpl idiom or opaque handles instead:
/* BAD: ThirdPartyHandle leaks into public API */
void process(ThirdPartyHandle* h);
/* GOOD: opaque handle โ caller never sees ThirdPartyHandle */
typedef struct foo_handle foo_handle_t;
foo_handle_t* foo_create(int x);
void foo_process(foo_handle_t* h);
void foo_destroy(foo_handle_t* h);
Real-world example: several gRPC components include grpc::Status,
which internally holds std::string. When libstdc++ changed std::string's
ABI (GCC 5.x, CXX11 dual ABI), every library exposing grpc::Status in its
public headers broke silently for consumers still on the old ABI โ the
.so files themselves hadn't changed. This is why Abseil and gRPC now favor
opaque handle types at API boundaries.
Cross-tool comparison¶
abidw --out-file foo_v1.xml libfoo_v1.so
abidw --out-file foo_v2.xml libfoo_v2.so
abidiff foo_v1.xml foo_v2.xml
abidiff with DWARF can catch this the same way abicheck does โ if the
third-party type is transitively present in libfoo.so's debug info and
that debug info isn't stripped, which many distributions do by default. A
naive symbol-only or header-blind comparison (plain nm/readelf diff, or
abidiff against stripped binaries) sees no difference at all.
References¶
- libstdc++ dual ABI notes (
std::stringABI split) - Pimpl / compilation firewall rationale (opaque ABI boundary)
Source files¶
CMakeLists.txtapp.cfoo_v1.hfoo_v2.hlibfoo_v1.clibfoo_v2.cthirdparty_v1.hthirdparty_v2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.