|
| 1 | +From f1513f7b7b0e218671e60f66ad139d6f081f69df Mon Sep 17 00:00:00 2001 |
| 2 | +From: Karel Zak <kzak@redhat.com> |
| 3 | +Date: Thu, 19 Feb 2026 13:59:46 +0100 |
| 4 | +Subject: [PATCH] loopdev: add LOOPDEV_FL_NOFOLLOW to prevent symlink attacks |
| 5 | + |
| 6 | +Add a new LOOPDEV_FL_NOFOLLOW flag for loop device context that |
| 7 | +prevents symlink following in both path canonicalization and file open. |
| 8 | + |
| 9 | +When set: |
| 10 | +- loopcxt_set_backing_file() uses strdup() instead of |
| 11 | + ul_canonicalize_path() (which calls realpath() and follows symlinks) |
| 12 | +- loopcxt_setup_device() adds O_NOFOLLOW to open() flags |
| 13 | + |
| 14 | +The flag is set for non-root (restricted) mount operations in |
| 15 | +libmount's loop device hook. This prevents a TOCTOU race condition |
| 16 | +where an attacker could replace the backing file (specified in |
| 17 | +/etc/fstab) with a symlink to an arbitrary root-owned file between |
| 18 | +path resolution and open(). |
| 19 | + |
| 20 | +Vulnerable Code Flow: |
| 21 | + |
| 22 | + mount /mnt/point (non-root, SUID) |
| 23 | + mount.c: sanitize_paths() on user args (mountpoint only) |
| 24 | + mnt_context_mount() |
| 25 | + mnt_context_prepare_mount() |
| 26 | + mnt_context_apply_fstab() <-- source path from fstab |
| 27 | + hooks run at MNT_STAGE_PREP_SOURCE |
| 28 | + hook_loopdev.c: setup_loopdev() |
| 29 | + backing_file = fstab source path ("/home/user/disk.img") |
| 30 | + loopcxt_set_backing_file() <-- calls realpath() as ROOT |
| 31 | + ul_canonicalize_path() <-- follows symlinks! |
| 32 | + loopcxt_setup_device() |
| 33 | + open(lc->filename, O_RDWR|O_CLOEXEC) <-- no O_NOFOLLOW |
| 34 | + |
| 35 | +Two vulnerabilities in the path: |
| 36 | + |
| 37 | +1) loopcxt_set_backing_file() calls ul_canonicalize_path() which uses |
| 38 | + realpath() -- this follows symlinks as euid=0. If the attacker swaps |
| 39 | + the file to a symlink before this call, lc->filename becomes the |
| 40 | + resolved target path (e.g., /root/secret.img). |
| 41 | + |
| 42 | +2) loopcxt_setup_device() opens lc->filename without O_NOFOLLOW. Even |
| 43 | + if canonicalization happened correctly, the file can be swapped to a |
| 44 | + symlink between canonicalize and open. |
| 45 | + |
| 46 | +Addresses: https://github.com/util-linux/util-linux/security/advisories/GHSA-qq4x-vfq4-9h9g |
| 47 | +Signed-off-by: Karel Zak <kzak@redhat.com> |
| 48 | +(cherry picked from commit 5e390467b26a3cf3fecc04e1a0d482dff3162fc4) |
| 49 | +Signed-off-by: Azure Linux Security Servicing Account <azurelinux-security@microsoft.com> |
| 50 | +Upstream-reference: https://github.com/util-linux/util-linux/commit/f55f9906b4f6eeb2b4a4120317df9de935253c10.patch |
| 51 | +--- |
| 52 | + include/loopdev.h | 3 ++- |
| 53 | + lib/loopdev.c | 7 ++++++- |
| 54 | + libmount/src/hook_loopdev.c | 3 ++- |
| 55 | + 3 files changed, 10 insertions(+), 3 deletions(-) |
| 56 | + |
| 57 | +diff --git a/include/loopdev.h b/include/loopdev.h |
| 58 | +index d10bf7f..0f85dd2 100644 |
| 59 | +--- a/include/loopdev.h |
| 60 | ++++ b/include/loopdev.h |
| 61 | +@@ -139,7 +139,8 @@ enum { |
| 62 | + LOOPDEV_FL_NOIOCTL = (1 << 6), |
| 63 | + LOOPDEV_FL_DEVSUBDIR = (1 << 7), |
| 64 | + LOOPDEV_FL_CONTROL = (1 << 8), /* system with /dev/loop-control */ |
| 65 | +- LOOPDEV_FL_SIZELIMIT = (1 << 9) |
| 66 | ++ LOOPDEV_FL_SIZELIMIT = (1 << 9), |
| 67 | ++ LOOPDEV_FL_NOFOLLOW = (1 << 10) /* O_NOFOLLOW, don't follow symlinks */ |
| 68 | + }; |
| 69 | + |
| 70 | + /* |
| 71 | +diff --git a/lib/loopdev.c b/lib/loopdev.c |
| 72 | +index c72fb2c..28fb489 100644 |
| 73 | +--- a/lib/loopdev.c |
| 74 | ++++ b/lib/loopdev.c |
| 75 | +@@ -1267,7 +1267,10 @@ int loopcxt_set_backing_file(struct loopdev_cxt *lc, const char *filename) |
| 76 | + if (!lc) |
| 77 | + return -EINVAL; |
| 78 | + |
| 79 | +- lc->filename = canonicalize_path(filename); |
| 80 | ++ if (lc->flags & LOOPDEV_FL_NOFOLLOW) |
| 81 | ++ lc->filename = strdup(filename); |
| 82 | ++ else |
| 83 | ++ lc->filename = canonicalize_path(filename); |
| 84 | + if (!lc->filename) |
| 85 | + return -errno; |
| 86 | + |
| 87 | +@@ -1408,6 +1411,8 @@ int loopcxt_setup_device(struct loopdev_cxt *lc) |
| 88 | + |
| 89 | + if (lc->config.info.lo_flags & LO_FLAGS_DIRECT_IO) |
| 90 | + flags |= O_DIRECT; |
| 91 | ++ if (lc->flags & LOOPDEV_FL_NOFOLLOW) |
| 92 | ++ flags |= O_NOFOLLOW; |
| 93 | + |
| 94 | + if ((file_fd = open(lc->filename, mode | flags)) < 0) { |
| 95 | + if (mode != O_RDONLY && (errno == EROFS || errno == EACCES)) |
| 96 | +diff --git a/libmount/src/hook_loopdev.c b/libmount/src/hook_loopdev.c |
| 97 | +index 597b933..4df1915 100644 |
| 98 | +--- a/libmount/src/hook_loopdev.c |
| 99 | ++++ b/libmount/src/hook_loopdev.c |
| 100 | +@@ -272,7 +272,8 @@ static int setup_loopdev(struct libmnt_context *cxt, |
| 101 | + } |
| 102 | + |
| 103 | + DBG(LOOP, ul_debugobj(cxt, "not found; create a new loop device")); |
| 104 | +- rc = loopcxt_init(&lc, 0); |
| 105 | ++ rc = loopcxt_init(&lc, |
| 106 | ++ mnt_context_is_restricted(cxt) ? LOOPDEV_FL_NOFOLLOW : 0); |
| 107 | + if (rc) |
| 108 | + goto done_no_deinit; |
| 109 | + if (mnt_opt_has_value(loopopt)) { |
| 110 | +-- |
| 111 | +2.45.4 |
| 112 | + |
0 commit comments