guides / soname-and-library-versioning conceptsupdated 2026-10-25 readelf -h ./app

SONAME and library versioning: why libssl.so.3 is not libssl.so.1.1

2026-10-25 By luckyray Debian 12 · Ubuntu 24.04 · Alpine 3.20

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
NamePurposeWho uses it
libssl.solink-time stub, provided by -dev packagesgcc -lssl at build time
libssl.so.3the real object, whose SONAME is libssl.so.3ld.so at run time
libssl.so.3.0.13upstream 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.

# 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

NeedCorrect action
App needs OpenSSL 1.1 on a modern distrorebuild against 3, or run in a container with 1.1, or bundle 1.1 with $ORIGIN RUNPATH
Distributing to unknown hostsship the libraries alongside, with patchelf --set-rpath '$ORIGIN/lib'
Building a library yourselfset the SONAME deliberately: -Wl,-soname,libfoo.so.1
Everything must be self-containedstatic 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.

Related

guides
  1. musl vs glibc: why an Alpine binary will not run on Ubuntu Both are C libraries; neither is compatible with the other's binaries. Here is how to tell which one a binary was built against, and what the misleading error message really means.
  2. Static vs dynamic linking: how to tell, and why a static build can still fail Two fields in the ELF header decide the answer, but 'static' is not a guarantee of portability — especially for glibc and for anything using NSS or dlopen.
  3. cannot execute binary file: Exec format error — causes and fixes Why Linux refuses to run a binary with Exec format error, how to confirm the cause in three commands, and the five fixes that actually apply.