Skip to content

GHSA-55fx-f4gg-cfhj on Cyberus Linux 26.05

Aliases: GHSA-55fx-f4gg-cfhj, CVE-2026-78408

Packages: util-linux

Status: Resolved

Advisory Information

UL-H02: nsenter --join-cgroup leaks root cgroup migration authority

Status: CONFIRMED_FINDING (private, not yet coordinated)
Project: util-linux
Component: sys-utils/nsenter.c
Severity: High
CVSS 3.1: 7.9 (CVSS:3.1/AV:L/AC:L/PR:L/UI:R/S:C/C:N/I:H/A:H)
Weaknesses: CWE-775 / CWE-270

Executive summary

nsenter --join-cgroup opens the target cgroup v2 cgroup.procs while the caller is still root, uses it to migrate nsenter itself, and leaves the descriptor open across all subsequent namespace and credential transitions and across execve(). The descriptor is neither closed nor marked O_CLOEXEC.

Linux deliberately performs later cgroup migration permission checks using the credentials captured in file->f_cred when the file was opened. As a result, a program executed inside an attacker-controlled target inherits root's migration authority even when that program is host UID 1000 with no effective, permitted, inheritable or ambient capabilities.

The deterministic Debian-stock demonstration transfers the FD with SCM_RIGHTS from an attacker-created user/mount/PID namespace to a host-side UID-1000 process. That receiver:

  1. fails with EACCES when it directly writes a root PID to the same destination cgroup.procs;
  2. succeeds through the inherited root-opened FD and moves the root process from an unrelated host cgroup into its systemd user-service cgroup; and
  3. uses its ordinary ownership of that service's cgroup.kill to terminate the migrated root process, whose wait status is 137.

This is not an autonomous SUID LPE. Debian installs nsenter as an ordinary 0755 binary. A root/CAP_SYS_ADMIN operator must invoke the documented --join-cgroup operation against the attacker-controlled target. The final trigger nevertheless requires no explicit untrusted command:

SHELL=/bin/bash /usr/bin/nsenter --target ATTACKER_PID --all --join-cgroup

The target's mount namespace has an attacker-owned wrapper bind-mounted over /bin/bash, so exec_shell() selects it after entering the namespace. The operation should move only the newly entered process; it must not delegate a reusable host cgroup capability.

Affected versions

  • Introduced by b40650b71a742c188d0986a1162a5730ecb95510 (nsenter: add option -c to join the cgroup of target process).
  • Author date: 2023-06-11; upstream commit date: 2023-06-28.
  • First release: util-linux v2.40.
  • Affected at the audit cutoff:
  • upstream master 1708e71e24163ffdbf5de1446c504ca1e271bf0c;
  • upstream stable/v2.42 84796d917bcbad37aecfdadf36d71fee5b356efd;
  • upstream releases v2.40 through v2.42.2;
  • Debian 13.6 (trixie) util-linux 2.41-5.
  • Debian 12's util-linux 2.38 predates --join-cgroup.
  • Tested architecture/kernel: amd64, 6.12.95+deb13-cloud-amd64 (6.12.95-1).

The Debian trixie source contains the same sequence. No downstream patch touches cgroup_procs_fd.

Threat model and preconditions

Attacker:

  • local host UID/GID 1000;
  • no administrative groups;
  • Inh/Prm/Eff/Amb capability sets all zero in the host receiver;
  • able to create an ordinary unprivileged user/mount/PID namespace;
  • owns the target user service and its delegated service cgroup, as provided by Debian's default systemd user manager.

Required operator action:

  • root or sufficient CAP_SYS_ADMIN/cgroup authority;
  • invokes nsenter --target PID --all --join-cgroup against the target.

Required platform properties:

  • cgroup v2;
  • unprivileged user namespaces for the namespace-relay trigger;
  • systemd's standard user-service cgroup ownership for the demonstrated cgroup.kill effect.

No modified fstab, privileged group, file capability, custom LSM policy or recompiled Debian package is used.

Root cause

At current master, open_cgroup_procs() performs:

int cgroup_fd = 0;
...
open_target_fd(&cgroup_fd, "cgroup", optarg);
...
cgroup_procs_fd = open(fdpath, O_WRONLY | O_APPEND);

join_into_cgroup() writes only the current PID and returns without closing:

if (ul_write_all(cgroup_procs_fd, buf, len))
        err(EXIT_FAILURE, _("write cgroup.procs failed"));

main() calls it before the final credential transition and exec:

if (cgroup_procs_fd >= 0)
        join_into_cgroup();
...
setgroups(...);
setgid(...);
setuid(...);
...
execvp(...);              /* or exec_shell() */

The source-to-sink chain is:

root open(cgroup.procs)
  -> write own PID
  -> no close / no FD_CLOEXEC
  -> setns(user,mount,pid,...)
  -> optional setgroups/setgid/setuid
  -> execve(target-controlled program)
  -> optional SCM_RIGHTS relay to initial PID namespace
  -> write(root victim PID)
  -> kernel permission check under file->f_cred from root open
  -> host root process migration

Linux v6.12 __cgroup_procs_write() explicitly does:

saved_cred = override_creds(of->file->f_cred);
ret = cgroup_attach_permissions(...);
revert_creds(saved_cred);
...
ret = cgroup_attach_task(dst_cgrp, task, threadgroup);

This kernel behavior is intentional. It is the fix direction for CVE-2021-4197 and makes passing an open cgroup FD equivalent to passing its opener's migration authority. util-linux passes it accidentally.

Observable security impact

On Debian stock, the host receiver has:

Uid:    1000 1000 1000 1000
Gid:    1000 1000 1000 1000
CapInh: 0000000000000000
CapPrm: 0000000000000000
CapEff: 0000000000000000
CapAmb: 0000000000000000

The same process observes:

RECEIVER_DIRECT_WRITE rc=-1 errno=13 (Permission denied)
RECEIVER_RELAYED_WRITE rc=4 errno=0 (success)

The root victim moves from:

/user.slice/user-1100.slice/session-37.scope

to the attacker-owned destination:

/user.slice/user-1000.slice/user@1000.service/app.slice/ul-h02-auto.service

A separate UID-1000/CapEff=0 process then writes the naturally owned cgroup.kill; the root victim exits with wait status 137. Beyond termination, migrating a selected root process places it under the resource, freezer and other policy controls available in that delegated cgroup.

No confidentiality primitive or root code execution was demonstrated.

Severity rationale

The tentative CVSS 3.1 base score is 7.9:

  • AV:L: local attacker;
  • AC:L: deterministic once the documented operator action occurs;
  • PR:L: ordinary UID-1000 account;
  • UI:R: a privileged operator must enter the attacker target;
  • S:C: authority crosses from an attacker user/namespace delegation into control over processes in unrelated host cgroups;
  • C:N: no protected read demonstrated;
  • I:H: arbitrary host process cgroup placement violates host security and resource-policy state;
  • A:H: the demonstrated cgroup.kill terminates a selected root process.

The administrative interaction is material and prevents a Critical rating or an autonomous-LPE claim. High is justified by the deterministic cross-namespace host effect and arbitrary root-process termination after a standard debugging/entry operation.

Validation matrix

Environment Result
Upstream master debug, unpatched vulnerable, 1/1
Upstream stable/v2.42 ASan+UBSan vulnerable, 3/3
Debian 13 stock synthetic campaign vulnerable, 20/20
Debian 13 stock default systemd delegation vulnerable, 2/2
Debian 13 stock namespace/SCM_RIGHTS relay vulnerable, 1/1
Debian 13 stock, discarded/relaunched snapshot, automatic shell vulnerable, 1/1
Patched master debug campaign no inherited FD, 20/20
Patched master namespace relay sender has no FD; victim unchanged

The clean-stock boot ID was 99eaf401-6b91-4a67-95a1-9bab93d47db5.

Negative controls

  1. Directly open/write the destination after the drop: the open may succeed because UID 1000 owns its service cgroup, but migrating the unrelated root process fails EACCES at the common-ancestor permission check.
  2. Omit --join-cgroup: no cgroup.procs descriptor is inherited and the victim remains in its source cgroup.
  3. Apply the experimental fix: nsenter still joins the target cgroup, but the executed process reports no inherited cgroup descriptor and cannot migrate the victim.
  4. Relay the descriptor from the patched namespace process: sender reports no inherited cgroup.procs descriptor; receiver times out and the victim remains unchanged.

Proposed correction

patches/experimental-fix.patch:

  • initializes the temporary target cgroup FD to -1;
  • closes it after reading /proc/PID/cgroup;
  • opens cgroup.procs with O_CLOEXEC as defense in depth; and
  • closes cgroup_procs_fd immediately after migrating nsenter itself.

The primary fix is the close after join_into_cgroup(). O_CLOEXEC protects future error/control-flow changes. The descriptor has no legitimate use after the one migration, so regression risk is low.

Deduplication

The final query log is in evidence/DEDUPLICATION.md.

Nearest public records:

  • util-linux issue #2006 and PR #2320 describe/introduce the feature. A comment's strace visibly leaves the FD open before clone(), but neither the commenter nor review identifies descriptor inheritance as a security boundary, file->f_cred, third-party PID migration or a fix.
  • CVE-2021-4197 is a Linux-kernel issue in the inverse trust direction. Its fix creates the open-time-credential behavior on which UL-H02 has impact.
  • util-linux PR #4412 fixes a separate false FD-0 test and does not touch the cgroup descriptor.
  • No matching util-linux advisory, CVE, Debian tracker/BTS report, oss-security post, mailing-list patch or downstream patch was found by the cutoff.

Reproduction

The proof moves and kills a root-owned sleep created by the proof itself; it does not select a real service. The destination is a transient UID-1000 user service.

The two commands deliberately model two actors:

  • setup.sh: UID 1000 in a normal PAM login with an active systemd user manager;
  • repro.sh: root administrator performing the documented namespace-entry operation.

Tested guest

Debian GNU/Linux 13.6 (trixie)
util-linux 2.41-5
kernel 6.12.95+deb13-cloud-amd64 (6.12.95-1)
amd64
cgroup v2

nsenter must be the Debian stock /usr/bin/nsenter; do not replace or recompile it for the stock validation.

Clean start

Restore or relaunch the guest with QEMU -snapshot, then verify:

cat /proc/sys/kernel/random/boot_id
dpkg-query -W util-linux
stat -fc %T /sys/fs/cgroup
find /tmp -maxdepth 1 -name 'ul-h02*' -print

The filesystem type must be cgroup2fs and the final find should be empty.

Copy this finding directory into a location readable by both actors. Do not put it below a mode-0700 home directory.

Actor 1: UID 1000 prepares the target

From an SSH or console login as UID 1000:

cd /path/to/UL-H02
./setup.sh

Expected properties:

  • a transient ul-h02-auto.service under user@1000.service/app.slice;
  • a child whose host UID is 1000;
  • user map 0 1000 1;
  • separate user, mount and PID namespaces;
  • attacker wrapper bind-mounted over /bin/bash only in that mount namespace.

Actor 2: root invokes stock nsenter

From an administrator login:

cd /path/to/UL-H02
sudo ./repro.sh

The only nsenter command issued by the harness is printed before execution:

SHELL=/bin/bash /usr/bin/nsenter --target PID --all --join-cgroup

Success requires all of:

RECEIVER_STATUS Uid: 1000 1000 1000 1000
RECEIVER_STATUS CapEff: 0000000000000000
RECEIVER_DIRECT_WRITE rc=-1 errno=13 (Permission denied)
RECEIVER_RELAYED_WRITE ... errno=0 (success)
AUTO_ROOT_VICTIM_AFTER .../ul-h02-auto.service
AUTO_KILL_WRITER_RC 0 AUTO_VICTIM_WAIT_RC 137
AUTO_RESULT PASS

The inner sender is namespace UID 0, but its UID map is 0 -> host 1000. It transfers the FD to the host-side UID-1000 receiver so that PID parsing is performed in the initial PID namespace.

Cleanup

The successful proof empties the transient service through cgroup.kill. From the UID-1000 login, remove the transient unit and exact temporary files:

./cleanup.sh

The files are disposable and are deleted rather than recoverable.

Directed synthetic campaign

For repeated source/fix testing, compile the probe:

cc -O0 -g -Wall -Wextra \
  -o /tmp/nsenter_cgroup_fd_probe \
  tests/nsenter_cgroup_fd_probe.c

Run an unpatched binary as root:

sudo tests/test_nsenter_cgroup_fd.sh \
  vulnerable /usr/bin/nsenter /tmp/nsenter_cgroup_fd_probe 20

For an upstream binary built with patches/experimental-fix.patch:

sudo tests/test_nsenter_cgroup_fd.sh \
  fixed /path/to/patched/nsenter /tmp/nsenter_cgroup_fd_probe 20

The fixed campaign verifies both continued self-migration and absence of the descriptor in the final UID-1000 process.

repro.sh

#!/bin/bash

set -euo pipefail
IFS=$'\n\t'
umask 077

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)

[[ $(id -u) == 0 ]] || {
  echo "repro.sh must run as root inside the disposable guest" >&2
  exit 77
}
[[ $(stat -fc %T /sys/fs/cgroup) == cgroup2fs ]] || {
  echo "cgroup v2 is required" >&2
  exit 77
}
[[ -x /usr/bin/nsenter ]] || {
  echo "Debian stock /usr/bin/nsenter is missing" >&2
  exit 77
}

dpkg-query -W util-linux
"$script_dir/tests/repro_nsenter_cgroup_fd_auto.sh" exercise-root

setup.sh

#!/bin/bash

set -euo pipefail
IFS=$'\n\t'
umask 077

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
wrapper=/tmp/ul-h02-auto-bash
relay=/tmp/nsenter_cgroup_fd_relay.py
socket_path=/tmp/ul-h02-auto.sock

[[ $(id -u) == 1000 && $(id -g) == 1000 ]] || {
  echo "setup.sh must run as UID:GID 1000:1000 in a PAM login" >&2
  exit 77
}
if [[ -z ${XDG_RUNTIME_DIR:-} ]] ||
   [[ ! -d $XDG_RUNTIME_DIR ]] ||
   ! systemctl --user --quiet is-active default.target; then
  echo "the UID-1000 systemd user manager is not available" >&2
  exit 77
fi
[[ $(stat -fc %T /sys/fs/cgroup) == cgroup2fs ]] || {
  echo "cgroup v2 is required" >&2
  exit 77
}

for path in "$wrapper" "$relay" "$socket_path"; do
  [[ ! -e $path ]] || {
      echo "refusing to replace existing path: $path" >&2
      exit 1
  }
done

install -m 0755 \
  "$script_dir/tests/nsenter_cgroup_fd_relay.py" \
  "$relay"
install -m 0755 \
  "$script_dir/tests/nsenter_cgroup_fd_auto_shell.sh" \
  "$wrapper"

"$script_dir/tests/repro_nsenter_cgroup_fd_auto.sh" prepare-user

Updates

2026-09-21 22:02 CEST

Metadata changes:

  • Status for package util-linux: “Resolved

2026-09-07 17:18 CEST

Metadata changes:

  • Status for package util-linux: “In Progress

2026-09-02 16:24 CEST

Metadata changes:

  • Status for package util-linux: “Plausible

2026-09-02 16:24 CEST

Metadata changes:

  • Status for package util-linux: “New