GHSA-55fx-f4gg-cfhj
CVE Information
UL-H02:
nsenter --join-cgroupleaks root cgroup migration authorityStatus:
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-270Executive summary
nsenter --join-cgroupopens the target cgroup v2cgroup.procswhile the caller is still root, uses it to migratensenteritself, and leaves the descriptor open across all subsequent namespace and credential transitions and acrossexecve(). The descriptor is neither closed nor markedO_CLOEXEC.Linux deliberately performs later cgroup migration permission checks using the credentials captured in
file->f_credwhen 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_RIGHTSfrom an attacker-created user/mount/PID namespace to a host-side UID-1000 process. That receiver:
- fails with
EACCESwhen it directly writes a root PID to the same destinationcgroup.procs;- succeeds through the inherited root-opened FD and moves the root process from an unrelated host cgroup into its systemd user-service cgroup; and
- uses its ordinary ownership of that service's
cgroup.killto terminate the migrated root process, whose wait status is 137.This is not an autonomous SUID LPE. Debian installs
nsenteras an ordinary 0755 binary. A root/CAP_SYS_ADMIN operator must invoke the documented--join-cgroupoperation 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-cgroupThe target's mount namespace has an attacker-owned wrapper bind-mounted over
/bin/bash, soexec_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.4284796d917bcbad37aecfdadf36d71fee5b356efd;- 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-cgroupagainst 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.killeffect.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 migrationLinux 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: 0000000000000000The 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.scopeto the attacker-owned destination:
/user.slice/user-1000.slice/user@1000.service/app.slice/ul-h02-auto.serviceA 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 demonstratedcgroup.killterminates 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
- 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
EACCESat the common-ancestor permission check.- Omit
--join-cgroup: nocgroup.procsdescriptor is inherited and the victim remains in its source cgroup.- Apply the experimental fix:
nsenterstill joins the target cgroup, but the executed process reports no inherited cgroup descriptor and cannot migrate the victim.- 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.procswithO_CLOEXECas defense in depth; and- closes
cgroup_procs_fdimmediately after migratingnsenteritself.The primary fix is the close after
join_into_cgroup().O_CLOEXECprotects 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
stracevisibly leaves the FD open beforeclone(), 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
sleepcreated 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
nsentermust 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*' -printThe filesystem type must be
cgroup2fsand the finalfindshould 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.shExpected properties:
- a transient
ul-h02-auto.serviceunderuser@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/bashonly in that mount namespace.Actor 2: root invokes stock nsenter
From an administrator login:
cd /path/to/UL-H02 sudo ./repro.shThe only
nsentercommand issued by the harness is printed before execution:SHELL=/bin/bash /usr/bin/nsenter --target PID --all --join-cgroupSuccess 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 PASSThe 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.shThe 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.cRun an unpatched binary as root:
sudo tests/test_nsenter_cgroup_fd.sh \ vulnerable /usr/bin/nsenter /tmp/nsenter_cgroup_fd_probe 20For 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 20The 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-rootsetup.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