SONAME and library versioning: why libssl.so.3 is not libssl.so.1.1
A shared library has three names and only one of them matters at run time. Confusing them is the direct cause of the symlink advice that circulates in every missing-library thread.
The three names
Using OpenSSL as the example, after apt install libssl3 you find:
$ ls -l /usr/lib/x86_64-linux-gnu/libssl.so*
lrwxrwxrwx 1 root root 11 ... libssl.so -> libssl.so.3
-rw-r--r-- 1 root root 823456 ... libssl.so.3
| Name | Purpose | Who uses it |
|---|---|---|
libssl.so | link-time stub, provided by -dev packages | gcc -lssl at build time |
libssl.so.3 | the real object, whose SONAME is libssl.so.3 | ld.so at run time |
libssl.so.3.0.13 | upstream file name (optional) | nobody |
The SONAME is stored inside the file:
$ readelf -d /usr/lib/x86_64-linux-gnu/libssl.so.3 | grep SONAME
0x000000000000000e (SONAME) Library soname: [libssl.so.3]
When you link against libssl.so, the linker records the SONAME, not the file name you passed.
That is why a binary built on one machine looks for libssl.so.3 on every machine.
Why the version is in the name
Because C libraries are not ABI-compatible across major versions, and there is no other mechanism in
the ELF format for “this is a different ABI”. A .so.1.1 and a .so.3 may export identical
function names with different struct layouts, different error semantics, or different
implementations of the same concept (OpenSSL 1.1 and 3.0 differ in exactly these ways).
The SONAME is the ABI identity. Two files with the same SONAME promise to be interchangeable; two
files with different SONAMEs promise the opposite. The loader’s whole search algorithm is built on
finding a file whose SONAME matches the DT_NEEDED string — in practice, a file with that name in
a searched directory.
$ readelf -d ./legacy-app | grep NEEDED
0x0000000000000001 (NEEDED) Shared library: [libssl.so.1.1]
legacy-app will never accept libssl.so.3. Not because the loader is being difficult, but because
the ABI it was compiled against is gone.
The symlink trap
# don't do this
sudo ln -s /usr/lib/x86_64-linux-gnu/libssl.so.3 /usr/lib/libssl.so.1.1
sudo ldconfig
The error disappears. Then, at the first call into a function whose signature changed, the process
crashes — or worse, silently misbehaves. A wrong-ABI load produces a stack that points into
libssl.so.3 while the debugger and the symbol table both say libssl.so.1.1, and the caller’s
frame is nonsense because the arguments were laid out differently.
There is no way for the loader to protect you here: it checks names and symbol versions, not struct layouts.
What to do instead
| Need | Correct action |
|---|---|
| App needs OpenSSL 1.1 on a modern distro | rebuild against 3, or run in a container with 1.1, or bundle 1.1 with $ORIGIN RUNPATH |
| Distributing to unknown hosts | ship the libraries alongside, with patchelf --set-rpath '$ORIGIN/lib' |
| Building a library yourself | set the SONAME deliberately: -Wl,-soname,libfoo.so.1 |
| Everything must be self-contained | static linking, accepting its own trade-offs |
When you build a library, the convention is: bump the SONAME when the ABI changes, keep it when it
does not. That single discipline is what allows distributions to ship one libfoo.so.1 for a decade.
gcc -shared -fPIC -Wl,-soname,libfoo.so.1 -o libfoo.so.1.2.3 foo.c
ln -s libfoo.so.1.2.3 libfoo.so.1 # what the loader looks for
ln -s libfoo.so.1 libfoo.so # what the linker looks for
Missing the first symlink is the classic packaging bug: the library is present, the SONAME is right, and nothing can load it because there is no file with the SONAME’s name.
Checking a dependency chain quickly
$ readelf -d ./app | grep NEEDED # what the binary records
$ readelf -d ./libfoo.so | grep SONAME # what the library claims
$ ldconfig -p | grep foo # what the host has indexed
If the NEEDED string does not appear in ldconfig -p but a same-named-family file does, you are
looking at a version problem, not a paths problem. The Dependency Doctor
prints DT_NEEDED, SONAME, RPATH and RUNPATH together, which is the shortest way to see
whether the mismatch is a name, a path or a missing file.
Related: missing shared libraries for the search order, and GLIBCXX version not found for the C++ runtime equivalent.
Every command in this guide was run against the distribution named in the output. If your output differs, the mechanism section explains why — and the ELF Inspector shows the same fields without needing a shell.