草稿
[WIP]fix CVEs #109
wangyuhang创建于 17 天前
[WIP]fix CVEs #109
草稿
共 38 个文件变更+16457-1
| @@ -0,0 +1,325 @@ | |||
| 1 | +From 1a5ad81add1004354a3d8ba841b94ffe19cd2505 Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Wed, 31 Dec 2025 10:01:23 +1100 | ||
| 4 | +Subject: [PATCH] syscall+clientserver: am_chrooted and use_secure_symlinks for | ||
| 5 | + daemon-no-chroot (CVE-2026-29518) | ||
| 6 | + | ||
| 7 | +CVE-2026-29518: an rsync daemon configured with "use chroot = no" | ||
| 8 | +is exposed to a TOCTOU race on parent path components. A local | ||
| 9 | +attacker with write access to a module can replace a parent | ||
| 10 | +directory component with a symlink between the receiver's check | ||
| 11 | +and its open(), redirecting reads (basis-file disclosure) and | ||
| 12 | +writes (file overwrite) outside the module. Under elevated daemon | ||
| 13 | +privilege this allows privilege escalation. Default | ||
| 14 | +"use chroot = yes" is not exposed. | ||
| 15 | + | ||
| 16 | +Add secure_relative_open() in syscall.c. It walks the parent | ||
| 17 | +components under RESOLVE_BENEATH (Linux 5.6+) / | ||
| 18 | +O_RESOLVE_BENEATH (FreeBSD 13+, macOS 15+) / per-component | ||
| 19 | +O_NOFOLLOW elsewhere, anchored at a trusted dirfd, so a parent- | ||
| 20 | +symlink swap is rejected by the kernel. Route the receiver's | ||
| 21 | +basis-file open in receiver.c through it when use_secure_symlinks | ||
| 22 | +is set in clientserver.c rsync_module(). | ||
| 23 | + | ||
| 24 | +Reporters: Nullx3D (Batuhan SANCAK); Damien Neil; Michael Stapelberg. | ||
| 25 | + | ||
| 26 | +Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 27 | + | ||
| 28 | +Conflict:NA | ||
| 29 | +Reference:https://github.com/RsyncProject/rsync/commit/1a5ad81add1004354a3d8ba841b94ffe19cd2505 | ||
| 30 | +--- | ||
| 31 | + clientserver.c | 25 +++++++++ | ||
| 32 | + options.c | 9 ++++ | ||
| 33 | + receiver.c | 22 ++++++-- | ||
| 34 | + syscall.c | 139 +++++++++++++++++++++++++++++++++++++++++++++++++ | ||
| 35 | + 4 files changed, 192 insertions(+), 3 deletions(-) | ||
| 36 | + | ||
| 37 | +diff --git a/clientserver.c b/clientserver.c | ||
| 38 | +index 7c897abc..b6eba098 100644 | ||
| 39 | +--- a/clientserver.c | ||
| 40 | ++++ b/clientserver.c | ||
| 41 | + extern int list_only; | ||
| 42 | + extern int am_sender; | ||
| 43 | + extern int am_server; | ||
| 44 | + extern int am_daemon; | ||
| 45 | ++extern int am_chrooted; | ||
| 46 | + extern int am_root; | ||
| 47 | + extern int msgs2stderr; | ||
| 48 | + extern int rsync_port; | ||
| 49 | + extern int ignore_errors; | ||
| 50 | + extern int preserve_xattrs; | ||
| 51 | + extern int kluge_around_eof; | ||
| 52 | + extern int munge_symlinks; | ||
| 53 | ++extern int use_secure_symlinks; | ||
| 54 | + extern int open_noatime; | ||
| 55 | + extern int sanitize_paths; | ||
| 56 | + extern int numeric_ids; | ||
| 57 | + static int rsync_module(int f_in, int f_out, int i, const char *addr, const char | ||
| 58 | + io_printf(f_out, "@ERROR: chroot failed\n"); | ||
| 59 | + return -1; | ||
| 60 | + } | ||
| 61 | ++ am_chrooted = 1; | ||
| 62 | + module_chdir = module_dir; | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + static int rsync_module(int f_in, int f_out, int i, const char *addr, const char | ||
| 66 | + } | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | ++ /* Enable secure symlink handling for any non-chrooted daemon module. | ||
| 70 | ++ * This prevents TOCTOU race attacks where an attacker could switch a | ||
| 71 | ++ * directory to a symlink between path validation and file open. | ||
| 72 | ++ * Match the gate used by the do_*_at() wrappers in syscall.c | ||
| 73 | ++ * (am_daemon && !am_chrooted) -- the protection has nothing to do | ||
| 74 | ++ * with symlink munging, so a module configured with | ||
| 75 | ++ * "munge symlinks = false" must still get the secure-open path. */ | ||
| 76 | ++ use_secure_symlinks = am_daemon && !am_chrooted; | ||
| 77 | ++ | ||
| 78 | + if (gid_list.count) { | ||
| 79 | + gid_t *gid_array = gid_list.items; | ||
| 80 | + if (setgid(gid_array[0])) { | ||
| 81 | + int start_daemon(int f_in, int f_out) | ||
| 82 | + rsyserr(FLOG, errno, "daemon chroot(\"%s\") failed", p); | ||
| 83 | + return -1; | ||
| 84 | + } | ||
| 85 | ++ /* Deliberately do NOT set am_chrooted here. am_chrooted | ||
| 86 | ++ * gates the per-module symlink-race defenses | ||
| 87 | ++ * (secure_relative_open() and the do_*_at() wrappers in | ||
| 88 | ++ * syscall.c) and means "the kernel is enforcing path | ||
| 89 | ++ * confinement at the module boundary". The daemon chroot | ||
| 90 | ++ * confines path resolution to the daemon-chroot directory, | ||
| 91 | ++ * not to any individual module path -- modules sharing the | ||
| 92 | ++ * daemon chroot are still distinguishable filesystem | ||
| 93 | ++ * subtrees and a sender-controlled symlink in module A | ||
| 94 | ++ * could redirect a syscall to module B (or to other files | ||
| 95 | ++ * inside the daemon chroot) without the per-module | ||
| 96 | ++ * defenses. Leave am_chrooted=0 here so secure_relative_open() | ||
| 97 | ++ * still fires for "use chroot = no" modules. */ | ||
| 98 | + if (chdir("/") < 0) { | ||
| 99 | + rsyserr(FLOG, errno, "daemon chdir(\"/\") failed"); | ||
| 100 | + return -1; | ||
| 101 | +diff --git a/options.c b/options.c | ||
| 102 | +index 3dfac358..e09c5cc5 100644 | ||
| 103 | +--- a/options.c | ||
| 104 | ++++ b/options.c | ||
| 105 | + int mkpath_dest_arg = 0; | ||
| 106 | + int allow_inc_recurse = 1; | ||
| 107 | + int xfer_dirs = -1; | ||
| 108 | + int am_daemon = 0; | ||
| 109 | ++/* Set after a successful per-module chroot ("use chroot = yes") in | ||
| 110 | ++ * clientserver.c. NOT set for the daemon-level "daemon chroot = /X" | ||
| 111 | ++ * chroot: that confines path resolution to /X, but module paths | ||
| 112 | ++ * /X/modA, /X/modB, etc. are not chroot boundaries, so the per-module | ||
| 113 | ++ * symlink-race defenses (secure_relative_open() / do_*_at() in | ||
| 114 | ++ * syscall.c, gated by `am_daemon && !am_chrooted`) must still fire | ||
| 115 | ++ * even when the daemon is inside a daemon chroot. */ | ||
| 116 | ++int am_chrooted = 0; | ||
| 117 | + int connect_timeout = 0; | ||
| 118 | + int keep_partial = 0; | ||
| 119 | + int safe_symlinks = 0; | ||
| 120 | + int copy_unsafe_links = 0; | ||
| 121 | + int munge_symlinks = 0; | ||
| 122 | ++int use_secure_symlinks = 0; | ||
| 123 | + int size_only = 0; | ||
| 124 | + int daemon_bwlimit = 0; | ||
| 125 | + int bwlimit = 0; | ||
| 126 | +diff --git a/receiver.c b/receiver.c | ||
| 127 | +index e584effd..2026078e 100644 | ||
| 128 | +--- a/receiver.c | ||
| 129 | ++++ b/receiver.c | ||
| 130 | + extern int fuzzy_basis; | ||
| 131 | + | ||
| 132 | + extern struct name_num_item *xfer_sum_nni; | ||
| 133 | + extern int xfer_sum_len; | ||
| 134 | ++extern int use_secure_symlinks; | ||
| 135 | + | ||
| 136 | + static struct bitbag *delayed_bits = NULL; | ||
| 137 | + static int phase = 0, redoing = 0; | ||
| 138 | + int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) | ||
| 139 | + * access to ensure that there is no race condition. They will be | ||
| 140 | + * correctly updated after the right owner and group info is set. | ||
| 141 | + * (Thanks to snabb@epipe.fi for pointing this out.) */ | ||
| 142 | +- fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); | ||
| 143 | ++ /* When use_secure_symlinks is on (non-chroot daemon with munge_symlinks), | ||
| 144 | ++ * use secure_mkstemp to prevent symlink race attacks on parent directories. */ | ||
| 145 | ++ if (use_secure_symlinks) | ||
| 146 | ++ fd = secure_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); | ||
| 147 | ++ else | ||
| 148 | ++ fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); | ||
| 149 | + | ||
| 150 | + #if 0 | ||
| 151 | + /* In most cases parent directories will already exist because their | ||
| 152 | + int recv_files(int f_in, int f_out, char *local_name) | ||
| 153 | + /* We now check to see if we are writing the file "inplace" */ | ||
| 154 | + if (inplace || one_inplace) { | ||
| 155 | + fnametmp = one_inplace ? partialptr : fname; | ||
| 156 | +- fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); | ||
| 157 | ++ /* When use_secure_symlinks is on (non-chroot daemon), | ||
| 158 | ++ * use secure open to prevent symlink race attacks where an | ||
| 159 | ++ * attacker could switch a directory to a symlink between | ||
| 160 | ++ * path validation and file open. */ | ||
| 161 | ++ if (use_secure_symlinks) | ||
| 162 | ++ fd2 = secure_relative_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600); | ||
| 163 | ++ else | ||
| 164 | ++ fd2 = do_open(fnametmp, O_WRONLY|O_CREAT, 0600); | ||
| 165 | + #ifdef linux | ||
| 166 | + if (fd2 == -1 && errno == EACCES) { | ||
| 167 | + /* Maybe the error was due to protected_regular setting? */ | ||
| 168 | +- fd2 = do_open(fname, O_WRONLY, 0600); | ||
| 169 | ++ if (use_secure_symlinks) | ||
| 170 | ++ fd2 = secure_relative_open(NULL, fname, O_WRONLY, 0600); | ||
| 171 | ++ else | ||
| 172 | ++ fd2 = do_open(fname, O_WRONLY, 0600); | ||
| 173 | + } | ||
| 174 | + #endif | ||
| 175 | + if (fd2 == -1) { | ||
| 176 | +diff --git a/syscall.c b/syscall.c | ||
| 177 | +index 37fd4dfb..0413209c 100644 | ||
| 178 | +--- a/syscall.c | ||
| 179 | ++++ b/syscall.c | ||
| 180 | + cleanup: | ||
| 181 | + #endif // O_NOFOLLOW, O_DIRECTORY | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | ++/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic | ||
| 185 | ++ * quality; falls back to rand() if /dev/urandom cannot be opened or read | ||
| 186 | ++ * (e.g. inside a chroot or container without /dev populated). */ | ||
| 187 | ++static void rand_bytes(unsigned char *buf, size_t len) | ||
| 188 | ++{ | ||
| 189 | ++#ifndef O_CLOEXEC | ||
| 190 | ++#define O_CLOEXEC 0 | ||
| 191 | ++#endif | ||
| 192 | ++ int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); | ||
| 193 | ++ if (fd >= 0) { | ||
| 194 | ++ ssize_t n = read(fd, buf, len); | ||
| 195 | ++ close(fd); | ||
| 196 | ++ if (n == (ssize_t)len) { | ||
| 197 | ++ return; | ||
| 198 | ++ } | ||
| 199 | ++ } | ||
| 200 | ++ for (size_t i = 0; i < len; i++) { | ||
| 201 | ++ buf[i] = (unsigned char)rand(); | ||
| 202 | ++ } | ||
| 203 | ++} | ||
| 204 | ++ | ||
| 205 | ++/* | ||
| 206 | ++ Secure version of mkstemp that prevents symlink attacks on parent directories. | ||
| 207 | ++ Like secure_relative_open(), this walks the path checking each component | ||
| 208 | ++ with O_NOFOLLOW to prevent TOCTOU race conditions. | ||
| 209 | ++ | ||
| 210 | ++ The template may be relative or absolute, but must not contain ../ components. | ||
| 211 | ++ Returns fd on success, -1 on error. | ||
| 212 | ++*/ | ||
| 213 | ++int secure_mkstemp(char *template, mode_t perms) | ||
| 214 | ++{ | ||
| 215 | ++#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) | ||
| 216 | ++ /* Fall back to regular mkstemp on old systems */ | ||
| 217 | ++ return do_mkstemp(template, perms); | ||
| 218 | ++#else | ||
| 219 | ++ char *lastslash; | ||
| 220 | ++ int dirfd = AT_FDCWD; | ||
| 221 | ++ int fd = -1; | ||
| 222 | ++ | ||
| 223 | ++ if (!template) { | ||
| 224 | ++ errno = EINVAL; | ||
| 225 | ++ return -1; | ||
| 226 | ++ } | ||
| 227 | ++ if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) { | ||
| 228 | ++ errno = EINVAL; | ||
| 229 | ++ return -1; | ||
| 230 | ++ } | ||
| 231 | ++ | ||
| 232 | ++ /* For absolute paths, start the secure walk from "/" rather than CWD. */ | ||
| 233 | ++ if (template[0] == '/') { | ||
| 234 | ++ dirfd = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW); | ||
| 235 | ++ if (dirfd < 0) | ||
| 236 | ++ return -1; | ||
| 237 | ++ } | ||
| 238 | ++ | ||
| 239 | ++ /* Find the last slash to separate directory from filename */ | ||
| 240 | ++ lastslash = strrchr(template, '/'); | ||
| 241 | ++ if (lastslash) { | ||
| 242 | ++ char *path_copy = my_strdup(template, __FILE__, __LINE__); | ||
| 243 | ++ if (!path_copy) | ||
| 244 | ++ return -1; | ||
| 245 | ++ | ||
| 246 | ++ /* Null-terminate at the last slash to get directory part */ | ||
| 247 | ++ path_copy[lastslash - template] = '\0'; | ||
| 248 | ++ | ||
| 249 | ++ /* Walk the directory path securely */ | ||
| 250 | ++ for (const char *part = strtok(path_copy, "/"); | ||
| 251 | ++ part != NULL; | ||
| 252 | ++ part = strtok(NULL, "/")) | ||
| 253 | ++ { | ||
| 254 | ++ int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); | ||
| 255 | ++ if (next_fd == -1) { | ||
| 256 | ++ int save_errno = errno; | ||
| 257 | ++ free(path_copy); | ||
| 258 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 259 | ++ errno = (save_errno == ELOOP) ? ELOOP : save_errno; | ||
| 260 | ++ return -1; | ||
| 261 | ++ } | ||
| 262 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 263 | ++ dirfd = next_fd; | ||
| 264 | ++ } | ||
| 265 | ++ free(path_copy); | ||
| 266 | ++ } | ||
| 267 | ++ | ||
| 268 | ++ /* Now create the temp file in the securely-opened directory */ | ||
| 269 | ++ perms |= S_IWUSR; | ||
| 270 | ++ | ||
| 271 | ++ /* Generate unique filename - we need to modify the template in place */ | ||
| 272 | ++ char *filename = lastslash ? lastslash + 1 : template; | ||
| 273 | ++ size_t filename_len = strlen(filename); | ||
| 274 | ++ | ||
| 275 | ++ if (filename_len < 6) { | ||
| 276 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 277 | ++ errno = EINVAL; | ||
| 278 | ++ return -1; | ||
| 279 | ++ } | ||
| 280 | ++ char *suffix = filename + filename_len - 6; /* Points to XXXXXX */ | ||
| 281 | ++ if (strcmp(suffix, "XXXXXX") != 0) { | ||
| 282 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 283 | ++ errno = EINVAL; | ||
| 284 | ++ return -1; | ||
| 285 | ++ } | ||
| 286 | ++ | ||
| 287 | ++ /* Try random suffixes until we find one that works */ | ||
| 288 | ++ static const char letters[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; | ||
| 289 | ++ for (int tries = 0; tries < 100; tries++) { | ||
| 290 | ++ unsigned char rbytes[6]; | ||
| 291 | ++ rand_bytes(rbytes, sizeof(rbytes)); | ||
| 292 | ++ for (int i = 0; i < 6; i++) | ||
| 293 | ++ suffix[i] = letters[rbytes[i] % (sizeof(letters) - 1)]; | ||
| 294 | ++ | ||
| 295 | ++ fd = openat(dirfd, filename, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, perms); | ||
| 296 | ++ if (fd >= 0) | ||
| 297 | ++ break; | ||
| 298 | ++ if (errno != EEXIST) { | ||
| 299 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 300 | ++ return -1; | ||
| 301 | ++ } | ||
| 302 | ++ } | ||
| 303 | ++ | ||
| 304 | ++ if (fd >= 0) { | ||
| 305 | ++ if (fchmod(fd, perms) != 0 && preserve_perms) { | ||
| 306 | ++ int errno_save = errno; | ||
| 307 | ++ close(fd); | ||
| 308 | ++ unlinkat(dirfd, filename, 0); | ||
| 309 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 310 | ++ errno = errno_save; | ||
| 311 | ++ return -1; | ||
| 312 | ++ } | ||
| 313 | ++#if defined HAVE_SETMODE && O_BINARY | ||
| 314 | ++ setmode(fd, O_BINARY); | ||
| 315 | ++#endif | ||
| 316 | ++ } | ||
| 317 | ++ | ||
| 318 | ++ if (dirfd != AT_FDCWD) close(dirfd); | ||
| 319 | ++ return fd; | ||
| 320 | ++#endif | ||
| 321 | ++} | ||
| 322 | ++ | ||
| 323 | + /* | ||
| 324 | + varient of do_open/do_open_nofollow which does do_open() if the | ||
| 325 | + copy_links or copy_unsafe_links options are set and does | ||
| @@ -0,0 +1,68 @@ | |||
| 1 | +From 99b36291d06ca66229942c7a525a1f5566f10c85 Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Sun, 1 Mar 2026 09:28:40 +1100 | ||
| 4 | +Subject: [PATCH] sender: fix read-path TOCTOU by opening from module root | ||
| 5 | + (CVE-2026-29518) | ||
| 6 | + | ||
| 7 | +The sender's file open was vulnerable to the same TOCTOU symlink | ||
| 8 | +race as the receiver-side basis-file open. change_pathname() calls | ||
| 9 | +chdir() into subdirectories, which follows symlinks; an attacker | ||
| 10 | +could race to swap a directory for a symlink between the chdir and | ||
| 11 | +the file open, allowing reads of privileged files through the | ||
| 12 | +daemon. | ||
| 13 | + | ||
| 14 | +Reconstruct the full relative path (F_PATHNAME + fname) and open | ||
| 15 | +via secure_relative_open() from the trusted module_dir, which | ||
| 16 | +walks each path component without following symlinks. This is | ||
| 17 | +independent of CWD, so the chdir race is neutralised. | ||
| 18 | + | ||
| 19 | +CVE-2026-29518. | ||
| 20 | + | ||
| 21 | +Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> | ||
| 22 | + | ||
| 23 | +Conflict:NA | ||
| 24 | +Reference:https://github.com/RsyncProject/rsync/commit/99b36291d06ca66229942c7a525a1f5566f10c85 | ||
| 25 | +--- | ||
| 26 | + sender.c | 22 +++++++++++++++++++++- | ||
| 27 | + 1 file changed, 21 insertions(+), 1 deletion(-) | ||
| 28 | + | ||
| 29 | +diff --git a/sender.c b/sender.c | ||
| 30 | +index c362c71d..033f87e5 100644 | ||
| 31 | +--- a/sender.c | ||
| 32 | ++++ b/sender.c | ||
| 33 | + extern int make_backups; | ||
| 34 | + extern int inplace; | ||
| 35 | + extern int inplace_partial; | ||
| 36 | + extern int batch_fd; | ||
| 37 | ++extern int use_secure_symlinks; | ||
| 38 | ++extern char *module_dir; | ||
| 39 | + extern int write_batch; | ||
| 40 | + extern int file_old_total; | ||
| 41 | + extern BOOL want_progress_now; | ||
| 42 | + void send_files(int f_in, int f_out) | ||
| 43 | + exit_cleanup(RERR_PROTOCOL); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | +- fd = do_open_checklinks(fname); | ||
| 47 | ++ if (use_secure_symlinks) { | ||
| 48 | ++ /* Open from module root to prevent TOCTOU race where | ||
| 49 | ++ * change_pathname's chdir follows a directory symlink. | ||
| 50 | ++ * Reconstruct the full path relative to module_dir | ||
| 51 | ++ * from F_PATHNAME (path) and f_name (fname). */ | ||
| 52 | ++ char secure_path[MAXPATHLEN]; | ||
| 53 | ++ int slen = snprintf(secure_path, sizeof secure_path, "%s%s%s", path, slash, fname); | ||
| 54 | ++ if (slen >= (int)sizeof secure_path) { | ||
| 55 | ++ io_error |= IOERR_GENERAL; | ||
| 56 | ++ rprintf(FERROR_XFER, "path too long: %s%s%s\n", path, slash, fname); | ||
| 57 | ++ free_sums(s); | ||
| 58 | ++ if (protocol_version >= 30) | ||
| 59 | ++ send_msg_int(MSG_NO_SEND, ndx); | ||
| 60 | ++ continue; | ||
| 61 | ++ } | ||
| 62 | ++ fd = secure_relative_open(module_dir, secure_path, O_RDONLY, 0); | ||
| 63 | ++ } else { | ||
| 64 | ++ fd = do_open_checklinks(fname); | ||
| 65 | ++ } | ||
| 66 | + if (fd == -1) { | ||
| 67 | + if (errno == ENOENT) { | ||
| 68 | + enum logcode c = am_daemon && protocol_version < 28 ? FERROR : FWARNING; | ||
| @@ -0,0 +1,192 @@ | |||
| 1 | +From 74ea276900779b95ddd1769d1d6ae78b2fd1a790 Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Wed, 31 Dec 2025 13:50:35 +1100 | ||
| 4 | +Subject: [PATCH] clientserver: fix hostname ACL bypass when using daemon | ||
| 5 | + chroot | ||
| 6 | + | ||
| 7 | +On an rsync daemon configured with "daemon chroot", the reverse-DNS | ||
| 8 | +lookup of the connecting client was performed *after* the chroot | ||
| 9 | +had been entered. If the chroot did not contain the files glibc | ||
| 10 | +needs for resolution (/etc/resolv.conf, /etc/nsswitch.conf, | ||
| 11 | +/etc/hosts, NSS service modules), the lookup failed and | ||
| 12 | +client_name() returned "UNKNOWN". Hostname-based deny rules | ||
| 13 | +("hosts deny = *.evil.example") therefore could not match, and | ||
| 14 | +an attacker controlling their PTR record could connect from a | ||
| 15 | +hostname the administrator had intended to deny. IP-based ACLs | ||
| 16 | +were unaffected. | ||
| 17 | + | ||
| 18 | +Do the reverse DNS lookup before chroot/setuid; client_name() | ||
| 19 | +caches its result, so the post-chroot call uses the cached value | ||
| 20 | +and hostname-based ACLs work even when DNS is unavailable | ||
| 21 | +post-chroot. | ||
| 22 | + | ||
| 23 | +Adds testsuite/daemon-chroot-acl.test as end-to-end regression | ||
| 24 | +coverage. The test sets up an empty chroot directory, configures | ||
| 25 | +"hosts deny = <localhost-resolved-name>" with daemon chroot, and | ||
| 26 | +asserts the connection is refused with @ERROR access denied. | ||
| 27 | +Uses unshare --user --map-root-user for non-root CAP_SYS_CHROOT; | ||
| 28 | +skips cleanly on non-Linux or when user namespaces aren't | ||
| 29 | +available. | ||
| 30 | + | ||
| 31 | +Reporter: Joshua Rogers (MegaManSec). | ||
| 32 | + | ||
| 33 | +Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 34 | + | ||
| 35 | +Conflict:NA | ||
| 36 | +Reference:https://github.com/RsyncProject/rsync/commit/74ea276900779b95ddd1769d1d6ae78b2fd1a790 | ||
| 37 | +--- | ||
| 38 | + clientserver.c | 22 ++++++ | ||
| 39 | + testsuite/daemon-chroot-acl.test | 111 +++++++++++++++++++++++++++++++ | ||
| 40 | + 2 files changed, 133 insertions(+) | ||
| 41 | + create mode 100644 testsuite/daemon-chroot-acl.test | ||
| 42 | + | ||
| 43 | +diff --git a/clientserver.c b/clientserver.c | ||
| 44 | +index b6eba098..3333aa96 100644 | ||
| 45 | +--- a/clientserver.c | ||
| 46 | ++++ b/clientserver.c | ||
| 47 | + int start_daemon(int f_in, int f_out) | ||
| 48 | + if (lp_proxy_protocol() && !read_proxy_protocol_header(f_in)) | ||
| 49 | + return -1; | ||
| 50 | + | ||
| 51 | ++ /* Do reverse DNS lookup before chroot/setuid. The result is cached, | ||
| 52 | ++ * so the later client_name() call will use this cached value. This | ||
| 53 | ++ * ensures hostname-based ACLs work even when DNS is unavailable | ||
| 54 | ++ * after chroot. | ||
| 55 | ++ * | ||
| 56 | ++ * "reverse lookup" can be set globally OR per-module, so we also | ||
| 57 | ++ * scan each module: a deployment with "reverse lookup = no" in the | ||
| 58 | ++ * global section but "reverse lookup = yes" in a specific module | ||
| 59 | ++ * still triggers a post-chroot lookup at access-check time | ||
| 60 | ++ * (rsync_module() in this file), which would also fail in the | ||
| 61 | ++ * chroot and turn hostname-based deny rules into silent bypasses. */ | ||
| 62 | ++ { | ||
| 63 | ++ int need_reverse = lp_reverse_lookup(-1); | ||
| 64 | ++ int j, num_modules = lp_num_modules(); | ||
| 65 | ++ for (j = 0; !need_reverse && j < num_modules; j++) { | ||
| 66 | ++ if (lp_reverse_lookup(j)) | ||
| 67 | ++ need_reverse = 1; | ||
| 68 | ++ } | ||
| 69 | ++ if (need_reverse) | ||
| 70 | ++ (void)client_name(client_addr(f_in)); | ||
| 71 | ++ } | ||
| 72 | ++ | ||
| 73 | + p = lp_daemon_chroot(); | ||
| 74 | + if (*p) { | ||
| 75 | + log_init(0); /* Make use we've initialized syslog before chrooting. */ | ||
| 76 | +diff --git a/testsuite/daemon-chroot-acl.test b/testsuite/daemon-chroot-acl.test | ||
| 77 | +new file mode 100644 | ||
| 78 | +index 00000000..9d1c1b63 | ||
| 79 | +--- /dev/null | ||
| 80 | ++++ b/testsuite/daemon-chroot-acl.test | ||
| 81 | + | ||
| 82 | ++#!/bin/sh | ||
| 83 | ++ | ||
| 84 | ++# Copyright (C) 2026 by Andrew Tridgell | ||
| 85 | ++ | ||
| 86 | ++# This program is distributable under the terms of the GNU GPL (see | ||
| 87 | ++# COPYING). | ||
| 88 | ++ | ||
| 89 | ++# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny" | ||
| 90 | ++# rule must still match when the daemon performs a 'daemon chroot' and | ||
| 91 | ++# the chroot does not contain the NSS files glibc needs for reverse DNS. | ||
| 92 | ++# | ||
| 93 | ++# Pre-fix, reverse DNS happened *after* the daemon chroot. With an empty | ||
| 94 | ++# chroot the NSS lookup failed, client_name() returned "UNKNOWN", and a | ||
| 95 | ++# deny rule referring to the connecting hostname silently failed to | ||
| 96 | ++# match. | ||
| 97 | ++# | ||
| 98 | ++# Two scenarios are exercised so we can distinguish the case the fix | ||
| 99 | ++# definitely covers from the per-module path that may still be | ||
| 100 | ++# vulnerable: | ||
| 101 | ++# A. global "reverse lookup = yes" (covered by b6abdb4c) | ||
| 102 | ++# B. only module "reverse lookup = yes" (gap to verify) | ||
| 103 | ++ | ||
| 104 | ++. "$suitedir/rsync.fns" | ||
| 105 | ++ | ||
| 106 | ++case `uname -s` in | ||
| 107 | ++Linux*) ;; | ||
| 108 | ++*) test_skipped "test is Linux-specific (uses chroot+unshare)" ;; | ||
| 109 | ++esac | ||
| 110 | ++ | ||
| 111 | ++# We need CAP_SYS_CHROOT. Re-exec under a user namespace if not root. | ||
| 112 | ++if ! chroot / /bin/true 2>/dev/null; then | ||
| 113 | ++ if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user true 2>/dev/null; then | ||
| 114 | ++ echo "Re-running under unshare --user --map-root-user..." | ||
| 115 | ++ RSYNC_UNSHARED=1 exec unshare --user --map-root-user "$SHELL_PATH" $RUNSHFLAGS "$0" | ||
| 116 | ++ fi | ||
| 117 | ++ test_skipped "need CAP_SYS_CHROOT (root or unshare --user --map-root-user)" | ||
| 118 | ++fi | ||
| 119 | ++ | ||
| 120 | ++# We need 127.0.0.1 to reverse-resolve to a real hostname while NSS is | ||
| 121 | ++# still working (i.e. before the daemon's chroot). The daemon will | ||
| 122 | ++# look that name up itself as part of its hostname-based ACL check; | ||
| 123 | ++# we then deny that name and assert the connection is rejected. | ||
| 124 | ++client_hostname=`getent hosts 127.0.0.1 2>/dev/null | awk 'NR==1 {print $2}'` | ||
| 125 | ++if [ -z "$client_hostname" ] || [ "$client_hostname" = "127.0.0.1" ]; then | ||
| 126 | ++ test_skipped "no reverse DNS for 127.0.0.1" | ||
| 127 | ++fi | ||
| 128 | ++ | ||
| 129 | ++chrootdir="$scratchdir/chroot" | ||
| 130 | ++rm -rf "$chrootdir" | ||
| 131 | ++mkdir -p "$chrootdir/modroot" | ||
| 132 | ++echo "from chroot" > "$chrootdir/modroot/file1" | ||
| 133 | ++ | ||
| 134 | ++conf="$scratchdir/test-rsyncd.conf" | ||
| 135 | ++logfile="$scratchdir/rsyncd.log" | ||
| 136 | ++ | ||
| 137 | ++write_conf() { | ||
| 138 | ++ cat >"$conf" <<EOF | ||
| 139 | ++use chroot = no | ||
| 140 | ++log file = $logfile | ||
| 141 | ++daemon chroot = $chrootdir | ||
| 142 | ++reverse lookup = $1 | ||
| 143 | ++hosts deny = $client_hostname | ||
| 144 | ++max verbosity = 4 | ||
| 145 | ++ | ||
| 146 | ++[chrootmod] | ||
| 147 | ++ path = /modroot | ||
| 148 | ++ read only = yes | ||
| 149 | ++ reverse lookup = $2 | ||
| 150 | ++EOF | ||
| 151 | ++} | ||
| 152 | ++ | ||
| 153 | ++# Run a transfer and return 0 if the daemon refused with @ERROR access | ||
| 154 | ++# denied (the expected outcome when the deny rule matches). | ||
| 155 | ++run_check() { | ||
| 156 | ++ label="$1" | ||
| 157 | ++ | ||
| 158 | ++ rm -f "$logfile" | ||
| 159 | ++ rm -rf "$todir" | ||
| 160 | ++ mkdir -p "$todir" | ||
| 161 | ++ | ||
| 162 | ++ out="$scratchdir/run.out" | ||
| 163 | ++ | ||
| 164 | ++ RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \ | ||
| 165 | ++ $RSYNC -av localhost::chrootmod/ "$todir/" >"$out" 2>&1 | ||
| 166 | ++ rc=$? | ||
| 167 | ++ | ||
| 168 | ++ echo "----- $label (rsync exit $rc):" | ||
| 169 | ++ cat "$out" | ||
| 170 | ++ echo "----- daemon log:" | ||
| 171 | ++ [ -f "$logfile" ] && cat "$logfile" | ||
| 172 | ++ echo "-----" | ||
| 173 | ++ | ||
| 174 | ++ grep -q '@ERROR.*access denied' "$out" | ||
| 175 | ++} | ||
| 176 | ++ | ||
| 177 | ++# Scenario A: global reverse lookup. Covered by b6abdb4c. | ||
| 178 | ++write_conf yes yes | ||
| 179 | ++if ! run_check "Scenario A (global reverse lookup = yes)"; then | ||
| 180 | ++ test_fail "Scenario A: hostname deny rule was bypassed" | ||
| 181 | ++fi | ||
| 182 | ++ | ||
| 183 | ++# Scenario B: only the per-module reverse-lookup setting is enabled. | ||
| 184 | ++# The b6abdb4c fix only pre-warms client_name()'s cache when the | ||
| 185 | ++# global setting is on, so the post-chroot lookup in this path may | ||
| 186 | ++# still produce "UNKNOWN" and bypass the deny rule. | ||
| 187 | ++write_conf no yes | ||
| 188 | ++if ! run_check "Scenario B (per-module reverse lookup only)"; then | ||
| 189 | ++ test_fail "Scenario B: hostname deny rule was bypassed (per-module reverse lookup with daemon chroot still has the bypass)" | ||
| 190 | ++fi | ||
| 191 | ++ | ||
| 192 | ++exit 0 | ||
| @@ -0,0 +1,391 @@ | |||
| 1 | +From 72d1cf1c288e5c526e906db2edafbf3d55762668 Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Thu, 30 Apr 2026 08:39:22 +1000 | ||
| 4 | +Subject: [PATCH] syscall: use openat2(RESOLVE_BENEATH) on Linux for | ||
| 5 | + secure_relative_open | ||
| 6 | + | ||
| 7 | +The CVE fix in commit c35e283 made secure_relative_open() walk every | ||
| 8 | +component of relpath with O_NOFOLLOW. That blocks every symlink in the | ||
| 9 | +path, which is stricter than the threat model required: legitimate | ||
| 10 | +directory symlinks within the destination tree (e.g. when using -K / | ||
| 11 | +--copy-dirlinks) are also rejected, breaking delta transfers with | ||
| 12 | +"failed verification -- update discarded". See issue #715. | ||
| 13 | + | ||
| 14 | +On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives | ||
| 15 | +us exactly what we want: the kernel rejects any resolution that would | ||
| 16 | +escape the starting directory (via "..", absolute paths, or symlinks | ||
| 17 | +pointing outside dirfd) while still following symlinks that resolve | ||
| 18 | +within it. /proc magic-links are blocked too. | ||
| 19 | + | ||
| 20 | +Use openat2 first; fall back to the existing per-component O_NOFOLLOW | ||
| 21 | +walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head | ||
| 22 | +of the function are kept as defense in depth. The Linux gate is | ||
| 23 | +plain #ifdef __linux__: the runtime ENOSYS fallback covers the only | ||
| 24 | +case that actually matters (header present + old kernel), and any | ||
| 25 | +Linux build environment without linux/openat2.h will fail with a | ||
| 26 | +clear "no such file" error rather than silently disabling the | ||
| 27 | +protection. | ||
| 28 | + | ||
| 29 | +Verified manually that openat2(RESOLVE_BENEATH) blocks all four | ||
| 30 | +escape patterns (absolute symlink, ../ symlink, lexical .., absolute | ||
| 31 | +path) while allowing direct and within-tree symlinks. The new | ||
| 32 | +testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel | ||
| 33 | +Henrique) exercises the issue #715 regression and passes; full | ||
| 34 | +make check passes 47/47. | ||
| 35 | + | ||
| 36 | +Test: testsuite/symlink-dirlink-basis.test (8 scenarios) | ||
| 37 | +Fixes: https://github.com/RsyncProject/rsync/issues/715 | ||
| 38 | + | ||
| 39 | +Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 40 | + | ||
| 41 | +Conflict:NA | ||
| 42 | +Reference:https://github.com/RsyncProject/rsync/commit/72d1cf1c288e5c526e906db2edafbf3d55762668 | ||
| 43 | +--- | ||
| 44 | + syscall.c | 64 ++++++- | ||
| 45 | + testsuite/symlink-dirlink-basis.test | 247 +++++++++++++++++++++++++++ | ||
| 46 | + 2 files changed, 305 insertions(+), 6 deletions(-) | ||
| 47 | + create mode 100755 testsuite/symlink-dirlink-basis.test | ||
| 48 | + | ||
| 49 | +diff --git a/syscall.c b/syscall.c | ||
| 50 | +index c55ae5f5..82b45b6a 100644 | ||
| 51 | +--- a/syscall.c | ||
| 52 | ++++ b/syscall.c | ||
| 53 | + | ||
| 54 | + #include <sys/syscall.h> | ||
| 55 | + #endif | ||
| 56 | + | ||
| 57 | ++#ifdef __linux__ | ||
| 58 | ++#include <sys/syscall.h> | ||
| 59 | ++#include <linux/openat2.h> | ||
| 60 | ++#endif | ||
| 61 | ++ | ||
| 62 | + #include "ifuncs.h" | ||
| 63 | + | ||
| 64 | + extern int dry_run; | ||
| 65 | + int do_open_nofollow(const char *pathname, int flags) | ||
| 66 | + /* | ||
| 67 | + open a file relative to a base directory. The basedir can be NULL, | ||
| 68 | + in which case the current working directory is used. The relpath | ||
| 69 | +- must be a relative path, and the relpath must not contain any | ||
| 70 | +- elements in the path which follow symlinks (ie. like O_NOFOLLOW, but | ||
| 71 | +- applies to all path components, not just the last component) | ||
| 72 | +- | ||
| 73 | +- The relpath must also not contain any ../ elements in the path | ||
| 74 | ++ must be a relative path. The kernel must guarantee that resolution | ||
| 75 | ++ cannot escape basedir (or the cwd, when basedir is NULL): no ".." | ||
| 76 | ++ jumps above the start, no symlinks pointing outside, no absolute | ||
| 77 | ++ paths, no /proc magic-link tricks. | ||
| 78 | ++ | ||
| 79 | ++ Symlinks *within* basedir are followed normally — earlier rsync | ||
| 80 | ++ versions rejected every symlink with O_NOFOLLOW on each component, | ||
| 81 | ++ which broke legitimate directory symlinks on the receiver side | ||
| 82 | ++ (https://github.com/RsyncProject/rsync/issues/715). The escape | ||
| 83 | ++ prevention is handled by the kernel via openat2(RESOLVE_BENEATH) | ||
| 84 | ++ on Linux 5.6+; older systems fall back to the per-component | ||
| 85 | ++ O_NOFOLLOW walk below. | ||
| 86 | ++ | ||
| 87 | ++ The relpath must also not contain any ../ elements in the path. | ||
| 88 | + */ | ||
| 89 | ++ | ||
| 90 | ++#ifdef __linux__ | ||
| 91 | ++static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) | ||
| 92 | ++{ | ||
| 93 | ++ struct open_how how; | ||
| 94 | ++ int dirfd, retfd; | ||
| 95 | ++ | ||
| 96 | ++ memset(&how, 0, sizeof how); | ||
| 97 | ++ how.flags = flags; | ||
| 98 | ++ how.mode = mode; | ||
| 99 | ++ how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; | ||
| 100 | ++ | ||
| 101 | ++ if (basedir == NULL) { | ||
| 102 | ++ dirfd = AT_FDCWD; | ||
| 103 | ++ } else { | ||
| 104 | ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); | ||
| 105 | ++ if (dirfd == -1) | ||
| 106 | ++ return -1; | ||
| 107 | ++ } | ||
| 108 | ++ | ||
| 109 | ++ retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); | ||
| 110 | ++ | ||
| 111 | ++ if (dirfd != AT_FDCWD) | ||
| 112 | ++ close(dirfd); | ||
| 113 | ++ return retfd; | ||
| 114 | ++} | ||
| 115 | ++#endif | ||
| 116 | ++ | ||
| 117 | + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) | ||
| 118 | + { | ||
| 119 | + if (!relpath || relpath[0] == '/') { | ||
| 120 | + int secure_relative_open(const char *basedir, const char *relpath, int flags, mo | ||
| 121 | + return -1; | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | +-#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) | ||
| 125 | ++#ifdef __linux__ | ||
| 126 | ++ { | ||
| 127 | ++ int fd = secure_relative_open_linux(basedir, relpath, flags, mode); | ||
| 128 | ++ /* ENOSYS = kernel < 5.6 doesn't have the syscall even though | ||
| 129 | ++ * glibc/kernel-headers do; fall through to the portable path. */ | ||
| 130 | ++ if (fd != -1 || errno != ENOSYS) | ||
| 131 | ++ return fd; | ||
| 132 | ++ } | ||
| 133 | ++#endif | ||
| 134 | ++ | ||
| 135 | ++#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) | ||
| 136 | + // really old system, all we can do is live with the risks | ||
| 137 | + if (!basedir) { | ||
| 138 | + return open(relpath, flags, mode); | ||
| 139 | +diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test | ||
| 140 | +new file mode 100755 | ||
| 141 | +index 00000000..9065dd81 | ||
| 142 | +--- /dev/null | ||
| 143 | ++++ b/testsuite/symlink-dirlink-basis.test | ||
| 144 | + | ||
| 145 | ++#!/bin/sh | ||
| 146 | ++ | ||
| 147 | ++# Test that updating a file through a directory symlink works when using | ||
| 148 | ++# -K (--copy-dirlinks). This is a regression test for: | ||
| 149 | ++# https://github.com/RsyncProject/rsync/issues/715 | ||
| 150 | ++# | ||
| 151 | ++# The CVE fix in commit c35e283 introduced secure_relative_open() which | ||
| 152 | ++# uses O_NOFOLLOW on all path components, breaking legitimate directory | ||
| 153 | ++# symlinks on the receiver side. The fix splits the path into basedir | ||
| 154 | ++# (dirname, symlinks followed) and basename (O_NOFOLLOW) so that | ||
| 155 | ++# directory symlinks are traversed while the final file component is | ||
| 156 | ++# still protected. | ||
| 157 | ++# | ||
| 158 | ++# The regression only manifests when delta matching is triggered (i.e., | ||
| 159 | ++# the sender finds matching blocks in the old file). Small files with | ||
| 160 | ++# completely different content are transferred in full and don't trigger | ||
| 161 | ++# the bug. We use a large file with a small modification to ensure | ||
| 162 | ++# delta transfer is used. | ||
| 163 | ++# | ||
| 164 | ++# In addition to the original regression, this test covers edge cases | ||
| 165 | ++# in the fix itself: | ||
| 166 | ++# - --backup with directory symlinks (finish_transfer pointer identity) | ||
| 167 | ++# - --partial-dir with protocol < 29 (fnamecmp != partialptr guard) | ||
| 168 | ++# - --inplace with directory symlinks (updating_basis_or_equiv check) | ||
| 169 | ++# - Files without a dirname (top-level files, no split needed) | ||
| 170 | ++ | ||
| 171 | ++. "$suitedir/rsync.fns" | ||
| 172 | ++ | ||
| 173 | ++RSYNC_RSH="$scratchdir/src/support/lsh.sh" | ||
| 174 | ++export RSYNC_RSH | ||
| 175 | ++ | ||
| 176 | ++# $HOME is set to $scratchdir by rsync.fns | ||
| 177 | ++# localhost: destination will cd to $HOME (i.e., $scratchdir) | ||
| 178 | ++ | ||
| 179 | ++# Helper: create a large file suitable for delta transfers. | ||
| 180 | ++# ~32KB is large enough for rsync's block matching to find matches. | ||
| 181 | ++make_testfile() { | ||
| 182 | ++ dd if=/dev/urandom of="$1" bs=1024 count=32 2>/dev/null \ | ||
| 183 | ++ || test_fail "failed to create test file $1" | ||
| 184 | ++} | ||
| 185 | ++ | ||
| 186 | ++# Set up source tree | ||
| 187 | ++srcbase="$tmpdir/src" | ||
| 188 | ++ | ||
| 189 | ++###################################################################### | ||
| 190 | ++# Test 1: Basic directory symlink update (the original issue #715) | ||
| 191 | ++###################################################################### | ||
| 192 | ++ | ||
| 193 | ++mkdir -p "$HOME/real-dir" | ||
| 194 | ++ln -s real-dir "$HOME/dir" | ||
| 195 | ++ | ||
| 196 | ++mkdir -p "$srcbase/dir" | ||
| 197 | ++make_testfile "$srcbase/dir/file" | ||
| 198 | ++ | ||
| 199 | ++# First transfer (initial): should create the file through the symlink | ||
| 200 | ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 201 | ++ || test_fail "test 1: initial transfer failed" | ||
| 202 | ++ | ||
| 203 | ++if [ ! -f "$HOME/real-dir/file" ]; then | ||
| 204 | ++ test_fail "test 1: initial transfer did not create file through symlink" | ||
| 205 | ++fi | ||
| 206 | ++ | ||
| 207 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 208 | ++ || test_fail "test 1: initial transfer content mismatch" | ||
| 209 | ++ | ||
| 210 | ++# Small modification to trigger delta transfer | ||
| 211 | ++echo "appended update" >> "$srcbase/dir/file" | ||
| 212 | ++sleep 1 | ||
| 213 | ++touch "$srcbase/dir/file" | ||
| 214 | ++ | ||
| 215 | ++# Second transfer (update): was failing with "failed verification" | ||
| 216 | ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 217 | ++ || test_fail "test 1: update through directory symlink failed" | ||
| 218 | ++ | ||
| 219 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 220 | ++ || test_fail "test 1: update transfer content mismatch" | ||
| 221 | ++ | ||
| 222 | ++###################################################################### | ||
| 223 | ++# Test 2: Compression (-z) as in the original reproducer | ||
| 224 | ++###################################################################### | ||
| 225 | ++ | ||
| 226 | ++echo "another line" >> "$srcbase/dir/file" | ||
| 227 | ++sleep 1 | ||
| 228 | ++touch "$srcbase/dir/file" | ||
| 229 | ++ | ||
| 230 | ++(cd "$srcbase" && $RSYNC -KRlptzv --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 231 | ++ || test_fail "test 2: compressed update through directory symlink failed" | ||
| 232 | ++ | ||
| 233 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 234 | ++ || test_fail "test 2: compressed update content mismatch" | ||
| 235 | ++ | ||
| 236 | ++###################################################################### | ||
| 237 | ++# Test 3: Nested directory symlinks (nested/sub/data.txt where | ||
| 238 | ++# "nested" is a symlink to "nested_real") | ||
| 239 | ++###################################################################### | ||
| 240 | ++ | ||
| 241 | ++mkdir -p "$HOME/nested_real/sub" | ||
| 242 | ++ln -s nested_real "$HOME/nested" | ||
| 243 | ++ | ||
| 244 | ++mkdir -p "$srcbase/nested/sub" | ||
| 245 | ++make_testfile "$srcbase/nested/sub/data.txt" | ||
| 246 | ++ | ||
| 247 | ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ | ||
| 248 | ++ || test_fail "test 3: initial nested transfer failed" | ||
| 249 | ++ | ||
| 250 | ++echo "appended nested" >> "$srcbase/nested/sub/data.txt" | ||
| 251 | ++sleep 1 | ||
| 252 | ++touch "$srcbase/nested/sub/data.txt" | ||
| 253 | ++ | ||
| 254 | ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ | ||
| 255 | ++ || test_fail "test 3: update through nested directory symlink failed" | ||
| 256 | ++ | ||
| 257 | ++diff "$srcbase/nested/sub/data.txt" "$HOME/nested_real/sub/data.txt" >/dev/null \ | ||
| 258 | ++ || test_fail "test 3: nested update content mismatch" | ||
| 259 | ++ | ||
| 260 | ++###################################################################### | ||
| 261 | ++# Test 4: --backup with directory symlinks | ||
| 262 | ++# | ||
| 263 | ++# Exercises the finish_transfer() "fnamecmp == fname" pointer | ||
| 264 | ++# comparison that determines whether to update fnamecmp to the | ||
| 265 | ++# backup name. If broken, --backup would reference a renamed file | ||
| 266 | ++# for xattr handling. | ||
| 267 | ++###################################################################### | ||
| 268 | ++ | ||
| 269 | ++# Reset destination | ||
| 270 | ++rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" | ||
| 271 | ++ | ||
| 272 | ++make_testfile "$srcbase/dir/file" | ||
| 273 | ++ | ||
| 274 | ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 275 | ++ || test_fail "test 4: initial transfer for backup test failed" | ||
| 276 | ++ | ||
| 277 | ++echo "backup update" >> "$srcbase/dir/file" | ||
| 278 | ++sleep 1 | ||
| 279 | ++touch "$srcbase/dir/file" | ||
| 280 | ++ | ||
| 281 | ++(cd "$srcbase" && $RSYNC -KRlptv --backup --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 282 | ++ || test_fail "test 4: update with --backup through directory symlink failed" | ||
| 283 | ++ | ||
| 284 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 285 | ++ || test_fail "test 4: backup update content mismatch" | ||
| 286 | ++ | ||
| 287 | ++if [ ! -f "$HOME/real-dir/file~" ]; then | ||
| 288 | ++ test_fail "test 4: backup file was not created" | ||
| 289 | ++fi | ||
| 290 | ++ | ||
| 291 | ++###################################################################### | ||
| 292 | ++# Test 5: --inplace with directory symlinks | ||
| 293 | ++# | ||
| 294 | ++# Exercises the updating_basis_or_equiv check which uses | ||
| 295 | ++# "fnamecmp == fname". With --inplace, rsync writes directly to | ||
| 296 | ++# the destination file instead of a temp file. | ||
| 297 | ++###################################################################### | ||
| 298 | ++ | ||
| 299 | ++rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" | ||
| 300 | ++ | ||
| 301 | ++make_testfile "$srcbase/dir/file" | ||
| 302 | ++ | ||
| 303 | ++(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 304 | ++ || test_fail "test 5: initial inplace transfer failed" | ||
| 305 | ++ | ||
| 306 | ++echo "inplace update" >> "$srcbase/dir/file" | ||
| 307 | ++sleep 1 | ||
| 308 | ++touch "$srcbase/dir/file" | ||
| 309 | ++ | ||
| 310 | ++(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 311 | ++ || test_fail "test 5: inplace update through directory symlink failed" | ||
| 312 | ++ | ||
| 313 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 314 | ++ || test_fail "test 5: inplace update content mismatch" | ||
| 315 | ++ | ||
| 316 | ++###################################################################### | ||
| 317 | ++# Test 6: Top-level file (no dirname, no split needed) | ||
| 318 | ++# | ||
| 319 | ++# Ensures the dirname/basename split is not attempted for files | ||
| 320 | ++# at the top level (file->dirname is NULL). | ||
| 321 | ++###################################################################### | ||
| 322 | ++ | ||
| 323 | ++make_testfile "$srcbase/topfile" | ||
| 324 | ++mkdir -p "$HOME" | ||
| 325 | ++ | ||
| 326 | ++(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ | ||
| 327 | ++ || test_fail "test 6: initial top-level transfer failed" | ||
| 328 | ++ | ||
| 329 | ++echo "toplevel update" >> "$srcbase/topfile" | ||
| 330 | ++sleep 1 | ||
| 331 | ++touch "$srcbase/topfile" | ||
| 332 | ++ | ||
| 333 | ++(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ | ||
| 334 | ++ || test_fail "test 6: top-level update failed" | ||
| 335 | ++ | ||
| 336 | ++diff "$srcbase/topfile" "$HOME/topfile" >/dev/null \ | ||
| 337 | ++ || test_fail "test 6: top-level update content mismatch" | ||
| 338 | ++ | ||
| 339 | ++###################################################################### | ||
| 340 | ++# Test 7: --partial-dir with protocol < 29 | ||
| 341 | ++# | ||
| 342 | ++# For protocol < 29, fnamecmp_type stays FNAMECMP_FNAME even when | ||
| 343 | ++# fnamecmp is set to partialptr. The dirname/basename split must | ||
| 344 | ++# NOT trigger in this case (guarded by "fnamecmp == fname"). | ||
| 345 | ++###################################################################### | ||
| 346 | ++ | ||
| 347 | ++rm -f "$HOME/real-dir/file" | ||
| 348 | ++make_testfile "$srcbase/dir/file" | ||
| 349 | ++ | ||
| 350 | ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ | ||
| 351 | ++ --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 352 | ++ || test_fail "test 7: initial proto28 partial-dir transfer failed" | ||
| 353 | ++ | ||
| 354 | ++echo "partial-dir update" >> "$srcbase/dir/file" | ||
| 355 | ++sleep 1 | ||
| 356 | ++touch "$srcbase/dir/file" | ||
| 357 | ++ | ||
| 358 | ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ | ||
| 359 | ++ --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 360 | ++ || test_fail "test 7: proto28 partial-dir update through dirlink failed" | ||
| 361 | ++ | ||
| 362 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 363 | ++ || test_fail "test 7: proto28 partial-dir update content mismatch" | ||
| 364 | ++ | ||
| 365 | ++###################################################################### | ||
| 366 | ++# Test 8: Protocol < 29 basic directory symlink update | ||
| 367 | ++# | ||
| 368 | ++# Exercises the protocol < 29 code path and its fallback logic | ||
| 369 | ++# (clearing basedir on retry). | ||
| 370 | ++###################################################################### | ||
| 371 | ++ | ||
| 372 | ++rm -f "$HOME/real-dir/file" | ||
| 373 | ++make_testfile "$srcbase/dir/file" | ||
| 374 | ++ | ||
| 375 | ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ | ||
| 376 | ++ --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 377 | ++ || test_fail "test 8: initial proto28 transfer failed" | ||
| 378 | ++ | ||
| 379 | ++echo "proto28 update" >> "$srcbase/dir/file" | ||
| 380 | ++sleep 1 | ||
| 381 | ++touch "$srcbase/dir/file" | ||
| 382 | ++ | ||
| 383 | ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ | ||
| 384 | ++ --rsync-path="$RSYNC" dir/file localhost:) \ | ||
| 385 | ++ || test_fail "test 8: proto28 update through directory symlink failed" | ||
| 386 | ++ | ||
| 387 | ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ | ||
| 388 | ++ || test_fail "test 8: proto28 update content mismatch" | ||
| 389 | ++ | ||
| 390 | ++# The script would have aborted on error, so getting here means we've won. | ||
| 391 | ++exit 0 | ||
| @@ -0,0 +1,95 @@ | |||
| 1 | +From 61d987c54a472d88855c5fbef3a4c7b51696f93a Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Thu, 30 Apr 2026 08:44:11 +1000 | ||
| 4 | +Subject: [PATCH] syscall: also use O_RESOLVE_BENEATH on FreeBSD and MacOS | ||
| 5 | + | ||
| 6 | +FreeBSD and MacOS have O_RESOLVE_BENEATH as an openat() flag with the same | ||
| 7 | +"must not escape dirfd" semantics as Linux's RESOLVE_BENEATH. The | ||
| 8 | +kernel rejects ".." escapes, absolute symlinks, and symlinks whose | ||
| 9 | +target lies outside dirfd, while still following symlinks that | ||
| 10 | +resolve within it -- the same trade-off that fixes issue #715 on | ||
| 11 | +Linux. | ||
| 12 | + | ||
| 13 | +Add a parallel BSD path in secure_relative_open(), gated on | ||
| 14 | +declared. Unlike Linux, BSD doesn't have the header/runtime split | ||
| 15 | +where the symbol can exist without kernel support, so no runtime | ||
| 16 | +fallback is needed: if the flag compiles in, the kernel honours it. | ||
| 17 | + | ||
| 18 | +OpenBSD and NetBSD have no equivalent kernel primitive and continue | ||
| 19 | +to use the existing per-component O_NOFOLLOW walk; issue #715 | ||
| 20 | +remains visible on those platforms (a userland resolver or | ||
| 21 | +unveil(2)-based fence would be follow-up work). | ||
| 22 | + | ||
| 23 | +Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 24 | + | ||
| 25 | +Conflict:NA | ||
| 26 | +Reference:https://github.com/RsyncProject/rsync/commit/61d987c54a472d88855c5fbef3a4c7b51696f93a | ||
| 27 | +--- | ||
| 28 | + syscall.c | 40 +++++++++++++++++++++++++++++++++++++--- | ||
| 29 | + 1 file changed, 37 insertions(+), 3 deletions(-) | ||
| 30 | + | ||
| 31 | +diff --git a/syscall.c b/syscall.c | ||
| 32 | +index 82b45b6a..37fd4dfb 100644 | ||
| 33 | +--- a/syscall.c | ||
| 34 | ++++ b/syscall.c | ||
| 35 | + int do_open_nofollow(const char *pathname, int flags) | ||
| 36 | + versions rejected every symlink with O_NOFOLLOW on each component, | ||
| 37 | + which broke legitimate directory symlinks on the receiver side | ||
| 38 | + (https://github.com/RsyncProject/rsync/issues/715). The escape | ||
| 39 | +- prevention is handled by the kernel via openat2(RESOLVE_BENEATH) | ||
| 40 | +- on Linux 5.6+; older systems fall back to the per-component | ||
| 41 | +- O_NOFOLLOW walk below. | ||
| 42 | ++ prevention is handled by: | ||
| 43 | ++ Linux 5.6+: openat2(RESOLVE_BENEATH) | ||
| 44 | ++ FreeBSD 13+: openat() with O_RESOLVE_BENEATH | ||
| 45 | ++ macOS 15+ / iOS 18+: openat() with O_RESOLVE_BENEATH (same | ||
| 46 | ++ flag name, picked up by the same #ifdef; | ||
| 47 | ++ flag value differs from FreeBSD) | ||
| 48 | ++ Other systems fall back to the per-component O_NOFOLLOW walk below. | ||
| 49 | + | ||
| 50 | + The relpath must also not contain any ../ elements in the path. | ||
| 51 | + */ | ||
| 52 | + static int secure_relative_open_linux(const char *basedir, const char *relpath, | ||
| 53 | + } | ||
| 54 | + #endif | ||
| 55 | + | ||
| 56 | ++#ifdef O_RESOLVE_BENEATH | ||
| 57 | ++/* FreeBSD 13+ and macOS 15+ (Sequoia) / iOS 18+: O_RESOLVE_BENEATH is | ||
| 58 | ++ * an openat() flag with the same "must not escape dirfd" semantics as | ||
| 59 | ++ * Linux's RESOLVE_BENEATH. The kernel rejects ".." escapes, absolute | ||
| 60 | ++ * symlinks, and symlinks whose target lies outside dirfd. (FreeBSD and | ||
| 61 | ++ * Apple use different flag bit values, but the same symbolic name.) */ | ||
| 62 | ++static int secure_relative_open_resolve_beneath(const char *basedir, const char *relpath, int flags, mode_t mode) | ||
| 63 | ++{ | ||
| 64 | ++ int dirfd, retfd; | ||
| 65 | ++ | ||
| 66 | ++ if (basedir == NULL) { | ||
| 67 | ++ dirfd = AT_FDCWD; | ||
| 68 | ++ } else { | ||
| 69 | ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); | ||
| 70 | ++ if (dirfd == -1) | ||
| 71 | ++ return -1; | ||
| 72 | ++ } | ||
| 73 | ++ | ||
| 74 | ++ retfd = openat(dirfd, relpath, flags | O_RESOLVE_BENEATH, mode); | ||
| 75 | ++ | ||
| 76 | ++ if (dirfd != AT_FDCWD) | ||
| 77 | ++ close(dirfd); | ||
| 78 | ++ return retfd; | ||
| 79 | ++} | ||
| 80 | ++#endif | ||
| 81 | ++ | ||
| 82 | + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) | ||
| 83 | + { | ||
| 84 | + if (!relpath || relpath[0] == '/') { | ||
| 85 | + int secure_relative_open(const char *basedir, const char *relpath, int flags, mo | ||
| 86 | + } | ||
| 87 | + #endif | ||
| 88 | + | ||
| 89 | ++#ifdef O_RESOLVE_BENEATH | ||
| 90 | ++ return secure_relative_open_resolve_beneath(basedir, relpath, flags, mode); | ||
| 91 | ++#endif | ||
| 92 | ++ | ||
| 93 | + #if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) | ||
| 94 | + // really old system, all we can do is live with the risks | ||
| 95 | + if (!basedir) { | ||
| @@ -0,0 +1,462 @@ | |||
| 1 | +From 24852cda3db38e2f2cd78a13703373c77f75f4d5 Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Mon, 4 May 2026 21:53:14 +1000 | ||
| 4 | +Subject: [PATCH] syscall+receiver: secure receiver-side do_chmod against | ||
| 5 | + symlink-race TOCTOU | ||
| 6 | + | ||
| 7 | +CVE-2026-29518's fix routed the receiver's open() through | ||
| 8 | +secure_relative_open(), but every other path-based syscall the | ||
| 9 | +receiver runs on sender-controllable paths is vulnerable to the | ||
| 10 | +same TOCTOU primitive. This commit closes the chmod variant. | ||
| 11 | + | ||
| 12 | +Add do_chmod_at() that opens the parent of fname under | ||
| 13 | +secure_relative_open() and uses fchmodat() against the resulting | ||
| 14 | +dirfd. Gate the secure path on am_daemon && !am_chrooted (the same | ||
| 15 | +gate use_secure_symlinks already uses for the receiver basis-file | ||
| 16 | +open), so non-daemon callers and chrooted daemons keep the original | ||
| 17 | +do_chmod() fast path. | ||
| 18 | + | ||
| 19 | +Migrate the receiver-side do_chmod() call sites in delete.c, | ||
| 20 | +generator.c, rsync.c, and xattrs.c. | ||
| 21 | + | ||
| 22 | +Adds testsuite/chmod-symlink-race.test (with t_chmod_secure helper) | ||
| 23 | +as regression coverage. | ||
| 24 | + | ||
| 25 | +Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 26 | + | ||
| 27 | +Conflict:Makefile.in omits simdtest, which is absent from rsync 3.2.7; production and security-test changes are otherwise unchanged. | ||
| 28 | +Reference:https://github.com/RsyncProject/rsync/commit/24852cda3db38e2f2cd78a13703373c77f75f4d5 | ||
| 29 | +--- | ||
| 30 | + Makefile.in | 9 ++- | ||
| 31 | + delete.c | 4 +- | ||
| 32 | + generator.c | 4 +- | ||
| 33 | + rsync.c | 2 +- | ||
| 34 | + syscall.c | 80 ++++++++++++++++++++ | ||
| 35 | + t_chmod_secure.c | 117 ++++++++++++++++++++++++++++++ | ||
| 36 | + t_stub.c | 2 + | ||
| 37 | + testsuite/chmod-symlink-race.test | 68 +++++++++++++++++ | ||
| 38 | + xattrs.c | 6 +- | ||
| 39 | + 9 files changed, 282 insertions(+), 10 deletions(-) | ||
| 40 | + create mode 100644 t_chmod_secure.c | ||
| 41 | + create mode 100755 testsuite/chmod-symlink-race.test | ||
| 42 | + | ||
| 43 | +diff --git a/Makefile.in b/Makefile.in | ||
| 44 | +index a1253e5d..1e5c0792 100644 | ||
| 45 | +--- a/Makefile.in | ||
| 46 | ++++ b/Makefile.in | ||
| 47 | + TLS_OBJ = tls.o syscall.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/perms | ||
| 48 | + | ||
| 49 | + # Programs we must have to run the test cases | ||
| 50 | + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ | ||
| 51 | +- testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) wildtest$(EXEEXT) | ||
| 52 | ++ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ | ||
| 53 | ++ wildtest$(EXEEXT) | ||
| 54 | + | ||
| 55 | + CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test | ||
| 56 | + | ||
| 57 | + # Objects for CHECK_PROGS to clean | ||
| 58 | +-CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o trimslash.o wildtest.o | ||
| 59 | ++CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o trimslash.o wildtest.o | ||
| 60 | + | ||
| 61 | + # note that the -I. is needed to handle config.h when using VPATH | ||
| 62 | + .c.o: | ||
| 63 | + T_UNSAFE_OBJ = t_unsafe.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/sn | ||
| 64 | + t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ) | ||
| 65 | + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS) | ||
| 66 | + | ||
| 67 | ++T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util1.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o | ||
| 68 | ++t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) | ||
| 69 | ++ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) | ||
| 70 | ++ | ||
| 71 | + .PHONY: conf | ||
| 72 | + conf: configure.sh config.h.in | ||
| 73 | + | ||
| 74 | +diff --git a/delete.c b/delete.c | ||
| 75 | +index 4a294853..3a625610 100644 | ||
| 76 | +--- a/delete.c | ||
| 77 | ++++ b/delete.c | ||
| 78 | + static enum delret delete_dir_contents(char *fname, uint16 flags) | ||
| 79 | + | ||
| 80 | + strlcpy(p, fp->basename, remainder); | ||
| 81 | + if (!(fp->mode & S_IWUSR) && !am_root && fp->flags & FLAG_OWNED_BY_US) | ||
| 82 | +- do_chmod(fname, fp->mode | S_IWUSR); | ||
| 83 | ++ do_chmod_at(fname, fp->mode | S_IWUSR); | ||
| 84 | + /* Save stack by recursing to ourself directly. */ | ||
| 85 | + if (S_ISDIR(fp->mode)) { | ||
| 86 | + if (delete_dir_contents(fname, flags | DEL_RECURSE) != DR_SUCCESS) | ||
| 87 | + enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + if (flags & DEL_NO_UID_WRITE) | ||
| 91 | +- do_chmod(fbuf, mode | S_IWUSR); | ||
| 92 | ++ do_chmod_at(fbuf, mode | S_IWUSR); | ||
| 93 | + | ||
| 94 | + if (S_ISDIR(mode) && !(flags & DEL_DIR_IS_EMPTY)) { | ||
| 95 | + /* This only happens on the first call to delete_item() since | ||
| 96 | +diff --git a/generator.c b/generator.c | ||
| 97 | +index 0a27ad2b..6b6b94e2 100644 | ||
| 98 | +--- a/generator.c | ||
| 99 | ++++ b/generator.c | ||
| 100 | + static void recv_generator(char *fname, struct file_struct *file, int ndx, | ||
| 101 | + #ifdef HAVE_CHMOD | ||
| 102 | + if (!am_root && (file->mode & S_IRWXU) != S_IRWXU && dir_tweaking) { | ||
| 103 | + mode_t mode = file->mode | S_IRWXU; | ||
| 104 | +- if (do_chmod(fname, mode) < 0) { | ||
| 105 | ++ if (do_chmod_at(fname, mode) < 0) { | ||
| 106 | + rsyserr(FERROR_XFER, errno, | ||
| 107 | + "failed to modify permissions on %s", | ||
| 108 | + full_fname(fname)); | ||
| 109 | + static void touch_up_dirs(struct file_list *flist, int ndx) | ||
| 110 | + continue; | ||
| 111 | + fname = f_name(file, NULL); | ||
| 112 | + if (fix_dir_perms) | ||
| 113 | +- do_chmod(fname, file->mode); | ||
| 114 | ++ do_chmod_at(fname, file->mode); | ||
| 115 | + if (need_retouch_dir_times) { | ||
| 116 | + STRUCT_STAT st; | ||
| 117 | + if (link_stat(fname, &st, 0) == 0 && mtime_differs(&st, file)) { | ||
| 118 | +diff --git a/rsync.c b/rsync.c | ||
| 119 | +index b130aba5..cc46a2f9 100644 | ||
| 120 | +--- a/rsync.c | ||
| 121 | ++++ b/rsync.c | ||
| 122 | + int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, | ||
| 123 | + | ||
| 124 | + #ifdef HAVE_CHMOD | ||
| 125 | + if (!BITS_EQUAL(sxp->st.st_mode, new_mode, CHMOD_BITS)) { | ||
| 126 | +- int ret = am_root < 0 ? 0 : do_chmod(fname, new_mode); | ||
| 127 | ++ int ret = am_root < 0 ? 0 : do_chmod_at(fname, new_mode); | ||
| 128 | + if (ret < 0) { | ||
| 129 | + rsyserr(FERROR_XFER, errno, | ||
| 130 | + "failed to set permissions on %s", | ||
| 131 | +diff --git a/syscall.c b/syscall.c | ||
| 132 | +index 0413209c..b3584677 100644 | ||
| 133 | +--- a/syscall.c | ||
| 134 | ++++ b/syscall.c | ||
| 135 | + int do_chmod(const char *path, mode_t mode) | ||
| 136 | + return code; | ||
| 137 | + return 0; | ||
| 138 | + } | ||
| 139 | ++ | ||
| 140 | ++/* | ||
| 141 | ++ Symlink-race-safe variant of do_chmod() for receiver-side use. | ||
| 142 | ++ | ||
| 143 | ++ Threat model: on a daemon running with "use chroot = no" (the prerequisite | ||
| 144 | ++ for CVE-2026-29518), a local attacker can race a symlink swap of one of | ||
| 145 | ++ the parent directory components of a path the receiver is about to chmod. | ||
| 146 | ++ Because chmod() resolves symlinks at every component, the swap redirects | ||
| 147 | ++ the chmod outside the receiver's confinement. | ||
| 148 | ++ | ||
| 149 | ++ Defence: open the *parent* directory of fname under secure_relative_open() | ||
| 150 | ++ (which uses openat2(RESOLVE_BENEATH) on Linux 5.6+, openat() with | ||
| 151 | ++ O_RESOLVE_BENEATH on FreeBSD 13+ and macOS 15+ (Sequoia), or a per-component | ||
| 152 | ++ O_NOFOLLOW walk elsewhere) and do fchmodat() against that dirfd. A symlink | ||
| 153 | ++ substituted into one of the parent components is then either followed | ||
| 154 | ++ within the tree (legitimate dir-symlinks still work) or rejected by the | ||
| 155 | ++ kernel (escape attempts fail). | ||
| 156 | ++ | ||
| 157 | ++ Final-component handling matches do_chmod(): fchmodat() with flag 0 | ||
| 158 | ++ follows a symlink at the final component, which is the same behaviour as | ||
| 159 | ++ chmod() and matches every current call site (the file being chmod'd is | ||
| 160 | ++ one the receiver itself just created or transferred). For the rare case | ||
| 161 | ++ where the caller wants to chmod a symlink-as-an-object (S_ISLNK in the | ||
| 162 | ++ mode bits), we fall through to do_chmod() which has portability code for | ||
| 163 | ++ that case. | ||
| 164 | ++ | ||
| 165 | ++ Falls back to do_chmod() for absolute paths and for paths with no parent | ||
| 166 | ++ component, where there is nothing to protect against. | ||
| 167 | ++*/ | ||
| 168 | ++int do_chmod_at(const char *fname, mode_t mode) | ||
| 169 | ++{ | ||
| 170 | ++#ifdef AT_FDCWD | ||
| 171 | ++ extern int am_daemon, am_chrooted; | ||
| 172 | ++ char dirpath[MAXPATHLEN]; | ||
| 173 | ++ const char *bname; | ||
| 174 | ++ const char *slash; | ||
| 175 | ++ int dfd, ret, e; | ||
| 176 | ++ size_t dlen; | ||
| 177 | ++ | ||
| 178 | ++ if (dry_run) return 0; | ||
| 179 | ++ RETURN_ERROR_IF_RO_OR_LO; | ||
| 180 | ++ | ||
| 181 | ++ /* Only the daemon-without-chroot case is exposed to the symlink- | ||
| 182 | ++ * race attack: a chroot already confines the receiver, and a | ||
| 183 | ++ * non-daemon rsync runs with the user's own authority so a | ||
| 184 | ++ * symlink they planted can only redirect to files they could | ||
| 185 | ++ * already access. Everywhere else, fall through to plain | ||
| 186 | ++ * do_chmod() to avoid the dirfd-open overhead on every call. */ | ||
| 187 | ++ if (!am_daemon || am_chrooted) | ||
| 188 | ++ return do_chmod(fname, mode); | ||
| 189 | ++ | ||
| 190 | ++ if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) | ||
| 191 | ++ return do_chmod(fname, mode); | ||
| 192 | ++ | ||
| 193 | ++ slash = strrchr(fname, '/'); | ||
| 194 | ++ if (!slash) | ||
| 195 | ++ return do_chmod(fname, mode); | ||
| 196 | ++ | ||
| 197 | ++ dlen = slash - fname; | ||
| 198 | ++ if (dlen >= sizeof dirpath) { | ||
| 199 | ++ errno = ENAMETOOLONG; | ||
| 200 | ++ return -1; | ||
| 201 | ++ } | ||
| 202 | ++ memcpy(dirpath, fname, dlen); | ||
| 203 | ++ dirpath[dlen] = '\0'; | ||
| 204 | ++ bname = slash + 1; | ||
| 205 | ++ | ||
| 206 | ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); | ||
| 207 | ++ if (dfd < 0) | ||
| 208 | ++ return -1; | ||
| 209 | ++ | ||
| 210 | ++ ret = fchmodat(dfd, bname, mode, 0); | ||
| 211 | ++ e = errno; | ||
| 212 | ++ close(dfd); | ||
| 213 | ++ errno = e; | ||
| 214 | ++ return ret; | ||
| 215 | ++#else | ||
| 216 | ++ return do_chmod(fname, mode); | ||
| 217 | ++#endif | ||
| 218 | ++} | ||
| 219 | + #endif | ||
| 220 | + | ||
| 221 | + int do_rename(const char *old_path, const char *new_path) | ||
| 222 | +diff --git a/t_chmod_secure.c b/t_chmod_secure.c | ||
| 223 | +new file mode 100644 | ||
| 224 | +index 00000000..114dfb2d | ||
| 225 | +--- /dev/null | ||
| 226 | ++++ b/t_chmod_secure.c | ||
| 227 | + | ||
| 228 | ++/* | ||
| 229 | ++ * Test harness for do_chmod_at(). Confirms the symlink-TOCTOU | ||
| 230 | ++ * primitive used by CVE-2026-29518 (and its incomplete-fix follow-up | ||
| 231 | ++ * for chmod) is closed by do_chmod_at(): a parent directory component | ||
| 232 | ++ * being a symlink that escapes the receiver's confinement must be | ||
| 233 | ++ * rejected, while a parent symlink that resolves *within* the tree | ||
| 234 | ++ * must still work (so legitimate dir-symlinks are not regressed). | ||
| 235 | ++ * | ||
| 236 | ++ * Not linked into rsync itself. | ||
| 237 | ++ * | ||
| 238 | ++ * This program is free software; you can redistribute it and/or modify | ||
| 239 | ++ * it under the terms of the GNU General Public License version 2 as | ||
| 240 | ++ * published by the Free Software Foundation. | ||
| 241 | ++ */ | ||
| 242 | ++ | ||
| 243 | ++#include "rsync.h" | ||
| 244 | ++ | ||
| 245 | ++#include <sys/stat.h> | ||
| 246 | ++ | ||
| 247 | ++int dry_run = 0; | ||
| 248 | ++int am_root = 0; | ||
| 249 | ++int am_sender = 0; | ||
| 250 | ++int read_only = 0; | ||
| 251 | ++int list_only = 0; | ||
| 252 | ++int copy_links = 0; | ||
| 253 | ++int copy_unsafe_links = 0; | ||
| 254 | ++extern int am_daemon, am_chrooted; | ||
| 255 | ++ | ||
| 256 | ++short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; | ||
| 257 | ++ | ||
| 258 | ++static int errs = 0; | ||
| 259 | ++ | ||
| 260 | ++static void check(const char *label, int actual_rc, int expect_ok, | ||
| 261 | ++ const char *path, mode_t expected_mode) | ||
| 262 | ++{ | ||
| 263 | ++ struct stat st; | ||
| 264 | ++ int got_ok = (actual_rc == 0); | ||
| 265 | ++ if (got_ok != expect_ok) { | ||
| 266 | ++ fprintf(stderr, "FAIL [%s]: rc=%d errno=%d (%s), expected %s\n", | ||
| 267 | ++ label, actual_rc, errno, strerror(errno), | ||
| 268 | ++ expect_ok ? "success" : "rejection"); | ||
| 269 | ++ errs++; | ||
| 270 | ++ return; | ||
| 271 | ++ } | ||
| 272 | ++ if (path && stat(path, &st) < 0) { | ||
| 273 | ++ fprintf(stderr, "FAIL [%s]: stat(%s) failed: %s\n", | ||
| 274 | ++ label, path, strerror(errno)); | ||
| 275 | ++ errs++; | ||
| 276 | ++ return; | ||
| 277 | ++ } | ||
| 278 | ++ if (path && (st.st_mode & 07777) != expected_mode) { | ||
| 279 | ++ fprintf(stderr, | ||
| 280 | ++ "FAIL [%s]: %s mode is 0%o, expected 0%o\n", | ||
| 281 | ++ label, path, st.st_mode & 07777, expected_mode); | ||
| 282 | ++ errs++; | ||
| 283 | ++ return; | ||
| 284 | ++ } | ||
| 285 | ++ fprintf(stderr, "OK [%s]\n", label); | ||
| 286 | ++} | ||
| 287 | ++ | ||
| 288 | ++int main(int argc, char **argv) | ||
| 289 | ++{ | ||
| 290 | ++ if (argc != 2) { | ||
| 291 | ++ fprintf(stderr, "usage: %s <module-dir>\n", argv[0]); | ||
| 292 | ++ return 2; | ||
| 293 | ++ } | ||
| 294 | ++ if (chdir(argv[1]) < 0) { | ||
| 295 | ++ perror("chdir"); | ||
| 296 | ++ return 2; | ||
| 297 | ++ } | ||
| 298 | ++ | ||
| 299 | ++ /* Simulate the daemon-without-chroot deployment that do_chmod_at() | ||
| 300 | ++ * defends. With am_daemon=0 or am_chrooted=1 the wrapper falls | ||
| 301 | ++ * through to plain do_chmod() and the symlink-race test would be | ||
| 302 | ++ * meaningless. */ | ||
| 303 | ++ am_daemon = 1; | ||
| 304 | ++ am_chrooted = 0; | ||
| 305 | ++ | ||
| 306 | ++ /* Test layout (all inside the directory we just chdir'd to): | ||
| 307 | ++ * | ||
| 308 | ++ * ./realdir/sentinel -- regular target file | ||
| 309 | ++ * ./inside_link -> realdir -- legitimate dir-symlink within the tree | ||
| 310 | ++ * ./escape_link -> ../trap -- attacker swap, target outside tree | ||
| 311 | ++ * ../trap/sentinel -- the file the attacker wants to alter | ||
| 312 | ++ * | ||
| 313 | ++ * The shell wrapper that calls this helper has set both sentinel | ||
| 314 | ++ * files to mode 0600 so we have a clean baseline to compare. | ||
| 315 | ++ */ | ||
| 316 | ++ | ||
| 317 | ++ /* Scenario A: legitimate parent dir-symlink, chmod must succeed. */ | ||
| 318 | ++ int rc = do_chmod_at("inside_link/sentinel", 0640); | ||
| 319 | ++ check("A: legit dir-symlink within tree", | ||
| 320 | ++ rc, 1, "realdir/sentinel", 0640); | ||
| 321 | ++ | ||
| 322 | ++ /* Scenario B: parent symlink escapes the tree -- chmod must be | ||
| 323 | ++ * rejected and the outside file's mode must be unchanged. */ | ||
| 324 | ++ rc = do_chmod_at("escape_link/sentinel", 0666); | ||
| 325 | ++ check("B: parent symlink escapes tree (the attack)", | ||
| 326 | ++ rc, 0, "../trap/sentinel", 0600); | ||
| 327 | ++ | ||
| 328 | ++ /* Scenario C: plain relative path with no symlink components, | ||
| 329 | ++ * regression check that the safe wrapper doesn't break the | ||
| 330 | ++ * normal case. */ | ||
| 331 | ++ rc = do_chmod_at("realdir/sentinel", 0644); | ||
| 332 | ++ check("C: plain relative path (regression check)", | ||
| 333 | ++ rc, 1, "realdir/sentinel", 0644); | ||
| 334 | ++ | ||
| 335 | ++ /* Scenario D: top-level file, no parent directory component. | ||
| 336 | ++ * Falls back to do_chmod(); should succeed. */ | ||
| 337 | ++ rc = do_chmod_at("topfile", 0640); | ||
| 338 | ++ check("D: top-level file, no parent component", | ||
| 339 | ++ rc, 1, "topfile", 0640); | ||
| 340 | ++ | ||
| 341 | ++ if (errs) | ||
| 342 | ++ fprintf(stderr, "%d failure(s)\n", errs); | ||
| 343 | ++ return errs ? 1 : 0; | ||
| 344 | ++} | ||
| 345 | +diff --git a/t_stub.c b/t_stub.c | ||
| 346 | +index 085378a8..904dac99 100644 | ||
| 347 | +--- a/t_stub.c | ||
| 348 | ++++ b/t_stub.c | ||
| 349 | + | ||
| 350 | + | ||
| 351 | + int do_fsync = 0; | ||
| 352 | + int inplace = 0; | ||
| 353 | ++int am_daemon = 0; | ||
| 354 | ++int am_chrooted = 0; | ||
| 355 | + int modify_window = 0; | ||
| 356 | + int preallocate_files = 0; | ||
| 357 | + int protect_args = 0; | ||
| 358 | +diff --git a/testsuite/chmod-symlink-race.test b/testsuite/chmod-symlink-race.test | ||
| 359 | +new file mode 100755 | ||
| 360 | +index 00000000..48bbfbb4 | ||
| 361 | +--- /dev/null | ||
| 362 | ++++ b/testsuite/chmod-symlink-race.test | ||
| 363 | + | ||
| 364 | ++#!/bin/sh | ||
| 365 | ++ | ||
| 366 | ++# Copyright (C) 2026 by Andrew Tridgell | ||
| 367 | ++ | ||
| 368 | ++# This program is distributable under the terms of the GNU GPL (see | ||
| 369 | ++# COPYING). | ||
| 370 | ++ | ||
| 371 | ++# Regression test for the symlink-TOCTOU class of bug applied to | ||
| 372 | ++# chmod() on the receiver side. The CVE-2026-29518 fix used | ||
| 373 | ++# secure_relative_open() for the basis-file open, but every other | ||
| 374 | ++# path-based syscall the receiver runs on sender-controllable paths | ||
| 375 | ++# is vulnerable to the same primitive: a local attacker swaps a | ||
| 376 | ++# symlink into one of the parent directory components between the | ||
| 377 | ++# receiver's check and its act, and the syscall escapes the module. | ||
| 378 | ++# | ||
| 379 | ++# This test exercises the new do_chmod_at() wrapper via the | ||
| 380 | ++# t_chmod_secure helper. The helper sets up two scenarios: | ||
| 381 | ++# - a parent dir-symlink that resolves WITHIN the module tree | ||
| 382 | ++# (legitimate -K-style use, must continue to work) | ||
| 383 | ++# - a parent dir-symlink that escapes the module tree (the | ||
| 384 | ++# attack, must be rejected) | ||
| 385 | ++# plus two regression scenarios (plain relative path, top-level | ||
| 386 | ++# file) that just confirm the safe wrapper doesn't break the | ||
| 387 | ++# normal case. | ||
| 388 | ++# | ||
| 389 | ++# The kernel-enforced "stay below dirfd" path resolution is | ||
| 390 | ++# only available on Linux 5.6+, FreeBSD 13+, and macOS 15+. | ||
| 391 | ++# Skip on platforms that fall back to per-component O_NOFOLLOW | ||
| 392 | ++# (Solaris, OpenBSD, NetBSD, Cygwin); the per-component fallback | ||
| 393 | ++# would also reject the attack but the legitimate dir-symlink | ||
| 394 | ++# scenario would fail there. | ||
| 395 | ++ | ||
| 396 | ++. "$suitedir/rsync.fns" | ||
| 397 | ++ | ||
| 398 | ++case "$(uname -s)" in | ||
| 399 | ++ SunOS|OpenBSD|NetBSD|CYGWIN*) | ||
| 400 | ++ test_skipped "do_chmod_at relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" | ||
| 401 | ++ ;; | ||
| 402 | ++esac | ||
| 403 | ++ | ||
| 404 | ++mod="$scratchdir/module" | ||
| 405 | ++trap_outside="$scratchdir/trap" | ||
| 406 | ++rm -rf "$mod" "$trap_outside" | ||
| 407 | ++mkdir -p "$mod/realdir" "$trap_outside" | ||
| 408 | ++ | ||
| 409 | ++# Set up the four file-system objects the helper expects: | ||
| 410 | ++echo bystander > "$mod/realdir/sentinel" | ||
| 411 | ++chmod 0600 "$mod/realdir/sentinel" | ||
| 412 | ++echo target > "$trap_outside/sentinel" | ||
| 413 | ++chmod 0600 "$trap_outside/sentinel" | ||
| 414 | ++ln -s realdir "$mod/inside_link" | ||
| 415 | ++ln -s ../trap "$mod/escape_link" | ||
| 416 | ++echo top > "$mod/topfile" | ||
| 417 | ++chmod 0600 "$mod/topfile" | ||
| 418 | ++ | ||
| 419 | ++"$TOOLDIR/t_chmod_secure" "$mod" || \ | ||
| 420 | ++ test_fail "t_chmod_secure reported failures (see stderr above)" | ||
| 421 | ++ | ||
| 422 | ++# Sanity-check from the shell side too: the outside file's mode must | ||
| 423 | ++# still be 0600 -- the helper checked this, but a second look from | ||
| 424 | ++# the shell guards against a helper-internal stat() bug. | ||
| 425 | ++mode=$(stat -c '%a' "$trap_outside/sentinel" 2>/dev/null \ | ||
| 426 | ++ || stat -f '%Lp' "$trap_outside/sentinel" 2>/dev/null) | ||
| 427 | ++if [ "$mode" != "600" ]; then | ||
| 428 | ++ test_fail "outside sentinel mode changed from 600 to $mode -- chmod escaped the module" | ||
| 429 | ++fi | ||
| 430 | ++ | ||
| 431 | ++exit 0 | ||
| 432 | +diff --git a/xattrs.c b/xattrs.c | ||
| 433 | +index 65166eed..e5d0dd43 100644 | ||
| 434 | +--- a/xattrs.c | ||
| 435 | ++++ b/xattrs.c | ||
| 436 | + int set_xattr(const char *fname, const struct file_struct *file, const char *fna | ||
| 437 | + && !S_ISLNK(sxp->st.st_mode) | ||
| 438 | + #endif | ||
| 439 | + && access(fname, W_OK) < 0 | ||
| 440 | +- && do_chmod(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) == 0) | ||
| 441 | ++ && do_chmod_at(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) == 0) | ||
| 442 | + added_write_perm = 1; | ||
| 443 | + | ||
| 444 | + ndx = F_XATTR(file); | ||
| 445 | + int set_xattr(const char *fname, const struct file_struct *file, const char *fna | ||
| 446 | + lst = &glst->xa_items; | ||
| 447 | + int return_value = rsync_xal_set(fname, lst, fnamecmp, sxp); | ||
| 448 | + if (added_write_perm) /* remove the temporary write permission */ | ||
| 449 | +- do_chmod(fname, sxp->st.st_mode); | ||
| 450 | ++ do_chmod_at(fname, sxp->st.st_mode); | ||
| 451 | + return return_value; | ||
| 452 | + } | ||
| 453 | + | ||
| 454 | + int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode) | ||
| 455 | + mode = (fst.st_mode & _S_IFMT) | (fmode & ACCESSPERMS) | ||
| 456 | + | (S_ISDIR(fst.st_mode) ? 0700 : 0600); | ||
| 457 | + if (fst.st_mode != mode) | ||
| 458 | +- do_chmod(fname, mode); | ||
| 459 | ++ do_chmod_at(fname, mode); | ||
| 460 | + if (!IS_DEVICE(fst.st_mode)) | ||
| 461 | + fst.st_rdev = 0; /* just in case */ | ||
| 462 | + | ||
| @@ -0,0 +1,202 @@ | |||
| 1 | +From d22b6bc7d1b1d7be9df1c0c6db1599cb7d5fd82c Mon Sep 17 00:00:00 2001 | ||
| 2 | +From: Andrew Tridgell <andrew@tridgell.net> | ||
| 3 | +Date: Tue, 5 May 2026 14:34:33 +1000 | ||
| 4 | +Subject: [PATCH] util1: secure change_dir() against symlink-race chdir-escape | ||
| 5 | + | ||
| 6 | +The receiver's chdir(2) into a destination subdirectory followed | ||
| 7 | +attacker-planted symlinks at every path component. Once CWD | ||
| 8 | +escaped the module, every subsequent path-relative syscall (open, | ||
| 9 | +chmod, lchown, ...) inherited the escape -- defeating | ||
| 10 | +secure_relative_open's RESOLVE_BENEATH anchor against AT_FDCWD, | ||
| 11 | +since the anchor itself was now outside the module. | ||
| 12 | + | ||
| 13 | +Route change_dir's relative target through secure_relative_open() | ||
| 14 | +and fchdir() to the resulting dirfd in am_daemon && !am_chrooted | ||
| 15 | +mode, so the chdir step itself can no longer follow a parent- | ||
| 16 | +symlink. Same treatment applied to the CD_SKIP_CHDIR / | ||
| 17 | +set_path_only path so it also can't follow attacker symlinks | ||
| 18 | +during path tracking. | ||
| 19 | + | ||
| 20 | +Adds testsuite/sender-flist-symlink-leak.test covering the | ||
| 21 | +sender-side flist resolution variant of the same primitive. | ||
| 22 | + | ||
| 23 | +Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | ||
| 24 | + | ||
| 25 | +Conflict:NA | ||
| 26 | +Reference:https://github.com/RsyncProject/rsync/commit/d22b6bc7d1b1d7be9df1c0c6db1599cb7d5fd82c | ||
| 27 | +--- | ||
| 28 | + testsuite/sender-flist-symlink-leak.test | 90 ++++++++++++++++++++++++ | ||
| 29 | + util1.c | 56 +++++++++++++-- | ||
| 30 | + 2 files changed, 142 insertions(+), 4 deletions(-) | ||
| 31 | + create mode 100755 testsuite/sender-flist-symlink-leak.test | ||
| 32 | + | ||
| 33 | +diff --git a/testsuite/sender-flist-symlink-leak.test b/testsuite/sender-flist-symlink-leak.test | ||
| 34 | +new file mode 100755 | ||
| 35 | +index 00000000..011d93d0 | ||
| 36 | +--- /dev/null | ||
| 37 | ++++ b/testsuite/sender-flist-symlink-leak.test | ||
| 38 | + | ||
| 39 | ++#!/bin/sh | ||
| 40 | ++ | ||
| 41 | ++# Copyright (C) 2026 by Andrew Tridgell | ||
| 42 | ++ | ||
| 43 | ++# This program is distributable under the terms of the GNU GPL (see | ||
| 44 | ++# COPYING). | ||
| 45 | ++ | ||
| 46 | ++# Regression test for codex re-check finding: the sender-side file- | ||
| 47 | ++# list generator can still follow an attacker-planted symlink out of | ||
| 48 | ++# the module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR) | ||
| 49 | ++# followed by change_dir(...,CD_NORMAL). The CD_SKIP_CHDIR sets | ||
| 50 | ++# skipped_chdir=1, and the next CD_NORMAL call's secure-branch in | ||
| 51 | ++# util1.c is gated on `!skipped_chdir`, so the secure path is | ||
| 52 | ++# bypassed and a raw chdir(curr_dir) follows attacker-controlled | ||
| 53 | ++# symlinks during flist generation. | ||
| 54 | ++# | ||
| 55 | ++# Reach: rsync daemon module with `use chroot = no`. A local | ||
| 56 | ++# attacker plants module/cd -> /outside. A client (innocent or | ||
| 57 | ++# malicious) pulls rsync://<daemon>/<module>/cd/. The daemon, as | ||
| 58 | ++# sender, enumerates files in /outside and ships their metadata | ||
| 59 | ++# (names, sizes, modes, mtimes) to the client. The actual content | ||
| 60 | ++# transfer fails later at the secure_relative_open step with EXDEV, | ||
| 61 | ++# but by then the metadata has already leaked. | ||
| 62 | ++# | ||
| 63 | ++# We detect by running a dry-run pull of the symlinked subdir and | ||
| 64 | ++# checking whether the client's --list-only output mentions any | ||
| 65 | ++# file from /outside. With the bug, /outside/secret.txt appears in | ||
| 66 | ++# the list with its size; with the fix, the daemon's chdir into | ||
| 67 | ++# the symlinked subdir is rejected and no /outside file is listed. | ||
| 68 | ++ | ||
| 69 | ++. "$suitedir/rsync.fns" | ||
| 70 | ++ | ||
| 71 | ++case "$(uname -s)" in | ||
| 72 | ++ SunOS|OpenBSD|NetBSD|CYGWIN*) | ||
| 73 | ++ test_skipped "secure change_dir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" | ||
| 74 | ++ ;; | ||
| 75 | ++esac | ||
| 76 | ++ | ||
| 77 | ++mod="$scratchdir/module" | ||
| 78 | ++outside="$scratchdir/outside" | ||
| 79 | ++listfile="$scratchdir/listed.txt" | ||
| 80 | ++conf="$scratchdir/test-rsyncd.conf" | ||
| 81 | ++ | ||
| 82 | ++rm -rf "$mod" "$outside" | ||
| 83 | ++mkdir -p "$mod" "$outside" | ||
| 84 | ++ | ||
| 85 | ++# Outside-the-module file the daemon should NOT enumerate to clients. | ||
| 86 | ++# A distinctive name + non-trivial size makes the leak easy to spot. | ||
| 87 | ++echo "OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR" > "$outside/leak_marker.txt" | ||
| 88 | ++chmod 0644 "$outside/leak_marker.txt" | ||
| 89 | ++ | ||
| 90 | ++# The symlink trap planted by the local attacker. | ||
| 91 | ++ln -s "$outside" "$mod/cd" | ||
| 92 | ++ | ||
| 93 | ++my_uid=`get_testuid` | ||
| 94 | ++root_uid=`get_rootuid` | ||
| 95 | ++root_gid=`get_rootgid` | ||
| 96 | ++uid_setting="uid = $root_uid" | ||
| 97 | ++gid_setting="gid = $root_gid" | ||
| 98 | ++if test x"$my_uid" != x"$root_uid"; then | ||
| 99 | ++ uid_setting="#$uid_setting" | ||
| 100 | ++ gid_setting="#$gid_setting" | ||
| 101 | ++fi | ||
| 102 | ++ | ||
| 103 | ++cat > "$conf" <<EOF | ||
| 104 | ++use chroot = no | ||
| 105 | ++$uid_setting | ||
| 106 | ++$gid_setting | ||
| 107 | ++log file = $scratchdir/rsyncd.log | ||
| 108 | ++[upload] | ||
| 109 | ++ path = $mod | ||
| 110 | ++ use chroot = no | ||
| 111 | ++ read only = no | ||
| 112 | ++EOF | ||
| 113 | ++ | ||
| 114 | ++# Pull recursively into the symlinked subdir with dry-run + verbose, | ||
| 115 | ++# capturing the daemon's flist (file list) on stdout. If the daemon | ||
| 116 | ++# enumerates /outside, leak_marker.txt will appear in the listing. | ||
| 117 | ++RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \ | ||
| 118 | ++ $RSYNC -nrv rsync://localhost/upload/cd/ "$scratchdir/dst/" \ | ||
| 119 | ++ > "$listfile" 2>&1 || true | ||
| 120 | ++ | ||
| 121 | ++if grep -q "leak_marker\.txt" "$listfile"; then | ||
| 122 | ++ echo "----- leaked listing follows" >&2 | ||
| 123 | ++ sed 's/^/ /' "$listfile" >&2 | ||
| 124 | ++ echo "----- leaked listing ends" >&2 | ||
| 125 | ++ test_fail "sender flist leak: outside/leak_marker.txt was enumerated to the client (daemon's chdir followed the cd symlink during flist generation)" | ||
| 126 | ++fi | ||
| 127 | ++ | ||
| 128 | ++exit 0 | ||
| 129 | +diff --git a/util1.c b/util1.c | ||
| 130 | +index d84bc414..6e457d4f 100644 | ||
| 131 | +--- a/util1.c | ||
| 132 | ++++ b/util1.c | ||
| 133 | + char *sanitize_path(char *dest, const char *p, const char *rootdir, int depth, i | ||
| 134 | + * Also cleans the path using the clean_fname() function. */ | ||
| 135 | + int change_dir(const char *dir, int set_path_only) | ||
| 136 | + { | ||
| 137 | ++ extern int am_daemon, am_chrooted; | ||
| 138 | + static int initialised, skipped_chdir; | ||
| 139 | + unsigned int len; | ||
| 140 | + | ||
| 141 | + int change_dir(const char *dir, int set_path_only) | ||
| 142 | + curr_dir[curr_dir_len++] = '/'; | ||
| 143 | + memcpy(curr_dir + curr_dir_len, dir, len + 1); | ||
| 144 | + | ||
| 145 | +- if (!set_path_only && chdir(curr_dir)) { | ||
| 146 | +- curr_dir_len = save_dir_len; | ||
| 147 | +- curr_dir[curr_dir_len] = '\0'; | ||
| 148 | +- return 0; | ||
| 149 | ++ if (!set_path_only) { | ||
| 150 | ++ int chdir_failed; | ||
| 151 | ++ /* In the daemon-without-chroot deployment we must not | ||
| 152 | ++ * follow a symlink in any component of the chdir | ||
| 153 | ++ * target -- otherwise CWD escapes the module and | ||
| 154 | ++ * every subsequent path-relative syscall (open, | ||
| 155 | ++ * chmod, lchown, ...) inherits the escape, which | ||
| 156 | ++ * defeats secure_relative_open's RESOLVE_BENEATH | ||
| 157 | ++ * anchor and re-opens the CVE-2026-29518 class of | ||
| 158 | ++ * symlink TOCTOU attacks. Use the secure resolver | ||
| 159 | ++ * to get a confined dirfd, then fchdir() to it. | ||
| 160 | ++ * | ||
| 161 | ++ * If skipped_chdir is set, a previous CD_SKIP_CHDIR | ||
| 162 | ++ * call buffered an absolute prefix in curr_dir | ||
| 163 | ++ * (e.g. change_pathname's CD_SKIP_CHDIR to orig_dir) | ||
| 164 | ++ * without syncing the kernel's CWD. Resolve `dir` | ||
| 165 | ++ * relative to that prefix as basedir so the secure | ||
| 166 | ++ * branch still anchors at the operator-trusted | ||
| 167 | ++ * directory rather than wherever the kernel CWD | ||
| 168 | ++ * happens to be. */ | ||
| 169 | ++ if (am_daemon && !am_chrooted) { | ||
| 170 | ++ const char *basedir = NULL; | ||
| 171 | ++ char prefix[MAXPATHLEN]; | ||
| 172 | ++ int dfd; | ||
| 173 | ++ if (skipped_chdir) { | ||
| 174 | ++ if (save_dir_len >= sizeof prefix) { | ||
| 175 | ++ errno = ENAMETOOLONG; | ||
| 176 | ++ chdir_failed = 1; | ||
| 177 | ++ goto chdir_cleanup; | ||
| 178 | ++ } | ||
| 179 | ++ memcpy(prefix, curr_dir, save_dir_len); | ||
| 180 | ++ prefix[save_dir_len] = '\0'; | ||
| 181 | ++ basedir = prefix; | ||
| 182 | ++ } | ||
| 183 | ++ dfd = secure_relative_open(basedir, dir, | ||
| 184 | ++ O_RDONLY | O_DIRECTORY, 0); | ||
| 185 | ++ if (dfd < 0) { | ||
| 186 | ++ chdir_failed = 1; | ||
| 187 | ++ } else { | ||
| 188 | ++ chdir_failed = fchdir(dfd) != 0; | ||
| 189 | ++ close(dfd); | ||
| 190 | ++ } | ||
| 191 | ++ } else { | ||
| 192 | ++ chdir_failed = chdir(curr_dir) != 0; | ||
| 193 | ++ } | ||
| 194 | ++ chdir_cleanup: | ||
| 195 | ++ if (chdir_failed) { | ||
| 196 | ++ curr_dir_len = save_dir_len; | ||
| 197 | ++ curr_dir[curr_dir_len] = '\0'; | ||
| 198 | ++ return 0; | ||
| 199 | ++ } | ||
| 200 | + } | ||
| 201 | + skipped_chdir = set_path_only; | ||
| 202 | + } | ||