Skip to content

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


Source files

  • CMakeLists.txt
  • app.c
  • foo_v1.h
  • foo_v2.h
  • libfoo_v1.c
  • libfoo_v2.c
  • thirdparty_v1.h
  • thirdparty_v2.h

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