Ghost Privileges
I asked one question. The answer broke everything I thought I knew about Unix security.
Every Unix daemon that handles untrusted input follows the same playbook. Start as root. Bind the resources you need. Fork a worker. Drop to nobody. Serve requests. The security argument is simple: if someone exploits the worker, they get nobody, not root. That's privilege separation. nginx does it. OpenSSH does it. Every serious daemon does it.
I wanted to know one thing: does anything actually verify that the drop happened?
The Shim
A program's identity on Unix comes from the kernel. The task_struct holds the real credentials. But programs don't read the task_struct. They call getuid(). They call setresuid() and check the return value. These are libc functions. They go through the dynamic linker. Anything that goes through the dynamic linker can be replaced.
I built a shared library. Six layers of interception. getuid returns 0. geteuid returns 0. setuid returns success without issuing the syscall. Load it with LD_PRELOAD and the process thinks it's root. The kernel never hears about any of it.
I pointed it at nginx 1.24.0. Standard config. user nobody nogroup;. Started as root.
BASELINE:
Master: Uid=0 CapEff=000001fffeffffff
Worker: Uid=65534 CapEff=0000000000000000
WITH SHIM:
Master: Uid=0 CapEff=000001fffeffffff
Worker: Uid=0 CapEff=000001fffeffffff
The worker is serving HTTP as root with full capabilities. It believes it's nobody. I watched the whole sequence. nginx sees geteuid()==0, enters its privileged startup path, looks up the nobody user, chowns five temp directories, forks a worker. The worker calls setgid, initgroups, setuid. All intercepted. All return success. Real uid never changes.
Python's http.server was worse. Its CGI handler wraps the setuid call in except OSError: pass. Silent failure by design. Ghost privs plus the post-drop check still sees root, so it skips secondary sandboxing. Two defensive layers removed by one interception.
Redis 7.0 was the exception. Doesn't import identity functions through the dynamic linker at all. Uses vDSO or static syscall stubs. The shim can't hook it. That told me something important. Not everything is equally vulnerable. If the binary resolves identity through the PLT, it's vulnerable. If it doesn't, it's resistant. Redis is the minority.
But nginx was too easy. I needed a harder target.
The Gold Standard
OpenSSH's permanently_set_uid() in uidswap.c is the reference implementation for privilege drop verification. Security engineers point to it and say "this is how you do it right." Eight checks. setresgid to target. setresuid to target. Try to restore old gid. Expect EPERM. Try to restore old egid. Expect EPERM. Verify getgid matches. Try to restore old uid. Expect EPERM. Try to restore old euid. Expect EPERM. Verify getuid matches.
Positive verification AND negative verification. If this didn't catch it, nothing would.
My dumb shim failed here. It always returns uid=0, and sshd expects uid=102 after the drop. Good. OpenSSH's paranoia works.
So I built a smarter one.
The state-aware shim tracks identity transitions. Before setresuid: report real uid. After setresuid(102,102,102): report 102. When sshd tries setuid(0) to test restoration: return EPERM. The process sees a consistent false reality that's internally coherent but has nothing to do with what the kernel actually knows.
setresuid(102,102,102): SPOOFED. believed=0→102. real stays 0.
setgid(0) restore: BLOCKED → EPERM
setegid(0) restore: BLOCKED → EPERM
setuid(0) restore: BLOCKED → EPERM
seteuid(0) restore: BLOCKED → EPERM
geteuid(): returning 102
ALL 8 CHECKS PASSED.
Process believes: uid=102 (sshd privsep)
Kernel reality: uid=0 (root)
Then I blocked sshd's seccomp sandbox. PR_SET_SECCOMP and PR_SET_NO_NEW_PRIVS both go through prctl, which goes through libc. Intercepted. Blocked. The privsep child that handles pre-authentication: root. No seccomp. No NoNewPrivs. Full unrestricted root processing SSH connections before the user even logs in.
That's when I knew this wasn't a bug in nginx or a bug in OpenSSH. If the gold standard falls, the problem is the floor.
How Deep Does It Go
I started eliminating defenses to see what would survive.
/proc/self/status was supposed to be safe. Kernel-maintained data. But /proc reads go through libc's open() and read(). Hook those. Read the real content via raw syscall. Rewrite the Uid and CapEff lines. Serve fake content through a pipe. Python and C programs both see spoofed values. Gone.
The "raw syscall" defense. Call syscall(SYS_geteuid) instead of geteuid(). Supposed to bypass the hook because it goes straight to the kernel. But syscall() is a libc function with a PLT entry. Hook it. That defense doesn't work either.
AFTER setresuid(102):
libc geteuid(): 102 ← spoofed
syscall(SYS_geteuid): 102 ← spoofed
inline asm: 0 ← truth
Nine of eleven verification methods. One shared library. The two survivors: inline assembly, which is a raw CPU instruction with no function call. And negative verification, which tests kernel enforcement directly. No production daemon uses either one.
I scanned every ELF binary in /usr/sbin and /usr/bin on a minimal Ubuntu 24.04 install. Checked which ones import priv-drop functions through the dynamic linker. Forty-five binaries. sshd, nginx, login, passwd, unix_chkpwd, mount, bash, python3, node, perl. All interceptable. Forty-five binaries that trust libc for their most security-critical operation.
Not Just LD_PRELOAD
The obvious objection: "If you control LD_PRELOAD, you already own the environment." That's fair. If you can set environment variables for a root process, you already have root. Ghost privs through LD_PRELOAD is a persistence mechanism, not an escalation.
But I wanted to know if the finding was structural or just an LD_PRELOAD trick. So I built it again with ptrace. A parent process intercepts the child's syscalls at the kernel level. Replaces setresuid with a no-op. Spoofs geteuid returns. Blocks restoration tests with EPERM.
CHILD REPORT (via ptrace, zero LD_PRELOAD):
euid before drop: 0
setresuid(102) rc: 0 ← ptrace replaced syscall
euid after drop: 102 ← ptrace spoofed return
setuid(0) restore rc: -1 ← ptrace returned EPERM
/proc real uid: 0 ← truth
GHOST PRIVS: YES
Same ghost privs, completely different mechanism. No shared libraries, no environment variables, no filesystem writes. But the same access bar: the parent needs to be privileged for the child to have privileges worth retaining. ptrace doesn't lower the bar. What it proves is that blocking LD_PRELOAD doesn't fix the problem. The vulnerability is in the verification gap, not in any single delivery mechanism.
Then I tested seccomp. SECCOMP_RET_USER_NOTIF lets a supervisor handle syscalls on behalf of a sandboxed child. The supervisor receives the syscall, returns a value. The kernel's own implementation never runs. I intercepted setresuid. The kernel's __sys_setresuid() never executed. /proc confirmed: credentials unchanged. The kernel built this mechanism. gVisor and Podman use it. A compromised supervisor turns it into a ghost privs factory.
Three mechanisms. LD_PRELOAD. ptrace. seccomp. Three completely independent paths. Same result every time. None of them are a clean escalation from unprivileged. All of them require some level of prior access. The finding isn't "here's a free path to root." The finding is that privilege separation doesn't verify itself, and there's no single chokepoint you can block to fix that.
Where It Gets Uncomfortable
I found that ambient capabilities bypass AT_SECURE. By design. The kernel considers them "not a new privilege." The consequence: LD_PRELOAD survives for processes that receive ambient capabilities.
Process with ambient CAP_SETUID (uid=1001):
AT_SECURE=0
LD_PRELOAD=shim ← loaded
setuid(0) → uid=0 euid=0 (real root via cap)
setresuid(65534) → BLOCKED
/proc: Uid: 0 0 0 0 (still root)
A systemd service with AmbientCapabilities=CAP_SETUID and User=www-data. Legitimate configuration. If an attacker writes to the service's environment file, they add LD_PRELOAD. Nothing strips it. The service escalates via the ambient cap, tries to drop back, and the shim catches it. Ghost privs through a documented systemd feature.
The Kubernetes angle is worse. Go's runtime bypasses libc for syscalls. kubelet is partially resistant. But kubelet launches containerd and runc. Both import setresuid through the GLIBC PLT. runc calls setuid when creating container processes. One shim on one node, and every container on that node has ghost privs. Security agents reading libc identity report everything is fine.
The Wall
I spent a long time trying to find a clean path from unprivileged to root. Every angle led to the same wall. AT_SECURE strips LD_PRELOAD on setuid exec. Ptrace strips privileges on exec. NO_NEW_PRIVS blocks privilege-granting exec when seccomp filters exist. User namespaces isolate correctly. I tested every combination. The kernel's exec-time boundary holds.
I could have stopped there. Filed some advisories. Published what I had.
Instead I applied my own methodology to the boundary itself.
Phase 2
The kernel enforces security at exec time. AT_SECURE. NNP. Ptrace stripping. Namespace isolation. That's Phase 1. I tested it thoroughly. It's solid.
Then the kernel is done. It walks away.
I traced su through a full invocation. After the linker finished, after AT_SECURE was processed, su running as root opened:
Phase 2 — su running as root, post-exec:
/etc/pam.d/su → config
/usr/lib/.../security/pam_rootok.so → dlopen()
/usr/lib/.../security/pam_env.so → dlopen()
/usr/lib/.../security/pam_unix.so → dlopen()
/usr/lib/.../security/pam_deny.so → dlopen()
/usr/lib/.../security/pam_permit.so → dlopen()
/usr/lib/.../security/pam_umask.so → dlopen()
/usr/lib/.../security/pam_systemd.so → dlopen()
/usr/lib/.../security/pam_limits.so → dlopen()
/var/run/nscd/socket → socket connect
/run/systemd/userdb/io.systemd.Multiplexer → socket connect
Eight shared libraries loaded via dlopen(). Two socket connections. Six config files. All as root. All after the kernel finished its security check and moved on.
Every one of these is protected by file permissions and nothing else.
The nscd socket path is hardcoded in glibc. Every setuid binary on every Linux system tries to connect to /var/run/nscd/socket. If nscd isn't running, the connection fails silently. If someone creates a socket at that path, the setuid binary connects as root and trusts whatever response comes back. Identity resolution inside a root process, controlled by whoever owns the socket.
The kernel enforces at exec time. After that, it's a gentleman's agreement all the way down.
This is the finding. It's a vulnerability class, not a single bug. The kernel spent decades hardening Phase 1. I tested it. It holds. Phase 2 has zero kernel enforcement. Setuid binaries running as root load code, connect to sockets, resolve identities, all from paths protected by nothing but file permissions. On a properly configured system, those permissions hold. On a misconfigured container, a boot race, an NFS mount with lax perms, Phase 2 is wide open.
And nobody is testing Phase 2 because everyone assumes the exec-time check is enough.
The Fix
Don't check who you are. Check what you can do.
// WRONG: check declared state (spoofable)
setresuid(nobody, nobody, nobody);
if (geteuid() != nobody) abort();
// RIGHT: check enforced behavior (unforgeable)
setresuid(nobody, nobody, nobody);
if (kill(1, 0) == 0) {
abort(); // can signal init. still privileged.
}
Send signal 0 to pid 1. If it succeeds, you can signal init. You're still privileged. Works everywhere. Containers, chroots, systems with MAC policies. No filesystem dependency. The kernel checks real credentials, not what libc says. One edge case: in rootless containers where PID 1 runs as the same unprivileged uid, kill succeeds regardless. Fall back to binding a privileged port.
I packaged this as phantom_verify.h. Single header. Call phantom_verify_drop(expected_uid) after setresuid. Three independent checks: inline asm identity, /proc via raw syscall, negative verification. Returns a bitmask. Public domain. Drop it in.
The Pattern
This is the same finding I keep making. At a different layer every time.
Zombie ZIP was the first time I saw it. The AV engine reads the header, the decompressor reads the data, and they disagree about what the file contains. The AV is wrong.
Same thing on Windows. Security tools read WMI for process identity while the kernel reads task_struct. 73.7% of identity markers turned out to be spoofable.
Ghost privileges is the Unix version. The daemon asks libc who it is. The kernel knows who it is. The daemon never asks the kernel.
Every security system checks at one point and trusts everything after. The gap between where checking stops and where execution continues is where all of this lives.
The fix is the same at every layer. Don't trust the declaration. Test the enforcement. Decompress and scan the real bytes. Measure the real silicon. Try the privileged operation and watch it fail. Keep checking. The moment you stop is the moment the attacker starts.
The systems that check enforcement are secure. Everything else is a gentleman's agreement.