* src/chcon.c: Include fcntl--.h.
(change_file_context): Open a file descriptor when only a partial
context is specified. Use it to get and subsequently set the file
context, instead of using getfileconat followed by setfileconat.
* tests/chcon/traversal-missing.sh: Adjust the test using partial
contexts to inject open and openat errors. Add a test for complete
contexts that injects errors on xattr system calls.
* NEWS: Mention the bug fix.

Discovered by Anthropic's OSS Scanner, as bug ANT-2026-9PFL6OLT.
---
 NEWS                             |  4 +++
 src/chcon.c                      | 57 +++++++++++++++++++++-----------
 tests/chcon/traversal-missing.sh | 35 ++++++++++++++++----
 3 files changed, 70 insertions(+), 26 deletions(-)

diff --git a/NEWS b/NEWS
index 02bb0d05c..c22547130 100644
--- a/NEWS
+++ b/NEWS
@@ -11,6 +11,10 @@ GNU coreutils NEWS                                    -*- 
outline -*-
   security context fails instead of the basename.
   [bug introduced with the chcon program in coreutils-6.9.90]
 
+  'chcon' avoids a race between getting and setting security contexts of a file
+  when only a partial context is specified.
+  [bug introduced with the chcon program in coreutils-6.9.90]
+
   'chroot --userspec=USER' now uses the primary group of USER as looked up
   inside the chroot.  Previously the group looked up outside the chroot
   was used, even when the look-up inside the chroot succeeded.
diff --git a/src/chcon.c b/src/chcon.c
index 4b0e71ed3..cadf5ace3 100644
--- a/src/chcon.c
+++ b/src/chcon.c
@@ -22,6 +22,7 @@
 
 #include "system.h"
 #include "dev-ino.h"
+#include "fcntl--.h"
 #include "fts-missing.h"
 #include "ignore-value.h"
 #include "quote.h"
@@ -138,26 +139,41 @@ compute_context_from_mask (char const *context, context_t 
*ret)
    Return 0 if successful, 1 if errors occurred. */
 
 static int
-change_file_context (char const *file_full_name, int fd, char const *file,
-                     bool ignore_missing)
+change_file_context (char const *file_full_name, int const dirfd,
+                     char const *file, bool ignore_missing)
 {
   char *file_context = NULL;
   context_t context IF_LINT (= NULL);
   char const * context_string;
   int errors = 0;
+  int fd = -1;
 
   if (specified_context == NULL)
     {
-      int status = (affect_symlink_referent
-                    ? getfileconat (fd, file, &file_context)
-                    : lgetfileconat (fd, file, &file_context));
-
-      if (status < 0 && errno != ENODATA)
+      /* libselinux versions before 3.4 did not support O_PATH.
+         The O_PATH support works by opening the file descriptor under
+         /proc/self/fd.  We could check if fsetfilecon returns EBADF and
+         do the same, but there is likely not much benefit over just
+         using O_RDONLY.  */
+      fd = openat (dirfd, file,
+                   (O_RDONLY | O_NOCTTY | O_NONBLOCK
+                    | (affect_symlink_referent ? 0 : O_NOFOLLOW)));
+
+      if (fd < 0)
         {
           if (ignore_missing && ignorable_traversal_errno (errno))
             return 0;
+          error (0, errno, _("failed to open %s"), quoteaf (file_full_name));
+          return 1;
+        }
+
+      int const status = fgetfilecon (fd, &file_context);
+
+      if (status < 0 && errno != ENODATA)
+        {
           error (0, errno, _("failed to get security context of %s"),
                  quoteaf (file_full_name));
+          close (fd);
           return 1;
         }
 
@@ -168,12 +184,14 @@ change_file_context (char const *file_full_name, int fd, 
char const *file,
         {
           error (0, 0, _("can't apply partial context to unlabeled file %s"),
                  quoteaf (file_full_name));
+          close (fd);
           return 1;
         }
 
       if (compute_context_from_mask (file_context, &context))
         {
           freecon (file_context);
+          close (fd);
           return 1;
         }
 
@@ -186,19 +204,19 @@ change_file_context (char const *file_full_name, int fd, 
char const *file,
 
   if (file_context == NULL || ! streq (context_string, file_context))
     {
-      int fail = (affect_symlink_referent
-                  ?  setfileconat (fd, file, context_string)
-                  : lsetfileconat (fd, file, context_string));
-
-      if (fail)
+      int fail = (0 <= fd
+                  ? fsetfilecon (fd, context_string)
+                  : affect_symlink_referent
+                  ? setfileconat (dirfd, file, context_string)
+                  : lsetfileconat (dirfd, file, context_string));
+
+      if (fail
+          && ! (fd < 0 && ignore_missing && ignorable_traversal_errno (errno)))
         {
-          if (! (ignore_missing && ignorable_traversal_errno (errno)))
-            {
-              errors = 1;
-              error (0, errno, _("failed to change context of %s to %s"),
-                     quoteaf_n (0, file_full_name),
-                     quote_n (1, context_string));
-            }
+          errors = 1;
+          error (0, errno, _("failed to change context of %s to %s"),
+                 quoteaf_n (0, file_full_name),
+                 quote_n (1, context_string));
         }
     }
 
@@ -206,6 +224,7 @@ change_file_context (char const *file_full_name, int fd, 
char const *file,
     {
       context_free (context);
       freecon (file_context);
+      close (fd);
     }
 
   return errors;
diff --git a/tests/chcon/traversal-missing.sh b/tests/chcon/traversal-missing.sh
index 7286cc8fd..6a0e912dd 100755
--- a/tests/chcon/traversal-missing.sh
+++ b/tests/chcon/traversal-missing.sh
@@ -20,22 +20,24 @@
 print_ver_ chcon
 require_strace_ getxattr
 
-mkdir d || framework_failure_
-touch d/foo || framework_failure_
-xattrs=getxattr,lgetxattr,setxattr,lsetxattr
+mkdir -p d/foo || framework_failure_
+touch d/foo/bar || framework_failure_
+opens=open,openat
 
 (cd d && chcon -R -t user_tmp_t . > /dev/null 2>&1) \
   || skip_ 'chcon does not work on the test file system'
 
 # Skip if strace does not support path-filtered syscall injection.
-strace --quiet=all -o /dev/null -P /proc/self/fd/4/foo \
-  -e inject=$xattrs:error=ENOENT true 2> /dev/null \
+strace --quiet=all -o /dev/null -P d/foo -P d/foo/bar \
+  -e inject=$opens:error=ENOENT true 2> /dev/null \
   || skip_ 'strace does not support the required options and syscalls'
 
+# Test traversal using a partial context, where we use openat to
+# avoid races.
 for errnum in ENOENT; do
   rm -f err trace
-  (cd d && strace --quiet=all -o ../trace -P /proc/self/fd/4/foo \
-     -e inject=$xattrs:error=$errnum chcon -R -t user_tmp_t . \
+  (cd d && strace --quiet=all -o ../trace -P foo -P foo/bar \
+     -e inject=$opens:error=$errnum chcon -R -t user_tmp_t . \
      > /dev/null 2> ../err)
   status=$?
 
@@ -45,4 +47,23 @@ for errnum in ENOENT; do
   test ! -s err || { cat err; fail=1; }
 done
 
+# Test traversal on a complete context, where getfileconat
+# or lgetfileconat are used.  This requires root, and we use
+# root:object_r:tmp_t:s0 since it works without mcstransd running.
+if uid_is_privileged_; then
+  xattrs=getxattr,lgetxattr,setxattr,lsetxattr
+  for errnum in ENOENT; do
+    rm -f err trace
+    (cd d && strace --quiet=all -o ../trace -P /proc/self/fd/4/foo \
+       -P /proc/self/fd/5/bar -e inject=$xattrs:error=$errnum \
+       chcon -R root:object_r:tmp_t:s0 . > /dev/null 2> ../err)
+    status=$?
+
+    grep ' (INJECTED)' trace > /dev/null 2>&1 \
+      || skip_ 'strace did not inject the requested failure'
+    test $status -eq 0 || fail=1
+    test ! -s err || { cat err; fail=1; }
+  done
+fi
+
 Exit $fail
-- 
2.56.0


Reply via email to