---
title: "Calibrating a Root-Namespace Load Detector"
description: "I ended a CVE-2025-32463 write-up with a recommendation and never carried it out. This is what happened when I built the instrument to do it: an inert marker running as root, two detection layers that had to be redesigned mid-flight, and three ways the measurement lies to you."
date: 2026-08-30
analysisType: "Variant Analysis"
cves: ["CVE-2025-32463"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/root-namespace-load-detection/
---

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.

| Control | Build | Root supplied | `nsswitch.conf` | Expected |
| --- | --- | --- | --- | --- |
| primary | 1.9.17 | yes | yes | **CONFIRMED** |
| no-config | 1.9.17 | yes | no | CLEAN |
| no-root | 1.9.17 | no | yes | CLEAN |
| fixed-build | 1.9.17p1 | yes | yes | CLEAN |

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

```bash
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.

```c
__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:

```text
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:

```text
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:

```text
kprobe:security_mmap_file { … path((struct file *)arg0) … }
```

```text
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:

```text
kfunc:security_mmap_file { … path(args.file) … }
```

```text
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:

```text
kfunc:security_file_open { … path(args.file) … }
```

```text
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:

```text
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);
}
```

```text
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.

<figure class="diagram">
<svg viewBox="0 0 760 372" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="One executable mapping of a caller-supplied file is observed through two independent channels. The strace channel resolves a path through proc fd and tests containment under the caller-supplied root. The bpftrace channel reports device and inode identity, which is joined against the inodes the harness placed in the synthetic root. A third route, the d_path helper, is refused by the kernel allowlist and by the available helper set. The two channels must agree, otherwise the verdict is a harness error rather than a finding.">
<defs><marker id="rnld-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow" /></marker></defs>
<text x="0" y="18" class="dg-label">TWO-CHANNEL DETECTION</text>
<rect x="0" y="112" width="152" height="86" rx="8" class="dg-box" />
<text x="16" y="144" class="dg-label">PROT_EXEC map</text><text x="16" y="166" class="dg-muted">backed by a file</text><text x="16" y="185" class="dg-muted">the caller supplied</text>
<line x1="154" y1="155" x2="186" y2="155" class="dg-line" />
<line x1="186" y1="155" x2="186" y2="78" class="dg-line" />
<line x1="186" y1="155" x2="186" y2="250" class="dg-line" />
<line x1="186" y1="78" x2="214" y2="78" class="dg-line" marker-end="url(#rnld-arrow)" />
<line x1="186" y1="250" x2="214" y2="250" class="dg-line" marker-end="url(#rnld-arrow)" />
<rect x="218" y="44" width="196" height="70" rx="8" class="dg-box" />
<text x="234" y="72" class="dg-label">strace -y</text><text x="234" y="94" class="dg-muted">resolves via /proc/PID/fd</text>
<line x1="416" y1="79" x2="452" y2="79" class="dg-line" marker-end="url(#rnld-arrow)" />
<rect x="456" y="44" width="196" height="70" rx="8" class="dg-box-accent" />
<text x="472" y="72" class="dg-label">path under root?</text><text x="472" y="94" class="dg-accent">readable, forgeable</text>
<rect x="218" y="216" width="196" height="70" rx="8" class="dg-box" />
<text x="234" y="244" class="dg-label">bpftrace kfunc</text><text x="234" y="266" class="dg-muted">reports (dev, ino)</text>
<line x1="416" y1="251" x2="452" y2="251" class="dg-line" marker-end="url(#rnld-arrow)" />
<rect x="456" y="216" width="196" height="70" rx="8" class="dg-box-accent" />
<text x="472" y="244" class="dg-label">inode we placed?</text><text x="472" y="266" class="dg-accent">identity, not a name</text>
<rect x="218" y="308" width="434" height="56" rx="8" class="dg-box-danger" />
<text x="234" y="332" class="dg-label">path() from this probe</text><text x="234" y="352" class="dg-danger">refused: d_path allowlist excludes security_mmap_file</text>
<line x1="316" y1="288" x2="316" y2="306" class="dg-line dg-dash" />
<line x1="654" y1="79" x2="690" y2="79" class="dg-line" />
<line x1="654" y1="251" x2="690" y2="251" class="dg-line" />
<line x1="690" y1="79" x2="690" y2="251" class="dg-line" />
<rect x="694" y="130" width="66" height="70" rx="8" class="dg-box-ok" />
<text x="708" y="158" class="dg-ok">agree</text><text x="708" y="180" class="dg-muted">or it</text><text x="708" y="196" class="dg-muted">is us</text>
</svg>
<figcaption>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 <code>HARNESS_ERROR</code> rather than a finding.</figcaption>
</figure>

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:

```text
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:

```bash
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:

```bash
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

```text
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.