Most of Steve DL's points are good, the "best" approach is to use a run-time linker (RTLD) that you have more control over. The "LD_
" variables are hard-coded into glibc (start with elf/rtld.c
). The glibc RTLD has many "features", and even ELF itself has a few surprises with its DT_RPATH and DT_RUNPATH entries, and $ORIGIN
(see https://unix.stackexchange.com/questions/22926/where-do-executables-look-for-shared-objects-at-runtime).
Normally if you want to prevent (or alter) certain operations when you cannot use normal permissions or a restricted shell, you can instead force loading of a library to wrap libc calls — this is exactly the trick that the malware is using, and this means it's hard to use the same technique against it.
One option which lets you hook the RTLD in action is the audit feature, to use this you set LD_AUDIT
to load a shared object (containing the defined audit API named functions). The benefit is you get to hook the individual libraries being loaded, the drawback is that it's controlled with an environment variable...
A lesser-used trick is one more of the ld.so
"features" : /etc/ld.so.preload
. What you can do with this is load your own code into every dynamic process, the advantage is that it's controlled by a restricted file, non-root users cannot modify it or override it (within reason, e.g. if users can install their own toolchain or similar tricks).
Below is some experimental code to do this, you should probably think hard about this before using in production, but it shows it can be done.
#define _GNU_SOURCE
#include <stdio.h>
#include <unistd.h>
#include <limits.h>
#include <stdlib.h>
#include <stdarg.h>
#include <string.h>
#include <dlfcn.h>
#include <link.h>
#include <assert.h>
#include <errno.h>
int dlcb(struct dl_phdr_info *info, size_t size, void *data);
#define DEBUG 1
#define dfprintf(fmt, ...) \
do { if (DEBUG) fprintf(stderr, "[%5i %14s#%04d:%8s()] " fmt, \
getpid(),__FILE__, __LINE__, __func__, __VA_ARGS__); } while (0)
void _init()
{
char **ep,**p_progname;
int dlcount[2]={0,0};
dfprintf("ldwrap2 invoked!\n","");
p_progname=dlsym(RTLD_NEXT, "__progname");
dfprintf("__progname=<%s>\n",*p_progname);
// invoke dlcb callback for every loaded shared object
dl_iterate_phdr(dlcb,dlcount);
dfprintf("good count %i, bad count %i\n",dlcount[0],dlcount[1]);
if ((geteuid()>100) && dlcount[1]) {
for (ep=environ; *ep!=NULL; ep++)
if (!strncmp(*ep,"LD_",3))
fprintf(stderr,"%s\n", *ep);
fprintf(stderr,"Terminating program: %s\n",*p_progname);
assert_perror(EPERM);
}
dfprintf("on with the show!\n","");
}
int dlcb(struct dl_phdr_info *info, size_t size, void *data)
{
char *trusted[]={"/lib/", "/lib64/",
"/usr/lib","/usr/lib64",
"/usr/local/lib/",
NULL};
char respath[PATH_MAX+1];
int *dlcount=data,nn;
if (!realpath(info->dlpi_name,respath)) { respath[0]='\0'; }
dfprintf("name=%s (%s)\n", info->dlpi_name, respath);
// special case [stack] and [vdso] which have no filename
if (respath && strlen(respath)) {
for (nn=0; trusted[nn];nn++) {
dfprintf("strncmp(%s,%s,%i)\n",
trusted[nn],respath,strlen(trusted[nn]));
if (!strncmp(trusted[nn],respath,strlen(trusted[nn]))) {
dlcount[0]++;
break;
}
}
if (trusted[nn]==NULL) {
dlcount[1]++;
fprintf(stderr,"Unexpected DSO loaded from %s\n",respath);
}
}
return 0;
}
Compile with gcc -nostartfiles -shared -Wl,-soname,ldwrap2.so -ldl -o ldwrap2 ldwrap2.c
.
You can test this with LD_PRELOAD
without modifying /etc/ld.so.conf
:
$ LD_PRELOAD=./ldwrap2.so ls
Unexpected DSO loaded from /home/mr/code/C/ldso/ldwrap2.so
LD_PRELOAD=./ldwrap2.so
Terminating program: ls
ls: ldwrap2.c:47: _init: Unexpected error: Operation not permitted.
Aborted
(yes, it stopped stopped the process because it detected itself, since that path is not "trusted".)
The way this works is:
- use a function named
_init()
to gain control before the process starts (a subtle point is that this works because ld.so.preload
startups are invoked before those any LD_PRELOAD
libraries, though I cannot find this documented)
- use
dl_iterate_phdr()
to iterate over all dynamic objects in this process (roughly equivalent to rummaging in /proc/self/maps
)
- resolve all paths, and compare with a hard-coded list of trusted prefixes
- it will find all libraries loaded at process start-time, even those found via
LD_LIBRARY_PATH
, but not those subsequently loaded with dlopen()
.
This has a simple geteuid()>100
condition to minimise problems. It does not trust RPATHS or handle those separately in any way, so this approach needs some tuning for such binaries. You could trivially alter the abort code to log via syslog instead.
If you modify /etc/ld.so.preload
and get any of this wrong you could badly break your system. (You do have a statically linked rescue shell, right?)
You could usefully test in a controlled way using unshare
and mount --bind
to limit its effect (i.e. having a private /etc/ld.so.preload
). You need root (or CAP_SYS_ADMIN
) for unshare
though:
echo "/usr/local/lib/ldwrap2.so" > /etc/ld.so.conf.test
unshare -m -- sh -c "mount --bind /etc/ld.so.preload.test /etc/ld.so.preload; /bin/bash"
If your users access via ssh, then OpenSSH's ForceCommand
and Match group
could probably be used, or a tailored startup script for a dedicated "untrusted user" sshd daemon.
To summarise: the only way you can do exactly what you request (prevent LD_PRELOAD) is by using a hacked or more configurable run-time linker. Above is a workaround which lets you restrict libraries by trusted path, which takes the sting out of such stealthy malware.
As a last resort you could force users to use sudo
to run all programs, this will nicely clean up their environment, and because it's setuid, it won't be affected itself. Just an idea ;-) On the subject of sudo
, it uses the same library trick to prevent programs giving users a backdoor shell with its NOEXEC
feature.