From: Karel Zak Date: Thu, 19 Feb 2026 12:59:46 +0000 (+0100) Subject: loopdev: add LOOPDEV_FL_NOFOLLOW to prevent symlink attacks X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=5e390467b26a3cf3fecc04e1a0d482dff3162fc4;p=thirdparty%2Futil-linux.git loopdev: add LOOPDEV_FL_NOFOLLOW to prevent symlink attacks Add a new LOOPDEV_FL_NOFOLLOW flag for loop device context that prevents symlink following in both path canonicalization and file open. When set: - loopcxt_set_backing_file() uses strdup() instead of ul_canonicalize_path() (which calls realpath() and follows symlinks) - loopcxt_setup_device() adds O_NOFOLLOW to open() flags The flag is set for non-root (restricted) mount operations in libmount's loop device hook. This prevents a TOCTOU race condition where an attacker could replace the backing file (specified in /etc/fstab) with a symlink to an arbitrary root-owned file between path resolution and open(). Vulnerable Code Flow: mount /mnt/point (non-root, SUID) mount.c: sanitize_paths() on user args (mountpoint only) mnt_context_mount() mnt_context_prepare_mount() mnt_context_apply_fstab() <-- source path from fstab hooks run at MNT_STAGE_PREP_SOURCE hook_loopdev.c: setup_loopdev() backing_file = fstab source path ("/home/user/disk.img") loopcxt_set_backing_file() <-- calls realpath() as ROOT ul_canonicalize_path() <-- follows symlinks! loopcxt_setup_device() open(lc->filename, O_RDWR|O_CLOEXEC) <-- no O_NOFOLLOW Two vulnerabilities in the path: 1) loopcxt_set_backing_file() calls ul_canonicalize_path() which uses realpath() -- this follows symlinks as euid=0. If the attacker swaps the file to a symlink before this call, lc->filename becomes the resolved target path (e.g., /root/secret.img). 2) loopcxt_setup_device() opens lc->filename without O_NOFOLLOW. Even if canonicalization happened correctly, the file can be swapped to a symlink between canonicalize and open. Addresses: https://github.com/util-linux/util-linux/security/advisories/GHSA-qq4x-vfq4-9h9g Signed-off-by: Karel Zak --- diff --git a/include/loopdev.h b/include/loopdev.h index d5ef85107..c686e7115 100644 --- a/include/loopdev.h +++ b/include/loopdev.h @@ -140,7 +140,8 @@ enum { LOOPDEV_FL_NOIOCTL = (1 << 6), LOOPDEV_FL_DEVSUBDIR = (1 << 7), LOOPDEV_FL_CONTROL = (1 << 8), /* system with /dev/loop-control */ - LOOPDEV_FL_SIZELIMIT = (1 << 9) + LOOPDEV_FL_SIZELIMIT = (1 << 9), + LOOPDEV_FL_NOFOLLOW = (1 << 10) /* O_NOFOLLOW, don't follow symlinks */ }; /* diff --git a/lib/loopdev.c b/lib/loopdev.c index baa736890..40768e773 100644 --- a/lib/loopdev.c +++ b/lib/loopdev.c @@ -1253,7 +1253,10 @@ int loopcxt_set_backing_file(struct loopdev_cxt *lc, const char *filename) if (!lc) return -EINVAL; - lc->filename = ul_canonicalize_path(filename); + if (lc->flags & LOOPDEV_FL_NOFOLLOW) + lc->filename = strdup(filename); + else + lc->filename = ul_canonicalize_path(filename); if (!lc->filename) return -errno; @@ -1394,6 +1397,8 @@ int loopcxt_setup_device(struct loopdev_cxt *lc) if (lc->config.info.lo_flags & LO_FLAGS_DIRECT_IO) flags |= O_DIRECT; + if (lc->flags & LOOPDEV_FL_NOFOLLOW) + flags |= O_NOFOLLOW; if ((file_fd = open(lc->filename, mode | flags)) < 0) { if (mode != O_RDONLY && (errno == EROFS || errno == EACCES)) diff --git a/libmount/src/hook_loopdev.c b/libmount/src/hook_loopdev.c index 811685b13..0c3832b36 100644 --- a/libmount/src/hook_loopdev.c +++ b/libmount/src/hook_loopdev.c @@ -272,7 +272,8 @@ static int setup_loopdev(struct libmnt_context *cxt, } DBG_OBJ(LOOP, cxt, ul_debug("not found; create a new loop device")); - rc = loopcxt_init(&lc, 0); + rc = loopcxt_init(&lc, + mnt_context_is_restricted(cxt) ? LOOPDEV_FL_NOFOLLOW : 0); if (rc) goto done_no_deinit; if (mnt_opt_has_value(loopopt)) {