systemd-tmpfiles Symlink Following + CHASE_SAFE Root Exception: Local Privilege Escalation to Root
Disclosed: 2026-07-22
Summary
Eresus Security identified and reported a local privilege escalation chain in systemd-tmpfiles, tracked as CVE-2026-16552 (CVSS 3.1: 6.3 Moderate, AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H, CWE-59: Improper Link Resolution Before File Access). Two independent bugs combine to let an unprivileged local user redirect a root-owned file write into a file of their choosing, by placing a symlink inside their own /run/user/<uid> runtime directory.
The demonstrated impact is full local root: injecting a passwordless NOPASSWD rule into /etc/sudoers and dropping into a root shell, without any credentials beyond an existing unprivileged local session.
Red Hat Product Security confirmed the mechanism, assigned CVE-2026-16552, and credited Eresus Security as the reporter. The case is now closed and the CVE is public on NVD and the Red Hat CVE database.
Affected component
- Component:
systemd-tmpfiles - Root cause present unconditionally on all versions carrying the
CHASE_SAFEroot-transition exception described below. - RHEL 7 and RHEL 8 systemd releases were also checked and produce the same outcome, but because they predate the
CHASE_SAFEtransition-safety mechanism entirely rather than containing the same narrow exception. - Verified working proof-of-concept on Ubuntu 26.04 (systemd 259) and Arch Linux (systemd 261, at commit
b0241a3onmain).
Root cause
Two bugs chain together.
Bug 1 — missing O_NOFOLLOW, src/tmpfiles/tmpfiles.c:2007. write_one_file() opens w-type tmpfiles.d targets without O_NOFOLLOW:
int flags = O_NONBLOCK | O_CLOEXEC | O_WRONLY | O_NOCTTY
| i->append_or_force * O_APPEND | arg_dry_run * O_PATH;
fd = openat(dir_fd, bn, flags, i->mode);
The comment directly above the call reads "Follow symlinks." If dir_fd points into attacker-controlled space and bn resolves to a symlink, the root-owned process opens and writes through the symlink to its target — wherever that target is.
Bug 2 — the CHASE_SAFE root exception, src/basic/chase.c:161. uid_unsafe_transition() is supposed to flag directory walks that cross from a more-privileged owner to a less-privileged one — exactly the condition CHASE_SAFE exists to catch. It unconditionally treats any transition away from UID 0 as safe:
static bool uid_unsafe_transition(uid_t a, uid_t b) {
if (a == 0) /* root->anything treated as safe */
return false;
return a != b;
}
The exception is there so root can traverse root-owned system directories without tripping false positives. It is too broad: it also suppresses detection of the one transition CHASE_SAFE is explicitly meant to catch — root walking into a directory owned by an unprivileged user. Isolated testing confirmed the asymmetry directly: constructing the same ownership transition between two non-root users is correctly detected and blocked with an explicit warning; the identical transition starting from root is not.
Combined effect. path_open_parent_safe() (tmpfiles.c:1087) calls chase() with CHASE_SAFE to resolve a dir_fd for the parent directory before write_one_file() runs. Bug 2 silently approves the walk from the root-owned /run/user/ into the user-owned /run/user/<uid>/. Bug 1 then follows any symlink the unprivileged user placed inside that directory, without complaint.
What the unprivileged user does and does not control
The unprivileged user chooses which root-owned file is written to, by placing the symlink. They do not choose what gets written — the content comes from the pre-existing tmpfiles.d configuration entry, unless they separately already have write access to that configuration. Red Hat's own review of every tmpfiles.d entry shipped in RHEL found exactly one w-type entry, targeting a path under /sys/fs/selinux/ — a kernel-controlled filesystem where every path component is root-owned with no non-root write access, so it does not itself provide an exploitation path on stock RHEL. Exposure depends on what w-type entries exist on a given system: enterprise deployments using configuration-management tooling (Ansible, Puppet) that provision per-user runtime paths, or systems where users have write access to /run/tmpfiles.d/, are the realistic exposure cases.
Proof of concept
# 1. plant symlink in user runtime dir (fully unprivileged)
ln -s /etc/sudoers /run/user/1000/pwntarget
# 2. write tmpfiles config
# w+ = O_APPEND, preserves existing sudoers content
# \n is unescaped by cunescape() at tmpfiles.c:3693
printf 'w+ /run/user/1000/pwntarget - - - - \\nattacker ALL=(ALL:ALL) NOPASSWD: ALL\\n\n' \
> /tmp/pwn.conf
# 3. root runs tmpfiles (boot service, timer, or admin trigger)
systemd-tmpfiles --create /tmp/pwn.conf
# 4. root shell
sudo /bin/bash
# uid=0(root)
/run/user/<uid> is created automatically by logind for any active session, or via loginctl enable-linger, so the precondition is met by ordinary session activity — no special setup is required beyond a tmpfiles.d entry that targets a path inside it.
Impact
Any local unprivileged user on an affected system can write arbitrary content to a root-owned file whenever a tmpfiles.d configuration processes a path inside their own runtime directory. Demonstrated impact is full local privilege escalation to root, via /etc/sudoers injection, without credentials. The underlying code defect is present unconditionally on affected versions regardless of the deployed tmpfiles.d configuration — actual exploitability on a given host depends on whether that host's configuration contains a reachable w-type entry, which is a fleet-specific question, not a package-version question.
Mitigation guidance
- Apply the vendor fix for CVE-2026-16552 once available for your distribution; track your distribution's own advisory for the exact patched package version.
- Audit every
w-typetmpfiles.dentry on your fleet (grep -rE '^w' /usr/lib/tmpfiles.d/ /etc/tmpfiles.d/ /run/tmpfiles.d/ 2>/dev/null) and confirm none target a path that resolves through a user-writable directory, including indirectly via a directory a user's runtime path could symlink into. - Restrict write access to
/run/tmpfiles.d/to trusted/administrative principals only — configuration-management tooling that provisions per-user runtime paths there is a direct exposure vector. - Where feasible, avoid
w-type entries that write to sensitive targets (/etc/sudoers, credential stores, authentication configuration) as a matter of policy, independent of this specific bug. - Do not treat "root-to-anything is safe" as a valid assumption in any custom path-chasing or privilege-transition logic you maintain elsewhere — this bug class generalises beyond
systemd-tmpfilesto any code that walks a path across a root-to-unprivileged ownership boundary without re-checking safety at each hop.
Detection considerations
- Monitor
/run/user/<uid>/for unexpected symlinks pointing outside the user's own writable space, particularly toward/etc/, credential stores, or authentication configuration. - Alert on
systemd-tmpfilesruns (boot, timers, or manual triggers) immediately followed by modification of/etc/sudoers,/etc/passwd,/etc/shadow, or SSH authorized-keys files, where the modifying process context doesn't match expected configuration-management activity. - Review
tmpfiles.ddrop-in directories forw-type entries added outside of change-managed deployment — an attacker with existing write access to/run/tmpfiles.d/can introduce a malicious entry directly rather than relying on a pre-existing one.
Credit
Discovered and reported by İbrahim Sağlam, Eresus Security. Confirmed and assigned by Red Hat Product Security (case PSIRTSUPT-17699).
References
Related Services