Skip to content

Case 06: Symbol Visibility Leak

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux
Flags ABI break, Bad practice
Detected ChangeKinds func_visibility_changed
Source files examples/case06_visibility/

Category: Visibility | Verdict: ๐Ÿ”ด BREAKING (bad practice)

Verdict and consumer impact

bad.c (v1) was compiled without -fvisibility=hidden and unintentionally exports two internal helpers (internal_helper, another_impl) as part of its public ABI surface, alongside the real public_api. good.c (v2) fixes this: it hides internal_helper and drops another_impl entirely. From a consumer's point of view this is indistinguishable from any other symbol removal โ€” any binary that resolved internal_helper or another_impl at load time (even by accident, having reached past the intended public API) fails to load against v2.

Old/new diff

bad.c (v1) good.c (v2)
__attribute__((visibility("default"))) int public_api(int x) __attribute__((visibility("default"))) int public_api(int x)
__attribute__((visibility("default"))) int internal_helper(int x) __attribute__((visibility("hidden"))) int internal_helper(int x)
__attribute__((visibility("default"))) int another_impl(int x) (removed)

abicheck command

gcc -shared -fPIC -g bad.c -o libfoo_v1.so
gcc -shared -fPIC -g -fvisibility=hidden good.c -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- func_removed: Public function removed: another_impl
  > Old binaries call a symbol that no longer exists; dynamic linker
    will refuse to load or crash at call site.
- func_visibility_changed: Function visibility changed to hidden:
  internal_helper (public -> hidden)
  > Symbol hidden from dynamic linking; old binaries can't find it at
    load time.

Minimum evidence

min_evidence: L0 โ€” both findings come straight from the .dynsym exported- symbol set: another_impl is present in v1's table and absent from v2's, and internal_helper moves from an exported binding to no binding at all. No debug info or headers are required; abicheck's DWARF-aware path (used here since the binaries were built with -g) additionally confirms internal_helper still has external linkage in the source, distinguishing a genuine visibility change from a function that was deleted outright.

Why abicheck catches it

The dynamic symbol table is authoritative L0 evidence for what's actually callable at runtime. abicheck diffs the exported-symbol sets directly, and (via dwarf_snapshot.py) can tell a symbol that disappeared from .dynsym while remaining externally-linked in DWARF apart from one that was deleted from the source โ€” surfacing the former as func_visibility_changed instead of a plain removal.

Runtime failure demonstration

Severity: CRITICAL

Scenario: an app that (incorrectly) relies on the leaked internal_helper symbol, run against both libraries.

# Build both libraries and the consumer
gcc -shared -fPIC -g bad.c -o libv1.so
gcc -shared -fPIC -g -fvisibility=hidden good.c -o libv2.so
gcc -g app.c -ldl -o app

./app
# โ†’ libv1.so (bad): internal_helper EXPORTED
# โ†’ libv2.so (good): internal_helper hidden
# โ†’ WRONG RESULT: libv2.so (good) hides internal_helper (symbol removed)
echo "exit: $?"   # โ†’ 1

Why CRITICAL: the consumer relies on the accidentally-exported internal_helper symbol. v2 hides it, so any binary that resolved the symbol at load time will now fail to link/symbolize and abort before it can handle the crash.

Why this case stays BASELINE_SIGNAL in the runtime-smoke matrix (intentionally, not a bug): this app.c doesn't fit validation/scripts/run_example_runtime_smoke.py's baseline-then-swap model โ€” it dlopens ./libv1.so and ./libv2.so by name in a single run, independent of which library the harness's swap step substitutes. Its exit code 1 is overloaded: it fires both when libv2.so correctly hides internal_helper (the intended demonstration above) and when libv1.so unexpectedly fails to export it (a real build regression, unrelated to this case). A per-case runtime_baseline_exit override can't distinguish those two conditions, so whitelisting exit 1 as "expected" would silently mask the second one. Leave this case's baseline unwhitelisted; it is correctly non-blocking today (see examples/README.md's "Known validation gaps").

Safe redesign

Add -fvisibility=hidden to build flags and annotate every intended public function with __attribute__((visibility("default"))). Use a FOO_EXPORT macro:

#define FOO_EXPORT __attribute__((visibility("default")))
FOO_EXPORT int public_api(void);  // exported
static int internal_helper(void); // or just leave it static

Real-world example: Qt, GCC libstdc++, LLVM, and most large C++ projects gate their public API with visibility macros (Q_DECL_EXPORT, _GLIBCXX_VISIBILITY) precisely to avoid this. -fvisibility=hidden is standard practice since GCC 4.

Cross-tool comparison

abidiff also flags this as an ABI change (the removed/hidden symbols), though it doesn't carry abicheck's VISIBILITY_LEAK-style framing of the bad practice on v1 alone โ€” it only ever compares the two binaries.

References


Source files

  • CMakeLists.txt
  • app.c
  • bad.c
  • good.c

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