7

Preferably with a vanilla Linux kernel i.e. not using SELinux or grsecurity?

Lately there has been a case of in-the-wild UNIX server malware:

https://www.virusbtn.com/virusbulletin/archive/2014/07/vb201407-Mayhem

that takes advantage of LD_PRELOAD to implement a hidden userspace trojan. I'm thinking it would be good to bar limited users from messing with shared library paths, if possible.

(I would go further, e.g. grsecurity/trusted path execution, but that is not practical at the moment.)

Is there any way I could reliably do this?

Edit: what I meant above is that, normally, any user can use LD_PRELOAD to inject any library into any executable that they can run, and that is not setuid or setgid. This is a known mechanism for creating hidden, persistent malware. Is there any way I can prevent a given user from using LD_PRELOAD at all?

1
  • LD_PRELOAD : does it fix it? Jun 10, 2022 at 22:06

3 Answers 3

12

Essentially, you need to control the execution environment of the apps. There's no magic about it. A couple of solutions that come to mind:

  1. You could somehow set all the binaries that worry you as setuid/setgid (that does not mean they must be owned by root, as far as I know). Linux normally prevents attaching to a setuid/setgid process. Please do verify if it does so for non-root-owned setuid though!

  2. You could use a secure loader to run your apps instead of ld, which refuses to acknowledge LD_PRELOADs. This may break some existing apps. See Mathias Payer's work for more, though I doubt there is any off-the-shelf tool you can just apply.

  3. You could rebuild your binaries with a libc that disables LD_PRELOAD and dlsym. I've heard musl can do that if passing the right options, but can't refind information on how right now.

  4. And finally, you could sandbox your apps and prevent apps from directly launching other processes with a custom environment or from modifying the user's home directory. There is no ready-made tool for this either (it's very much work in progress and nothing is yet deployable).

There probably are limits to the above solutions and other candidate solutions depending on what apps you need to run, who the users are and what the threat model is. If you can make your question more accurate I'll try and improve that answer accordingly.

Edit: keep in mind that a malicious user can only modify their own execution environment (unless they can escalate privileges to root with some exploit but then you have other issues to handle). So, a user would usually not use LD_PRELOAD injections because they can already run code with the same privileges. Attacks make sense for a few scenarios:

  • breaking security-related checks on the client side of client-server software (typically cheats in video games, or making a client app bypass some validation step with its distributor's server)
  • installing permanent malware when you take over a user's session or process (either because they forgot to log out and you have physical access to the device or because you exploited one of their apps with crafted content)
9
  • My hope was to find a way to prevent a limited user (e.g. the user running a web server) from launching any process whatsoever with LD_PRELOAD, in order to prevent the introduction of self-hiding trojan libraries. In retrospect though this would be an incomplete solution.
    – DanL4096
    Jul 21, 2014 at 17:23
  • 1
    Actually, I think I need to first figure out exactly what is needed here in terms of process restriction...
    – DanL4096
    Jul 21, 2014 at 17:24
  • 1
    Wait, I'm confused here. Do you mean users (e.g. non wheel/admin UIDs) who should be allowed to run one app (a Web server) and not be allowed to run any other piece of code whatsoever? Do you mean users allowed to run one app in whatever way they fancy but not affect how other users can run it? Do you mean visitors of a website you host who should not be able to run code on your server? Jul 21, 2014 at 17:26
  • I meant "disallow limited users from using LD_PRELOAD or LD_LIBRARY_PATH to inject unwanted libraries into applications." Whether that would actually be helpful I'm not sure any more, though.
    – DanL4096
    Jul 21, 2014 at 18:13
  • Attacks relying on modifying your own execution environment make sense only when you want to run code in a very strict environment, or if you want to abuse a client-server app that does security checks on client-side (always a very wrong idea!); a typical example is "wall hack" cheats in games. It also makes sense for an exploit taking control of e.g. your browser to add e.g., export LD_LIBRARY_PATH=~/.hidden/path/malicious.so to your ~/.bashrc to propagate the exploit to other apps and make the infection permanent. Jul 22, 2014 at 9:24
7

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.

1
  • Did you preload the preloader? btw RTPATH is now deprecated, a rewrite is in order.
    – tijko
    Jul 26, 2022 at 20:23
2

Yes, there is a way: don't let that user run arbitrary code. Give them a restricted shell, or better, only a predefined set of commands.

You wouldn't prevent any malware from running, unless you've used some non-standard privilege escalation mechanism that doesn't erase these variables. Normal privilege escalation mechanisms (setuid, setgid or setcap executables; inter-process calls) ignore these variables. So this isn't about preventing malware, it's only about detecting malware.

LD_PRELOAD and LD_LIBRARY_PATH allows a user to run installed executables and make them behave differently. Big deal: the user can run their own executables, (including statically linked ones). All you would get is a little bit of accountability if you're logging all execve calls. But if you're relying on that to detect malware, there's so much that can escape your surveillance that I wouldn't bother. Many programming languages offer facilities similar to LD_LIBRARY_PATH: CLASSPATH, PERLLIB, PYTHONPATH, etc. You aren't going to blacklist them all, only a whitelist approach would be useful.

At the very least, you'd need to block ptrace as well: with ptrace, any executable can be made to execute any code. Blocking ptrace can be a good idea — but primarily because so many vulnerabilities have been found around it that it's likely that a few are left undiscovered.

With a restricted shell, the LD_* variables are actually a concern, because the user can only run a pre-approved set of programs and LD_* allows them to bypass this restriction. Some restricted shells allow variables to be made read-only.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .