草稿
[WIP]fix CVEs #109
[WIP]fix CVEs #109
草稿
wangyuhang创建于 17 天前
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+@@ -30,6 +30,7 @@ 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+@@ -38,6 +39,7 @@ 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+@@ -981,6 +983,7 @@ 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+@@ -1003,6 +1006,15 @@ 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+@@ -1305,6 +1317,19 @@ 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+@@ -113,11 +113,20 @@ 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+@@ -70,6 +70,7 @@ 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+@@ -214,7 +215,12 @@ 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+@@ -868,11 +874,21 @@ 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+@@ -882,6 +882,145 @@ 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+@@ -48,6 +48,8 @@ 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+@@ -354,7 +356,25 @@ 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+@@ -1310,6 +1310,28 @@ 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+@@ -0,0 +1,111 @@
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+@@ -33,6 +33,11 @@
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+@@ -720,12 +725,49 @@ 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+@@ -739,7 +781,17 @@ 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+@@ -0,0 +1,247 @@
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+@@ -734,9 +734,13 @@ 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+@@ -768,6 +772,32 @@ 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+@@ -791,6 +821,10 @@ 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+@@ -57,12 +57,13 @@ 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+@@ -178,6 +179,10 @@ 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+@@ -98,7 +98,7 @@ 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+@@ -139,7 +139,7 @@ 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+@@ -1499,7 +1499,7 @@ 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+@@ -2111,7 +2111,7 @@ 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+@@ -657,7 +657,7 @@ 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+@@ -281,6 +281,86 @@ 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+@@ -0,0 +1,117 @@
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+@@ -23,6 +23,8 @@
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+@@ -0,0 +1,68 @@
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+@@ -1086,7 +1086,7 @@ 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+@@ -1094,7 +1094,7 @@ 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+@@ -1211,7 +1211,7 @@ 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+@@ -0,0 +1,90 @@
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+@@ -1112,6 +1112,7 @@ 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+@@ -1150,10 +1151,57 @@ 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+ }