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 browse on GitHub

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

ground_truth.json: expected: BREAKING, category: breaking checker_policy.py: FUNC_REMOVED โˆˆ BREAKING_KINDS

What this case is about

This case detects a single-library quality issue: a library that was compiled without -fvisibility=hidden unintentionally exports all internal symbols as part of its public ABI surface.

This is NOT a comparison between two libraries. The bad practice lives in libv1.so (the "bad" library) alone. libv2.so (the "good" library) is provided only as the correct reference โ€” it shows how the library should look.

Why exposing internal symbols is bad practice

  • Every internal symbol (internal_helper, another_impl, etc.) accidentally becomes part of the public ABI contract.
  • Any future refactor of internal helpers โ€” rename, split, remove โ€” risks being detected as an ABI break or actually breaking consumers that mistakenly linked against them.
  • Bloated .dynsym tables slow dynamic linker startup (symbol resolution scan).

What abicheck detects

Running abicheck dump -H bad.c libv1.so + comparing to abicheck dump -H good.c libv2.so:

  • VISIBILITY_LEAK (BAD PRACTICE / COMPATIBLE): libv1.so exports internal-looking symbols (internal_helper, another_impl) without -fvisibility=hidden. Reported on the old library, not the transition.
  • FUNC_REMOVED (BREAKING): another_impl() is declared in bad.c but absent from good.c entirely โ€” the function was removed from both the header and the library. Any consumer that called another_impl will fail to load.
  • FUNC_VISIBILITY_CHANGED (BREAKING): internal_helper() changes from default to hidden visibility โ€” it disappears from .dynsym.

Overall verdict: BREAKING โ€” removing or hiding previously-exported symbols is an ABI break for any consumer that depended on them, even if the symbols were only exported by accident.

Note: In ELF-only mode (without -H), both removals are classified as FUNC_REMOVED_ELF_ONLY (COMPATIBLE) because the tool cannot distinguish intentional public API from accidentally-leaked internals. Use -H to get the accurate BREAKING verdict.

Dual nature of this case

This case is both a bad practice (v1 leaked internal symbols) and a breaking change (v2 removes those symbols from the dynamic table). The root cause is the visibility leak in v1; fixing it in v2 is the right thing to do, but it requires a SONAME bump or a transition plan.

How to reproduce

# Build
make -C examples/case06_visibility

# Check libv1.so (bad โ€” leaks internal symbols)
nm --dynamic --defined-only examples/case06_visibility/libv1.so
# โ†’ public_api, internal_helper, another_impl  โ† leak!

# Check libv2.so (good โ€” only public API)
nm --dynamic --defined-only examples/case06_visibility/libv2.so
# โ†’ public_api only  โ† correct

# Run abicheck (with headers for accurate detection)
python3 -m abicheck.cli dump examples/case06_visibility/libv1.so \
    -H examples/case06_visibility/bad.c -o /tmp/v1.json
python3 -m abicheck.cli dump examples/case06_visibility/libv2.so \
    -H examples/case06_visibility/good.c -o /tmp/v2.json
python3 -m abicheck.cli compare /tmp/v1.json /tmp/v2.json
# โ†’ BREAKING (FUNC_REMOVED: another_impl) + VISIBILITY_LEAK warning on libv1.so

How to fix

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.

References

Real Failure Demo

Severity: CRITICAL

Scenario: compile the app against the leaky v1 library and observe that it finds internal_helper. Rebuild the shared object with the hidden-symbol v2 source, rerun the same binary, and notice that the symbol becomes unavailable (exit code 1).

# Build the two libraries and keep them beside the app
gcc -shared -fPIC -g bad.c -o libv1.so
gcc -shared -fPIC -g good.c -o libv2.so
gcc -g app.c -ldl -o app

# Run the app while both libs are present
./app
# โ†’ v1.so (bad): internal_helper EXPORTED (leak!)
# โ†’ v2.so (good): internal_helper hidden (correct)
# โ†’ WRONG RESULT: visibility contract not demonstrated as expected

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. This app shows the missing symbol and exits with failure to make the issue obvious.


Source files

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