diff --git a/SOURCES/rsync-3.1.3-fix-cve-2026-29518-regressions.patch b/SOURCES/rsync-3.1.3-fix-cve-2026-29518-regressions.patch new file mode 100644 index 0000000..be2454e --- /dev/null +++ b/SOURCES/rsync-3.1.3-fix-cve-2026-29518-regressions.patch @@ -0,0 +1,610 @@ +From ba9dd4ec47c6e49f21e5905a91af68aad3c4678e Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sun, 24 May 2026 08:48:42 +1000 +Subject: [PATCH 61/81] receiver: fix absolute --partial-dir delta resume + (false verification) + +A delta (--no-whole-file) resume whose basis is an absolute --partial-dir +looped forever on exit code 23 ("failed verification -- update put into +partial-dir"), stranding the correct data in the partial-dir and never +populating the destination. + +Cause: an absolute --partial-dir makes the basis path absolute, but the +receiver opened it with secure_relative_open(NULL, fnamecmp, ...), which by +design rejects an absolute relpath (EINVAL). The basis fd was then -1, so +receive_data() mapped no basis and (because the matched-block sum_update() is +guarded by "if (mapbuf)") computed the whole-file verification checksum over +the literal data only -> a spurious mismatch every run. (The data itself was +correct, since the in-place update leaves the matched basis bytes in place.) +Under a non-chroot daemon the in-place write went through the same call and +failed outright. + +Fix: add secure_basis_open(), which treats an operator-trusted absolute basis +path as (trusted directory + confined leaf) -- the same way secure_relative_open +already trusts an absolute basedir while keeping O_NOFOLLOW on the leaf -- and +use it for both the basis read and the inplace-partial write. The strict +"reject absolute relpath" contract of secure_relative_open is left intact. + +Defense-in-depth: receive_data() now treats a block-match token with no mapped +basis as a protocol inconsistency (it can only arise from a basis that the +generator opened but the receiver could not), failing cleanly instead of +silently dropping those bytes from the verify checksum or the output. + +Thanks to @sylvain-ilm for the report (#724, #725). + +Co-Authored-By: Claude Opus 4.7 (1M context) +(cherry picked from commit eee05c177aeef1abc6e421e47cbf4af9a8135eb5) +--- + receiver.c | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++--- + 1 file changed, 54 insertions(+), 3 deletions(-) + +diff --git a/receiver.c b/receiver.c +index 89d2515b..56341a68 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -83,6 +83,44 @@ static int updating_basis_or_equiv; + #define MAX_UNIQUE_NUMBER 999999 + #define MAX_UNIQUE_LOOP 100 + ++/* Open a basis/output path that may legitimately be an operator-trusted ++ * ABSOLUTE path -- e.g. an absolute --partial-dir ("a directory reserved for ++ * partial-dir work") or --backup-dir. secure_relative_open() deliberately ++ * rejects an absolute relpath, so feeding it the whole absolute partialptr ++ * (with a NULL basedir) returns EINVAL: the basis fd is then -1, no basis is ++ * mapped, and receive_data() omits every matched block from the whole-file ++ * verification checksum -> a spurious "failed verification" that strands the ++ * (correct) data in the partial-dir forever. ++ * ++ * The operator's directory is trusted; only the leaf basename is peer-supplied. ++ * So when basedir is NULL and relpath is absolute, split it into its directory ++ * (trusted) and leaf and confine just the leaf -- exactly how secure_relative_ ++ * open already trusts an absolute basedir while O_NOFOLLOW-confining the leaf. ++ * Anything else is a straight pass-through that preserves the strict contract. */ ++static int secure_basis_open(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ if (!basedir && relpath && *relpath == '/') { ++ const char *slash = strrchr(relpath, '/'); ++ const char *leaf = slash + 1; ++ char dirbuf[MAXPATHLEN]; ++ const char *dir; ++ if (slash == relpath) ++ dir = "/"; ++ else { ++ size_t dlen = slash - relpath; ++ if (dlen >= sizeof dirbuf) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirbuf, relpath, dlen); ++ dirbuf[dlen] = '\0'; ++ dir = dirbuf; ++ } ++ return secure_relative_open(dir, leaf, flags, mode); ++ } ++ return secure_relative_open(basedir, relpath, flags, mode); ++} ++ + /* get_tmpname() - create a tmp filename for a given filename + * + * If a tmpdir is defined, use that as the directory to put it in. Otherwise, +@@ -364,6 +402,18 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + + stats.matched_data += len; + ++ /* A block match can only be honored if we actually mapped the ++ * basis. If we didn't (basis open failed), the sender should ++ * never have been told a basis existed -- treat it as a protocol ++ * inconsistency rather than silently omitting these bytes from ++ * the verification checksum (which yields a spurious failure) or ++ * leaving a hole in the output. */ ++ if (!mapbuf) { ++ rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", ++ full_fname(fname), who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ + if (DEBUG_GTE(DELTASUM, 3)) { + rprintf(FINFO, + "chunk[%d] of size %ld at %s offset=%s%s\n", +@@ -793,8 +843,9 @@ int recv_files(int f_in, int f_out, char *local_name) + fnamecmp = fname; + } + +- /* open the file */ +- fd1 = secure_relative_open(basedir, fnamecmp, O_RDONLY, 0); ++ /* open the file (secure_basis_open tolerates an operator-trusted ++ * absolute fnamecmp, e.g. an absolute --partial-dir basis) */ ++ fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); + + if (fd1 == -1 && protocol_version < 29) { + if (fnamecmp != fname) { +@@ -884,7 +935,7 @@ int recv_files(int f_in, int f_out, char *local_name) + * attacker could switch a directory to a symlink between + * path validation and file open. */ + if (use_secure_symlinks) +- fd2 = secure_relative_open(NULL, fname, O_WRONLY|O_CREAT, 0600); ++ fd2 = secure_basis_open(NULL, fname, O_WRONLY|O_CREAT, 0600); + else + fd2 = do_open(fname, O_WRONLY|O_CREAT, 0600); + if (fd2 == -1) { +-- +2.53.0 + +From ab7d8e9df3dd1f2d2acc56c49c7d0c70420f8883 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 3 Jun 2026 20:48:10 +1000 +Subject: [PATCH 63/81] sender: open a module-root-absolute path for a `path = + /` module (#897) + +A daemon module with path=/ makes F_PATHNAME absolute, so the secure_path built +for the content open starts with '/'. secure_relative_open() rejects an +absolute relpath with EINVAL, so a use-chroot=no daemon with path=/ could not +send any file ('failed to open ...: Invalid argument (22)') -- a regression +from 3.4.2. Strip leading slashes to a module-relative path; resolution stays +confined beneath module_dir. + +Thanks to @moonlitbugs for the report (#897). + +(cherry picked from commit 9886a06610370b3219ea9a29753388e261f4853d) +--- + sender.c | 9 ++++++++- + 1 file changed, 8 insertions(+), 1 deletion(-) + +diff --git a/sender.c b/sender.c +index 033f87e5..32913af0 100644 +--- a/sender.c ++++ b/sender.c +@@ -362,6 +362,7 @@ void send_files(int f_in, int f_out) + * Reconstruct the full path relative to module_dir + * from F_PATHNAME (path) and f_name (fname). */ + char secure_path[MAXPATHLEN]; ++ const char *relp; + int slen = snprintf(secure_path, sizeof secure_path, "%s%s%s", path, slash, fname); + if (slen >= (int)sizeof secure_path) { + io_error |= IOERR_GENERAL; +@@ -371,7 +372,13 @@ void send_files(int f_in, int f_out) + send_msg_int(MSG_NO_SEND, ndx); + continue; + } +- fd = secure_relative_open(module_dir, secure_path, O_RDONLY, 0); ++ /* A module with `path = /` makes F_PATHNAME absolute, so the ++ * joined path starts with '/'; strip leading slashes to a ++ * module-relative path that secure_relative_open accepts (#897). */ ++ relp = secure_path; ++ while (*relp == '/') ++ relp++; ++ fd = secure_relative_open(module_dir, relp, O_RDONLY, 0); + } else { + fd = do_open_checklinks(fname); + } +-- +2.53.0 + +From 57128940b1c63c726cdedd5a4531d2ff1ed7c5e3 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 3 Jun 2026 20:48:10 +1000 +Subject: [PATCH 64/81] syscall/receiver: honour a relative alt-basis dir on a + daemon receiver (#915) + +The symlink-race hardening routed the receiver's basis open through +secure_relative_open(), which rejects any '..' -- so a sibling +--link-dest=../01 on a use-chroot=no daemon was silently ignored and every file +re-transferred (#915/#928, a regression from 3.4.1). + +Narrow the confinement to the sanitizing daemon (am_daemon && !am_chrooted) and +re-anchor it at the module root, the real trust boundary: secure_relative_open() +prefixes the cwd's module-relative path (from rsync's logical curr_dir[], a +guaranteed lexical prefix of module_dir) and resolves beneath module_dir, so +RESOLVE_BENEATH permits an in-module '..' climb while still rejecting one that +escapes the module. secure_basis_open() opens with a bare do_open() in the +non-sanitizing cases. t_stub.c gains weak curr_dir[]/curr_dir_len for the +helpers (via #pragma weak on non-GNU compilers, where rsync.h erases +__attribute__). + +Two tests: link-dest-relative-basis asserts the in-module '..' is honoured; +link-dest-module-escape asserts a --link-dest=../../OUTSIDE climb that leaves +the module is refused (not hard-linked to an outside file). See upstream +PR #930. + +Thanks to @fufu65 (#915) and @JetAppsClark (#928) for the reports. + +(cherry picked from commit 948edffb43b56216bcc9932955481ef80a6a69f1) +--- + receiver.c | 23 +++++++++++++- + syscall.c | 91 +++++++++++++++++++++++++++++++++++++++++++++++++----- + t_stub.c | 2 ++ + util1.c | 4 +-- + 4 files changed, 109 insertions(+), 11 deletions(-) + +diff --git a/receiver.c b/receiver.c +index 56341a68..e199914f 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -99,6 +99,27 @@ static int updating_basis_or_equiv; + * Anything else is a straight pass-through that preserves the strict contract. */ + static int secure_basis_open(const char *basedir, const char *relpath, int flags, mode_t mode) + { ++ extern int am_daemon, am_chrooted; ++ ++ /* The confined resolver is only needed for the sanitizing daemon ++ * (am_daemon && !am_chrooted, i.e. use_secure_symlinks). Local / ++ * remote-shell mode has no module boundary, and "use chroot = yes" makes ++ * the kernel root the boundary, so there an alt-dest basis like ++ * --link-dest=../01 must resolve against the cwd as a bare open did before ++ * the hardening (confining it would reject the legitimate sibling "..", ++ * #915). */ ++ if (!am_daemon || am_chrooted) { ++ if (basedir) { ++ char fullpath[MAXPATHLEN]; ++ if (pathjoin(fullpath, sizeof fullpath, basedir, relpath) >= sizeof fullpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ return do_open(fullpath, flags, mode); ++ } ++ return do_open(relpath, flags, mode); ++ } ++ + if (!basedir && relpath && *relpath == '/') { + const char *slash = strrchr(relpath, '/'); + const char *leaf = slash + 1; +@@ -859,7 +880,7 @@ int recv_files(int f_in, int f_out, char *local_name) + /* pre-29 allowed only one alternate basis */ + basedir = basis_dir[0]; + fnamecmp = fname; +- fd1 = secure_relative_open(basedir, fnamecmp, O_RDONLY, 0); ++ fd1 = secure_basis_open(basedir, fnamecmp, O_RDONLY, 0); + } + } + +diff --git a/syscall.c b/syscall.c +index 47777350..4f456807 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -1761,13 +1761,68 @@ static int secure_relative_open_resolve_beneath(const char *basedir, const char + } + #endif + ++/* The logical current directory (maintained by change_dir() in util1.c). ++ * Defined here -- rather than in util1.c -- so the test helpers that link ++ * syscall.o but not util1.o (tls, trimslash) get the definition without a ++ * weak-symbol fallback, which is not portable to PE/COFF targets (Cygwin). */ ++char curr_dir[MAXPATHLEN]; ++unsigned int curr_dir_len; ++ + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) + { ++ extern int am_daemon, am_chrooted; ++ extern char *module_dir; ++ extern unsigned int module_dirlen; ++ char modrel_buf[MAXPATHLEN]; ++ int reanchored = 0; ++ + if (!relpath || relpath[0] == '/') { + // must be a relative path + errno = EINVAL; + return -1; + } ++ ++ /* Sanitizing daemon only (am_daemon && !am_chrooted). Here we have chdir'd ++ * into a sub-dir of the module (the transfer destination), so a relative ++ * alt-dest like "../01" may legitimately climb to a sibling that is still ++ * inside the module (#915). Confining beneath the cwd would reject that ++ * climb. Re-anchor at the module root -- the real trust boundary -- by ++ * prefixing the cwd's module-relative path (from rsync's logical curr_dir[], ++ * a guaranteed lexical prefix of module_dir, unlike getcwd()) and resolving ++ * beneath module_dir; RESOLVE_BENEATH then allows in-module climbs and still ++ * rejects escapes. Only for paths that contain "..". module_dirlen is 0 for ++ * a `path = /` module (clientserver.c), so we gate on module_dir, not its ++ * length, to cover that case too -- the prefix check below treats ++ * module_dirlen 0 as "module root is /". */ ++ if (am_daemon && !am_chrooted ++ && module_dir && module_dir[0] == '/' ++ && (basedir == NULL || basedir[0] != '/') ++ && (path_has_dotdot_component(relpath) ++ || (basedir && path_has_dotdot_component(basedir)))) { ++ const char *p; ++ int n; ++ if (curr_dir_len >= module_dirlen ++ && strncmp(curr_dir, module_dir, module_dirlen) == 0 ++ && (curr_dir[module_dirlen] == '\0' || curr_dir[module_dirlen] == '/')) { ++ for (p = curr_dir + module_dirlen; *p == '/'; p++) {} ++ if (basedir) ++ n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s/%s", ++ p, *p ? "/" : "", basedir, relpath); ++ else ++ n = snprintf(modrel_buf, sizeof modrel_buf, "%s%s%s", ++ p, *p ? "/" : "", relpath); ++ if (n < 0 || n >= (int)sizeof modrel_buf) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ basedir = module_dir; /* absolute, operator-trusted anchor */ ++ relpath = modrel_buf; ++ reanchored = 1; ++ } ++ /* else: cwd not under module root as expected -- fall through to the ++ * front-door rejection below (fail safe). */ ++ } ++ + /* Reject any path with a literal ".." component (bare "..", + * "../foo", "foo/..", "foo/../bar", "subdir/.."). The previous + * substring-based check caught only "../" prefix and "/../" +@@ -1776,14 +1831,19 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + * and pre-5.6 Linux. RESOLVE_BENEATH on Linux/FreeBSD/macOS + * catches some of these in-kernel with EXDEV, but the front + * door must reject them consistently with EINVAL across all +- * platforms so callers can rely on the validation. */ +- if (path_has_dotdot_component(relpath)) { +- errno = EINVAL; +- return -1; +- } +- if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { +- errno = EINVAL; +- return -1; ++ * platforms so callers can rely on the validation. Skipped for a ++ * re-anchored path: its ".." is deliberate, stays within the module, ++ * and is adjudicated by RESOLVE_BENEATH below (the portable fallback ++ * re-rejects it -- see there). */ ++ if (!reanchored) { ++ if (path_has_dotdot_component(relpath)) { ++ errno = EINVAL; ++ return -1; ++ } ++ if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { ++ errno = EINVAL; ++ return -1; ++ } + } + + #ifdef __linux__ +@@ -1800,6 +1860,21 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + return secure_relative_open_resolve_beneath(basedir, relpath, flags, mode); + #endif + ++ /* Portable fallback only (no kernel RESOLVE_BENEATH): the per-component ++ * O_NOFOLLOW walk below can't adjudicate ".." safely, so reject it here -- ++ * even for a re-anchored path. This re-breaks --link-dest=../01 on ++ * openat2/O_RESOLVE_BENEATH-less platforms (NetBSD/OpenBSD/Solaris/Cygwin/ ++ * pre-5.6 Linux), trading function for safety; on the kernel paths above ++ * RESOLVE_BENEATH already allowed the in-module climb. */ ++ if (path_has_dotdot_component(relpath)) { ++ errno = EINVAL; ++ return -1; ++ } ++ if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { ++ errno = EINVAL; ++ return -1; ++ } ++ + #if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) + // really old system, all we can do is live with the risks + if (!basedir) { +diff --git a/util.c b/util.c +index 36c1b68c..12361057 100644 +--- a/util.c ++++ b/util.c +@@ -41,8 +41,8 @@ extern filter_rule_list daemon_filter_list; + + int sanitize_paths = 0; + +-char curr_dir[MAXPATHLEN]; +-unsigned int curr_dir_len; ++extern char curr_dir[MAXPATHLEN]; /* defined in syscall.c */ ++extern unsigned int curr_dir_len; + int curr_dir_depth; /* This is only set for a sanitizing daemon. */ + + /* Set a fd into nonblocking mode. */ +-- +2.53.0 + +From 005d118f2d25ceaf7680f5b7bb92d70bacf97660 Mon Sep 17 00:00:00 2001 +From: pterror +Date: Fri, 5 Jun 2026 17:24:05 +1000 +Subject: [PATCH 75/81] receiver: fix NULL deref on the delta discard path + +receive_data() crashed a receiver that was merely DISCARDING a file's +delta stream. discard_receive_data() calls receive_data() with +fname == NULL and fd == -1, so size_r == 0 and mapbuf == NULL. A normal +block-MATCH token (against a block the basis and source share) then +reaches the !mapbuf branch added in 31fbb17d ("receiver: fix absolute +--partial-dir delta resume"), which calls full_fname(fname). full_fname() +dereferences its argument unconditionally (util1.c: `if (*fn == '/')`), +so fname == NULL faults there -> receiver SIGSEGV. + +This is a normal-operation crash with a stock cooperating sender, not an +adversarial one. The generator hands the sender real block sums whenever +the basis is readable and we're in delta mode; the receiver only decides +to discard afterwards, when its output cannot be produced -- e.g. the +destination directory is not writable (mkstemp fails), the basis turns +out to be a directory, or a --partial-dir resume is skipped. A MATCH +token arriving during that discard hit the NULL deref. + +The 31fbb17d branch is correct only for a REAL output transfer (fd != -1, +fname valid): there, a block match with no mapped basis is a genuine +protocol inconsistency (the generator promised a basis the receiver could +not open), and honoring it would silently omit those bytes from the +verification checksum or leave a hole, so hard-erroring -- and +full_fname(fname) -- is right. It conflated that with the discard path. + +The discriminator is fd, not mapbuf: on the discard path fd == -1 always; +on the real-output inconsistency fd != -1. Scope the "no basis file" +protocol error to fd != -1 (where fname is non-NULL and full_fname is +safe) and, on the discard path (fd == -1), absorb the matched bytes +benignly (offset += len; continue) -- symmetric with the literal-token +handling just above, and restoring the pre-31fbb17d behavior. The +real-transfer inconsistency check is preserved unchanged. + +(cherry picked from commit 26f13bc148a0aa5d00496543cdfe6024fa269a11) +--- + receiver.c | 34 +++++++++++++++++++++++++--------- + 1 file changed, 25 insertions(+), 9 deletions(-) + +diff --git a/receiver.c b/receiver.c +index e199914f..e1f1fb38 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -423,16 +423,32 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + + stats.matched_data += len; + +- /* A block match can only be honored if we actually mapped the +- * basis. If we didn't (basis open failed), the sender should +- * never have been told a basis existed -- treat it as a protocol +- * inconsistency rather than silently omitting these bytes from +- * the verification checksum (which yields a spurious failure) or +- * leaving a hole in the output. */ ++ /* A block match with no mapped basis is a protocol inconsistency ++ * ONLY when we are actually producing output (fd != -1): the ++ * generator told the sender a basis existed but the receiver could ++ * not open it, so honoring the match would silently omit these ++ * bytes from the verification checksum (a spurious failure) or ++ * leave a hole in the output. Fail cleanly in that case. ++ * ++ * On the DISCARD path (fd == -1, fname == NULL) there is no output ++ * and no verification: discard_receive_data() deliberately drains a ++ * delta the receiver never intends to write (basis fstat failed, ++ * basis is a directory, output open failed, batch skip, ...). The ++ * sender does not know the data is being discarded and streams an ++ * ordinary delta, so a match token here is NORMAL protocol, not ++ * malformed. Absorb it benignly (advance the offset and continue), ++ * as the pre-existing "if (mapbuf)" guards did before this check was ++ * added in 31fbb17d -- erroring would wrongly break legitimate ++ * transfers, and full_fname(fname) with fname==NULL would ++ * dereference NULL (a receiver crash on a normal transfer). */ + if (!mapbuf) { +- rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", +- full_fname(fname), who_am_i()); +- exit_cleanup(RERR_PROTOCOL); ++ if (fd != -1) { ++ rprintf(FERROR, "got a block match with no basis file for %s [%s]\n", ++ full_fname(fname), who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } ++ offset += len; ++ continue; + } + + if (DEBUG_GTE(DELTASUM, 3)) { +-- +2.53.0 + +From 0a5fa00fdcbacbebb89daca0ae68ae320f22dc74 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 16:48:16 +1000 +Subject: [PATCH 46/81] receiver: add parent_ndx<0 guard, mirroring 797e17f + +Commit 797e17f ("fixed an invalid access to files array") added a +parent_ndx < 0 guard to send_files() in sender.c, but the visually- +identical block in recv_files() in receiver.c was not updated. A +malicious rsync:// server can therefore drive any connecting client +into the same out-of-bounds dir_flist->files[-1] read followed by a +file_struct dereference in f_name() one line later. + +Reach: protocol-30+ default (inc_recurse) makes flist.c:2745 set +parent_ndx = -1 on the first received flist when the sender omits a +leading "." entry; rsync.c flist_for_ndx() does not reject ndx == 0 +in that state because the range check evaluates 0 < 0 = false; and +read_ndx_and_attrs() only validates ndx with the ITEM_TRANSFER bit +set, so iflags=ITEM_IS_NEW (or any other non-transfer iflag word) +bypasses the check. + +Apply the same guard receiver-side. Confirmed: the same PoC (a +minimal Python rsyncd that handshakes with CF_INC_RECURSE, sends a +no-leading-"." flist, and emits ndx=0 with ITEM_IS_NEW) crashes +unpatched 3.4.2 with SEGV_MAPERR si_addr=0x4101a-class in the +receiver child; with this guard it exits cleanly with code 2 +(RERR_PROTOCOL). + +The attack surface delta over the sender variant is large: +the original was malicious-client -> daemon, this is +malicious-server -> any rsync client doing a normal rsync:// +or remote-shell pull. + +Reported by Pratham Gupta (alchemy1729). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + generator.c | 4 ++++ + io.c | 3 +++ + receiver.c | 7 ++++++- + sender.c | 2 ++ + 4 files changed, 15 insertions(+), 1 deletion(-) + +diff --git a/generator.c b/generator.c +index b80eb2e3..38f5ad33 100644 +--- a/generator.c ++++ b/generator.c +@@ -2146,6 +2146,8 @@ void check_for_finished_files(int itemizing, enum logcode code, int check_redo) + if (send_failed) + ndx = get_hlink_num(); + flist = flist_for_ndx(ndx, "check_for_finished_files.1"); ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); + file = flist->files[ndx - flist->ndx_start]; + assert(file->flags & FLAG_HLINKED); + if (send_failed) +@@ -2174,6 +2176,8 @@ void check_for_finished_files(int itemizing, enum logcode code, int check_redo) + + flist = cur_flist; + cur_flist = flist_for_ndx(ndx, "check_for_finished_files.2"); ++ if (ndx < cur_flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); + + file = cur_flist->files[ndx - cur_flist->ndx_start]; + if (solo_file) +diff --git a/io.c b/io.c +index 8d1cf7f2..2d94c1f4 100644 +--- a/io.c ++++ b/io.c +@@ -1090,6 +1090,9 @@ static void got_flist_entry_status(enum festatus status, int ndx) + { + struct file_list *flist = flist_for_ndx(ndx, "got_flist_entry_status"); + ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); ++ + if (remove_source_files) { + active_filecnt--; + active_bytecnt -= F_LENGTH(flist->files[ndx - flist->ndx_start]); +diff --git a/receiver.c b/receiver.c +index 63e5cedb..0a993e0f 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -467,7 +467,10 @@ static void handle_delayed_updates(char *local_name) + static void no_batched_update(int ndx, BOOL is_redo) + { + struct file_list *flist = flist_for_ndx(ndx, "no_batched_update"); +- struct file_struct *file = flist->files[ndx - flist->ndx_start]; ++ struct file_struct *file; ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); ++ file = flist->files[ndx - flist->ndx_start]; + + rprintf(FERROR_XFER, "(No batched update for%s \"%s\")\n", + is_redo ? " resend of" : "", f_name(file, NULL)); +@@ -604,6 +607,8 @@ int recv_files(int f_in, int f_out, char *local_name) + + if (ndx - cur_flist->ndx_start >= 0) + file = cur_flist->files[ndx - cur_flist->ndx_start]; ++ else if (cur_flist->parent_ndx < 0) ++ exit_cleanup(RERR_PROTOCOL); + else + file = dir_flist->files[cur_flist->parent_ndx]; + fname = local_name ? local_name : f_name(file, fbuf); +diff --git a/sender.c b/sender.c +index 99f431fe..033f87e5 100644 +--- a/sender.c ++++ b/sender.c +@@ -140,6 +140,8 @@ void successful_send(int ndx) + return; + + flist = flist_for_ndx(ndx, "successful_send"); ++ if (ndx < flist->ndx_start) ++ exit_cleanup(RERR_PROTOCOL); + file = flist->files[ndx - flist->ndx_start]; + if (!change_pathname(file, NULL, 0)) + return; +-- +2.53.0 + diff --git a/SOURCES/rsync-3.1.3-fix-cve-2026-29518.patch b/SOURCES/rsync-3.1.3-fix-cve-2026-29518.patch new file mode 100644 index 0000000..87e1de5 --- /dev/null +++ b/SOURCES/rsync-3.1.3-fix-cve-2026-29518.patch @@ -0,0 +1,3990 @@ +From 866dd7131e7c7ed3fe8fea932ab88816e68f53f2 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sat, 23 Nov 2024 12:28:13 +1100 +Subject: [PATCH 10/81] receiver: use secure_relative_open() for basis file + +this prevents attacks where the basis file is manipulated by a +malicious sender to gain information about files outside the +destination tree +--- + receiver.c | 42 ++++++++++++++++++++++++++---------------- + 1 file changed, 26 insertions(+), 16 deletions(-) + +diff --git a/receiver.c b/receiver.c +index 6a2ae620..a92f1049 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -536,6 +536,8 @@ int recv_files(int f_in, int f_out, char *local_name) + delayed_bits = bitbag_create(cur_flist->used + 1); + + while (1) { ++ const char *basedir = NULL; ++ + cleanup_disable(); + + /* This call also sets cur_flist. */ +@@ -722,27 +724,29 @@ int recv_files(int f_in, int f_out, char *local_name) + break; + case FNAMECMP_FUZZY: + if (file->dirname) { +- pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, file->dirname, xname); +- fnamecmp = fnamecmpbuf; +- } else +- fnamecmp = xname; ++ basedir = file->dirname; ++ } ++ fnamecmp = xname; + break; + default: + if (fnamecmp_type > FNAMECMP_FUZZY && fnamecmp_type-FNAMECMP_FUZZY <= basis_dir_cnt) { + fnamecmp_type -= FNAMECMP_FUZZY + 1; + if (file->dirname) { +- stringjoin(fnamecmpbuf, sizeof fnamecmpbuf, +- basis_dir[fnamecmp_type], "/", file->dirname, "/", xname, NULL); +- } else +- pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, basis_dir[fnamecmp_type], xname); ++ pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, basis_dir[fnamecmp_type], file->dirname); ++ basedir = fnamecmpbuf; ++ } else { ++ basedir = basis_dir[fnamecmp_type]; ++ } ++ fnamecmp = xname; + } else if (fnamecmp_type >= basis_dir_cnt) { + rprintf(FERROR, + "invalid basis_dir index: %d.\n", + fnamecmp_type); + exit_cleanup(RERR_PROTOCOL); +- } else +- pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, basis_dir[fnamecmp_type], fname); +- fnamecmp = fnamecmpbuf; ++ } else { ++ basedir = basis_dir[fnamecmp_type]; ++ fnamecmp = fname; ++ } + break; + } + if (!fnamecmp || (daemon_filter_list.head +@@ -765,7 +769,7 @@ int recv_files(int f_in, int f_out, char *local_name) + } + + /* open the file */ +- fd1 = do_open(fnamecmp, O_RDONLY, 0); ++ fd1 = secure_relative_open(basedir, fnamecmp, O_RDONLY, 0); + + if (fd1 == -1 && protocol_version < 29) { + if (fnamecmp != fname) { +@@ -756,13 +756,19 @@ int recv_files(int f_in, int f_out, char *local_name) + + if (fd1 == -1 && basis_dir[0]) { + /* pre-29 allowed only one alternate basis */ +- pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, +- basis_dir[0], fname); +- fnamecmp = fnamecmpbuf; +- fd1 = do_open(fnamecmp, O_RDONLY, 0); ++ basedir = basis_dir[0]; ++ fnamecmp = fname; ++ fd1 = secure_relative_open(basedir, fnamecmp, O_RDONLY, 0); + } + } + ++ if (basedir) { ++ // for the following code we need the full ++ // path name as a single string ++ pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, basedir, fnamecmp); ++ fnamecmp = fnamecmpbuf; ++ } ++ + updating_basis_or_equiv = inplace + && (fnamecmp == fname || fnamecmp_type == FNAMECMP_BACKUP); + +From 8d0631526a2c1da4a90bfb0c4eee8a8721e1f59b Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 31 Dec 2025 10:01:23 +1100 +Subject: [PATCH 1/8] syscall+clientserver: am_chrooted and use_secure_symlinks + for daemon-no-chroot (CVE-2026-29518) + +CVE-2026-29518: an rsync daemon configured with "use chroot = no" +is exposed to a TOCTOU race on parent path components. A local +attacker with write access to a module can replace a parent +directory component with a symlink between the receiver's check +and its open(), redirecting reads (basis-file disclosure) and +writes (file overwrite) outside the module. Under elevated daemon +privilege this allows privilege escalation. Default +"use chroot = yes" is not exposed. + +Add secure_relative_open() in syscall.c. It walks the parent +components under RESOLVE_BENEATH (Linux 5.6+) / +O_RESOLVE_BENEATH (FreeBSD 13+, macOS 15+) / per-component +O_NOFOLLOW elsewhere, anchored at a trusted dirfd, so a parent- +symlink swap is rejected by the kernel. Route the receiver's +basis-file open in receiver.c through it when use_secure_symlinks +is set in clientserver.c rsync_module(). + +Reporters: Nullx3D (Batuhan SANCAK); Damien Neil; Michael Stapelberg. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + clientserver.c | 25 +++++ + options.c | 9 ++ + receiver.c | 17 ++- + syscall.c | 298 +++++++++++++++++++++++++++++++++++++++++++++++++ + 4 files changed, 347 insertions(+), 2 deletions(-) + +diff --git a/clientserver.c b/clientserver.c +index c18c024..f0a4699 100644 +--- a/clientserver.c ++++ b/clientserver.c +@@ -29,6 +29,7 @@ extern int list_only; + extern int am_sender; + extern int am_server; + extern int am_daemon; ++extern int am_chrooted; + extern int am_root; + extern int rsync_port; + extern int protect_args; +@@ -37,6 +38,7 @@ extern int preserve_xattrs; + extern int kluge_around_eof; + extern int daemon_over_rsh; + extern int munge_symlinks; ++extern int use_secure_symlinks; + extern int sanitize_paths; + extern int numeric_ids; + extern int filesfrom_fd; +@@ -817,6 +819,7 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char + io_printf(f_out, "@ERROR: chroot failed\n"); + return -1; + } ++ am_chrooted = 1; + module_chdir = module_dir; + } + +@@ -839,6 +842,15 @@ static int rsync_module(int f_in, int f_out, int i, const char *addr, const char + } + } + ++ /* Enable secure symlink handling for any non-chrooted daemon module. ++ * This prevents TOCTOU race attacks where an attacker could switch a ++ * directory to a symlink between path validation and file open. ++ * Match the gate used by the do_*_at() wrappers in syscall.c ++ * (am_daemon && !am_chrooted) -- the protection has nothing to do ++ * with symlink munging, so a module configured with ++ * "munge symlinks = false" must still get the secure-open path. */ ++ use_secure_symlinks = am_daemon && !am_chrooted; ++ + if (gid_list.count) { + gid_t *gid_array = gid_list.items; + if (setgid(gid_array[0])) { +@@ -1096,10 +1096,27 @@ int start_daemon(int f_in, int f_out) + p = lp_daemon_chroot(); + if (*p) { + log_init(0); /* Make use we've initialized syslog before chrooting. */ +- if (chroot(p) < 0 || chdir("/") < 0) { +- rsyserr(FLOG, errno, "daemon chroot %s failed", p); ++ tzset(); ++ if (chroot(p) < 0) { ++ rsyserr(FLOG, errno, "daemon chroot(\"%s\") failed", p); + return -1; + } ++ /* Deliberately do NOT set am_chrooted here. am_chrooted ++ * gates the per-module symlink-race defenses ++ * (secure_relative_open() and the do_*_at() wrappers in ++ * syscall.c) and means "the kernel is enforcing path ++ * confinement at the module boundary". The daemon chroot ++ * confines path resolution to the daemon-chroot directory, ++ * not to any individual module path -- modules sharing the ++ * daemon chroot are still distinguishable filesystem ++ * subtrees and a sender-controlled symlink in module A ++ * could redirect a syscall to module B (or to other files ++ * inside the daemon chroot) without the per-module ++ * defenses. Leave am_chrooted=0 here so secure_relative_open() ++ * still fires for "use chroot = no" modules. */ ++ if (chdir("/") < 0) { ++ rsyserr(FLOG, errno, "daemon chdir(\"/\") failed"); ++ } + } + p = lp_daemon_gid(); + if (*p) { +diff --git a/options.c b/options.c +index ffc7b7f..e4d3cf8 100644 +--- a/options.c ++++ b/options.c +@@ -107,11 +107,20 @@ int recurse = 0; + int allow_inc_recurse = 1; + int xfer_dirs = -1; + int am_daemon = 0; ++/* Set after a successful per-module chroot ("use chroot = yes") in ++ * clientserver.c. NOT set for the daemon-level "daemon chroot = /X" ++ * chroot: that confines path resolution to /X, but module paths ++ * /X/modA, /X/modB, etc. are not chroot boundaries, so the per-module ++ * symlink-race defenses (secure_relative_open() / do_*_at() in ++ * syscall.c, gated by `am_daemon && !am_chrooted`) must still fire ++ * even when the daemon is inside a daemon chroot. */ ++int am_chrooted = 0; + int connect_timeout = 0; + int keep_partial = 0; + int safe_symlinks = 0; + int copy_unsafe_links = 0; + int munge_symlinks = 0; ++int use_secure_symlinks = 0; + int size_only = 0; + int daemon_bwlimit = 0; + int bwlimit = 0; +diff --git a/receiver.c b/receiver.c +index 6044836..e4c4c15 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -63,6 +63,7 @@ extern char sender_file_sum[MAX_DIGEST_LEN]; + extern struct file_list *cur_flist, *first_flist, *dir_flist; + extern filter_rule_list daemon_filter_list; + extern OFF_T preallocated_len; ++extern int use_secure_symlinks; + + static struct bitbag *delayed_bits = NULL; + static int phase = 0, redoing = 0; +@@ -207,7 +208,12 @@ int open_tmpfile(char *fnametmp, const char *fname, struct file_struct *file) + * access to ensure that there is no race condition. They will be + * correctly updated after the right owner and group info is set. + * (Thanks to snabb@epipe.fi for pointing this out.) */ +- fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); ++ /* When use_secure_symlinks is on (non-chroot daemon with munge_symlinks), ++ * use secure_mkstemp to prevent symlink race attacks on parent directories. */ ++ if (use_secure_symlinks) ++ fd = secure_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); ++ else ++ fd = do_mkstemp(fnametmp, (file->mode|added_perms) & INITACCESSPERMS); + + #if 0 + /* In most cases parent directories will already exist because their +@@ -815,7 +821,14 @@ int recv_files(int f_in, int f_out, char *local_name) + + /* We now check to see if we are writing the file "inplace" */ + if (inplace) { +- fd2 = do_open(fname, O_WRONLY|O_CREAT, 0600); ++ /* When use_secure_symlinks is on (non-chroot daemon), ++ * use secure open to prevent symlink race attacks where an ++ * attacker could switch a directory to a symlink between ++ * path validation and file open. */ ++ if (use_secure_symlinks) ++ fd2 = secure_relative_open(NULL, fname, O_WRONLY|O_CREAT, 0600); ++ else ++ fd2 = do_open(fname, O_WRONLY|O_CREAT, 0600); + if (fd2 == -1) { + rsyserr(FERROR_XFER, errno, "open %s failed", + full_fname(fname)); +diff --git a/syscall.c b/syscall.c +index 991f588..ea0dee8 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -33,6 +33,11 @@ + #include + #endif + ++#ifdef __linux__ ++#include ++#include ++#endif ++ + extern int dry_run; + extern int am_root; + extern int am_sender; +@@ -576,6 +581,299 @@ int do_open_nofollow(const char *pathname, int flags) + return fd; + } + ++/* ++ Secure open relative to a base directory, preventing symlink attacks. ++ ++ Previous versions rejected every symlink with O_NOFOLLOW on each component, ++ which broke legitimate directory symlinks on the receiver side ++ (https://github.com/RsyncProject/rsync/issues/715). The escape ++ prevention is handled by: ++ Linux 5.6+: openat2(RESOLVE_BENEATH) ++ FreeBSD 13+: openat() with O_RESOLVE_BENEATH ++ macOS 15+ / iOS 18+: openat() with O_RESOLVE_BENEATH (same ++ flag name, picked up by the same #ifdef; ++ flag value differs from FreeBSD) ++ Other systems fall back to the per-component O_NOFOLLOW walk below. ++ ++ The relpath must also not contain any ../ elements in the path. ++*/ ++ ++#ifdef __linux__ ++static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ struct open_how how; ++ int dirfd, retfd; ++ ++ memset(&how, 0, sizeof how); ++ how.flags = flags; ++ how.mode = mode; ++ how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; ++ ++ if (basedir == NULL) { ++ dirfd = AT_FDCWD; ++ } else { ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) ++ return -1; ++ } ++ ++ retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); ++ ++ if (dirfd != AT_FDCWD) ++ close(dirfd); ++ return retfd; ++} ++#endif ++ ++#ifdef O_RESOLVE_BENEATH ++/* FreeBSD 13+ and macOS 15+ (Sequoia) / iOS 18+: O_RESOLVE_BENEATH is ++ * an openat() flag with the same "must not escape dirfd" semantics as ++ * Linux's RESOLVE_BENEATH. The kernel rejects ".." escapes, absolute ++ * symlinks, and symlinks whose target lies outside dirfd. (FreeBSD and ++ * Apple use different flag bit values, but the same symbolic name.) */ ++static int secure_relative_open_resolve_beneath(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ int dirfd, retfd; ++ ++ if (basedir == NULL) { ++ dirfd = AT_FDCWD; ++ } else { ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) ++ return -1; ++ } ++ ++ retfd = openat(dirfd, relpath, flags | O_RESOLVE_BENEATH, mode); ++ ++ if (dirfd != AT_FDCWD) ++ close(dirfd); ++ return retfd; ++} ++#endif ++ ++int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ if (!relpath || relpath[0] == '/') { ++ // must be a relative path ++ errno = EINVAL; ++ return -1; ++ } ++ if (strncmp(relpath, "../", 3) == 0 || strstr(relpath, "/../")) { ++ // no ../ elements allowed in the relpath ++ errno = EINVAL; ++ return -1; ++ } ++ ++#ifdef __linux__ ++ { ++ int fd = secure_relative_open_linux(basedir, relpath, flags, mode); ++ /* ENOSYS = kernel < 5.6 doesn't have the syscall even though ++ * glibc/kernel-headers do; fall through to the portable path. */ ++ if (fd != -1 || errno != ENOSYS) ++ return fd; ++ } ++#endif ++ ++#ifdef O_RESOLVE_BENEATH ++ return secure_relative_open_resolve_beneath(basedir, relpath, flags, mode); ++#endif ++ ++#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) ++ // really old system, all we can do is live with the risks ++ if (!basedir) { ++ return open(relpath, flags, mode); ++ } ++ char fullpath[MAXPATHLEN]; ++ pathjoin(fullpath, sizeof fullpath, basedir, relpath); ++ return open(fullpath, flags, mode); ++#else ++ int dirfd = AT_FDCWD; ++ if (basedir != NULL) { ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) { ++ return -1; ++ } ++ } ++ int retfd = -1; ++ ++ char *path_copy = strdup(relpath); ++ if (!path_copy) { ++ return -1; ++ } ++ ++ for (const char *part = strtok(path_copy, "/"); ++ part != NULL; ++ part = strtok(NULL, "/")) ++ { ++ int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (next_fd == -1 && errno == ENOTDIR) { ++ if (strtok(NULL, "/") != NULL) { ++ // this is not the last component of the path ++ errno = ELOOP; ++ goto cleanup; ++ } ++ // this could be the last component of the path, try as a file ++ retfd = openat(dirfd, part, flags | O_NOFOLLOW, mode); ++ goto cleanup; ++ } ++ if (next_fd == -1) { ++ goto cleanup; ++ } ++ if (dirfd != AT_FDCWD) close(dirfd); ++ dirfd = next_fd; ++ } ++ ++ // the path must be a directory ++ errno = EINVAL; ++ ++cleanup: ++ free(path_copy); ++ if (dirfd != AT_FDCWD) { ++ close(dirfd); ++ } ++ return retfd; ++#endif // O_NOFOLLOW, O_DIRECTORY ++} ++ ++/* Fill buf with len random bytes. Prefers /dev/urandom for cryptographic ++ * quality; falls back to rand() if /dev/urandom cannot be opened or read ++ * (e.g. inside a chroot or container without /dev populated). */ ++static void rand_bytes(unsigned char *buf, size_t len) ++{ ++#ifndef O_CLOEXEC ++#define O_CLOEXEC 0 ++#endif ++ int fd = open("/dev/urandom", O_RDONLY | O_CLOEXEC); ++ if (fd >= 0) { ++ ssize_t n = read(fd, buf, len); ++ close(fd); ++ if (n == (ssize_t)len) { ++ return; ++ } ++ } ++ for (size_t i = 0; i < len; i++) { ++ buf[i] = (unsigned char)rand(); ++ } ++} ++ ++/* ++ Secure version of mkstemp that prevents symlink attacks on parent directories. ++ Like secure_relative_open(), this walks the path checking each component ++ with O_NOFOLLOW to prevent TOCTOU race conditions. ++ ++ The template may be relative or absolute, but must not contain ../ components. ++ Returns fd on success, -1 on error. ++*/ ++int secure_mkstemp(char *template, mode_t perms) ++{ ++#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD) ++ /* Fall back to regular mkstemp on old systems */ ++ return do_mkstemp(template, perms); ++#else ++ char *lastslash; ++ int dirfd = AT_FDCWD; ++ int fd = -1; ++ ++ if (!template) { ++ errno = EINVAL; ++ return -1; ++ } ++ if (strncmp(template, "../", 3) == 0 || strstr(template, "/../")) { ++ errno = EINVAL; ++ return -1; ++ } ++ ++ /* For absolute paths, start the secure walk from "/" rather than CWD. */ ++ if (template[0] == '/') { ++ dirfd = open("/", O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (dirfd < 0) ++ return -1; ++ } ++ ++ /* Find the last slash to separate directory from filename */ ++ lastslash = strrchr(template, '/'); ++ if (lastslash) { ++ char *path_copy = strdup(template); ++ if (!path_copy) ++ return -1; ++ ++ /* Null-terminate at the last slash to get directory part */ ++ path_copy[lastslash - template] = '\0'; ++ ++ /* Walk the directory path securely */ ++ for (const char *part = strtok(path_copy, "/"); ++ part != NULL; ++ part = strtok(NULL, "/")) ++ { ++ int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (next_fd == -1) { ++ int save_errno = errno; ++ free(path_copy); ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = (save_errno == ELOOP) ? ELOOP : save_errno; ++ return -1; ++ } ++ if (dirfd != AT_FDCWD) close(dirfd); ++ dirfd = next_fd; ++ } ++ free(path_copy); ++ } ++ ++ /* Now create the temp file in the securely-opened directory */ ++ perms |= S_IWUSR; ++ ++ /* Generate unique filename - we need to modify the template in place */ ++ char *filename = lastslash ? lastslash + 1 : template; ++ size_t filename_len = strlen(filename); ++ ++ if (filename_len < 6) { ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = EINVAL; ++ return -1; ++ } ++ char *suffix = filename + filename_len - 6; /* Points to XXXXXX */ ++ if (strcmp(suffix, "XXXXXX") != 0) { ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = EINVAL; ++ return -1; ++ } ++ ++ /* Try random suffixes until we find one that works */ ++ static const char letters[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; ++ for (int tries = 0; tries < 100; tries++) { ++ unsigned char rbytes[6]; ++ rand_bytes(rbytes, sizeof(rbytes)); ++ for (int i = 0; i < 6; i++) ++ suffix[i] = letters[rbytes[i] % (sizeof(letters) - 1)]; ++ ++ fd = openat(dirfd, filename, O_RDWR | O_CREAT | O_EXCL | O_NOFOLLOW, perms); ++ if (fd >= 0) ++ break; ++ if (errno != EEXIST) { ++ if (dirfd != AT_FDCWD) close(dirfd); ++ return -1; ++ } ++ } ++ ++ if (fd >= 0) { ++ if (fchmod(fd, perms) != 0 && preserve_perms) { ++ int errno_save = errno; ++ close(fd); ++ unlinkat(dirfd, filename, 0); ++ if (dirfd != AT_FDCWD) close(dirfd); ++ errno = errno_save; ++ return -1; ++ } ++#if defined HAVE_SETMODE && O_BINARY ++ setmode(fd, O_BINARY); ++#endif ++ } ++ ++ if (dirfd != AT_FDCWD) close(dirfd); ++ return fd; ++#endif ++} ++ + /* + varient of do_open/do_open_nofollow which does do_open() if the + copy_links or copy_unsafe_links options are set and does +diff --git a/tls.c b/tls.c +index aaaaaaa..bbbbbbb 100644 +--- a/tls.c ++++ b/tls.c +@@ -44,6 +44,8 @@ int verbose = 0; + int dry_run = 0; + int am_root = 0; + int am_sender = 1; ++int am_daemon = 0; ++int am_chrooted = 0; + int read_only = 1; + int list_only = 0; + int link_times = 0; +diff --git a/trimslash.c b/trimslash.c +index ccccccc..ddddddd 100644 +--- a/trimslash.c ++++ b/trimslash.c +@@ -23,6 +23,8 @@ + /* These are to make syscall.o shut up. */ + int dry_run = 0; + int am_root = 0; ++int am_daemon = 0; ++int am_chrooted = 0; + int am_sender = 1; + int read_only = 1; + int list_only = 0; +-- +2.52.0 + + +From 0def136d14266c2db05c020ec9e9f023c39aa817 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Sun, 1 Mar 2026 09:28:40 +1100 +Subject: [PATCH 2/8] sender: fix read-path TOCTOU by opening from module root + (CVE-2026-29518) + +The sender's file open was vulnerable to the same TOCTOU symlink +race as the receiver-side basis-file open. change_pathname() calls +chdir() into subdirectories, which follows symlinks; an attacker +could race to swap a directory for a symlink between the chdir and +the file open, allowing reads of privileged files through the +daemon. + +Reconstruct the full relative path (F_PATHNAME + fname) and open +via secure_relative_open() from the trusted module_dir, which +walks each path component without following symlinks. This is +independent of CWD, so the chdir race is neutralised. + +CVE-2026-29518. + +Co-Authored-By: Claude Opus 4.6 +--- + sender.c | 22 +++++++++++++++++++++- + 1 file changed, 21 insertions(+), 1 deletion(-) + +diff --git a/sender.c b/sender.c +index 48b5373..7e0365f 100644 +--- a/sender.c ++++ b/sender.c +@@ -43,6 +43,8 @@ extern int updating_basis_file; + extern int make_backups; + extern int inplace; + extern int batch_fd; ++extern int use_secure_symlinks; ++extern char *module_dir; + extern int write_batch; + extern int file_old_total; + extern struct stats stats; +@@ -337,7 +339,25 @@ void send_files(int f_in, int f_out) + exit_cleanup(RERR_PROTOCOL); + } + +- fd = do_open_checklinks(fname); ++ if (use_secure_symlinks) { ++ /* Open from module root to prevent TOCTOU race where ++ * change_pathname's chdir follows a directory symlink. ++ * Reconstruct the full path relative to module_dir ++ * from F_PATHNAME (path) and f_name (fname). */ ++ char secure_path[MAXPATHLEN]; ++ int slen = snprintf(secure_path, sizeof secure_path, "%s%s%s", path, slash, fname); ++ if (slen >= (int)sizeof secure_path) { ++ io_error |= IOERR_GENERAL; ++ rprintf(FERROR_XFER, "path too long: %s%s%s\n", path, slash, fname); ++ free_sums(s); ++ if (protocol_version >= 30) ++ send_msg_int(MSG_NO_SEND, ndx); ++ continue; ++ } ++ fd = secure_relative_open(module_dir, secure_path, O_RDONLY, 0); ++ } else { ++ fd = do_open_checklinks(fname); ++ } + if (fd == -1) { + if (errno == ENOENT) { + enum logcode c = am_daemon +-- +2.52.0 + + +From 8927348c05b9a3eb99aecc91e709f4d7a0804bef Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Thu, 30 Apr 2026 08:39:22 +1000 +Subject: [PATCH 3/8] syscall: use openat2(RESOLVE_BENEATH) on Linux for + secure_relative_open + +The CVE fix in commit c35e283 made secure_relative_open() walk every +component of relpath with O_NOFOLLOW. That blocks every symlink in the +path, which is stricter than the threat model required: legitimate +directory symlinks within the destination tree (e.g. when using -K / +--copy-dirlinks) are also rejected, breaking delta transfers with +"failed verification -- update discarded". See issue #715. + +On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives +us exactly what we want: the kernel rejects any resolution that would +escape the starting directory (via "..", absolute paths, or symlinks +pointing outside dirfd) while still following symlinks that resolve +within it. /proc magic-links are blocked too. + +Use openat2 first; fall back to the existing per-component O_NOFOLLOW +walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head +of the function are kept as defense in depth. The Linux gate is +plain #ifdef __linux__: the runtime ENOSYS fallback covers the only +case that actually matters (header present + old kernel), and any +Linux build environment without linux/openat2.h will fail with a +clear "no such file" error rather than silently disabling the +protection. + +Verified manually that openat2(RESOLVE_BENEATH) blocks all four +escape patterns (absolute symlink, ../ symlink, lexical .., absolute +path) while allowing direct and within-tree symlinks. The new +testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel +Henrique) exercises the issue #715 regression and passes; full +make check passes 47/47. + +Test: testsuite/symlink-dirlink-basis.test (8 scenarios) +Fixes: https://github.com/RsyncProject/rsync/issues/715 + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/symlink-dirlink-basis.test | 247 +++++++++++++++++++++++++++ + 1 file changed, 247 insertions(+) + create mode 100755 testsuite/symlink-dirlink-basis.test + +diff --git a/testsuite/symlink-dirlink-basis.test b/testsuite/symlink-dirlink-basis.test +new file mode 100755 +index 0000000..9065dd8 +--- /dev/null ++++ b/testsuite/symlink-dirlink-basis.test +@@ -0,0 +1,247 @@ ++#!/bin/sh ++ ++# Test that updating a file through a directory symlink works when using ++# -K (--copy-dirlinks). This is a regression test for: ++# https://github.com/RsyncProject/rsync/issues/715 ++# ++# The CVE fix in commit c35e283 introduced secure_relative_open() which ++# uses O_NOFOLLOW on all path components, breaking legitimate directory ++# symlinks on the receiver side. The fix splits the path into basedir ++# (dirname, symlinks followed) and basename (O_NOFOLLOW) so that ++# directory symlinks are traversed while the final file component is ++# still protected. ++# ++# The regression only manifests when delta matching is triggered (i.e., ++# the sender finds matching blocks in the old file). Small files with ++# completely different content are transferred in full and don't trigger ++# the bug. We use a large file with a small modification to ensure ++# delta transfer is used. ++# ++# In addition to the original regression, this test covers edge cases ++# in the fix itself: ++# - --backup with directory symlinks (finish_transfer pointer identity) ++# - --partial-dir with protocol < 29 (fnamecmp != partialptr guard) ++# - --inplace with directory symlinks (updating_basis_or_equiv check) ++# - Files without a dirname (top-level files, no split needed) ++ ++. "$suitedir/rsync.fns" ++ ++RSYNC_RSH="$scratchdir/src/support/lsh.sh" ++export RSYNC_RSH ++ ++# $HOME is set to $scratchdir by rsync.fns ++# localhost: destination will cd to $HOME (i.e., $scratchdir) ++ ++# Helper: create a large file suitable for delta transfers. ++# ~32KB is large enough for rsync's block matching to find matches. ++make_testfile() { ++ dd if=/dev/urandom of="$1" bs=1024 count=32 2>/dev/null \ ++ || test_fail "failed to create test file $1" ++} ++ ++# Set up source tree ++srcbase="$tmpdir/src" ++ ++###################################################################### ++# Test 1: Basic directory symlink update (the original issue #715) ++###################################################################### ++ ++mkdir -p "$HOME/real-dir" ++ln -s real-dir "$HOME/dir" ++ ++mkdir -p "$srcbase/dir" ++make_testfile "$srcbase/dir/file" ++ ++# First transfer (initial): should create the file through the symlink ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 1: initial transfer failed" ++ ++if [ ! -f "$HOME/real-dir/file" ]; then ++ test_fail "test 1: initial transfer did not create file through symlink" ++fi ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 1: initial transfer content mismatch" ++ ++# Small modification to trigger delta transfer ++echo "appended update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++# Second transfer (update): was failing with "failed verification" ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 1: update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 1: update transfer content mismatch" ++ ++###################################################################### ++# Test 2: Compression (-z) as in the original reproducer ++###################################################################### ++ ++echo "another line" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptzv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 2: compressed update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 2: compressed update content mismatch" ++ ++###################################################################### ++# Test 3: Nested directory symlinks (nested/sub/data.txt where ++# "nested" is a symlink to "nested_real") ++###################################################################### ++ ++mkdir -p "$HOME/nested_real/sub" ++ln -s nested_real "$HOME/nested" ++ ++mkdir -p "$srcbase/nested/sub" ++make_testfile "$srcbase/nested/sub/data.txt" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ ++ || test_fail "test 3: initial nested transfer failed" ++ ++echo "appended nested" >> "$srcbase/nested/sub/data.txt" ++sleep 1 ++touch "$srcbase/nested/sub/data.txt" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" nested/sub/data.txt localhost:) \ ++ || test_fail "test 3: update through nested directory symlink failed" ++ ++diff "$srcbase/nested/sub/data.txt" "$HOME/nested_real/sub/data.txt" >/dev/null \ ++ || test_fail "test 3: nested update content mismatch" ++ ++###################################################################### ++# Test 4: --backup with directory symlinks ++# ++# Exercises the finish_transfer() "fnamecmp == fname" pointer ++# comparison that determines whether to update fnamecmp to the ++# backup name. If broken, --backup would reference a renamed file ++# for xattr handling. ++###################################################################### ++ ++# Reset destination ++rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" ++ ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 4: initial transfer for backup test failed" ++ ++echo "backup update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --backup --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 4: update with --backup through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 4: backup update content mismatch" ++ ++if [ ! -f "$HOME/real-dir/file~" ]; then ++ test_fail "test 4: backup file was not created" ++fi ++ ++###################################################################### ++# Test 5: --inplace with directory symlinks ++# ++# Exercises the updating_basis_or_equiv check which uses ++# "fnamecmp == fname". With --inplace, rsync writes directly to ++# the destination file instead of a temp file. ++###################################################################### ++ ++rm -f "$HOME/real-dir/file" "$HOME/real-dir/file~" ++ ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 5: initial inplace transfer failed" ++ ++echo "inplace update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --inplace --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 5: inplace update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 5: inplace update content mismatch" ++ ++###################################################################### ++# Test 6: Top-level file (no dirname, no split needed) ++# ++# Ensures the dirname/basename split is not attempted for files ++# at the top level (file->dirname is NULL). ++###################################################################### ++ ++make_testfile "$srcbase/topfile" ++mkdir -p "$HOME" ++ ++(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ ++ || test_fail "test 6: initial top-level transfer failed" ++ ++echo "toplevel update" >> "$srcbase/topfile" ++sleep 1 ++touch "$srcbase/topfile" ++ ++(cd "$srcbase" && $RSYNC -Rlptv --rsync-path="$RSYNC" topfile localhost:) \ ++ || test_fail "test 6: top-level update failed" ++ ++diff "$srcbase/topfile" "$HOME/topfile" >/dev/null \ ++ || test_fail "test 6: top-level update content mismatch" ++ ++###################################################################### ++# Test 7: --partial-dir with protocol < 29 ++# ++# For protocol < 29, fnamecmp_type stays FNAMECMP_FNAME even when ++# fnamecmp is set to partialptr. The dirname/basename split must ++# NOT trigger in this case (guarded by "fnamecmp == fname"). ++###################################################################### ++ ++rm -f "$HOME/real-dir/file" ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 7: initial proto28 partial-dir transfer failed" ++ ++echo "partial-dir update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 --partial-dir=.rsync-partial \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 7: proto28 partial-dir update through dirlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 7: proto28 partial-dir update content mismatch" ++ ++###################################################################### ++# Test 8: Protocol < 29 basic directory symlink update ++# ++# Exercises the protocol < 29 code path and its fallback logic ++# (clearing basedir on retry). ++###################################################################### ++ ++rm -f "$HOME/real-dir/file" ++make_testfile "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 8: initial proto28 transfer failed" ++ ++echo "proto28 update" >> "$srcbase/dir/file" ++sleep 1 ++touch "$srcbase/dir/file" ++ ++(cd "$srcbase" && $RSYNC -KRlptv --protocol=28 \ ++ --rsync-path="$RSYNC" dir/file localhost:) \ ++ || test_fail "test 8: proto28 update through directory symlink failed" ++ ++diff "$srcbase/dir/file" "$HOME/real-dir/file" >/dev/null \ ++ || test_fail "test 8: proto28 update content mismatch" ++ ++# The script would have aborted on error, so getting here means we've won. ++exit 0 +-- +2.52.0 + + +From eb24b4368b38f8599a83ad9d33a8452a19cecbd3 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Thu, 30 Apr 2026 08:44:11 +1000 +Subject: [PATCH 4/8] syscall: also use O_RESOLVE_BENEATH on FreeBSD and MacOS + +FreeBSD and MacOS have O_RESOLVE_BENEATH as an openat() flag with the same +"must not escape dirfd" semantics as Linux's RESOLVE_BENEATH. The +kernel rejects ".." escapes, absolute symlinks, and symlinks whose +target lies outside dirfd, while still following symlinks that +resolve within it -- the same trade-off that fixes issue #715 on +Linux. + +Add a parallel BSD path in secure_relative_open(), gated on +declared. Unlike Linux, BSD doesn't have the header/runtime split +where the symbol can exist without kernel support, so no runtime +fallback is needed: if the flag compiles in, the kernel honours it. + +OpenBSD and NetBSD have no equivalent kernel primitive and continue +to use the existing per-component O_NOFOLLOW walk; issue #715 +remains visible on those platforms (a userland resolver or +unveil(2)-based fence would be follow-up work). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + syscall.c | 26 ++++++++++++++++++++++++++ + 1 file changed, 26 insertions(+) + +diff --git a/syscall.c b/syscall.c +index ea0dee8..bf5da66 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -651,6 +651,32 @@ static int secure_relative_open_resolve_beneath(const char *basedir, const char + } + #endif + ++#ifdef O_RESOLVE_BENEATH ++/* FreeBSD 13+ and macOS 15+ (Sequoia) / iOS 18+: O_RESOLVE_BENEATH is ++ * an openat() flag with the same "must not escape dirfd" semantics as ++ * Linux's RESOLVE_BENEATH. The kernel rejects ".." escapes, absolute ++ * symlinks, and symlinks whose target lies outside dirfd. (FreeBSD and ++ * Apple use different flag bit values, but the same symbolic name.) */ ++static int secure_relative_open_resolve_beneath(const char *basedir, const char *relpath, int flags, mode_t mode) ++{ ++ int dirfd, retfd; ++ ++ if (basedir == NULL) { ++ dirfd = AT_FDCWD; ++ } else { ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) ++ return -1; ++ } ++ ++ retfd = openat(dirfd, relpath, flags | O_RESOLVE_BENEATH, mode); ++ ++ if (dirfd != AT_FDCWD) ++ close(dirfd); ++ return retfd; ++} ++#endif ++ + int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode) + { + if (!relpath || relpath[0] == '/') { +-- +2.52.0 + + +From 68eff28e4b2137616f96008b7af5fd1e8c4ee845 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Mon, 4 May 2026 21:53:14 +1000 +Subject: [PATCH 5/8] syscall+receiver: secure receiver-side do_chmod against + symlink-race TOCTOU + +CVE-2026-29518's fix routed the receiver's open() through +secure_relative_open(), but every other path-based syscall the +receiver runs on sender-controllable paths is vulnerable to the +same TOCTOU primitive. This commit closes the chmod variant. + +Add do_chmod_at() that opens the parent of fname under +secure_relative_open() and uses fchmodat() against the resulting +dirfd. Gate the secure path on am_daemon && !am_chrooted (the same +gate use_secure_symlinks already uses for the receiver basis-file +open), so non-daemon callers and chrooted daemons keep the original +do_chmod() fast path. + +Migrate the receiver-side do_chmod() call sites in delete.c, +generator.c, rsync.c, and xattrs.c. + +Adds testsuite/chmod-symlink-race.test (with t_chmod_secure helper) +as regression coverage. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + Makefile.in | 9 ++- + delete.c | 4 +- + generator.c | 4 +- + rsync.c | 2 +- + syscall.c | 80 ++++++++++++++++++++ + t_chmod_secure.c | 117 ++++++++++++++++++++++++++++++ + t_stub.c | 2 + + testsuite/chmod-symlink-race.test | 68 +++++++++++++++++ + xattrs.c | 6 +- + 9 files changed, 282 insertions(+), 10 deletions(-) + create mode 100644 t_chmod_secure.c + create mode 100755 testsuite/chmod-symlink-race.test + +diff --git a/Makefile.in b/Makefile.in +index f912f31..7e603a7 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -50,12 +50,13 @@ TLS_OBJ = tls.o syscall.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxa + + # Programs we must have to run the test cases + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ +- testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) wildtest$(EXEEXT) ++ testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ ++ wildtest$(EXEEXT) + + CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test + + # Objects for CHECK_PROGS to clean +-CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o trimslash.o wildtest.o ++CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o trimslash.o wildtest.o + + # note that the -I. is needed to handle config.h when using VPATH + .c.o: +@@ -136,6 +137,10 @@ T_UNSAFE_OBJ = t_unsafe.o syscall.o util.o util2.o t_stub.o lib/compat.o lib/snp + t_unsafe$(EXEEXT): $(T_UNSAFE_OBJ) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_UNSAFE_OBJ) $(LIBS) + ++T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o ++t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) ++ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) ++ + gen: conf proto.h man + + gensend: gen +diff --git a/delete.c b/delete.c +index 716e546..7b87e0e 100644 +--- a/delete.c ++++ b/delete.c +@@ -98,7 +98,7 @@ static enum delret delete_dir_contents(char *fname, uint16 flags) + + strlcpy(p, fp->basename, remainder); + if (!(fp->mode & S_IWUSR) && !am_root && fp->flags & FLAG_OWNED_BY_US) +- do_chmod(fname, fp->mode | S_IWUSR); ++ do_chmod_at(fname, fp->mode | S_IWUSR); + /* Save stack by recursing to ourself directly. */ + if (S_ISDIR(fp->mode)) { + if (delete_dir_contents(fname, flags | DEL_RECURSE) != DR_SUCCESS) +@@ -139,7 +139,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) + } + + if (flags & DEL_NO_UID_WRITE) +- do_chmod(fbuf, mode | S_IWUSR); ++ do_chmod_at(fbuf, mode | S_IWUSR); + + if (S_ISDIR(mode) && !(flags & DEL_DIR_IS_EMPTY)) { + /* This only happens on the first call to delete_item() since +diff --git a/generator.c b/generator.c +index a6260cb..e189e51 100644 +--- a/generator.c ++++ b/generator.c +@@ -1469,7 +1469,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + #ifdef HAVE_CHMOD + if (!am_root && (file->mode & S_IRWXU) != S_IRWXU && dir_tweaking) { + mode_t mode = file->mode | S_IRWXU; +- if (do_chmod(fname, mode) < 0) { ++ if (do_chmod_at(fname, mode) < 0) { + rsyserr(FERROR_XFER, errno, + "failed to modify permissions on %s", + full_fname(fname)); +@@ -2072,7 +2072,7 @@ static void touch_up_dirs(struct file_list *flist, int ndx) + continue; + fname = f_name(file, NULL); + if (fix_dir_perms) +- do_chmod(fname, file->mode); ++ do_chmod_at(fname, file->mode); + if (need_retouch_dir_times) { + STRUCT_STAT st; + if (link_stat(fname, &st, 0) == 0 && time_diff(&st, file)) +diff --git a/rsync.c b/rsync.c +index ff761c0..05f667c 100644 +--- a/rsync.c ++++ b/rsync.c +@@ -588,7 +588,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, + + #ifdef HAVE_CHMOD + if (!BITS_EQUAL(sxp->st.st_mode, new_mode, CHMOD_BITS)) { +- int ret = am_root < 0 ? 0 : do_chmod(fname, new_mode); ++ int ret = am_root < 0 ? 0 : do_chmod_at(fname, new_mode); + if (ret < 0) { + rsyserr(FERROR_XFER, errno, + "failed to set permissions on %s", +diff --git a/syscall.c b/syscall.c +index bf5da66..50494fe 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -240,6 +240,86 @@ int do_chmod(const char *path, mode_t mode) + return code; + return 0; + } ++ ++/* ++ Symlink-race-safe variant of do_chmod() for receiver-side use. ++ ++ Threat model: on a daemon running with "use chroot = no" (the prerequisite ++ for CVE-2026-29518), a local attacker can race a symlink swap of one of ++ the parent directory components of a path the receiver is about to chmod. ++ Because chmod() resolves symlinks at every component, the swap redirects ++ the chmod outside the receiver's confinement. ++ ++ Defence: open the *parent* directory of fname under secure_relative_open() ++ (which uses openat2(RESOLVE_BENEATH) on Linux 5.6+, openat() with ++ O_RESOLVE_BENEATH on FreeBSD 13+ and macOS 15+ (Sequoia), or a per-component ++ O_NOFOLLOW walk elsewhere) and do fchmodat() against that dirfd. A symlink ++ substituted into one of the parent components is then either followed ++ within the tree (legitimate dir-symlinks still work) or rejected by the ++ kernel (escape attempts fail). ++ ++ Final-component handling matches do_chmod(): fchmodat() with flag 0 ++ follows a symlink at the final component, which is the same behaviour as ++ chmod() and matches every current call site (the file being chmod'd is ++ one the receiver itself just created or transferred). For the rare case ++ where the caller wants to chmod a symlink-as-an-object (S_ISLNK in the ++ mode bits), we fall through to do_chmod() which has portability code for ++ that case. ++ ++ Falls back to do_chmod() for absolute paths and for paths with no parent ++ component, where there is nothing to protect against. ++*/ ++int do_chmod_at(const char *fname, mode_t mode) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ /* Only the daemon-without-chroot case is exposed to the symlink- ++ * race attack: a chroot already confines the receiver, and a ++ * non-daemon rsync runs with the user's own authority so a ++ * symlink they planted can only redirect to files they could ++ * already access. Everywhere else, fall through to plain ++ * do_chmod() to avoid the dirfd-open overhead on every call. */ ++ if (!am_daemon || am_chrooted) ++ return do_chmod(fname, mode); ++ ++ if (!fname || !*fname || *fname == '/' || S_ISLNK(mode)) ++ return do_chmod(fname, mode); ++ ++ slash = strrchr(fname, '/'); ++ if (!slash) ++ return do_chmod(fname, mode); ++ ++ dlen = slash - fname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, fname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = fchmodat(dfd, bname, mode, 0); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_chmod(fname, mode); ++#endif ++} + #endif + + int do_rename(const char *fname1, const char *fname2) +diff --git a/t_chmod_secure.c b/t_chmod_secure.c +new file mode 100644 +index 0000000..114dfb2 +--- /dev/null ++++ b/t_chmod_secure.c +@@ -0,0 +1,119 @@ ++/* ++ * Test harness for do_chmod_at(). Confirms the symlink-TOCTOU ++ * primitive used by CVE-2026-29518 (and its incomplete-fix follow-up ++ * for chmod) is closed by do_chmod_at(): a parent directory component ++ * being a symlink that escapes the receiver's confinement must be ++ * rejected, while a parent symlink that resolves *within* the tree ++ * must still work (so legitimate dir-symlinks are not regressed). ++ * ++ * Not linked into rsync itself. ++ * ++ * This program is free software; you can redistribute it and/or modify ++ * it under the terms of the GNU General Public License version 2 as ++ * published by the Free Software Foundation. ++ */ ++ ++#include "rsync.h" ++ ++#include ++ ++int dry_run = 0; ++int am_root = 0; ++int am_sender = 0; ++int read_only = 0; ++int list_only = 0; ++int copy_links = 0; ++int copy_unsafe_links = 0; ++int preserve_perms = 0; ++int preserve_executability = 0; ++extern int am_daemon, am_chrooted; ++ ++short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; ++ ++static int errs = 0; ++ ++static void check(const char *label, int actual_rc, int expect_ok, ++ const char *path, mode_t expected_mode) ++{ ++ struct stat st; ++ int got_ok = (actual_rc == 0); ++ if (got_ok != expect_ok) { ++ fprintf(stderr, "FAIL [%s]: rc=%d errno=%d (%s), expected %s\n", ++ label, actual_rc, errno, strerror(errno), ++ expect_ok ? "success" : "rejection"); ++ errs++; ++ return; ++ } ++ if (path && stat(path, &st) < 0) { ++ fprintf(stderr, "FAIL [%s]: stat(%s) failed: %s\n", ++ label, path, strerror(errno)); ++ errs++; ++ return; ++ } ++ if (path && (st.st_mode & 07777) != expected_mode) { ++ fprintf(stderr, ++ "FAIL [%s]: %s mode is 0%o, expected 0%o\n", ++ label, path, st.st_mode & 07777, expected_mode); ++ errs++; ++ return; ++ } ++ fprintf(stderr, "OK [%s]\n", label); ++} ++ ++int main(int argc, char **argv) ++{ ++ if (argc != 2) { ++ fprintf(stderr, "usage: %s \n", argv[0]); ++ return 2; ++ } ++ if (chdir(argv[1]) < 0) { ++ perror("chdir"); ++ return 2; ++ } ++ ++ /* Simulate the daemon-without-chroot deployment that do_chmod_at() ++ * defends. With am_daemon=0 or am_chrooted=1 the wrapper falls ++ * through to plain do_chmod() and the symlink-race test would be ++ * meaningless. */ ++ am_daemon = 1; ++ am_chrooted = 0; ++ ++ /* Test layout (all inside the directory we just chdir'd to): ++ * ++ * ./realdir/sentinel -- regular target file ++ * ./inside_link -> realdir -- legitimate dir-symlink within the tree ++ * ./escape_link -> ../trap -- attacker swap, target outside tree ++ * ../trap/sentinel -- the file the attacker wants to alter ++ * ++ * The shell wrapper that calls this helper has set both sentinel ++ * files to mode 0600 so we have a clean baseline to compare. ++ */ ++ ++ /* Scenario A: legitimate parent dir-symlink, chmod must succeed. */ ++ int rc = do_chmod_at("inside_link/sentinel", 0640); ++ check("A: legit dir-symlink within tree", ++ rc, 1, "realdir/sentinel", 0640); ++ ++ /* Scenario B: parent symlink escapes the tree -- chmod must be ++ * rejected and the outside file's mode must be unchanged. */ ++ rc = do_chmod_at("escape_link/sentinel", 0666); ++ check("B: parent symlink escapes tree (the attack)", ++ rc, 0, "../trap/sentinel", 0600); ++ ++ /* Scenario C: plain relative path with no symlink components, ++ * regression check that the safe wrapper doesn't break the ++ * normal case. */ ++ rc = do_chmod_at("realdir/sentinel", 0644); ++ check("C: plain relative path (regression check)", ++ rc, 1, "realdir/sentinel", 0644); ++ ++ /* Scenario D: top-level file, no parent directory component. ++ * Falls back to do_chmod(); should succeed. */ ++ rc = do_chmod_at("topfile", 0640); ++ check("D: top-level file, no parent component", ++ rc, 1, "topfile", 0640); ++ ++ if (errs) ++ fprintf(stderr, "%d failure(s)\n", errs); ++ return errs ? 1 : 0; ++} +diff --git a/t_stub.c b/t_stub.c +index ef3416a..b1db8d4 100644 +--- a/t_stub.c ++++ b/t_stub.c +@@ -22,6 +22,8 @@ + #include "rsync.h" + + int inplace = 0; ++int am_daemon = 0; ++int am_chrooted = 0; + int modify_window = 0; + int preallocate_files = 0; + int protect_args = 0; +diff --git a/testsuite/chmod-symlink-race.test b/testsuite/chmod-symlink-race.test +new file mode 100755 +index 0000000..48bbfbb +--- /dev/null ++++ b/testsuite/chmod-symlink-race.test +@@ -0,0 +1,68 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for the symlink-TOCTOU class of bug applied to ++# chmod() on the receiver side. The CVE-2026-29518 fix used ++# secure_relative_open() for the basis-file open, but every other ++# path-based syscall the receiver runs on sender-controllable paths ++# is vulnerable to the same primitive: a local attacker swaps a ++# symlink into one of the parent directory components between the ++# receiver's check and its act, and the syscall escapes the module. ++# ++# This test exercises the new do_chmod_at() wrapper via the ++# t_chmod_secure helper. The helper sets up two scenarios: ++# - a parent dir-symlink that resolves WITHIN the module tree ++# (legitimate -K-style use, must continue to work) ++# - a parent dir-symlink that escapes the module tree (the ++# attack, must be rejected) ++# plus two regression scenarios (plain relative path, top-level ++# file) that just confirm the safe wrapper doesn't break the ++# normal case. ++# ++# The kernel-enforced "stay below dirfd" path resolution is ++# only available on Linux 5.6+, FreeBSD 13+, and macOS 15+. ++# Skip on platforms that fall back to per-component O_NOFOLLOW ++# (Solaris, OpenBSD, NetBSD, Cygwin); the per-component fallback ++# would also reject the attack but the legitimate dir-symlink ++# scenario would fail there. ++ ++. "$suitedir/rsync.fns" ++ ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "do_chmod_at relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++trap_outside="$scratchdir/trap" ++rm -rf "$mod" "$trap_outside" ++mkdir -p "$mod/realdir" "$trap_outside" ++ ++# Set up the four file-system objects the helper expects: ++echo bystander > "$mod/realdir/sentinel" ++chmod 0600 "$mod/realdir/sentinel" ++echo target > "$trap_outside/sentinel" ++chmod 0600 "$trap_outside/sentinel" ++ln -s realdir "$mod/inside_link" ++ln -s ../trap "$mod/escape_link" ++echo top > "$mod/topfile" ++chmod 0600 "$mod/topfile" ++ ++"$TOOLDIR/t_chmod_secure" "$mod" || \ ++ test_fail "t_chmod_secure reported failures (see stderr above)" ++ ++# Sanity-check from the shell side too: the outside file's mode must ++# still be 0600 -- the helper checked this, but a second look from ++# the shell guards against a helper-internal stat() bug. ++mode=$(stat -c '%a' "$trap_outside/sentinel" 2>/dev/null \ ++ || stat -f '%Lp' "$trap_outside/sentinel" 2>/dev/null) ++if [ "$mode" != "600" ]; then ++ test_fail "outside sentinel mode changed from 600 to $mode -- chmod escaped the module" ++fi ++ ++exit 0 +diff --git a/xattrs.c b/xattrs.c +index b1b4217..9afef2f 100644 +--- a/xattrs.c ++++ b/xattrs.c +@@ -1146,7 +1146,7 @@ int set_xattr(const char *fname, const struct file_struct *file, + && !S_ISLNK(sxp->st.st_mode) + #endif + && access(fname, W_OK) < 0 +- && do_chmod(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) == 0) ++ && do_chmod_at(fname, (sxp->st.st_mode & CHMOD_BITS) | S_IWUSR) == 0) + added_write_perm = 1; + + ndx = F_XATTR(file); +@@ -1154,7 +1154,7 @@ int set_xattr(const char *fname, const struct file_struct *file, + lst = &glst->xa_items; + int return_value = rsync_xal_set(fname, lst, fnamecmp, sxp); + if (added_write_perm) /* remove the temporary write permission */ +- do_chmod(fname, sxp->st.st_mode); ++ do_chmod_at(fname, sxp->st.st_mode); + return return_value; + } + +@@ -1270,7 +1270,7 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode) + mode = (fst.st_mode & _S_IFMT) | (fmode & ACCESSPERMS) + | (S_ISDIR(fst.st_mode) ? 0700 : 0600); + if (fst.st_mode != mode) +- do_chmod(fname, mode); ++ do_chmod_at(fname, mode); + if (!IS_DEVICE(fst.st_mode)) + fst.st_rdev = 0; /* just in case */ + +-- +2.52.0 + + +From 79ee9121dde17b5982dd9f18c8b49d41b715e35e Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 14:34:33 +1000 +Subject: [PATCH 6/8] util1: secure change_dir() against symlink-race + chdir-escape + +The receiver's chdir(2) into a destination subdirectory followed +attacker-planted symlinks at every path component. Once CWD +escaped the module, every subsequent path-relative syscall (open, +chmod, lchown, ...) inherited the escape -- defeating +secure_relative_open's RESOLVE_BENEATH anchor against AT_FDCWD, +since the anchor itself was now outside the module. + +Route change_dir's relative target through secure_relative_open() +and fchdir() to the resulting dirfd in am_daemon && !am_chrooted +mode, so the chdir step itself can no longer follow a parent- +symlink. Same treatment applied to the CD_SKIP_CHDIR / +set_path_only path so it also can't follow attacker symlinks +during path tracking. + +Adds testsuite/sender-flist-symlink-leak.test covering the +sender-side flist resolution variant of the same primitive. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + testsuite/sender-flist-symlink-leak.test | 90 ++++++++++++++++++++++++ + util.c | 55 ++++++++++++++- + 2 files changed, 142 insertions(+), 3 deletions(-) + create mode 100755 testsuite/sender-flist-symlink-leak.test + +diff --git a/testsuite/sender-flist-symlink-leak.test b/testsuite/sender-flist-symlink-leak.test +new file mode 100755 +index 0000000..66c11a8 +--- /dev/null ++++ b/testsuite/sender-flist-symlink-leak.test +@@ -0,0 +1,90 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex re-check finding: the sender-side file- ++# list generator can still follow an attacker-planted symlink out of ++# the module via change_pathname() -> change_dir(...,CD_SKIP_CHDIR) ++# followed by change_dir(...,CD_NORMAL). The CD_SKIP_CHDIR sets ++# skipped_chdir=1, and the next CD_NORMAL call's secure-branch in ++# util.c is gated on `!skipped_chdir`, so the secure path is ++# bypassed and a raw chdir(curr_dir) follows attacker-controlled ++# symlinks during flist generation. ++# ++# Reach: rsync daemon module with `use chroot = no`. A local ++# attacker plants module/cd -> /outside. A client (innocent or ++# malicious) pulls rsync:////cd/. The daemon, as ++# sender, enumerates files in /outside and ships their metadata ++# (names, sizes, modes, mtimes) to the client. The actual content ++# transfer fails later at the secure_relative_open step with EXDEV, ++# but by then the metadata has already leaked. ++# ++# We detect by running a dry-run pull of the symlinked subdir and ++# checking whether the client's --list-only output mentions any ++# file from /outside. With the bug, /outside/secret.txt appears in ++# the list with its size; with the fix, the daemon's chdir into ++# the symlinked subdir is rejected and no /outside file is listed. ++ ++. "$suitedir/rsync.fns" ++ ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "secure change_dir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++listfile="$scratchdir/listed.txt" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" ++mkdir -p "$mod" "$outside" ++ ++# Outside-the-module file the daemon should NOT enumerate to clients. ++# A distinctive name + non-trivial size makes the leak easy to spot. ++echo "OUTSIDE_PROTECTED_FILE_USED_AS_LEAK_DETECTOR" > "$outside/leak_marker.txt" ++chmod 0644 "$outside/leak_marker.txt" ++ ++# The symlink trap planted by the local attacker. ++ln -s "$outside" "$mod/cd" ++ ++my_uid=`get_testuid` ++root_uid=`id -u root` ++root_gid=`id -g root` ++uid_setting="uid = $root_uid" ++gid_setting="gid = $root_gid" ++if test x"$my_uid" != x"$root_uid"; then ++ uid_setting="#$uid_setting" ++ gid_setting="#$gid_setting" ++fi ++ ++cat > "$conf" < "$listfile" 2>&1 || true ++ ++if grep -q "leak_marker\.txt" "$listfile"; then ++ echo "----- leaked listing follows" >&2 ++ sed 's/^/ /' "$listfile" >&2 ++ echo "----- leaked listing ends" >&2 ++ test_fail "sender flist leak: outside/leak_marker.txt was enumerated to the client (daemon's chdir followed the cd symlink during flist generation)" ++fi ++ ++exit 0 +diff --git a/util.c b/util.c +index 2516714..eabb5b7 100644 +--- a/util.c ++++ b/util.c +@@ -1085,6 +1085,7 @@ char *sanitize_path(char *dest, const char *p, const char *rootdir, int depth, + * Also cleans the path using the clean_fname() function. */ + int change_dir(const char *dir, int set_path_only) + { ++ extern int am_daemon, am_chrooted; + static int initialised, skipped_chdir; + unsigned int len; + +@@ -1120,11 +1121,59 @@ int change_dir(const char *dir, int set_path_only) + } + if (!(curr_dir_len && curr_dir[curr_dir_len-1] == '/')) + curr_dir[curr_dir_len++] = '/'; ++ unsigned int save_dir_len = curr_dir_len; + memcpy(curr_dir + curr_dir_len, dir, len + 1); + +- if (!set_path_only && chdir(curr_dir)) { +- curr_dir[curr_dir_len] = '\0'; +- return 0; ++ if (!set_path_only) { ++ int chdir_failed; ++ /* In the daemon-without-chroot deployment we must not ++ * follow a symlink in any component of the chdir ++ * target -- otherwise CWD escapes the module and ++ * every subsequent path-relative syscall (open, ++ * chmod, lchown, ...) inherits the escape, which ++ * defeats secure_relative_open's RESOLVE_BENEATH ++ * anchor and re-opens the CVE-2026-29518 class of ++ * symlink TOCTOU attacks. Use the secure resolver ++ * to get a confined dirfd, then fchdir() to it. ++ * ++ * If skipped_chdir is set, a previous CD_SKIP_CHDIR ++ * call buffered an absolute prefix in curr_dir ++ * without syncing the kernel's CWD. Resolve `dir` ++ * relative to that prefix as basedir so the secure ++ * branch still anchors at the operator-trusted ++ * directory rather than wherever the kernel CWD ++ * happens to be. */ ++ if (am_daemon && !am_chrooted) { ++ const char *basedir = NULL; ++ char prefix[MAXPATHLEN]; ++ int dfd; ++ if (skipped_chdir) { ++ if (save_dir_len >= sizeof prefix) { ++ errno = ENAMETOOLONG; ++ chdir_failed = 1; ++ goto chdir_cleanup; ++ } ++ memcpy(prefix, curr_dir, save_dir_len); ++ prefix[save_dir_len] = '\0'; ++ basedir = prefix; ++ } ++ dfd = secure_relative_open(basedir, dir, ++ O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) { ++ chdir_failed = 1; ++ } else { ++ chdir_failed = fchdir(dfd) != 0; ++ close(dfd); ++ } ++ } else { ++ chdir_failed = chdir(curr_dir) != 0; ++ } ++ chdir_cleanup: ++ if (chdir_failed) { ++ curr_dir_len = save_dir_len; ++ curr_dir[curr_dir_len] = '\0'; ++ return 0; ++ } + } + skipped_chdir = set_path_only; + } +-- +2.52.0 + + +From e97ee36ca020e4f08e5bc27b0418b41290452194 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Tue, 5 May 2026 15:02:48 +1000 +Subject: [PATCH 7/8] syscall: add symlink-race-safe do_*_at() wrappers and + harden secure_relative_open + +Add the rest of the path-based syscall wrappers and migrate every +receiver-side caller: + - do_lchown_at, do_rename_at, do_mkdir_at, do_symlink_at, + do_mknod_at, do_link_at, do_unlink_at, do_rmdir_at, + do_utimensat_at, do_stat_at, do_lstat_at + +Same shape as do_chmod_at: open each parent under +secure_relative_open(), call the *at() variant against the dirfd, +fall through to the bare path-based syscall in non-daemon / +chrooted / absolute-path / no-parent cases. macOS's +setattrlist-based set_times tier is also routed through the +utimensat_at path on daemon-no-chroot. + +Hardenings to secure_relative_open() itself: + - confine basedir resolution under the same kernel mechanism + used for relpath (basedirs from --copy-dest / --link-dest are + sender-controllable in daemon mode) + - reject any '..' component (bare '..', 'foo/..', 'subdir/..') + so the per-component O_NOFOLLOW fallback can't escape + - return the dirfd we built up from the per-component fallback + when the caller passed O_DIRECTORY (otherwise every do_*_at + failed with EINVAL on platforms without RESOLVE_BENEATH) + +Adds testsuite/alt-dest-symlink-race.test and +testsuite/secure-relpath-validation.test (with t_secure_relpath +helper) as regression coverage for the new hardenings. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + Makefile.in | 8 +- + backup.c | 14 +- + cleanup.c | 2 +- + delete.c | 2 +- + generator.c | 28 +- + hlink.c | 2 +- + receiver.c | 8 +- + rsync.c | 6 +- + syscall.c | 734 ++++++++++++++++++++++- + t_secure_relpath.c | 151 +++++ + testsuite/alt-dest-symlink-race.test | 96 +++ + testsuite/secure-relpath-validation.test | 34 ++ + util.c | 20 +- + xattrs.c | 9 +- + 14 files changed, 1062 insertions(+), 52 deletions(-) + create mode 100644 t_secure_relpath.c + create mode 100755 testsuite/alt-dest-symlink-race.test + create mode 100755 testsuite/secure-relpath-validation.test + +diff --git a/Makefile.in b/Makefile.in +index 7e603a7..9fa3995 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -51,12 +51,12 @@ TLS_OBJ = tls.o syscall.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxa + # Programs we must have to run the test cases + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ + testrun$(EXEEXT) trimslash$(EXEEXT) t_unsafe$(EXEEXT) t_chmod_secure$(EXEEXT) \ +- wildtest$(EXEEXT) ++ t_secure_relpath$(EXEEXT) wildtest$(EXEEXT) + + CHECK_SYMLINKS = testsuite/chown-fake.test testsuite/devices-fake.test testsuite/xattrs-hlink.test + + # Objects for CHECK_PROGS to clean +-CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o trimslash.o wildtest.o ++CHECK_OBJS=tls.o testrun.o getgroups.o getfsdev.o t_stub.o t_unsafe.o t_chmod_secure.o t_secure_relpath.o trimslash.o wildtest.o + + # note that the -I. is needed to handle config.h when using VPATH + .c.o: +@@ -141,6 +141,10 @@ T_CHMOD_SECURE_OBJ = t_chmod_secure.o syscall.o util.o util2.o t_stub.o lib/comp + t_chmod_secure$(EXEEXT): $(T_CHMOD_SECURE_OBJ) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_CHMOD_SECURE_OBJ) $(LIBS) + ++T_SECURE_RELPATH_OBJ = t_secure_relpath.o syscall.o util.o util2.o t_stub.o lib/compat.o lib/snprintf.o lib/wildmatch.o lib/permstring.o ++t_secure_relpath$(EXEEXT): $(T_SECURE_RELPATH_OBJ) ++ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(T_SECURE_RELPATH_OBJ) $(LIBS) ++ + gen: conf proto.h man + + gensend: gen +diff --git a/backup.c b/backup.c +index 5f40b39..fd6a1f0 100644 +--- a/backup.c ++++ b/backup.c +@@ -39,7 +39,7 @@ static int validate_backup_dir(void) + { + STRUCT_STAT st; + +- if (do_lstat(backup_dir_buf, &st) < 0) { ++ if (do_lstat_at(backup_dir_buf, &st) < 0) { + if (errno == ENOENT) + return 0; + rsyserr(FERROR, errno, "backup lstat %s failed", backup_dir_buf); +@@ -98,7 +98,7 @@ static BOOL copy_valid_path(const char *fname) + for ( ; b; name = b + 1, b = strchr(name, '/')) { + *b = '\0'; + +- while (do_mkdir(backup_dir_buf, ACCESSPERMS) < 0) { ++ while (do_mkdir_at(backup_dir_buf, ACCESSPERMS) < 0) { + if (errno == EEXIST) { + val = validate_backup_dir(); + if (val > 0) +@@ -197,7 +197,7 @@ static inline int link_or_rename(const char *from, const char *to, + if (IS_SPECIAL(stp->st_mode) || IS_DEVICE(stp->st_mode)) + return 0; /* Use copy code. */ + #endif +- if (do_link(from, to) == 0) { ++ if (do_link_at(from, to) == 0) { + if (DEBUG_GTE(BACKUP, 1)) + rprintf(FINFO, "make_backup: HLINK %s successful.\n", from); + return 2; +@@ -207,7 +207,7 @@ static inline int link_or_rename(const char *from, const char *to, + return 0; + } + #endif +- if (do_rename(from, to) == 0) { ++ if (do_rename_at(from, to) == 0) { + if (stp->st_nlink > 1 && !S_ISDIR(stp->st_mode)) { + /* If someone has hard-linked the file into the backup + * dir, rename() might return success but do nothing! */ +@@ -246,7 +246,7 @@ int make_backup(const char *fname, BOOL prefer_rename) + goto success; + if (errno == EEXIST || errno == EISDIR) { + STRUCT_STAT bakst; +- if (do_lstat(buf, &bakst) == 0) { ++ if (do_lstat_at(buf, &bakst) == 0) { + int flags = get_del_for_flag(bakst.st_mode) | DEL_FOR_BACKUP | DEL_RECURSE; + if (delete_item(buf, bakst.st_mode, flags) != 0) + return 0; +@@ -277,7 +277,7 @@ int make_backup(const char *fname, BOOL prefer_rename) + /* Check to see if this is a device file, or link */ + if ((am_root && preserve_devices && IS_DEVICE(file->mode)) + || (preserve_specials && IS_SPECIAL(file->mode))) { +- if (do_mknod(buf, file->mode, sx.st.st_rdev) < 0) ++ if (do_mknod_at(buf, file->mode, sx.st.st_rdev) < 0) + rsyserr(FERROR, errno, "mknod %s failed", full_fname(buf)); + else if (DEBUG_GTE(BACKUP, 1)) + rprintf(FINFO, "make_backup: DEVICE %s successful.\n", fname); +@@ -294,7 +294,7 @@ int make_backup(const char *fname, BOOL prefer_rename) + } + ret = 2; + } else { +- if (do_symlink(sl, buf) < 0) ++ if (do_symlink_at(sl, buf) < 0) + rsyserr(FERROR, errno, "link %s -> \"%s\"", full_fname(buf), sl); + else if (DEBUG_GTE(BACKUP, 1)) + rprintf(FINFO, "make_backup: SYMLINK %s successful.\n", fname); +diff --git a/cleanup.c b/cleanup.c +index 95595f1..c709396 100644 +--- a/cleanup.c ++++ b/cleanup.c +@@ -200,7 +200,7 @@ NORETURN void _exit_cleanup(int code, const char *file, int line) + switch_step++; + + if (cleanup_fname) +- do_unlink(cleanup_fname); ++ do_unlink_at(cleanup_fname); + if (exit_code) + kill_all(SIGUSR1); + if (cleanup_pid && cleanup_pid == getpid()) { +diff --git a/delete.c b/delete.c +index 7b87e0e..23365ee 100644 +--- a/delete.c ++++ b/delete.c +@@ -160,7 +160,7 @@ enum delret delete_item(char *fbuf, uint16 mode, uint16 flags) + + if (S_ISDIR(mode)) { + what = "rmdir"; +- ok = do_rmdir(fbuf) == 0; ++ ok = do_rmdir_at(fbuf) == 0; + } else { + if (make_backups > 0 && !(flags & DEL_FOR_BACKUP) && (backup_dir || !is_backup_file(fbuf))) { + what = "make_backup"; +diff --git a/generator.c b/generator.c +index e189e51..a157414 100644 +--- a/generator.c ++++ b/generator.c +@@ -914,7 +914,7 @@ static int try_dests_reg(struct file_struct *file, char *fname, int ndx, + if (find_exact_for_existing) { + if (link_dest && real_st.st_dev == sxp->st.st_dev && real_st.st_ino == sxp->st.st_ino) + return -1; +- if (do_unlink(fname) < 0 && errno != ENOENT) ++ if (do_unlink_at(fname) < 0 && errno != ENOENT) + goto got_nothing_for_ya; + } + #ifdef SUPPORT_HARD_LINKS +@@ -1094,7 +1094,7 @@ static int try_dests_non(struct file_struct *file, char *fname, int ndx, + && !IS_SPECIAL(file->mode) && !IS_DEVICE(file->mode) + #endif + && !S_ISDIR(file->mode)) { +- if (do_link(cmpbuf, fname) < 0) { ++ if (do_link_at(cmpbuf, fname) < 0) { + rsyserr(FERROR_XFER, errno, + "failed to hard-link %s with %s", + cmpbuf, fname); +@@ -1284,7 +1284,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + } + } + if (relative_paths && !implied_dirs +- && do_stat(dn, &sx.st) < 0) { ++ && do_stat_at(dn, &sx.st) < 0) { + if (dry_run) + goto parent_is_dry_missing; + if (make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0) { +@@ -1394,7 +1394,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + && (S_ISDIR(sx.st.st_mode) + || delete_item(fname, sx.st.st_mode, del_opts | DEL_FOR_DIR) != 0)) + goto cleanup; /* Any errors get reported later. */ +- if (do_mkdir(fname, (file->mode|added_perms) & 0700) == 0) ++ if (do_mkdir_at(fname, (file->mode|added_perms) & 0700) == 0) + file->flags |= FLAG_DIR_CREATED; + goto cleanup; + } +@@ -1438,10 +1438,10 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + itemize(fnamecmp, file, ndx, statret, &sx, + statret ? ITEM_LOCAL_CHANGE : 0, 0, NULL); + } +- if (real_ret != 0 && do_mkdir(fname,file->mode|added_perms) < 0 && errno != EEXIST) { ++ if (real_ret != 0 && do_mkdir_at(fname,file->mode|added_perms) < 0 && errno != EEXIST) { + if (!relative_paths || errno != ENOENT + || make_path(fname, MKP_DROP_NAME | MKP_SKIP_SLASH) < 0 +- || (do_mkdir(fname, file->mode|added_perms) < 0 && errno != EEXIST)) { ++ || (do_mkdir_at(fname, file->mode|added_perms) < 0 && errno != EEXIST)) { + rsyserr(FERROR_XFER, errno, + "recv_generator: mkdir %s failed", + full_fname(fname)); +@@ -1769,7 +1769,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + ; + else if (unchanged_file(fnamecmp, file, &sx.st)) { + if (partialptr) { +- do_unlink(partialptr); ++ do_unlink_at(partialptr); + handle_partial_dir(partialptr, PDIR_DELETE); + } + set_file_attrs(fname, file, &sx, NULL, maybe_ATTRS_REPORT | maybe_ATTRS_SET_NANO); +@@ -1981,7 +1981,7 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const + + if (slnk) { + #ifdef SUPPORT_LINKS +- if (do_symlink(slnk, create_name) < 0) { ++ if (do_symlink_at(slnk, create_name) < 0) { + rsyserr(FERROR_XFER, errno, "symlink %s -> \"%s\" failed", + full_fname(create_name), slnk); + return 0; +@@ -1997,7 +1997,7 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const + return 0; + #endif + } else { +- if (do_mknod(create_name, file->mode, rdev) < 0) { ++ if (do_mknod_at(create_name, file->mode, rdev) < 0) { + rsyserr(FERROR_XFER, errno, "mknod %s failed", + full_fname(create_name)); + return 0; +@@ -2005,10 +2005,14 @@ int atomic_create(struct file_struct *file, char *fname, const char *slnk, const + } + + if (!skip_atomic) { +- if (do_rename(tmpname, fname) < 0) { ++ if (do_rename_at(tmpname, fname) < 0) { ++ char *full_tmpname = strdup(full_fname(tmpname)); ++ if (full_tmpname == NULL) ++ out_of_memory("atomic_create"); + rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\" failed", +- full_fname(tmpname), full_fname(fname)); +- do_unlink(tmpname); ++ full_tmpname, full_fname(fname)); ++ free(full_tmpname); ++ do_unlink_at(tmpname); + return 0; + } + } +diff --git a/hlink.c b/hlink.c +index 851d0d8..145306b 100644 +--- a/hlink.c ++++ b/hlink.c +@@ -458,7 +458,7 @@ int hard_link_check(struct file_struct *file, int ndx, char *fname, + int hard_link_one(struct file_struct *file, const char *fname, + const char *oldname, int terse) + { +- if (do_link(oldname, fname) < 0) { ++ if (do_link_at(oldname, fname) < 0) { + enum logcode code; + if (terse) { + if (!INFO_GTE(NAME, 1)) +diff --git a/receiver.c b/receiver.c +index e4c4c15..a13d567 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -430,7 +430,7 @@ static void handle_delayed_updates(char *local_name) + } + /* We don't use robust_rename() here because the + * partial-dir must be on the same drive. */ +- if (do_rename(partialptr, fname) < 0) { ++ if (do_rename_at(partialptr, fname) < 0) { + rsyserr(FERROR_XFER, errno, + "rename failed for %s (from %s)", + full_fname(fname), partialptr); +@@ -876,7 +876,7 @@ int recv_files(int f_in, int f_out, char *local_name) + partialptr, file, recv_ok, 1)) + recv_ok = -1; + else if (fnamecmp == partialptr) { +- do_unlink(partialptr); ++ do_unlink_at(partialptr); + handle_partial_dir(partialptr, PDIR_DELETE); + } + } else if (keep_partial && partialptr) { +@@ -885,7 +885,7 @@ int recv_files(int f_in, int f_out, char *local_name) + "Unable to create partial-dir for %s -- discarding %s.\n", + local_name ? local_name : f_name(file, NULL), + recv_ok ? "completed file" : "partial file"); +- do_unlink(fnametmp); ++ do_unlink_at(fnametmp); + recv_ok = -1; + } else if (!finish_transfer(partialptr, fnametmp, fnamecmp, NULL, + file, recv_ok, !partial_dir)) +@@ -896,7 +896,7 @@ int recv_files(int f_in, int f_out, char *local_name) + } else + partialptr = NULL; + } else +- do_unlink(fnametmp); ++ do_unlink_at(fnametmp); + + cleanup_disable(); + +diff --git a/rsync.c b/rsync.c +index 05f667c..faa604f 100644 +--- a/rsync.c ++++ b/rsync.c +@@ -521,7 +521,7 @@ int set_file_attrs(const char *fname, struct file_struct *file, stat_x *sxp, + if (am_root >= 0) { + uid_t uid = change_uid ? (uid_t)F_OWNER(file) : sxp->st.st_uid; + gid_t gid = change_gid ? (gid_t)F_GROUP(file) : sxp->st.st_gid; +- if (do_lchown(fname, uid, gid) != 0) { ++ if (do_lchown_at(fname, uid, gid) != 0) { + /* We shouldn't have attempted to change uid + * or gid unless have the privilege. */ + rsyserr(FERROR_XFER, errno, "%s %s failed", +@@ -686,7 +686,7 @@ int finish_transfer(const char *fname, const char *fnametmp, + full_fname(fnametmp), fname); + if (!partialptr || (ret == -2 && temp_copy_name) + || robust_rename(fnametmp, partialptr, NULL, file->mode) < 0) +- do_unlink(fnametmp); ++ do_unlink_at(fnametmp); + return 0; + } + if (ret == 0) { +@@ -702,7 +702,7 @@ int finish_transfer(const char *fname, const char *fnametmp, + ok_to_set_time ? ATTRS_SET_NANO : ATTRS_SKIP_MTIME); + + if (temp_copy_name) { +- if (do_rename(fnametmp, fname) < 0) { ++ if (do_rename_at(fnametmp, fname) < 0) { + rsyserr(FERROR_XFER, errno, "rename %s -> \"%s\"", + full_fname(fnametmp), fname); + return 0; +diff --git a/syscall.c b/syscall.c +index 50494fe..0f7e7a6 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -77,6 +77,60 @@ int do_unlink(const char *fname) + return unlink(fname); + } + ++/* ++ Symlink-race-safe variant of do_unlink() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. unlink() resolves ++ parent components, so a parent-symlink swap can delete an outside ++ file under the daemon's authority. Defence: open the parent of path ++ under secure_relative_open() and use unlinkat() (flags=0) against ++ that dirfd. ++*/ ++int do_unlink_at(const char *path) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return unlink(path); ++ ++ if (!path || !*path || *path == '/') ++ return unlink(path); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return unlink(path); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = unlinkat(dfd, bname, 0); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_unlink(path); ++#endif ++} ++ + #ifdef SUPPORT_LINKS + int do_symlink(const char *lnk, const char *fname) + { +@@ -101,6 +155,60 @@ int do_symlink(const char *lnk, const char *fname) + return symlink(lnk, fname); + } + ++/* ++ Symlink-race-safe variant of do_symlink() for receiver-side use. ++*/ ++int do_symlink_at(const char *lnk, const char *path) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_symlink(lnk, path); ++ ++#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS ++ if (am_root < 0) ++ return do_symlink(lnk, path); ++#endif ++ ++ if (!path || !*path || *path == '/') ++ return do_symlink(lnk, path); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return do_symlink(lnk, path); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = symlinkat(lnk, dfd, bname); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_symlink(lnk, path); ++#endif ++} ++ + #if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS + ssize_t do_readlink(const char *path, char *buf, size_t bufsiz) + { +@@ -133,6 +241,89 @@ int do_link(const char *fname1, const char *fname2) + RETURN_ERROR_IF_RO_OR_LO; + return link(fname1, fname2); + } ++ ++/* ++ Symlink-race-safe variant of do_link() for receiver-side use. ++*/ ++int do_link_at(const char *old_path, const char *new_path) ++{ ++#if defined AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; ++ const char *old_bname, *new_bname; ++ const char *old_slash, *new_slash; ++ int old_dfd = AT_FDCWD, new_dfd = AT_FDCWD; ++ BOOL old_owns = False, new_owns = False; ++ int ret, e; ++ size_t old_dlen = 0, new_dlen = 0; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_link(old_path, new_path); ++ ++ if (!old_path || !*old_path || *old_path == '/' ++ || !new_path || !*new_path || *new_path == '/') ++ return do_link(old_path, new_path); ++ ++ old_slash = strrchr(old_path, '/'); ++ new_slash = strrchr(new_path, '/'); ++ ++ if (old_slash) { ++ old_dlen = old_slash - old_path; ++ if (old_dlen >= sizeof old_dirpath) { errno = ENAMETOOLONG; return -1; } ++ memcpy(old_dirpath, old_path, old_dlen); ++ old_dirpath[old_dlen] = '\0'; ++ old_bname = old_slash + 1; ++ old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (old_dfd < 0) ++ return -1; ++ old_owns = True; ++ } else { ++ old_bname = old_path; ++ } ++ ++ if (new_slash) { ++ new_dlen = new_slash - new_path; ++ if (new_dlen >= sizeof new_dirpath) { ++ e = ENAMETOOLONG; ++ if (old_owns) close(old_dfd); ++ errno = e; ++ return -1; ++ } ++ memcpy(new_dirpath, new_path, new_dlen); ++ new_dirpath[new_dlen] = '\0'; ++ new_bname = new_slash + 1; ++ if (old_owns && old_dlen == new_dlen ++ && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { ++ new_dfd = old_dfd; ++ } else { ++ new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (new_dfd < 0) { ++ e = errno; ++ if (old_owns) close(old_dfd); ++ errno = e; ++ return -1; ++ } ++ new_owns = True; ++ } ++ } else { ++ new_bname = new_path; ++ } ++ ++ ret = linkat(old_dfd, old_bname, new_dfd, new_bname, 0); ++ e = errno; ++ if (new_owns) ++ close(new_dfd); ++ if (old_owns) ++ close(old_dfd); ++ errno = e; ++ return ret; ++#else ++ return do_link(old_path, new_path); ++#endif ++} + #endif + + int do_lchown(const char *path, uid_t owner, gid_t group) +@@ -145,6 +336,66 @@ int do_lchown(const char *path, uid_t owner, gid_t group) + return lchown(path, owner, group); + } + ++/* ++ Symlink-race-safe variant of do_lchown() for receiver-side use. See the ++ comment on do_chmod_at() for the threat model and design rationale. ++ ++ Resolves the parent directory under secure_relative_open() and invokes ++ fchownat(..., AT_SYMLINK_NOFOLLOW) against that dirfd, so that an ++ attacker who substitutes a symlink into one of the parent components ++ cannot redirect the chown outside the receiver's confinement. The ++ AT_SYMLINK_NOFOLLOW flag matches lchown()'s "do not follow a final- ++ component symlink" semantics. ++ ++ Falls through to do_lchown() in the dry-run / non-daemon / chrooted / ++ absolute-path / no-parent cases, identical to do_chmod_at(). ++*/ ++int do_lchown_at(const char *fname, uid_t owner, gid_t group) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_lchown(fname, owner, group); ++ ++ if (!fname || !*fname || *fname == '/') ++ return do_lchown(fname, owner, group); ++ ++ slash = strrchr(fname, '/'); ++ if (!slash) ++ return do_lchown(fname, owner, group); ++ ++ dlen = slash - fname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, fname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = fchownat(dfd, bname, owner, group, AT_SYMLINK_NOFOLLOW); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_lchown(fname, owner, group); ++#endif ++} ++ + int do_mknod(const char *pathname, mode_t mode, dev_t dev) + { + if (dry_run) return 0; +@@ -195,6 +446,76 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) + #endif + } + ++/* ++ Symlink-race-safe variant of do_mknod() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. Defence: open ++ the parent of pathname under secure_relative_open() and use ++ mknodat() against that dirfd. mknodat() covers both regular-file ++ (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. ++ ++ Falls through to do_mknod() for fake-super (am_root < 0) and for ++ sockets, both of which use auxiliary path-based syscalls that ++ don't have an *at() variant in any portable form. ++*/ ++int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_mknod(pathname, mode, dev); ++ ++ if (am_root < 0) ++ return do_mknod(pathname, mode, dev); ++ ++#if !defined MKNOD_CREATES_SOCKETS && defined HAVE_SYS_UN_H ++ if (S_ISSOCK(mode)) ++ return do_mknod(pathname, mode, dev); ++#endif ++ ++ if (!pathname || !*pathname || *pathname == '/') ++ return do_mknod(pathname, mode, dev); ++ ++ slash = strrchr(pathname, '/'); ++ if (!slash) ++ return do_mknod(pathname, mode, dev); ++ ++ dlen = slash - pathname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, pathname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++#if !defined MKNOD_CREATES_FIFOS && defined HAVE_MKFIFO ++ if (S_ISFIFO(mode)) ++ ret = mkfifoat(dfd, bname, mode); ++ else ++#endif ++ ret = mknodat(dfd, bname, mode, dev); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_mknod(pathname, mode, dev); ++#endif ++} ++ + int do_rmdir(const char *pathname) + { + if (dry_run) return 0; +@@ -202,6 +523,57 @@ int do_rmdir(const char *pathname) + return rmdir(pathname); + } + ++/* ++ Symlink-race-safe variant of do_rmdir(). See do_unlink_at() above; ++ same shape but with AT_REMOVEDIR set to require the target be a ++ directory. ++*/ ++int do_rmdir_at(const char *pathname) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return rmdir(pathname); ++ ++ if (!pathname || !*pathname || *pathname == '/') ++ return rmdir(pathname); ++ ++ slash = strrchr(pathname, '/'); ++ if (!slash) ++ return rmdir(pathname); ++ ++ dlen = slash - pathname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, pathname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = unlinkat(dfd, bname, AT_REMOVEDIR); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_rmdir(pathname); ++#endif ++} ++ + int do_open(const char *pathname, int flags, mode_t mode) + { + if (flags != O_RDONLY) { +@@ -329,6 +701,75 @@ int do_rename(const char *fname1, const char *fname2) + return rename(fname1, fname2); + } + ++/* ++ Symlink-race-safe variant of do_rename() for receiver-side use. ++*/ ++int do_rename_at(const char *old_path, const char *new_path) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char old_dirpath[MAXPATHLEN], new_dirpath[MAXPATHLEN]; ++ const char *old_bname, *new_bname; ++ const char *old_slash, *new_slash; ++ int old_dfd = -1, new_dfd = -1, ret = -1, e; ++ size_t old_dlen, new_dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_rename(old_path, new_path); ++ ++ if (!old_path || !*old_path || *old_path == '/' ++ || !new_path || !*new_path || *new_path == '/') ++ return do_rename(old_path, new_path); ++ ++ old_slash = strrchr(old_path, '/'); ++ new_slash = strrchr(new_path, '/'); ++ if (!old_slash || !new_slash) ++ return do_rename(old_path, new_path); ++ ++ old_dlen = old_slash - old_path; ++ new_dlen = new_slash - new_path; ++ if (old_dlen >= sizeof old_dirpath || new_dlen >= sizeof new_dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(old_dirpath, old_path, old_dlen); ++ old_dirpath[old_dlen] = '\0'; ++ memcpy(new_dirpath, new_path, new_dlen); ++ new_dirpath[new_dlen] = '\0'; ++ old_bname = old_slash + 1; ++ new_bname = new_slash + 1; ++ ++ old_dfd = secure_relative_open(NULL, old_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (old_dfd < 0) ++ return -1; ++ ++ if (old_dlen == new_dlen && memcmp(old_dirpath, new_dirpath, old_dlen) == 0) { ++ new_dfd = old_dfd; ++ } else { ++ new_dfd = secure_relative_open(NULL, new_dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (new_dfd < 0) { ++ e = errno; ++ close(old_dfd); ++ errno = e; ++ return -1; ++ } ++ } ++ ++ ret = renameat(old_dfd, old_bname, new_dfd, new_bname); ++ e = errno; ++ if (new_dfd != old_dfd) ++ close(new_dfd); ++ close(old_dfd); ++ errno = e; ++ return ret; ++#else ++ return do_rename(old_path, new_path); ++#endif ++} ++ + #ifdef HAVE_FTRUNCATE + int do_ftruncate(int fd, OFF_T size) + { +@@ -371,6 +812,56 @@ int do_mkdir(char *fname, mode_t mode) + return mkdir(fname, mode); + } + ++/* ++ Symlink-race-safe variant of do_mkdir() for receiver-side use. ++*/ ++int do_mkdir_at(char *path, mode_t mode) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ trim_trailing_slashes(path); ++ ++ if (!am_daemon || am_chrooted) ++ return mkdir(path, mode); ++ ++ if (!path || !*path || *path == '/') ++ return mkdir(path, mode); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return mkdir(path, mode); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = mkdirat(dfd, bname, mode); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_mkdir(path, mode); ++#endif ++} ++ + /* like mkstemp but forces permissions */ + int do_mkstemp(char *template, mode_t perms) + { +@@ -424,6 +915,76 @@ int do_lstat(const char *fname, STRUCT_STAT *st) + #endif + } + ++/* ++ Symlink-race-safe variants of do_stat() / do_lstat() for receiver- ++ side use. See the comment on do_chmod_at() for the threat model. ++ stat() and lstat() resolve parent components, so a parent-symlink ++ swap can make the receiver's stat see attributes of a victim file ++ outside the module -- which then drives later behaviour (e.g. ++ "this isn't a directory, delete it" -> attacker-controlled unlink ++ on something outside the module). ++ ++ Defence: open the parent under secure_relative_open() and use ++ fstatat() with AT_SYMLINK_NOFOLLOW (lstat) or 0 (stat) against ++ that dirfd. Same fall-through gating as the other wrappers. ++*/ ++static int do_xstat_at(const char *path, STRUCT_STAT *st, int at_flags, int (*fallback)(const char *, STRUCT_STAT *)) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (!am_daemon || am_chrooted) ++ return fallback(path, st); ++ ++ if (!path || !*path || *path == '/') ++ return fallback(path, st); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return fallback(path, st); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = fstatat(dfd, bname, st, at_flags); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return fallback(path, st); ++#endif ++} ++ ++int do_stat_at(const char *path, STRUCT_STAT *st) ++{ ++ return do_xstat_at(path, st, 0, do_stat); ++} ++ ++int do_lstat_at(const char *path, STRUCT_STAT *st) ++{ ++#ifdef SUPPORT_LINKS ++ return do_xstat_at(path, st, AT_SYMLINK_NOFOLLOW, do_lstat); ++#else ++ return do_xstat_at(path, st, 0, do_stat); ++#endif ++} ++ + int do_fstat(int fd, STRUCT_STAT *st) + { + #ifdef USE_STAT64_FUNCS +@@ -450,12 +1011,21 @@ OFF_T do_lseek(int fd, OFF_T offset, int whence) + #ifdef HAVE_SETATTRLIST + int do_setattrlist_times(const char *fname, time_t modtime, uint32 mod_nsec) + { ++ extern int am_daemon, am_chrooted; + struct attrlist attrList; + struct timespec ts; + + if (dry_run) return 0; + RETURN_ERROR_IF_RO_OR_LO; + ++ /* setattrlist() takes a raw path and follows parent symlinks. ++ * On a daemon-no-chroot deployment, return ENOSYS so the ++ * tier walk falls through to do_utimensat_at(). */ ++ if (am_daemon && !am_chrooted) { ++ errno = ENOSYS; ++ return -1; ++ } ++ + ts.tv_sec = modtime; + ts.tv_nsec = mod_nsec; + +@@ -480,6 +1050,61 @@ int do_utimensat(const char *fname, time_t modtime, uint32 mod_nsec) + t[1].tv_nsec = mod_nsec; + return utimensat(AT_FDCWD, fname, t, AT_SYMLINK_NOFOLLOW); + } ++ ++/* ++ Symlink-race-safe variant of do_utimensat() for receiver-side use. ++*/ ++int do_utimensat_at(const char *path, time_t modtime, uint32 mod_nsec) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ struct timespec t[2]; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (dry_run) return 0; ++ RETURN_ERROR_IF_RO_OR_LO; ++ ++ if (!am_daemon || am_chrooted) ++ return do_utimensat(path, modtime, mod_nsec); ++ ++ if (!path || !*path || *path == '/') ++ return do_utimensat(path, modtime, mod_nsec); ++ ++ slash = strrchr(path, '/'); ++ if (!slash) ++ return do_utimensat(path, modtime, mod_nsec); ++ ++ dlen = slash - path; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, path, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ t[0].tv_sec = 0; ++ t[0].tv_nsec = UTIME_NOW; ++ t[1].tv_sec = modtime; ++ t[1].tv_nsec = mod_nsec; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = utimensat(dfd, bname, t, AT_SYMLINK_NOFOLLOW); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_utimensat(path, modtime, mod_nsec); ++#endif ++} + #endif + + #ifdef HAVE_LUTIMES +@@ -678,6 +1303,30 @@ int do_open_nofollow(const char *pathname, int flags) + The relpath must also not contain any ../ elements in the path. + */ + ++/* Returns 1 if path has any "/"-separated component that is exactly ++ * "..", 0 otherwise. Used by secure_relative_open's front-door ++ * validation to reject inputs that the per-component walk fallback ++ * would otherwise resolve through ".." -- e.g. bare "..", "foo/..", ++ * "subdir/.." -- which RESOLVE_BENEATH-equivalent kernels reject in ++ * the kernel but the per-component fallback (NetBSD/OpenBSD/Solaris/ ++ * Cygwin/pre-5.6 Linux) does not. */ ++static int path_has_dotdot_component(const char *path) ++{ ++ const char *p = path; ++ ++ while (*p) { ++ const char *q; ++ if (*p == '/') { p++; continue; } ++ q = p; ++ while (*q && *q != '/') ++ q++; ++ if (q - p == 2 && p[0] == '.' && p[1] == '.') ++ return 1; ++ p = q; ++ } ++ return 0; ++} ++ + #ifdef __linux__ + static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode) + { +@@ -691,10 +1340,25 @@ static int secure_relative_open_linux(const char *basedir, const char *relpath, + + if (basedir == NULL) { + dirfd = AT_FDCWD; +- } else { ++ } else if (basedir[0] == '/') { ++ /* Absolute basedir: operator-trusted (module_dir and the ++ * like). Plain openat. */ + dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); + if (dirfd == -1) + return -1; ++ } else { ++ /* Relative basedir: may be wire-influenced via ++ * --link-dest / --copy-dest / --compare-dest. Resolve it ++ * under the same RESOLVE_BENEATH guarantee as relpath, so ++ * a parent symlink on basedir cannot redirect the dirfd ++ * outside the CWD anchor. */ ++ struct open_how bhow; ++ memset(&bhow, 0, sizeof bhow); ++ bhow.flags = O_RDONLY | O_DIRECTORY; ++ bhow.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS; ++ dirfd = syscall(SYS_openat2, AT_FDCWD, basedir, &bhow, sizeof bhow); ++ if (dirfd == -1) ++ return -1; + } + + retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how); +@@ -743,10 +1407,17 @@ static int secure_relative_open_resolve_beneath(const char *basedir, const char + + if (basedir == NULL) { + dirfd = AT_FDCWD; +- } else { ++ } else if (basedir[0] == '/') { ++ /* Absolute basedir: operator-trusted, plain openat. */ + dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); + if (dirfd == -1) + return -1; ++ } else { ++ /* Relative basedir: confine its resolution beneath CWD ++ * (see secure_relative_open_linux for the rationale). */ ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY | O_RESOLVE_BENEATH); ++ if (dirfd == -1) ++ return -1; + } + + retfd = openat(dirfd, relpath, flags | O_RESOLVE_BENEATH, mode); +@@ -764,8 +1435,20 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + errno = EINVAL; + return -1; + } +- if (strncmp(relpath, "../", 3) == 0 || strstr(relpath, "/../")) { +- // no ../ elements allowed in the relpath ++ /* Reject any path with a literal ".." component (bare "..", ++ * "../foo", "foo/..", "foo/../bar", "subdir/.."). The previous ++ * substring-based check caught only "../" prefix and "/../" ++ * substring; bare ".." and trailing "/.." escape on the per- ++ * component walk fallback used by NetBSD/OpenBSD/Solaris/Cygwin ++ * and pre-5.6 Linux. RESOLVE_BENEATH on Linux/FreeBSD/macOS ++ * catches some of these in-kernel with EXDEV, but the front ++ * door must reject them consistently with EINVAL across all ++ * platforms so callers can rely on the validation. */ ++ if (path_has_dotdot_component(relpath)) { ++ errno = EINVAL; ++ return -1; ++ } ++ if (basedir && basedir[0] != '/' && path_has_dotdot_component(basedir)) { + errno = EINVAL; + return -1; + } +@@ -795,15 +1478,41 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + #else + int dirfd = AT_FDCWD; + if (basedir != NULL) { +- dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); +- if (dirfd == -1) { +- return -1; ++ if (basedir[0] == '/') { ++ /* Absolute basedir: operator-trusted, plain openat. */ ++ dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY); ++ if (dirfd == -1) { ++ return -1; ++ } ++ } else { ++ /* Relative basedir: walk it component-by-component ++ * with O_NOFOLLOW. */ ++ char *bcopy = strdup(basedir); ++ if (!bcopy) ++ return -1; ++ for (const char *part = strtok(bcopy, "/"); ++ part != NULL; ++ part = strtok(NULL, "/")) ++ { ++ int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); ++ if (next_fd == -1) { ++ int save_errno = errno; ++ if (dirfd != AT_FDCWD) close(dirfd); ++ free(bcopy); ++ errno = save_errno; ++ return -1; ++ } ++ if (dirfd != AT_FDCWD) close(dirfd); ++ dirfd = next_fd; ++ } ++ free(bcopy); + } + } + int retfd = -1; + + char *path_copy = strdup(relpath); + if (!path_copy) { ++ if (dirfd != AT_FDCWD) close(dirfd); + return -1; + } + +@@ -829,8 +1538,15 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo + dirfd = next_fd; + } + +- // the path must be a directory +- errno = EINVAL; ++ /* All components walked as directories. If the caller asked for ++ * O_DIRECTORY, return the dirfd we built up; otherwise the path ++ * resolved to a directory but the caller wanted a regular file. */ ++ if ((flags & O_DIRECTORY) && dirfd != AT_FDCWD) { ++ retfd = dirfd; ++ dirfd = AT_FDCWD; ++ goto cleanup; ++ } ++ errno = EISDIR; + + cleanup: + free(path_copy); +diff --git a/t_secure_relpath.c b/t_secure_relpath.c +new file mode 100644 +index 0000000..a0fdf0d +--- /dev/null ++++ b/t_secure_relpath.c +@@ -0,0 +1,153 @@ ++/* ++ * Test harness for secure_relative_open()'s front-door input ++ * validation. Codex audit Finding 5 noted that the existing check ++ * ++ * if (strncmp(relpath, "../", 3) == 0 || strstr(relpath, "/../")) ++ * ++ * catches "../foo" and "foo/../bar" but misses bare ".." (an actual ++ * one-level escape on platforms that fall back to the per-component ++ * walk), as well as "a/..", "foo/..", and any other form that ++ * decomposes to a ".." component when split on "/". The kernel- ++ * enforced RESOLVE_BENEATH (Linux 5.6+) and O_RESOLVE_BENEATH ++ * (FreeBSD 13+, macOS 15+) reject these in-kernel; the per- ++ * component fallback used on NetBSD, OpenBSD, Solaris, Cygwin and ++ * pre-5.6 Linux does not, so the validation must happen at the ++ * front door. ++ * ++ * This helper invokes secure_relative_open() with each suspect ++ * input and checks both the failure (rc < 0) and the errno ++ * (EINVAL means "rejected at the front door"). Pre-fix, the kernel ++ * may reject with a different errno (EXDEV from RESOLVE_BENEATH); ++ * post-fix, the front-door check catches every variant up front ++ * with a consistent EINVAL across platforms. ++ * ++ * Not linked into rsync itself. ++ */ ++ ++#include "rsync.h" ++ ++#include ++ ++int dry_run = 0; ++int am_root = 0; ++int am_sender = 0; ++int read_only = 0; ++int list_only = 0; ++int copy_links = 0; ++int copy_unsafe_links = 0; ++int preserve_perms = 0; ++int preserve_executability = 0; ++extern int am_daemon, am_chrooted; ++ ++short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG]; ++ ++static int errs = 0; ++ ++static void check_relpath(const char *relpath) ++{ ++ int fd; ++ int saved_errno; ++ ++ errno = 0; ++ fd = secure_relative_open(NULL, relpath, O_RDONLY | O_DIRECTORY, 0); ++ saved_errno = errno; ++ ++ if (fd >= 0) { ++ fprintf(stderr, ++ "FAIL [relpath=%-12s]: returned valid fd %d (escape) -- expected -1 EINVAL\n", ++ relpath, fd); ++ close(fd); ++ errs++; ++ return; ++ } ++ ++ if (saved_errno != EINVAL) { ++ fprintf(stderr, ++ "FAIL [relpath=%-12s]: rejected but errno=%d (%s), expected EINVAL\n", ++ relpath, saved_errno, strerror(saved_errno)); ++ errs++; ++ return; ++ } ++ ++ fprintf(stderr, "OK [relpath=%-12s]: rejected with EINVAL\n", relpath); ++} ++ ++static void check_basedir(const char *basedir) ++{ ++ int fd; ++ int saved_errno; ++ ++ errno = 0; ++ fd = secure_relative_open(basedir, "ok", O_RDONLY | O_DIRECTORY, 0); ++ saved_errno = errno; ++ ++ if (fd >= 0) { ++ fprintf(stderr, ++ "FAIL [basedir=%-12s]: returned valid fd %d -- expected -1 EINVAL\n", ++ basedir, fd); ++ close(fd); ++ errs++; ++ return; ++ } ++ ++ if (saved_errno != EINVAL) { ++ fprintf(stderr, ++ "FAIL [basedir=%-12s]: rejected but errno=%d (%s), expected EINVAL\n", ++ basedir, saved_errno, strerror(saved_errno)); ++ errs++; ++ return; ++ } ++ ++ fprintf(stderr, "OK [basedir=%-12s]: rejected with EINVAL\n", basedir); ++} ++ ++int main(int argc, char **argv) ++{ ++ if (argc != 2) { ++ fprintf(stderr, "usage: %s \n", argv[0]); ++ return 2; ++ } ++ if (chdir(argv[1]) < 0) { ++ perror("chdir"); ++ return 2; ++ } ++ ++ /* secure_relative_open's daemon-only confinement protections only ++ * fire when am_daemon && !am_chrooted (the threat model is the ++ * daemon-no-chroot deployment), but the front-door input ++ * validation runs unconditionally. We set am_daemon anyway so the ++ * helper exercises the same code shape the receiver does. */ ++ am_daemon = 1; ++ am_chrooted = 0; ++ ++ mkdir("subdir", 0755); ++ ++ /* Each of these relpaths must be rejected with EINVAL at the ++ * secure_relative_open() front door. ".." is the actual one-level ++ * escape; the others ("subdir/..", "subdir/../subdir") resolve ++ * back to the start dir on systems that allow them, but we still ++ * reject them as defence-in-depth: a path containing a ".." token ++ * is suspicious and the caller should normalise before passing ++ * it in. The "../foo" / "foo/../bar" / "/foo" / "/" cases are ++ * regression checks for the existing checks. */ ++ check_relpath(".."); ++ check_relpath("../foo"); ++ check_relpath("subdir/.."); ++ check_relpath("subdir/../subdir"); ++ check_relpath("foo/../bar"); ++ check_relpath("/foo"); ++ check_relpath("/"); ++ ++ /* Same checks against basedir (which the codex Finding 2 fix ++ * routes through the same RESOLVE_BENEATH-equivalent). Absolute ++ * basedirs are operator-trusted and intentionally not validated ++ * here. */ ++ check_basedir(".."); ++ check_basedir("../subdir"); ++ check_basedir("subdir/.."); ++ check_basedir("foo/../bar"); ++ ++ if (errs) ++ fprintf(stderr, "\n%d failure(s)\n", errs); ++ return errs ? 1 : 0; ++} +diff --git a/testsuite/alt-dest-symlink-race.test b/testsuite/alt-dest-symlink-race.test +new file mode 100755 +index 0000000..2256f2f +--- /dev/null ++++ b/testsuite/alt-dest-symlink-race.test +@@ -0,0 +1,96 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for the basedir-confinement gap in ++# secure_relative_open(). The function opens basedir with a plain ++# openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY), without ++# RESOLVE_BENEATH or a per-component O_NOFOLLOW walk, so a parent ++# symlink ON basedir is followed unrestrictedly. RESOLVE_BENEATH is ++# then applied only to relpath, anchored at the wrong directory. ++# ++# The receiver's basis-file lookup at receiver.c passes ++# basis_dir[fnamecmp_type] (from --copy-dest / --link-dest / ++# --compare-dest -- all sender-controllable in daemon mode) as ++# basedir. A daemon-module attacker with write access can plant a ++# symlink at module/cd -> /outside, then run --link-dest=cd to ++# make the daemon's basis-file lookup resolve into /outside, ++# leaking the contents of daemon-readable files via the rsync ++# delta-rolling read-disclosure primitive. ++# ++# We detect the escape by leveraging --link-dest: when basis ++# matches source exactly (content + mtime + mode), --link-dest ++# hard-links the destination to the basis file. With the bug, the ++# destination ends up as a hard link to the outside-the-module ++# file (same inode). With the fix, no basis is found and the ++# destination is a fresh copy (different inode). ++# ++# The vulnerable code path is the same on every platform ++# (including the per-component fallback on systems without ++# RESOLVE_BENEATH), so this test is not platform-gated. ++ ++. "$suitedir/rsync.fns" ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" "$src" ++mkdir -p "$mod" "$outside" "$src" ++ ++# Portable inode-number helper (GNU coreutils stat -c, BSD stat -f). ++file_inode() { ++ stat -c %i "$1" 2>/dev/null || stat -f %i "$1" ++} ++ ++# Outside-the-module file an attacker would like the daemon to ++# treat as a basis. ++echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt" ++chmod 0644 "$outside/target.txt" ++ ++# The symlink trap planted in the module by the local attacker. ++ln -s "$outside" "$mod/cd" ++ ++# Source file matches outside/target.txt exactly (content + mtime ++# + mode) so --link-dest will hard-link the destination to the ++# basis file iff the daemon's basedir lookup reaches outside/. ++echo "OUTSIDE_SECRET_DATA" > "$src/target.txt" ++touch -r "$outside/target.txt" "$src/target.txt" ++chmod 0644 "$src/target.txt" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++if [ ! -f "$mod/target.txt" ]; then ++ test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour" ++fi ++ ++outside_inode=$(file_inode "$outside/target.txt") ++dst_inode=$(file_inode "$mod/target.txt") ++ ++if [ "$outside_inode" = "$dst_inode" ]; then ++ test_fail "basedir-escape: --link-dest hard-linked module/target.txt to outside/target.txt (inode $outside_inode); daemon's basis-file lookup followed the parent symlink on the basedir" ++fi ++ ++exit 0 +diff --git a/testsuite/secure-relpath-validation.test b/testsuite/secure-relpath-validation.test +new file mode 100755 +index 0000000..5b77f7c +--- /dev/null ++++ b/testsuite/secure-relpath-validation.test +@@ -0,0 +1,34 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex audit Finding 5: secure_relative_open()'s ++# front-door input check rejects "../foo" and "foo/../bar" but ++# misses bare "..", "subdir/..", and other variants whose "/"-split ++# components contain a literal "..". The kernel-enforced ++# RESOLVE_BENEATH (Linux 5.6+) and O_RESOLVE_BENEATH ++# (FreeBSD 13+, macOS 15+) reject these in-kernel; the per-component ++# walk fallback used on NetBSD, OpenBSD, Solaris, Cygwin and pre-5.6 ++# Linux does not -- so the validation must happen at the front door. ++# ++# This test invokes the t_secure_relpath helper, which calls ++# secure_relative_open() with each suspect input and verifies the ++# return value is -1 with errno == EINVAL. EINVAL is the marker ++# that the front-door rejected the input, not the kernel; pre-fix ++# the kernel returns -1 with EXDEV (or, on the per-component ++# fallback, may return a valid fd at all -- "escape"). ++ ++. "$suitedir/rsync.fns" ++ ++testdir="$scratchdir/relpath-test" ++rm -rf "$testdir" ++mkdir -p "$testdir" ++ ++if ! "$TOOLDIR/t_secure_relpath" "$testdir"; then ++ test_fail "t_secure_relpath rejected one or more inputs incorrectly (see stderr above for the specific case)" ++fi ++ ++exit 0 +diff --git a/util.c b/util.c +index eabb5b7..59b27d7 100644 +--- a/util.c ++++ b/util.c +@@ -140,7 +140,7 @@ int set_modtime(const char *fname, time_t modtime, uint32 mod_nsec, mode_t mode) + + #ifdef HAVE_UTIMENSAT + #include "case_N.h" +- if (do_utimensat(fname, modtime, mod_nsec) == 0) ++ if (do_utimensat_at(fname, modtime, mod_nsec) == 0) + break; + if (errno != ENOSYS) + return -1; +@@ -448,13 +448,13 @@ int copy_file(const char *source, const char *dest, int ofd, mode_t mode) + int robust_unlink(const char *fname) + { + #ifndef ETXTBSY +- return do_unlink(fname); ++ return do_unlink_at(fname); + #else + static int counter = 1; + int rc, pos, start; + char path[MAXPATHLEN]; + +- rc = do_unlink(fname); ++ rc = do_unlink_at(fname); + if (rc == 0 || errno != ETXTBSY) + return rc; + +@@ -484,7 +484,7 @@ int robust_unlink(const char *fname) + } + + /* maybe we should return rename()'s exit status? Nah. */ +- if (do_rename(fname, path) != 0) { ++ if (do_rename_at(fname, path) != 0) { + errno = ETXTBSY; + return -1; + } +@@ -502,7 +502,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, + int tries = 4; + + while (tries--) { +- if (do_rename(from, to) == 0) ++ if (do_rename_at(from, to) == 0) + return 0; + + switch (errno) { +@@ -523,7 +523,7 @@ int robust_rename(const char *from, const char *to, const char *partialptr, + } + if (copy_file(from, to, -1, mode) != 0) + return -2; +- do_unlink(from); ++ do_unlink_at(from); + return 1; + default: + return -1; +@@ -1304,20 +1304,20 @@ int handle_partial_dir(const char *fname, int create) + dir = partial_fname; + if (create) { + STRUCT_STAT st; +- int statret = do_lstat(dir, &st); ++ int statret = do_lstat_at(dir, &st); + if (statret == 0 && !S_ISDIR(st.st_mode)) { +- if (do_unlink(dir) < 0) { ++ if (do_unlink_at(dir) < 0) { + *fn = '/'; + return 0; + } + statret = -1; + } +- if (statret < 0 && do_mkdir(dir, 0700) < 0) { ++ if (statret < 0 && do_mkdir_at(dir, 0700) < 0) { + *fn = '/'; + return 0; + } + } else +- do_rmdir(dir); ++ do_rmdir_at(dir); + *fn = '/'; + + return 1; +diff --git a/xattrs.c b/xattrs.c +index 9afef2f..16fc322 100644 +--- a/xattrs.c ++++ b/xattrs.c +@@ -1308,7 +1308,12 @@ int set_stat_xattr(const char *fname, struct file_struct *file, mode_t new_mode) + + int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) + { +- int ret = do_stat(fname, fst); ++ /* Use the *_at variants so that on a daemon-no-chroot deployment ++ * the metadata read goes through a secure parent dirfd instead ++ * of bare path resolution. The *_at wrappers fall through to ++ * plain do_stat outside the daemon-no-chroot context, so this ++ * change is transparent for non-daemon use. */ ++ int ret = do_stat_at(fname, fst); + if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) + xst->st_mode = 0; + return ret; +@@ -1316,7 +1321,7 @@ int x_stat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) + + int x_lstat(const char *fname, STRUCT_STAT *fst, STRUCT_STAT *xst) + { +- int ret = do_lstat(fname, fst); ++ int ret = do_lstat_at(fname, fst); + if ((ret < 0 || get_stat_xattr(fname, -1, fst, xst) < 0) && xst) + xst->st_mode = 0; + return ret; +-- +2.52.0 + + +From c7183418cc193fd142b9984bd3475932caef4736 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 6 May 2026 09:45:30 +1000 +Subject: [PATCH 8/8] util1+syscall: secure copy_file source/dest opens; + bare-path defence-in-depth + +Three related codex audit findings: + + Finding 3a: copy_file()'s source open in util.c used + do_open_nofollow(), which only rejects a final-component + symlink. A parent-component symlink (e.g. --copy-dest=cd where + cd -> /outside) follows freely and reads outside the module. + Route through secure_relative_open() with O_NOFOLLOW. + + Finding 3b: generator.c's in-place backup-file create still + used a bare do_open with O_CREAT, leaving a tiny but reachable + parent-symlink window between the secure unlink (already + through do_unlink_at) and the create. Add do_open_at() that + goes through a secure parent dirfd, and route the call site + through it. + + Finding 3c: copy_file()'s destination open in + unlink_and_reopen() had the same bare-do_open pattern; route + through do_open_at as well. + +Adds testsuite/copy-dest-source-symlink.test and +testsuite/bare-do-open-symlink-race.test as regression coverage +for both attack shapes. + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + generator.c | 2 +- + syscall.c | 138 +++++++++++++++-- + testsuite/bare-do-open-symlink-race.test | 186 +++++++++++++++++++++++ + testsuite/copy-dest-source-symlink.test | 83 ++++++++++ + util.c | 14 +- + 5 files changed, 409 insertions(+), 14 deletions(-) + create mode 100755 testsuite/bare-do-open-symlink-race.test + create mode 100755 testsuite/copy-dest-source-symlink.test + +diff --git a/generator.c b/generator.c +index a157414..decddbe 100644 +--- a/generator.c ++++ b/generator.c +@@ -1860,7 +1860,7 @@ static void recv_generator(char *fname, struct file_struct *file, int ndx, + close(fd); + goto cleanup; + } +- if ((f_copy = do_open(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { ++ if ((f_copy = do_open_at(backupptr, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, 0600)) < 0) { + rsyserr(FERROR_XFER, errno, "open %s", full_fname(backupptr)); + unmake_file(back_file); + back_file = NULL; +diff --git a/syscall.c b/syscall.c +index 0f7e7a6..1dc7d1c 100644 +--- a/syscall.c ++++ b/syscall.c +@@ -174,11 +174,6 @@ int do_symlink_at(const char *lnk, const char *path) + if (!am_daemon || am_chrooted) + return do_symlink(lnk, path); + +-#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS +- if (am_root < 0) +- return do_symlink(lnk, path); +-#endif +- + if (!path || !*path || *path == '/') + return do_symlink(lnk, path); + +@@ -199,6 +194,34 @@ int do_symlink_at(const char *lnk, const char *path) + if (dfd < 0) + return -1; + ++#if defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS ++ /* For --fake-super, do_symlink writes the link target into a ++ * regular file rather than creating a real symlink. Do that ++ * here against the secure dirfd, with O_NOFOLLOW so a pre- ++ * planted symlink at the basename can't redirect the file ++ * creation. (Previously the fake-super branch fell through to ++ * the bare-path do_symlink at the top of the function.) */ ++ if (am_root < 0) { ++ int len = strlen(lnk); ++ int fd = openat(dfd, bname, ++ O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, ++ S_IWUSR | S_IRUSR); ++ if (fd < 0) { ++ e = errno; ++ close(dfd); ++ errno = e; ++ return -1; ++ } ++ ret = (write(fd, lnk, len) == len) ? 0 : -1; ++ if (close(fd) < 0) ++ ret = -1; ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++ } ++#endif ++ + ret = symlinkat(lnk, dfd, bname); + e = errno; + close(dfd); +@@ -453,9 +476,12 @@ int do_mknod(const char *pathname, mode_t mode, dev_t dev) + mknodat() against that dirfd. mknodat() covers both regular-file + (S_IFREG with dev=0) and FIFO (S_IFIFO) and device-node creation. + +- Falls through to do_mknod() for fake-super (am_root < 0) and for +- sockets, both of which use auxiliary path-based syscalls that +- don't have an *at() variant in any portable form. ++ Fake-super (am_root < 0) is handled inline against the secure ++ parent dirfd: it creates a regular empty file (the same file-as- ++ metadata-placeholder pattern do_mknod uses) via openat() with ++ O_NOFOLLOW. Sockets fall through to do_mknod() because their ++ bind(2) takes a path argument with no portable bindat() variant; ++ this is documented as a residual. + */ + int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) + { +@@ -473,9 +499,6 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) + if (!am_daemon || am_chrooted) + return do_mknod(pathname, mode, dev); + +- if (am_root < 0) +- return do_mknod(pathname, mode, dev); +- + #if !defined MKNOD_CREATES_SOCKETS && defined HAVE_SYS_UN_H + if (S_ISSOCK(mode)) + return do_mknod(pathname, mode, dev); +@@ -501,6 +524,29 @@ int do_mknod_at(const char *pathname, mode_t mode, dev_t dev) + if (dfd < 0) + return -1; + ++ if (am_root < 0) { ++ /* For --fake-super, do_mknod creates a regular empty ++ * file as a placeholder for the special-file metadata ++ * (which is stored in xattrs elsewhere). Do that against ++ * the secure dirfd, with O_NOFOLLOW so a pre-planted ++ * symlink at the basename can't redirect the file ++ * creation. */ ++ int fd = openat(dfd, bname, ++ O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW, ++ S_IWUSR | S_IRUSR); ++ if (fd < 0) { ++ e = errno; ++ close(dfd); ++ errno = e; ++ return -1; ++ } ++ ret = (close(fd) < 0) ? -1 : 0; ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++ } ++ + #if !defined MKNOD_CREATES_FIFOS && defined HAVE_MKFIFO + if (S_ISFIFO(mode)) + ret = mkfifoat(dfd, bname, mode); +@@ -584,6 +630,71 @@ int do_open(const char *pathname, int flags, mode_t mode) + return open(pathname, flags | O_BINARY, mode); + } + ++/* ++ Symlink-race-safe variant of do_open() for receiver-side use. See ++ the comment on do_chmod_at() for the threat model. open() resolves ++ parent components, so a parent-symlink swap can redirect the open ++ to a file outside the module. This wrapper is defence-in-depth for ++ bare-path do_open() sites that callers know are otherwise ++ protected by secure parent-syscalls (e.g. generator.c's in-place ++ backup creation, where robust_unlink() rejects the symlinked ++ parent before this open is reached): if any of those upstream ++ protections is later removed or regresses, the open here still ++ refuses to escape the module. ++ ++ Defence: open the parent of pathname under secure_relative_open() ++ and call openat() against the resulting dirfd with O_NOFOLLOW ++ (so the basename itself isn't followed if it happens to be a ++ pre-planted symlink, which is what we want for O_CREAT|O_EXCL). ++*/ ++int do_open_at(const char *pathname, int flags, mode_t mode) ++{ ++#ifdef AT_FDCWD ++ extern int am_daemon, am_chrooted; ++ char dirpath[MAXPATHLEN]; ++ const char *bname; ++ const char *slash; ++ int dfd, ret, e; ++ size_t dlen; ++ ++ if (flags != O_RDONLY) { ++ RETURN_ERROR_IF(dry_run, 0); ++ RETURN_ERROR_IF_RO_OR_LO; ++ } ++ ++ if (!am_daemon || am_chrooted) ++ return do_open(pathname, flags, mode); ++ ++ if (!pathname || !*pathname || *pathname == '/') ++ return do_open(pathname, flags, mode); ++ ++ slash = strrchr(pathname, '/'); ++ if (!slash) ++ return do_open(pathname, flags, mode); ++ ++ dlen = slash - pathname; ++ if (dlen >= sizeof dirpath) { ++ errno = ENAMETOOLONG; ++ return -1; ++ } ++ memcpy(dirpath, pathname, dlen); ++ dirpath[dlen] = '\0'; ++ bname = slash + 1; ++ ++ dfd = secure_relative_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0); ++ if (dfd < 0) ++ return -1; ++ ++ ret = openat(dfd, bname, flags | O_NOFOLLOW | O_BINARY, mode); ++ e = errno; ++ close(dfd); ++ errno = e; ++ return ret; ++#else ++ return do_open(pathname, flags, mode); ++#endif ++} ++ + #ifdef HAVE_CHMOD + int do_chmod(const char *path, mode_t mode) + { +diff --git a/testsuite/bare-do-open-symlink-race.test b/testsuite/bare-do-open-symlink-race.test +new file mode 100755 +index 0000000..b8c51bb +--- /dev/null ++++ b/testsuite/bare-do-open-symlink-race.test +@@ -0,0 +1,186 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex audit Findings 3b and 3c: ++# ++# 3b: generator.c:1905 -- the in-place backup creation opens ++# backupptr via bare do_open(O_WRONLY|O_CREAT|O_TRUNC|O_EXCL). ++# With --backup-dir set to an attacker-planted parent symlink, ++# the backup file is written outside the module under the ++# daemon's authority. ++# ++# 3c-symlink: syscall.c:207 -- do_symlink_at falls through to bare ++# do_symlink for am_root < 0 (fake-super), which then opens ++# the destination path with bare open() (final-component ++# fake-super file). A parent symlink on the destination path ++# redirects the file creation outside the module. ++# ++# 3c-mknod: syscall.c:506 -- do_mknod_at falls through to bare ++# do_mknod for am_root < 0, same path-based open(). For ++# FIFOs/sockets/devices the bare path is also used. ++# ++# Each scenario plants a "secret" file outside the module at a ++# location the symlink trap points to. The check is that the ++# outside file's content and mode are unchanged after the attack ++# attempt. ++ ++. "$suitedir/rsync.fns" ++ ++# All three scenarios depend on receiver-side daemon code paths ++# that are only secured on platforms with a working ++# secure_relative_open. The chdir/chmod tests already skip the ++# same set; mirror that. ++case "$(uname -s)" in ++ SunOS|OpenBSD|NetBSD|CYGWIN*) ++ test_skipped "secure_relative_open relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)" ++ ;; ++esac ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++# Portable inode-and-mode helpers. ++file_mode() { ++ stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" ++} ++ ++setup() { ++ rm -rf "$mod" "$outside" "$src" ++ mkdir -p "$mod" "$outside" "$src" ++ ++ echo "OUTSIDE_PROTECTED_DATA" > "$outside/target.txt" ++ chmod 0644 "$outside/target.txt" ++ outside_pristine="$scratchdir/outside-pristine.txt" ++ cp -p "$outside/target.txt" "$outside_pristine" ++ ++ ln -s "$outside" "$mod/cd" ++} ++ ++verify_outside_unchanged() { ++ label="$1" ++ mode=$(file_mode "$outside/target.txt") ++ case "$mode" in ++ 644|0644) ;; ++ *) test_fail "$label: outside/target.txt mode changed from 644 to $mode" ;; ++ esac ++ if ! cmp -s "$outside/target.txt" "$outside_pristine"; then ++ test_fail "$label: outside/target.txt content changed -- daemon followed the cd symlink" ++ fi ++} ++ ++verify_outside_unchanged_or_absent() { ++ label="$1" ++ target="$2" # specific file under outside/ to check absence of ++ if [ -e "$outside/$target" ]; then ++ test_fail "$label: outside/$target was created -- daemon followed the cd symlink" ++ fi ++} ++ ++ ++############################################################ ++# Scenario 3b: --inplace --backup --backup-dir=cd ++# ++# Pre-create module/target.txt so the receiver enters the in-place ++# update path; a backup of the existing content must be made ++# before the update. With --backup-dir=cd, backupptr resolves to ++# "cd/target.txt"; with the bug, robust_unlink and the bare ++# do_open at generator.c:1905 both follow the cd symlink, the ++# unlink deletes outside/target.txt and the create writes the ++# pre-existing module/target.txt content there. ++############################################################ ++ ++setup ++echo "EXISTING_MODULE_DATA" > "$mod/target.txt" ++chmod 0666 "$mod/target.txt" ++echo "NEW_DATA_FROM_SENDER" > "$src/target.txt" ++chmod 0644 "$src/target.txt" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++verify_outside_unchanged "3b inplace+backup-dir=cd" ++ ++ ++############################################################ ++# Scenario 3c-symlink: fake-super symlink push to a path with a ++# symlinked parent ++# ++# With "fake super = yes" set on the module, the receiver ++# represents symlinks as fake-super files (regular files with the ++# link target written to them). The path-based open() in ++# do_symlink's fake-super branch follows parent symlinks. We push ++# a single symlink to the destination path "cd/sym" so the ++# receiver's create-file call lands at "cd/sym" relative to the ++# module root, where cd is the symlink trap. ++############################################################ ++ ++setup ++ ++mkdir -p "$src/cd" ++ln -s /etc/passwd "$src/cd/sym" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++verify_outside_unchanged_or_absent "3c-symlink fake-super symlink push" "sym" ++ ++ ++############################################################ ++# Scenario 3c-mknod: fake-super FIFO push to a path with a ++# symlinked parent ++# ++# Similar to 3c-symlink but for special files. mkfifo works ++# without root; we push a FIFO and verify the receiver doesn't ++# create a fake-super file at outside/fifo. ++############################################################ ++ ++setup ++ ++mkdir -p "$src/cd" ++mkfifo "$src/cd/fifo" 2>/dev/null ++if [ ! -p "$src/cd/fifo" ]; then ++ test_skipped "mkfifo unavailable; cannot exercise 3c-mknod" ++fi ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++verify_outside_unchanged_or_absent "3c-mknod fake-super FIFO push" "fifo" ++ ++exit 0 +diff --git a/testsuite/copy-dest-source-symlink.test b/testsuite/copy-dest-source-symlink.test +new file mode 100755 +index 0000000..2d20fab +--- /dev/null ++++ b/testsuite/copy-dest-source-symlink.test +@@ -0,0 +1,83 @@ ++#!/bin/sh ++ ++# Copyright (C) 2026 by Andrew Tridgell ++ ++# This program is distributable under the terms of the GNU GPL (see ++# COPYING). ++ ++# Regression test for codex audit Finding 3a: copy_file()'s source ++# open in copy_altdest_file() is via do_open_nofollow(), which only ++# refuses a final-component symlink. Parent components are still ++# resolved with normal symlink-following. A daemon module attacker ++# who plants a parent symlink at module/cd -> /outside, then runs ++# --copy-dest=cd against a source file matching the size+mtime of ++# /outside/target.txt, drives the receiver to: ++# ++# 1. Find a match-level >= 2 basis at "cd/target.txt" ++# 2. Call copy_altdest_file -> copy_file(src="cd/target.txt", ...) ++# 3. do_open_nofollow follows the "cd" parent symlink and reads ++# the contents of /outside/target.txt under the daemon's ++# authority ++# 4. Copy that content into the module destination ++# ++# Result: outside/target.txt content lands at module/target.txt, ++# accessible to the attacker on a subsequent pull. ++# ++# We detect by content: src/target.txt and outside/target.txt have ++# identical metadata (size + mtime + mode) but different content. ++# After the transfer, module/target.txt should match src (no ++# basedir escape) -- if it matches outside, the bug copied across ++# the symlink boundary. ++ ++. "$suitedir/rsync.fns" ++ ++mod="$scratchdir/module" ++outside="$scratchdir/outside" ++src="$scratchdir/src" ++conf="$scratchdir/test-rsyncd.conf" ++ ++rm -rf "$mod" "$outside" "$src" ++mkdir -p "$mod" "$outside" "$src" ++ ++# Outside-the-module file the daemon should not read on the ++# attacker's behalf. ++echo "OUTSIDE_LEAKED_DATA!" > "$outside/target.txt" ++chmod 0644 "$outside/target.txt" ++ ++# The symlink trap. ++ln -s "$outside" "$mod/cd" ++ ++# Source: same size, same mtime, same mode as outside -- so the ++# generator's link_stat + quick_check_ok finds a match-level >= 2 ++# basis and calls copy_altdest_file. ++echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt" ++touch -r "$outside/target.txt" "$src/target.txt" ++chmod 0644 "$src/target.txt" ++ ++cat > "$conf" </dev/null 2>&1 || true ++ ++if [ ! -f "$mod/target.txt" ]; then ++ test_fail "destination file was not created -- daemon transfer failed before the test could observe the basedir behaviour" ++fi ++ ++if cmp -s "$mod/target.txt" "$outside/target.txt"; then ++ test_fail "basedir-escape via copy_file source: module/target.txt now contains the contents of outside/target.txt -- daemon read /outside via the cd symlink and copied it into the module" ++fi ++ ++if ! cmp -s "$mod/target.txt" "$src/target.txt"; then ++ test_fail "destination doesn't match source content (and isn't outside content either): unexpected state" ++fi ++ ++exit 0 +diff --git a/util.c b/util.c +index 59b27d7..5199da8 100644 +--- a/util.c ++++ b/util.c +@@ -330,12 +330,20 @@ static int safe_read(int desc, char *ptr, size_t len) + * --copy-dest options. */ + int copy_file(const char *source, const char *dest, int ofd, mode_t mode) + { ++ extern int am_daemon, am_chrooted; + int ifd; + char buf[1024 * 8]; + int len; /* Number of bytes read into `buf'. */ + OFF_T prealloc_len = 0, offset = 0; + +- if ((ifd = do_open_nofollow(source, O_RDONLY)) < 0) { ++ /* On a daemon without chroot, route the source open through ++ * secure_relative_open so a parent-symlink on the source path ++ * cannot redirect the read outside the module. */ ++ if (am_daemon && !am_chrooted && source && *source && source[0] != '/') ++ ifd = secure_relative_open(NULL, source, O_RDONLY | O_NOFOLLOW, 0); ++ else ++ ifd = do_open_nofollow(source, O_RDONLY); ++ if (ifd < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, errno, "open %s", full_fname(source)); + errno = save_errno; +@@ -356,7 +364,9 @@ int copy_file(const char *source, const char *dest, int ofd, mode_t mode) + mode |= S_IWUSR; + #endif + mode &= INITACCESSPERMS; +- if ((ofd = do_open(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { ++ /* Use do_open_at so the create/truncate goes through a secure ++ * parent dirfd in the daemon-no-chroot deployment. */ ++ if ((ofd = do_open_at(dest, O_WRONLY | O_CREAT | O_TRUNC | O_EXCL, mode)) < 0) { + int save_errno = errno; + rsyserr(FERROR_XFER, save_errno, "open %s", full_fname(dest)); + close(ifd); +-- +2.52.0 +diff --git a/Makefile.in b/Makefile.in +index 9fa3995..37a32cd 100644 +--- a/Makefile.in ++++ b/Makefile.in +@@ -46,7 +46,7 @@ popt_OBJS=popt/findme.o popt/popt.o popt/poptconfig.o \ + popt/popthelp.o popt/poptparse.o + OBJS=$(OBJS1) $(OBJS2) $(OBJS3) $(DAEMON_OBJ) $(LIBOBJ) @BUILD_ZLIB@ @BUILD_POPT@ + +-TLS_OBJ = tls.o syscall.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ ++TLS_OBJ = tls.o syscall.o t_stub.o lib/compat.o lib/snprintf.o lib/permstring.o lib/sysxattrs.o @BUILD_POPT@ + + # Programs we must have to run the test cases + CHECK_PROGS = rsync$(EXEEXT) tls$(EXEEXT) getgroups$(EXEEXT) getfsdev$(EXEEXT) \ +@@ -129,7 +129,7 @@ getgroups$(EXEEXT): getgroups.o + getfsdev$(EXEEXT): getfsdev.o + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ getfsdev.o $(LIBS) + +-TRIMSLASH_OBJ = trimslash.o syscall.o lib/compat.o lib/snprintf.o ++TRIMSLASH_OBJ = trimslash.o syscall.o t_stub.o lib/compat.o lib/snprintf.o + trimslash$(EXEEXT): $(TRIMSLASH_OBJ) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(TRIMSLASH_OBJ) $(LIBS) + +diff --git a/tls.c b/tls.c +index 5105774..4a2b372 100644 +--- a/tls.c ++++ b/tls.c +@@ -44,8 +44,6 @@ + int dry_run = 0; + int am_root = 0; + int am_sender = 1; +-int am_daemon = 0; +-int am_chrooted = 0; + int read_only = 1; + int list_only = 0; + int link_times = 0; +@@ -53,8 +51,6 @@ int link_owner = 0; + int nsec_times = 0; + int preserve_perms = 0; + int preserve_executability = 0; +-int preallocate_files = 0; +-int inplace = 0; + int safe_symlinks = 0; + int copy_links = 0; + int copy_unsafe_links = 0; +diff --git a/trimslash.c b/trimslash.c +index 5881e24..cf58dd8 100644 +--- a/trimslash.c ++++ b/trimslash.c +@@ -23,15 +23,11 @@ + /* These are to make syscall.o shut up. */ + int dry_run = 0; + int am_root = 0; +-int am_daemon = 0; +-int am_chrooted = 0; + int am_sender = 1; + int read_only = 1; + int list_only = 0; + int preserve_perms = 0; + int preserve_executability = 0; +-int preallocate_files = 0; +-int inplace = 0; + int copy_links = 0; + int copy_unsafe_links = 0; + diff --git a/SOURCES/rsync-3.1.3-fix-cve-2026-43618.patch b/SOURCES/rsync-3.1.3-fix-cve-2026-43618.patch new file mode 100644 index 0000000..272687b --- /dev/null +++ b/SOURCES/rsync-3.1.3-fix-cve-2026-43618.patch @@ -0,0 +1,174 @@ +From 9ec57dd61762175a5fef11b66f36cbe6bd451178 Mon Sep 17 00:00:00 2001 +From: Andrew Tridgell +Date: Wed, 29 Apr 2026 11:10:59 +1000 +Subject: [PATCH] token: harden compressed-token decoding against integer + overflow + +The receiver's three compressed-token decoders -- +recv_deflated_token (zlib), recv_zstd_token, and +recv_compressed_token (lz4) -- accumulated rx_token (a 32-bit +signed counter) without overflow checking. A malicious sender +could craft a compressed-token stream that walked rx_token past +INT32_MAX, with careful manipulation leaking process memory +contents to the wire (environment variables, passwords, heap +pointers, library pointers -- significantly weakening ASLR +and facilitating further exploitation). + +Cap rx_token at MAX_TOKEN_INDEX = 0x7ffffffe. Fold the +bookkeeping into recv_compressed_token_num() and +recv_compressed_token_run() shared by all three decoders. Reject +negative or out-of-range token values explicitly. Also cap the +simple_recv_token literal-block length at the source: any +wire-supplied length > CHUNK_SIZE is ill-formed (the matching +simple_send_token never writes a chunk larger than CHUNK_SIZE), +so reject before looping on attacker-controlled bytes. + +Reach: an authenticated daemon connection with compression +enabled (the default for protocols >= 30 when both peers +advertise it). Disabling compression on the daemon +("refuse options = compress" in rsyncd.conf) is the available +workaround. + +Reporter: Omar Elsayed (seks99x). + +Co-Authored-By: Claude Opus 4.7 (1M context) +--- + receiver.c | 11 ++++++++- + token.c | 68 ++++++++++++++++++++++++++++++++++++++++++------------ + 2 files changed, 63 insertions(+), 16 deletions(-) + +diff --git a/receiver.c b/receiver.c +index 6044836..59f9759 100644 +--- a/receiver.c ++++ b/receiver.c +@@ -305,7 +305,12 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + } + } + +- while ((i = recv_token(f_in, &data)) != 0) { ++ while (1) { ++ data = NULL; ++ i = recv_token(f_in, &data); ++ if (i == 0) ++ break; ++ + if (INFO_GTE(PROGRESS, 1)) + show_progress(offset, total_size); + +@@ -313,6 +318,10 @@ static int receive_data(int f_in, char *fname_r, int fd_r, OFF_T size_r, + maybe_send_keepalive(time(NULL), MSK_ALLOW_FLUSH | MSK_ACTIVE_RECEIVER); + + if (i > 0) { ++ if (!data) { ++ rprintf(FERROR, "Invalid literal token with no data [%s]\n", who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + if (DEBUG_GTE(DELTASUM, 3)) { + rprintf(FINFO,"data recv %d at %s\n", + i, big_num(offset)); +diff --git a/token.c b/token.c +index f1299ee..fb722a5 100644 +--- a/token.c ++++ b/token.c +@@ -228,6 +228,14 @@ static int32 simple_recv_token(int f, char **data) + int32 i = read_int(f); + if (i <= 0) + return i; ++ /* simple_send_token caps each literal chunk at CHUNK_SIZE; ++ * reject anything larger so a hostile peer cannot drive the ++ * read_buf below past our static CHUNK_SIZE buffer. */ ++ if (i > CHUNK_SIZE) { ++ rprintf(FERROR, "invalid uncompressed token length %ld [%s]\n", ++ (long)i, who_am_i()); ++ exit_cleanup(RERR_PROTOCOL); ++ } + residue = i; + } + +@@ -441,9 +449,52 @@ static char *cbuf; + static char *dbuf; + + /* for decoding runs of tokens */ ++#define MAX_TOKEN_INDEX ((int32)0x7ffffffe) ++ + static int32 rx_token; + static int32 rx_run; + ++static NORETURN void invalid_compressed_token(void) ++{ ++ rprintf(FERROR, "invalid token number in compressed stream\n"); ++ exit_cleanup(RERR_PROTOCOL); ++} ++ ++static int32 recv_compressed_token_num(int f, int32 flag) ++{ ++ if (flag & TOKEN_REL) { ++ int32 incr = flag & 0x3f; ++ if (rx_token > MAX_TOKEN_INDEX - incr) ++ invalid_compressed_token(); ++ rx_token += incr; ++ flag >>= 6; ++ } else { ++ rx_token = read_int(f); ++ if (rx_token < 0 || rx_token > MAX_TOKEN_INDEX) ++ invalid_compressed_token(); ++ } ++ ++ if (flag & 1) { ++ rx_run = read_byte(f); ++ rx_run += read_byte(f) << 8; ++ if (rx_run <= 0 || rx_token > MAX_TOKEN_INDEX - rx_run) ++ invalid_compressed_token(); ++ recv_state = r_running; ++ } ++ ++ return -1 - rx_token; ++} ++ ++static int32 recv_compressed_token_run(void) ++{ ++ if (rx_run <= 0 || rx_token >= MAX_TOKEN_INDEX) ++ invalid_compressed_token(); ++ ++rx_token; ++ if (--rx_run == 0) ++ recv_state = r_idle; ++ return -1 - rx_token; ++} ++ + /* Receive a deflated token and inflate it */ + static int32 recv_deflated_token(int f, char **data) + { +@@ -535,17 +586,7 @@ static int32 recv_deflated_token(int f, char **data) + } + + /* here we have a token of some kind */ +- if (flag & TOKEN_REL) { +- rx_token += flag & 0x3f; +- flag >>= 6; +- } else +- rx_token = read_int(f); +- if (flag & 1) { +- rx_run = read_byte(f); +- rx_run += read_byte(f) << 8; +- recv_state = r_running; +- } +- return -1 - rx_token; ++ return recv_compressed_token_num(f, flag); + + case r_inflating: + rx_strm.next_out = (Bytef *)dbuf; +@@ -565,10 +606,7 @@ static int32 recv_deflated_token(int f, char **data) + break; + + case r_running: +- ++rx_token; +- if (--rx_run == 0) +- recv_state = r_idle; +- return -1 - rx_token; ++ return recv_compressed_token_run(); + } + } + } +-- +2.52.0 + diff --git a/SPECS/rsync.spec b/SPECS/rsync.spec index ad94c24..ca8e7bf 100644 --- a/SPECS/rsync.spec +++ b/SPECS/rsync.spec @@ -9,7 +9,7 @@ Summary: A program for synchronizing files over a network Name: rsync Version: 3.1.3 -Release: 25%{?dist} +Release: 27%{?dist} Group: Applications/Internet URL: http://rsync.samba.org/ @@ -52,6 +52,23 @@ Patch20: rsync-3.1.3-trust-sender.patch Patch21: rsync-3.1.3-cve-2025-10158.patch # https://github.com/RsyncProject/rsync/commit/bb0a8118c2d2ab01140bac5e4e327e5e1ef90c9c Patch22: rsync-3.1.3-cve-2026-41035.patch +# https://github.com/RsyncProject/rsync/commit/1a5ad81add1004354a3d8ba841b94ffe19cd2505 +# https://github.com/RsyncProject/rsync/commit/99b36291d06ca66229942c7a525a1f5566f10c85 +# https://github.com/RsyncProject/rsync/commit/72d1cf1c288e5c526e906db2edafbf3d55762668 +# https://github.com/RsyncProject/rsync/commit/61d987c54a472d88855c5fbef3a4c7b51696f93a +# https://github.com/RsyncProject/rsync/commit/24852cda3db38e2f2cd78a13703373c77f75f4d5 +# https://github.com/RsyncProject/rsync/commit/d22b6bc7d1b1d7be9df1c0c6db1599cb7d5fd82c +# https://github.com/RsyncProject/rsync/commit/39b3074a1ab18705cd685fe0659fc958c8cd3db5 +# https://github.com/RsyncProject/rsync/commit/a277a06b1017b4cf6bb0fe33d5823869ed02dfd9 +Patch23: rsync-3.1.3-fix-cve-2026-29518.patch +# Backporting a couple of regression fixes +# https://github.com/RsyncProject/rsync/commit/f6b39cca +# https://github.com/RsyncProject/rsync/commit/5ce33659 +# https://github.com/RsyncProject/rsync/commit/3526884f +# https://github.com/RsyncProject/rsync/commit/7192db98 +Patch24: rsync-3.1.3-fix-cve-2026-29518-regressions.patch +# https://github.com/RsyncProject/rsync/commit/c44c90e9460c666c965446a8c0957f0b9fa4c66a +Patch25: rsync-3.1.3-fix-cve-2026-43618.patch %description Rsync uses a reliable algorithm to bring remote and host files into @@ -112,6 +129,9 @@ patch -p1 -i patches/copy-devices.diff %patch20 -p1 -b .trust-sender %patch21 -p1 -b .cve-2025-10158 %patch22 -p1 -b .cve-2026-41035 +%patch23 -p1 -b .cve-2026-29518 +%patch24 -p1 -b .cve-2026-29518-regressions +%patch25 -p1 -b .cve-2026-43618 %build %configure @@ -158,6 +178,14 @@ chmod -x support/* %systemd_postun_with_restart rsyncd.service %changelog +* Mon Jun 15 2026 Michal Ruprich - 3.1.3-27 +- Integer overflow in compressed-token decoding (CVE-2026-43618) +- Resolves: RHEL-174951 + +* Thu May 28 2026 RHEL Packaging Agent - 3.1.3-26 +- Resolves: RHEL-174950 - CVE-2026-29518 - TOCTOU symlink race in + non-chrooted daemon modules + * Tue May 05 2026 Michal Ruprich - 3.1.3-25 - Resolves: RHEL-169141 - CVE-2026-41035 - Use-after-free vulnerability in extended attribute handling