]> git.ipfire.org Git - thirdparty/util-linux.git/commitdiff
loopdev: add LOOPDEV_FL_NOFOLLOW to prevent symlink attacks
authorKarel Zak <kzak@redhat.com>
Thu, 19 Feb 2026 12:59:46 +0000 (13:59 +0100)
committerKarel Zak <kzak@redhat.com>
Wed, 1 Apr 2026 08:47:03 +0000 (10:47 +0200)
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 <kzak@redhat.com>
include/loopdev.h
lib/loopdev.c
libmount/src/hook_loopdev.c

index d5ef85107b15332fd8cd9973160c71e6d5dc2efd..c686e711517d8fa915b253fb08aea02d492192a6 100644 (file)
@@ -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 */
 };
 
 /*
index baa736890aa8ceaee83c12a24fec59d762ad6c66..40768e773e08c970318a842894e9617ef211afe8 100644 (file)
@@ -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))
index 811685b136f4e2cd1cb6a7b972a0fc6394d947eb..0c3832b36d4f193811c25b67f43eac01673ad87d 100644 (file)
@@ -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)) {