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.txtapp.cbad.cgood.c
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.