I finished my CVE-2025-32463 write-up with a sentence that reads, in hindsight, like a research plan I assigned to somebody else:

When privileged code enters an attacker-controlled namespace, inventory every implicit interpreter, loader, resolver, and configuration search — not only the file named in the patch.

Then I published it as a conclusion and moved on. The sentence names locale catalogues, dynamic loader configuration, PAM stacks, plugin discovery, certificate stores. I had inventoried none of them. I had read an advisory carefully and written a good explanation of somebody else’s finding.

So I built the instrument. Not the inventory yet — the instrument that makes the inventory mean something. This is what that cost, including the two designs that did not survive contact with the kernel.

The signal has to be mechanical

The first question is not “which tools are vulnerable” but “what, exactly, am I measuring.” If the answer is a judgement call, the sweep produces opinions.

CVE-2025-32463 gives a clean definition. sudo -R let an unprivileged caller pick a root directory; sudo entered it before policy evaluation finished; a privileged NSS lookup read the caller’s /etc/nsswitch.conf; libc loaded a shared object the caller supplied. The generalisation is not about sudo or NSS:

A privileged process selects the implementation of an operation — it loads code, not just data — from inside a namespace the caller chose.

That reduces to something a machine can decide: does the process map a PROT_EXEC region backed by a file the caller supplied? Configuration reads are context. The executable mapping is the finding.

The known-positive comes first

An instrument that has never caught something it should catch tells you nothing when it stays quiet. So before any sweep target, the harness has to detect the bug I already know is there, and stay silent on three controls that should produce nothing.

ControlBuildRoot suppliednsswitch.confExpected
primary1.9.17yesyesCONFIRMED
no-config1.9.17yesnoCLEAN
no-root1.9.17noyesCLEAN
fixed-build1.9.17p1yesyesCLEAN

Both sudo builds come from upstream tarballs, compiled side by side in a throwaway VM:

build 1.9.17   /opt/sudo-vuln
build 1.9.17p1 /opt/sudo-fixed

The caller-supplied side is a synthetic root containing a name-service selector and an inert module. The module implements no NSS entry points at all. It does not need to: being loaded is the signal, and a module that resolves nothing cannot change host behaviour.

__attribute__((constructor))
static void prns_marker_loaded(void)
{
    FILE *log = fopen("/tmp/prns-marker.log", "a");
    if (log == NULL) {
        return;
    }
    fprintf(log, "LOADED pid=%d uid=%u euid=%u\n",
            (int)getpid(), (unsigned)getuid(), (unsigned)geteuid());
    fclose(log);
}

That constructor is the whole payload. Here is what it wrote:

LOADED pid=47155 uid=1000 euid=0

Invoked by uid 1000. Executed with euid 0. A file placed by an unprivileged user ran as root.

The trace says it in two lines

The strace layer runs with -y, which annotates every file descriptor with the path it resolves to through /proc/PID/fd. For a process that has entered a caller-supplied root, that annotation and the raw syscall argument stop agreeing — and the disagreement is the vulnerability, printed:

openat(AT_FDCWD</tmp/prns>,     "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3</etc/nsswitch.conf>
openat(AT_FDCWD</…/synthroot>,  "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 8</…/synthroot/etc/nsswitch.conf>
openat(AT_FDCWD</…/synthroot>,  "/lib/aarch64-linux-gnu/libnss_lab.so.2", …) = 8</…/synthroot/lib/aarch64-linux-gnu/libnss_lab.so.2>
mmap(…, PROT_READ|PROT_EXEC, …, 8</…/synthroot/lib/aarch64-linux-gnu/libnss_lab.so.2>, 0)

The process asked for /etc/nsswitch.conf twice. The first time it got the real file. The second time it got the caller’s. Nothing about the request changed; the ground underneath it did. Then the caller’s shared object is mapped executable.

I find this more convincing than any diagram I could draw of it, which is roughly the point.

The second layer did not survive first contact

One layer is not enough. strace uses ptrace, which perturbs timing and — as I will get to — can silently defeat the whole test against setuid binaries. I wanted a kernel-side observer that agrees or disagrees independently.

The obvious design was a kprobe on security_mmap_file, printing the backing file’s path:

kprobe:security_mmap_file { … path((struct file *)arg0) … }
ERROR: BPF_FUNC_d_path not available for your kernel version
ERROR: The path function can only be used with 'kfunc', 'kretfunc', 'iter' probes

Fair enough — wrong probe type. I rewrote it as kfunc, which also gets typed arguments:

kfunc:security_mmap_file { … path(args.file) … }
ERROR: BPF_FUNC_d_path not available for your kernel version

The probe-type complaint is gone and the helper is still refused. At this point the error message is actively misleading, because the kernel is 6.8 and bpf_d_path has existed since 5.9. The real constraint is narrower: bpf_d_path() is gated behind a BTF allowlist, and security_mmap_file is not on it. The security hooks that are on it are security_file_permission, security_inode_getattr, and security_file_open.

So I tested the allowlisted one, expecting it to work and to give me a way to correlate:

kfunc:security_file_open { … path(args.file) … }
ERROR: BPF_FUNC_d_path not available for your kernel version

Refused there too. Whatever this bpftrace build’s feature detection is doing, d_path is not available to me on this pair at all — and even if it were, my actual probe point is excluded by the kernel. Two independent reasons, one conclusion: this layer is not going to resolve paths.

Losing the path made the detector better

If the kernel will not give me a name, it will still give me an identity:

kfunc:security_mmap_file
{
    $f = args.file;
    if ($f == 0) { return; }
    if ((args.prot & 4) == 0) { return; }
    printf("EXECMAP pid=%d comm=%s dev=%u ino=%lu\n",
           pid, comm, $f->f_inode->i_sb->s_dev, $f->f_inode->i_ino);
}
EXECMAP pid=47155 comm=sudo dev=265289729 ino=530735

The harness stat()s every marker copy it places into the synthetic root, so it knows exactly which inodes are attacker-supplied. A match is proof — and it is proof of a stronger kind than a path string. A path can be pointed somewhere else with a symlink or a bind mount. An inode is the object.

TWO-CHANNEL DETECTION PROT_EXEC mapbacked by a filethe caller supplied strace -yresolves via /proc/PID/fd path under root?readable, forgeable bpftrace kfuncreports (dev, ino) inode we placed?identity, not a name path() from this proberefused: d_path allowlist excludes security_mmap_file agreeor itis us
The same mapping is measured twice on purpose. A path can be pointed elsewhere with a symlink; an inode is the object. When the channels disagree the instrument is wrong, so the verdict is HARNESS_ERROR rather than a finding.

The two layers also stopped being redundant. strace supplies readable paths and the chroot disagreement; bpftrace supplies identity. When they disagree, the verdict is HARNESS_ERROR — never a finding. An instrument whose channels contradict each other has not discovered anything.

Three ways the measurement lies

Each of these produces a clean result that means nothing, which is worse than a crash.

Tracing a setuid binary as the wrong user. Run strace as the unprivileged caller and the kernel drops the setuid bit. sudo never becomes root, the vulnerable path is never reached, and the harness reports CLEAN with total confidence. strace has to stay root and drop privileges for the traced command itself:

strace -f -y -e trace=openat,mmap -u labuser -- /opt/sudo-vuln/bin/sudo -n -R "$ROOT" /bin/true

Comparing device numbers across the kernel boundary. The probe reports dev=265289729. Python’s os.stat().st_dev for the same file reports something else entirely, because the kernel packs dev_t as (major << 20) | minor and glibc does not. Compare them raw and nothing ever matches — a permanently silent detector that looks like a clean sweep. Both sides normalise to (major, minor) before the join.

A gate that waits for an event that never comes. My probe-attach check was:

bpftrace -e 'kprobe:security_mmap_file { printf("ok\n"); exit(); }'

It worked every time I ran it by hand, and produced nothing during a clean-room rebuild. It was never broken. security_mmap_file only fires when some process maps a file, and on an idle VM that can take arbitrarily long — my own shell activity had been triggering it all along. Under a timeout it looks like a hard failure. The fix is to stop depending on ambient activity:

bpftrace -e 'kprobe:security_mmap_file { printf("ok\n"); exit(); }' -c /bin/true

I take the general lesson to be that a check which passes because of your own presence is not a check.

What it prints now

primary        CONFIRMED
no-config      CLEAN
no-root        CLEAN
fixed-build    CLEAN

CALIBRATION: PASS

Destroying the VM and rebuilding it from the documentation alone reproduces this. A lab that only works on the machine that built it is not a lab.

The harness is stdlib-only Python with 73 unit tests that run on the host without a VM, because trace parsing and containment logic are pure functions and deserve to be tested like it. The lab lives in the repository under labs/privileged-root-namespace/, with the bring-up commands and the expected evidence.

What this is not

It is not a vulnerability. Nothing here is new about CVE-2025-32463, which was fixed in 1.9.17p1 and is being used only as a known-positive — a bug I can point the instrument at to find out whether the instrument works.

The sweep is the next piece of work, and it is the part that might produce nothing. Enumerating the privileged binaries that take a caller-supplied root — --root, --installroot, --sysroot, RootDirectory=, ChrootDirectory — is real reading, and every one of them may turn out to resolve its loaders before it enters, or to be reachable only by someone who is already root. That result is worth publishing too, provided the instrument that produced it has proven it can see.

Which is the only claim I am making today: when the sweep says clean, that will mean something. Last week it would not have.

Sources & limits

Evidence used for this analysis

Sources checkedAugust 30, 2026

Version and exploitation status can change; follow the linked vendor records.

Review statusPublic sources reviewed

Primary public records were checked. Environment-specific behavior remains outside the claim unless separately reproduced.

◈ Cite This ResearchBibTeX · Markdown

Reference this analysis, root-cause teardown, or vulnerability discovery in your technical reports or academic publications:

@misc{jankesec_root_namespace_load_detection_2026,
  author       = {Sevban D\"{o}nmez},
  title        = {Calibrating a Root-Namespace Load Detector},
  year         = {2026},
  howpublished = {\url{https://jankesec.com/research/root-namespace-load-detection/}},
  note         = {jankesec technical security research (CVE-2025-32463)}
}