LD_TRACE_LOADED_OBJECTS, but not really

Zephyr was talking about the ability to dispute CVE assignments, and CVE-2009-5064: “glibc: ldd unexpected code execution issue” came up as an example. MITRE says:

** DISPUTED ** ldd in the GNU C Library (aka glibc or libc6) 2.13 and earlier allows local users to gain privileges via a Trojan horse executable file linked with a modified loader that omits certain LD_TRACE_LOADED_OBJECTS checks. NOTE: the GNU C Library vendor states “This is just nonsense. There are a gazillion other ways to introduce code if people are downloading arbitrary binaries and install them in appropriate directories or set LD_LIBRARY_PATH etc.”

This got me curious about the LD_TRACE_LOADED_OBJECTS environment variable and what the libc authors were disputing. Googling around got me to this 2009 article by Peteris Krumins. The premise is:

  1. ldd is mostly just a wrapper around invoking the dynamic linker on an executable with LD_TRACE_LOADED_OBJECTS set. The GNU dynamic linker (e.g. /lib/ld-linux.so.2 or /lib64/ld-linux-x86-64.so.2) checks for LD_TRACE_LOADED_OBJECTS and if set prints the shared libraries required by the executable instead of running it.
  2. If you can get a sysadmin to run ldd on a malicious executable which was compiled to use a dynamic linker that doesn’t check for LD_TRACE_LOADED_OBJECTS, that malicious executable will run instead of just having its shared library dependencies printed to stdout.

The blog post jumps through quite a few hoops to use a modified uClibc dynamic linker that doesn’t try to check for LD_TRACE_LOADED_OBJECTS. Comments on the post pointed out that there was an easier way to do this:

1. Create a test executable

$ cat > test_ld.c
#include <stdio.h>

int main() {
  if (getenv("LD_TRACE_LOADED_OBJECTS")) {
    printf("LD_TRACE_LOADED_OBJECTS is set, and yet I am executing!\n");
  }
  else {
    printf("LD_TRACE_LOADED_OBJECTS is not set, so I am executing.\n");
  }
  return 0;
}

2. Statically compile the test executable into what will be our dynamic linker

$ gcc -static -o test_ld.so test_ld.c

3. Tell the linker to specify our test executable as the dynamic linker to use for our test executable

$ gcc -Wl,-dynamic-linker,test_ld.so test_ld.c -o test_ld

The -Wl,<option>,<option args> syntax passes <option> and <option args> on to the linker.

The dynamic linker is written into the PT_INTERP program header in the resulting ELF executable. Chapter 2 of the ELF format specification describes dynamic linking in detail.

4. Run our executable which specifies a custom dynamic linker

$ ./test_ld
LD_TRACE_LOADED_OBJECTS is not set, so I am executing.
$ LD_TRACE_LOADED_OBJECTS=1 ./test_ld
LD_TRACE_LOADED_OBJECTS is set, and yet I am executing!

And we see that running test_ld, even when LD_TRACE_LOADED_OBJECTS is set, the program executes. What is actually going on here is that test_ld.so is getting invoked as the dynamic linker. test_ld never actually executes, since test_ld.so didn’t set up the execution environment and jump to the _start of the executable, which is what a dynamic linker is supposed to do.

I don’t like this example, though, because using the test executable as both the dynamic linker and target executable makes it less obvious what is actually executing. It also over-emphasizes the security implications of LD_TRACE_LOADED_OBJECTS: if you are running or probing untrusted executables, you don’t need LD_TRACE_LOADED_OBJECTS to get yourself into trouble.

This is a clearer example:

1. Create and compile a stub dynamic linker

$ cat > stub_ld.c 
int main() {
  return 27;
}
$ gcc -static -o stub_ld.so stub_ld.c

2. Create and compile a test executable that specifies our stub linker as the dynamic linker

$ cat test.c 
#include <stdio.h>

int main()
{
  printf("Hello world\n");
  return 0;
} 
$ gcc -Wl,-dynamic-linker,stub_ld.so test.c -o test

3. Run the test executable

$ ./test
$ echo $?
27

The fact that "Hello world" isn’t printed makes it clear that only stub_ld.so executes.

ldd security improvements

The specific ldd trick from Krumins’ 2009 article — social engineering a sysadmin into running ldd on a executable that invokes a custom dynamic linker that ignores LD_TRACE_LOADED_OBJECTS — doesn’t work on newer systems. On older systems (e.g. RHEL 4), ldd will use the dynamic linker specified in the executable. On newer systems (e.g. Ubuntu Oneiric) ldd hardcodes and will only use the GNU dynamic linkers.

Here are the relevant snippets from old and new ldd versions for comparison, with extraneous code elided.

Older ldd

RTLDLIST="/lib/ld-linux.so.2 /lib64/ld-linux-x86-64.so.2"

try_trace() {
  eval $add_env '"$@"' | cat
}

for rtld in ${RTLDLIST}; do                                                                                                                                                                                      
  if test -x $rtld; then                                                                                                                                                                                         
    verify_out=`${rtld} --verify "$file"`
    ret=$?
    case $ret in
    [02]) RTLD=${rtld}; break;;                                                                                                                                                                                  
    esac
  fi
done
case $ret in
0)
  try_trace "$file"

Note that if verification returns 0, try_trace just evals the executable directly, so the dynamic linker specified in the executable is invoked. With this ldd we get:

$ ldd ./test
$ echo $?
27

Newer ldd

RTLDLIST="/lib/ld-linux.so.2 /lib64/ld-linux-x86-64.so.2"

try_trace() {
  eval $add_env '"$@"' | cat
}

for rtld in ${RTLDLIST}; do                                                                                                                                                                                      
  if test -x $rtld; then                                                                                                                                                                                         
    verify_out=`${rtld} --verify "$file"`
    ret=$?
    case $ret in
    [02]) RTLD=${rtld}; break;;                                                                                                                                                                                  
    esac
  fi
done
case $ret in
0|2)
  try_trace "$RTLD" "$file" || result=1

In this case try_trace always use the GNU dynamic linker for our architecture to run the executable, even if something else was specified in PT_INTERP. With this ldd we get:

$ ldd ./test
	linux-vdso.so.1 =>  (0x00007fff4cbff000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd51eec9000)
	stub_ld.so => /lib64/ld-linux-x86-64.so.2 (0x00007fd51f287000)

which is the same as if we hadn't specified a custom dynamic linker. Note that our stub_ld.so is printed as living at the location of the GNU dynamic linker /lib64/ld-linux-x86-64.so.2.

I'm not sure where the newer version of ldd is coming from, though. dpkg -S `which ldd` says libc-bin, which comes from eglibc, is the package supplying ldd. I checked out the eglibc and glibc source repositories and neither have the updated ldd in their histories.

Comments are closed.