read_ndx_and_attrs() sanitized the wire-supplied xname (the alternate-basis
leaf name sent with ITEM_XNAME_FOLLOWS) only when sanitize_paths was set,
which is the daemon side. A client receiver has sanitize_paths == 0, so a
malicious server could send an xname containing ".." and, joined to an
operator basedir (--link-dest / --compare-dest / --copy-dest, or the fuzzy
dir), have the client open an out-of-tree file as the delta basis -- a
client-side arbitrary-read / file-existence-oracle / FIFO-hang. The ownership
walk in secure_basis_open() does not stop this: it deliberately follows a
plain ".." to a regular file (the legitimate --link-dest=../01 sibling, #915)
and only refuses foreign-owned symlink components.
Sanitize xname unconditionally. The operator basedir may legitimately be
relative, but the leaf name that arrives over the wire never legitimately
needs ".." or a leading "/".
Reported by z3r0s.
set_file_attrs() pins the entry via the cached held dir fd (held_dfd_for) and
drives the xattr/ACL ops off that fd (fsetxattr). But when the pin missed --
held_dfd_for() returns -1 (the path is deeper than the dirfd cache, or its dir
isn't the held one), or the leaf openat() loses a race -- held_fd stayed -1 and
set_stat_xattr()/set_xattr()/get_acl_fdat()/set_acl_fdat() fell through to the
path-based branch (sys_lsetxattr(fname,...)). Unlike the chmod/chown/times path
wrappers (which secure-resolve), that raw lsetxattr re-resolves the parent, so a
concurrent flip of a dest parent component to a symlink->outside lands the xattr
OUTSIDE the destination tree (the intermittent copy-xattrs-symlink-race escape
that surfaces under -j load, which widens the open->setxattr window).
Re-pin through secure_relative_open() when the cached pin misses on a confined,
non-operator receiver path, so the xattr/ACL ops always use a confined fd -- NOT
a raw path lsetxattr; if the re-pin also fails (a genuinely raced parent/leaf
symlink) skip the path-based ops (xattr_refuse) rather than redirecting them.
The re-pin passes O_DIRECTORY for a directory leaf. Apply the same re-pin/refuse
to gen_entry_copy_xattrs() (the dir xattr copy), whose dfd<0 path likewise fell
to copy_xattrs() with dest_fd==-1. chmod/chown/times are unchanged (confined via
their *at wrappers); operator paths keep op_pin/op_refuse.
set_file_attrs() pins a cross-tree operator leaf (an absolute --temp-dir /
--backup-dir / --*-dest path) with an O_NOFOLLOW fd (op_leaf_fd) and drives
chmod/chown/xattr/ACL/times off it so a flipped parent can't redirect them, but
the pin was opened only when am_root >= 0. A daemon module with "fake super =
yes" runs with am_root < 0, so the cross-tree leaf kept op_leaf_fd/held_fd == -1
and the fake-super %stat, preserved-xattr and ACL-as-xattr writes fell back to
path-based sys_lsetxattr(): a local module writer racing the staging parent to a
symlink could redirect those metadata writes outside the module
(CVE-2026-53799 residual on the fake-super path).
Open the pin for fake-super too -- it has nothing to do with privilege: the
daemon owns the freshly-staged leaf it is about to set metadata on, so any
O_NOFOLLOW open failure is a race and is refused rather than redirected through a
re-resolvable path. Every metadata op already routes through
held_fd/op_leaf_fd/op_refuse, so they all become fd-based; strace confirms the
cross-tree fake-super write uses fsetxattr()/fchmod(), never the l-variant.
The backup-dir fix pinned chmod/chown for an operator leaf; extend the same
confinement to every other sink that re-resolves a cross-tree operator path
(an absolute --temp-dir/--partial-dir/--*-dest), each confirmed by a cross-uid
race PoC that is RED on stock 3.2.7 and GREEN here:
- set_file_attrs (rsync.c): route times (do_futimens), and -- by aliasing
held_fd to the pinned op_leaf_fd -- the xattr/ACL ops through the same
O_NOFOLLOW leaf fd, not a re-resolvable path. A raced/refused pin skips them
(op_refuse) rather than redirecting. finish_transfer now wraps the pre-rename
set_file_attrs in operator_path_resolve so an absolute --temp-dir temp file's
metadata is pinned too (in-tree temps keep their held dirfd, so op_pin is off).
- do_rename_at / do_link_at (syscall.c): an ABSOLUTE side was left at AT_FDCWD
and followed a flipped parent symlink, letting a name-disclosed --temp-dir /
predictable --partial-dir rename pull an attacker file into the destination
(content injection). Resolve an absolute (operator) side via the ownership
walk -- with module-exclude enforced -- while a relative (transfer) side stays
on secure_relative_open. --insecure-links keeps the legacy path.
- secure_basis_open (receiver.c): an alt-dest basis read
(--copy-dest/--compare-dest/--link-dest) on a non-daemon receiver used a bare
do_open; route it through the ownership walk (refuses a foreign-owned basis
symlink, still allows the "../sibling" basis of #915). Daemons keep their
existing confinement branch.
- configure.ac: probe futimens (do_futimens is gated on HAVE_FUTIMENS).
New fd wrappers: do_fchmod, do_fchown, do_futimens. Documented residuals left
as-is: copy_altdest_file's basis copy (routing it re-opens copy-xattrs-symlink-
race; basis_link_stat already refuses the foreign symlink), crtimes
(do_setattrlist_crtime/do_SetFileTime, path-based on macOS/Cygwin), and
device/socket leaf metadata (pinning a device via open has side effects).
A symlink race on a non-daemon --backup-dir let a local attacker redirect
rsync's backup writes and chmods outside the backup tree when rsync runs as
root. make_backup() sets operator_path_resolve, but set_file_attrs() had no
held parent dir fd for an absolute backup path (held_dfd_for() returns -1), so
its chmod/chown fell through to the path-based wrappers and, for an absolute
path, to raw chmod()/lchown(). copy_valid_path() mirrors the source dir's
attrs onto each backup subdir; when an attacker flips a backup component to a
symlink in that window the raw lchown retags the planted symlink as root-owned
-- laundering it into a "trusted" (uid 0) symlink that the owner-walk then
follows, so the backup rename/chmod escapes the tree.
Pin the leaf inode of a cross-tree operator path with an O_NOFOLLOW open via
the operator owner-walk resolver and drive fchmod/fchown off that fd; a raced
symlink leaf makes the open fail and the op is refused, never redirected. Gate
on the INTENDED type (new_mode), not the attacker-controlled on-disk type. As
root any open failure is the race (a real owned leaf never fails); a non-root
operator, which cannot launder a uid-0 symlink, falls back to the legacy path
op on a benign EACCES. --insecure-links opts back out.
Adds do_fchmod()/do_fchown() fd wrappers. Residual cross-tree metadata sinks
(times, ACLs, xattrs, and --temp-dir's finish_transfer set_file_attrs) are not
covered here and are tracked for a follow-up.
set_file_attrs() resolved the entry's directory into a held dirfd (for the
chmod/chown/times *at ops) but its initial link_stat() -- and the post-chown
setuid/setgid re-stat -- still went through x_lstat()/do_lstat_at(), re-walking
the full path per file. Do the stat with link_stat_at() against the held dirfd
(mirroring gen_entry_stat, guarded on am_root >= 0 for the fake-super xattr
fold) so the directory is opened once and reused.
set_file_attrs() holds an O_RDONLY|O_NOFOLLOW fd (held_fd) for the entry's
regular-file/dir/FIFO inode so metadata is applied to the pinned inode rather
than by re-resolving the path, which a parent-symlink race could redirect.
- xattrs: the set/remove/list of -X xattrs use the f-variant calls on held_fd
(lib/sysxattrs gains the wrappers); a raced FIFO can't block the open
(O_NONBLOCK).
- ACLs (real root): get_acl_fdat()/set_acl_fdat() apply the ACL via lib/acl.c's
fd path (xacl_*_fd), or setxattrat(AT_SYMLINK_NOFOLLOW) on the dirfd+leaf for
a socket/device, covering both the access and default ACL. pack_smb_acl()+
change_sacl_perms() still build the entries, so the bytes written match
acl_set_file() exactly. With no safe primitive we skip rather than re-resolve
the path. The legacy path-based libacl calls remain only for the cross-tree
(no held dirfd) and non-Linux cases.
- --fake-super ACL-as-xattr writes/deletes also go through held_fd, closing the
last path-based metadata write under fake-super.
Behaviour-neutral held-dirfd conversion (the substrate the broadened gate
hangs off):
- set_file_attrs() resolves the entry's dir once (held_dfd_for) and issues
chown/chmod/times as single-component *at() calls against that held fd;
- the generator routes per-entry stat/mkdir/chmod through a held dirfd;
- the receiver reads the delta basis via the held dirfd and resets the
one-slot dir-fd cache at flist-chunk boundaries;
- the sender scan stats entries via the opendir fd.
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
- Put sum2_array into sum_struct to hold an array of sum2 checksums
that are each xfer_sum_len bytes.
- Remove sum2 buf from sum_buf.
- Add macro sum2_at() to access each sum2 array element.
- Throw an error if a sums header has an s2length larger than
xfer_sum_len.
- Stop setting the mtime on a file we didn't transfer (or didn't verify
the checksum) when the time diff is within the modify window.
- Stop computing a time difference (-1|0|1) when all we care about is
time equality.
I replaced git-set-file-times with an improved version that I wrote
recently (in python3). A new script uses it to figure out the
last-modified year for each *.[ch] file and updates its copyright.
It also puts the latest year into the latest-year.h file for the
output of --version.
The cleanup code will try to flush the output buffer in some
circumstances, which is not valid if we're handling an async signal
(since it might have interrupted some partial I/O in the main thread).
These signals now set a flag and try to let the main I/O handler take
care of the exit strategy. Fixes a protocol error that could happen
when trying to exit after a kill signal.
- If iconv() returns EINVAL or EILSEQ and the error is being ignored, make
sure that there is room in the output buffer to store the erroneous char.
- When accepting an erroneous char, be sure to break if there are no more
input characters (without calling iconv() with a zero input length).
The code now avoids any special internal meaning for uid/gid -1, which
allows it to be mapped to a better value (use 4294967295 instead of -1
as the ID to map). Replaced atol() with something than can return a
value > 0x7FFFFFFF and that will error-out if the value overflows. If
chown() is called with a uid or gid of -1, complain that the ID is not
settable and signal a transfer error. Fixes bug 6936.