]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
syscall+clientserver: am_chrooted and use_secure_symlinks for daemon-no-chroot (CVE...
authorAndrew Tridgell <andrew@tridgell.net>
Tue, 30 Dec 2025 23:01:23 +0000 (10:01 +1100)
committerAndrew Tridgell <andrew@tridgell.net>
Thu, 7 May 2026 21:48:53 +0000 (07:48 +1000)
CVE-2026-29518: an rsync daemon configured with "use chroot = no"
is exposed to a TOCTOU race on parent path components. A local
attacker with write access to a module can replace a parent
directory component with a symlink between the receiver's check
and its open(), redirecting reads (basis-file disclosure) and
writes (file overwrite) outside the module. Under elevated daemon
privilege this allows privilege escalation. Default
"use chroot = yes" is not exposed.

Add secure_relative_open() in syscall.c. It walks the parent
components under RESOLVE_BENEATH (Linux 5.6+) /
O_RESOLVE_BENEATH (FreeBSD 13+, macOS 15+) / per-component
O_NOFOLLOW elsewhere, anchored at a trusted dirfd, so a parent-
symlink swap is rejected by the kernel. Route the receiver's
basis-file open in receiver.c through it when use_secure_symlinks
is set in clientserver.c rsync_module().

Reporters: Nullx3D (Batuhan SANCAK); Damien Neil; Michael Stapelberg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
clientserver.c
options.c
receiver.c
syscall.c

index 3800f0d62d63c7fa3567799e5e5d9df2582e66ed..e8dfddb1bf77cdf1cfc2d227a5d74ede0ffd79d5 100644 (file)
@@ -30,6 +30,7 @@ extern int list_only;
 extern int am_sender;
 extern int am_server;
 extern int am_daemon;
+extern int am_chrooted;
 extern int am_root;
 extern int msgs2stderr;
 extern int rsync_port;
@@ -38,6 +39,7 @@ extern int ignore_errors;
 extern int preserve_xattrs;
 extern int kluge_around_eof;
 extern int munge_symlinks;
+extern int use_secure_symlinks;
 extern int open_noatime;
 extern int sanitize_paths;
 extern int numeric_ids;
@@ -983,6 +985,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
                        io_printf(f_out, "@ERROR: chroot failed\n");
                        return -1;
                }
+               am_chrooted = 1;
                module_chdir = module_dir;
        }
 
@@ -1005,6 +1008,15 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char
                }
        }
 
+       /* Enable secure symlink handling for any non-chrooted daemon module.
+        * This prevents TOCTOU race attacks where an attacker could switch a
+        * directory to a symlink between path validation and file open.
+        * Match the gate used by the do_*_at() wrappers in syscall.c
+        * (am_daemon && !am_chrooted) -- the protection has nothing to do
+        * with symlink munging, so a module configured with
+        * "munge symlinks = false" must still get the secure-open path. */
+       use_secure_symlinks = am_daemon && !am_chrooted;
+
        if (gid_list.count) {
                gid_t *gid_array = gid_list.items;
                if (setgid(gid_array[0])) {
@@ -1308,6 +1320,19 @@ int start_daemon(int f_in, int f_out)
                        rsyserr(FLOG, errno, "daemon chroot(\"%s\") failed", p);
                        return -1;
                }
+               /* Deliberately do NOT set am_chrooted here.  am_chrooted
+                * gates the per-module symlink-race defenses
+                * (secure_relative_open() and the do_*_at() wrappers in
+                * syscall.c) and means "the kernel is enforcing path
+                * confinement at the module boundary".  The daemon chroot
+                * confines path resolution to the daemon-chroot directory,
+                * not to any individual module path -- modules sharing the
+                * daemon chroot are still distinguishable filesystem
+                * subtrees and a sender-controlled symlink in module A
+                * could redirect a syscall to module B (or to other files
+                * inside the daemon chroot) without the per-module
+                * defenses.  Leave am_chrooted=0 here so secure_relative_open()
+                * still fires for "use chroot = no" modules. */
                if (chdir("/") < 0) {
                        rsyserr(FLOG, errno, "daemon chdir(\"/\") failed");
                        return -1;
index 58ed035fe8ff5fcb90f3b200983dc970a7de2cb3..3c2d235261ad9755d470030b9939f4565cdc9e36 100644 (file)
--- a/options.c
+++ b/options.c
@@ -114,11 +114,20 @@ int mkpath_dest_arg = 0;
 int allow_inc_recurse = 1;
 int xfer_dirs = -1;
 int am_daemon = 0;
+/* Set after a successful per-module chroot ("use chroot = yes") in
+ * clientserver.c. NOT set for the daemon-level "daemon chroot = /X"
+ * chroot: that confines path resolution to /X, but module paths
+ * /X/modA, /X/modB, etc. are not chroot boundaries, so the per-module
+ * symlink-race defenses (secure_relative_open() / do_*_at() in
+ * syscall.c, gated by `am_daemon && !am_chrooted`) must still fire
+ * even when the daemon is inside a daemon chroot. */
+int am_chrooted = 0;
 int connect_timeout = 0;
 int keep_partial = 0;
 int safe_symlinks = 0;
 int copy_unsafe_links = 0;
 int munge_symlinks = 0;
+int use_secure_symlinks = 0;
 int size_only = 0;
 int daemon_bwlimit = 0;
 int bwlimit = 0;
index edfbb210656ce45824ea6ceea8a498d6b2f277fe..5a2c8c5af071ca6693d8dd0ce373d6878984337e 100644 (file)
@@ -70,6 +70,7 @@ extern int fuzzy_basis;
 
 extern struct name_num_item *xfer_sum_nni;
 extern int xfer_sum_len;
+extern int use_secure_symlinks;
 
 static struct bitbag *delayed_bits = NULL;
 static int phase = 0, redoing = 0;
@@ -214,7 +215,12 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file)
         * access to ensure that there is no race condition.  They will be
         * correctly updated after the right owner and group info is set.
         * (Thanks to snabb@epipe.fi for pointing this out.) */
-       fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS);
+       /* When use_secure_symlinks is on (non-chroot daemon with munge_symlinks),
+        * use secure_mkstemp to prevent symlink race attacks on parent directories. */
+       if (use_secure_symlinks)
+               fd = secure_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS);
+       else
+               fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS);
 
 #if 0
        /* In most cases parent directories will already exist because their
@@ -854,11 +860,21 @@ int recv_files(int f_in, int f_out, char *local_name)
                /* We now check to see if we are writing the file "inplace" */
                if (inplace || one_inplace)  {
                        fnametmp = one_inplace ? partialptr : fname;
-                       fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600);
+                       /* When use_secure_symlinks is on (non-chroot daemon),
+                        * use secure open to prevent symlink race attacks where an
+                        * attacker could switch a directory to a symlink between
+                        * path validation and file open. */
+                       if (use_secure_symlinks)
+                               fd2 = secure_relative_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600);
+                       else
+                               fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600);
 #ifdef linux
                        if (fd2 == -1 && errno == EACCES) {
                                /* Maybe the error was due to protected_regular setting? */
-                               fd2 = do_open(fname, O_WRONLY, 0600);
+                               if (use_secure_symlinks)
+                                       fd2 = secure_relative_open(NULL, fname, O_WRONLY, 0600);
+                               else
+                                       fd2 = do_open(fname, O_WRONLY, 0600);
                        }
 #endif
                        if (fd2 == -1) {
index aeda9b32985f7e2987505b3b2ab7f5668199f62f..e4af46bd928d68bdeead782f439e1f0c33769b3d 100644 (file)
--- a/syscall.c
+++ b/syscall.c
@@ -882,6 +882,145 @@ cleanup:
 #endif // O_NOFOLLOW, O_DIRECTORY
 }
 
+/* Fill buf with len random bytes.  Prefers /dev/urandom for cryptographic
+ * quality; falls back to rand() if /dev/urandom cannot be opened or read
+ * (e.g. inside a chroot or container without /dev populated). */
+static void rand_bytes(unsigned char *buf, size_t len)
+{
+#ifndef O_CLOEXEC
+#define O_CLOEXEC 0
+#endif
+       int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC);
+       if (fd >= 0) {
+               ssize_t n = read(fd, buf, len);
+               close(fd);
+               if (n == (ssize_t)len) {
+                       return;
+               }
+       }
+       for (size_t i = 0; i < len; i++) {
+               buf[i] = (unsigned char)rand();
+       }
+}
+
+/*
+  Secure version of mkstemp that prevents symlink attacks on parent directories.
+  Like secure_relative_open(), this walks the path checking each component
+  with O_NOFOLLOW to prevent TOCTOU race conditions.
+
+  The template may be relative or absolute, but must not contain ../ components.
+  Returns fd on success, -1 on error.
+*/
+int secure_mkstemp(char *template, mode_t perms)
+{
+#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD)
+       /* Fall back to regular mkstemp on old systems */
+       return do_mkstemp(template, perms);
+#else
+       char *lastslash;
+       int dirfd = AT_FDCWD;
+       int fd = -1;
+
+       if (!template) {
+               errno = EINVAL;
+               return -1;
+       }
+       if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) {
+               errno = EINVAL;
+               return -1;
+       }
+
+       /* For absolute paths, start the secure walk from "/" rather than CWD. */
+       if (template[0] == '/') {
+               dirfd = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
+               if (dirfd < 0)
+                       return -1;
+       }
+
+       /* Find the last slash to separate directory from filename */
+       lastslash = strrchr(template, '/');
+       if (lastslash) {
+               char *path_copy = my_strdup(template, __FILE__, __LINE__);
+               if (!path_copy)
+                       return -1;
+
+               /* Null-terminate at the last slash to get directory part */
+               path_copy[lastslash - template] = '\0';
+
+               /* Walk the directory path securely */
+               for (const char *part = strtok(path_copy, "/");
+                    part != NULL;
+                    part = strtok(NULL, "/"))
+               {
+                       int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
+                       if (next_fd == -1) {
+                               int save_errno = errno;
+                               free(path_copy);
+                               if (dirfd != AT_FDCWD) close(dirfd);
+                               errno = (save_errno == ELOOP) ? ELOOP : save_errno;
+                               return -1;
+                       }
+                       if (dirfd != AT_FDCWD) close(dirfd);
+                       dirfd = next_fd;
+               }
+               free(path_copy);
+       }
+
+       /* Now create the temp file in the securely-opened directory */
+       perms |= S_IWUSR;
+
+       /* Generate unique filename - we need to modify the template in place */
+       char *filename = lastslash ? lastslash + 1 : template;
+       size_t filename_len = strlen(filename);
+
+       if (filename_len < 6) {
+               if (dirfd != AT_FDCWD) close(dirfd);
+               errno = EINVAL;
+               return -1;
+       }
+       char *suffix = filename + filename_len - 6; /* Points to XXXXXX */
+       if (strcmp(suffix, "XXXXXX") != 0) {
+               if (dirfd != AT_FDCWD) close(dirfd);
+               errno = EINVAL;
+               return -1;
+       }
+
+       /* Try random suffixes until we find one that works */
+       static const char letters[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
+       for (int tries = 0; tries < 100; tries++) {
+               unsigned char rbytes[6];
+               rand_bytes(rbytes, sizeof(rbytes));
+               for (int i = 0; i < 6; i++)
+                       suffix[i] = letters[rbytes[i] % (sizeof(letters) - 1)];
+
+               fd = openat(dirfd, filename, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, perms);
+               if (fd >= 0)
+                       break;
+               if (errno != EEXIST) {
+                       if (dirfd != AT_FDCWD) close(dirfd);
+                       return -1;
+               }
+       }
+
+       if (fd >= 0) {
+               if (fchmod(fd, perms) != 0 && preserve_perms) {
+                       int errno_save = errno;
+                       close(fd);
+                       unlinkat(dirfd, filename, 0);
+                       if (dirfd != AT_FDCWD) close(dirfd);
+                       errno = errno_save;
+                       return -1;
+               }
+#if defined HAVE_SETMODE && O_BINARY
+               setmode(fd, O_BINARY);
+#endif
+       }
+
+       if (dirfd != AT_FDCWD) close(dirfd);
+       return fd;
+#endif
+}
+
 /*
   varient of do_open/do_open_nofollow which does do_open() if the
   copy_links or copy_unsafe_links options are set and does