Shared libraries as executables

Aug 20th, 2022

Typically you either have an executable or a shared library, but can you have an executable shared library too?

The ELF file header has a tag for the object type, which includes ET_EXEC or ET_DYN. That suggests an ELF file is either an executable or a shared library. However, in practice many executables are in fact ET_DYN objects:

$ cat hello.c
#include <stdio.h>
void hello() { puts("hello"); }
int main() { hello(); hello(); }

$ gcc -o hello hello.c

$ ./hello
hello
hello

$ file hello
hello: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bec6318280e1ed6d7d271d870a7d22f589c233f2, for GNU/Linux 3.2.0, not stripped

Notice that file outputs shared object which means the executable is an ET_DYN object.

The reason for this is that my GCC is configured to enable -fpie by default.

Trying to link against hello

However, the hello executable is not particularly useful as a shared library, since it does not export the hello symbol:

$ nm --dynamic --defined-only hello
$ # nothing here.

If we compile it with -shared both symbols get exposed

$ gcc -o hello -shared hello.c

$ nm --dynamic --defined-only hello
0000000000001139 T hello
0000000000001150 T main

The main symbol in a shared library certainly looks odd, but the linker does not complain:

$ cat main.c 
void hello();
int main(){ hello(); }

$ gcc -o main main.c -L. -l:hello -Wl,-rpath,.

$ ./main
hello

However, by specifying -shared we lost the ability to meaningfully execute hello:

$ ./hello 
Segmentation fault (core dumped)

Giving the shared library a program interpreter

One effect of specifying -shared is that the linker will not add a PT_INTERP type program header to the binary. This program header specifies an absolute path to the interpreter, which is typically the dynamic linker. When executing an ELF file, the kernel parses the ELF file and looks for the interpreter. When it finds it, it effectively executes the interpreter instead and gives it a file descriptor of the ELF file, so the dynamic linker can continue to parse the ELF file, mapping the relevant sections of it and its dependencies into memory before executing the entry point.

So, to create a shared executable, we will need to manually create a PT_INTERP section. In my case the dynamic linker lives in /lib64/ld-linux-x86-64.so.2. Registering it goes as follows:

$ cat hello.c
#include <stdio.h>
const char interp[] __attribute__ ((section(".interp"))) = PT_INTERP;
void hello() { puts("hello"); }
int main() { hello(); hello(); }

$ gcc -shared -o hello hello.c -D 'PT_INTERP="/lib64/ld-linux-x86-64.so.2"'

$ file hello
hello: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=492b8f47d786a0d3d7152e131fd2c096135f1e53, not stripped

So, we now have a shared library with an interpreter. Unfortunately it's not quite enough:

$ ./hello 
Segmentation fault (core dumped)

Setting the entrypoint

To fix the segfault, we need to inform the linker what symbol to use as entrypoint of execution:

$ gcc -shared -o hello -Wl,--entry,main hello.c

$ ./hello
hello
hello
Segmentation fault (core dumped)

So, it almost works, except for a segfault on exit. The reason is we're missing an exit call:

$ cat hello.c
#include <stdio.h>
#include <stdlib.h>
const char interp[] __attribute__ ((section(".interp"))) = PT_INTERP;
void hello() { puts("hello"); }
int main() { hello(); hello(); exit(EXIT_SUCCESS); }

$ gcc -shared -o hello -Wl,--entry,main hello.c -D 'PT_INTERP="/lib64/ld-linux-x86-64.so.2"'

$ ./hello
hello
hello

Linking an executable against hello again

Finally, let's verify our main executable can be linked against the hello executable:

$ gcc -o main main.c -L. -l:hello -Wl,-rpath,.

$ ldd main | grep hello
	hello => ./hello (0x00007fd419bb6000)

$ ./main
hello

Success!

Examples in the wild

So far I've seen two cases where shared libraries are used as executables.

The first and most obvious example is the dynamic linker itself. For example glibc's dynamic linker allows you to run an executable as follows:

$ /lib64/ld-linux-x86-64.so.2 ./main
hello

The use case is that you can run existing executables under a new glibc version without the need to modify the PT_INTERP in the executable.

Another neat use case is truly relocatable and self-contained software. If you want to ship your software with all its dependencies up to glibc to another system, you immediately hit a Linux issue where PT_INTERP can only be an absolute path, so you can't make it point to your own glibc, and you'll end up using the possibly incompatible system glibc (if it is even available...). This can be worked around by replacing your executables with a script that invokes the dynamic linker directly, since the dynamic linker itself does not specify an interpreter.

The second example is libQt5Core.so, which dumps version info when executing it. Sounds useful!

$ ./libQt5Core.so.5
This is the QtCore library version Qt 5.15.2
...