Commit Graph
7589 Commits
Author SHA1 Message Date
Andrew Tridgell 49bb61b8eb syscall/util1: race-safe path resolution via a held dirfd-stack resolver
Resolve every operator/peer-reachable filesystem path one component at a time
through a stack of O_NOFOLLOW-held directory fds, so a symlink swapped in mid-walk
cannot redirect the operation (TOCTOU).  Adds the do_*_atfd() wrappers, the held
dirfd cache (held_dfd_for) and do_mkstemp_atfd() for race-safe temp-file creation,
plus secure change_dir()/robust_rename() in util1.c.  t_stub.c gains the matching
test stubs.
2026-06-21 18:25:28 +10:00
Andrew Tridgell ff544c6920 io: defer in_multiplexed=1 in the other write-side read_a_msg cases
The run4 0002 fix deferred the in_multiplexed re-arm past the call-out only for
MSG_NOOP and MSG_IO_ERROR (the two fuzzed tags), but the same anti-pattern --
re-arming the re-entry guard before a write-side call-out that can reach
perform_io() -> read_a_msg() -- remained in the sibling cases (Codex review).
Defer it past the call-out in those too:

  - MSG_NO_SEND (!am_generator -> send_msg_int): exploitable at a daemon sender,
    the same stack-exhaustion recursion as MSG_NOOP.
  - MSG_DELETED (!am_generator -> log_delete -> send_msg when am_server): same.
  - MSG_DELETED (am_generator -> send_msg): not currently exploitable (the
    generator is not am_sender, so send_msg doesn't perform_io), deferred for
    uniformity.

MSG_SUCCESS/MSG_REDO are left as-is: successful_send() returns early unless
--remove-source-files and does not reach send_msg()->perform_io(), and the
generator-status path runs only in the (non-am_sender) generator.

(cherry picked from commit f4f1905f5cfd83bd10d4db3b734da34cc43d33b0)
2026-06-15 17:47:45 +10:00
Andrew Tridgell ffc91be590 io: ensure argv has room for the trailing NULL in read_args
After the '.' separator, read_args delegates argv growth to
glob_expand_module() -> glob_expand()/glob_match(), each of which reserves
glob.argc+1 slots -- room for the entry being added but not for the trailing
NULL that read_args writes after the loop.  A daemon client can send a single
post-dot request line whose " mod/" splits land argc on exactly maxargs
(initially MAX_ARGS=1000, then any ENSURE_MEMSPACE doubling boundary), so the
'argv[argc] = NULL' at the end is an 8-byte NULL write one slot past the argv
heap allocation.  Reachable from an unauthenticated client; runs after
chroot/setuid in rsync_module so the corruption is in the privilege-dropped
per-connection child.

Ensure the +1 slot before the store.  The pre-dot growth check at
'argc == maxargs-1' already leaves the slot but never fires once dot_pos is set.

(cherry picked from commit ec4f7a03c42e93a48fce20d0b7667235564f9248)
2026-06-15 17:15:28 +10:00
Andrew Tridgell e3986e768f io: defer in_multiplexed=1 past MSG_NOOP/MSG_IO_ERROR call-outs
read_a_msg() sets iobuf.in_multiplexed = -1 on entry so that any perform_io()
reached while the message body is being consumed will not re-enter read_a_msg()
(IN_MULTIPLEXED_AND_READY tests > 0). The MSG_NOOP and MSG_IO_ERROR handlers
reset it to 1 *before* calling maybe_send_keepalive() / send_msg_int(), both of
which can hit perform_io(PIO_NEED_MSGROOM). With more frames already buffered
(iobuf.in.len > 512), perform_io() then loops back into read_a_msg() -- each
frame stacks a fresh BIGPATHBUFLEN local, so a hostile peer that floods MSG_NOOP
at a daemon sender (or MSG_IO_ERROR at a daemon receiver child) exhausts the
stack.

Move the reset after the call-out so the guard stays armed across the write-side
flush.  The outer caller continues draining input once we return.

(cherry picked from commit 5d2794fca309dd558f68b21bff6d3e9728e58237)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 355c115e80 exclude: count trailing backslash in add_implied_include
Phase 1 of add_implied_include() decides whether a '\' is escaping a wildcard
via !strchr("*[?", cp[1]).  strchr() returns a pointer to the string literal's
NUL terminator when cp[1]=='\0', so a trailing backslash matches neither branch:
it is emitted into new_pat via the fall-through but backslash_cnt is not
incremented.  The phase-2 recurse/xfer_dirs '/**' rule then allocates
arg_len + backslash_cnt + 3 + 1 and doubles every '\' in new_pat, including the
uncounted trailing one, so the closing '*p = '\0'' lands one byte past the heap
buffer.

Reachable on a standard 'rsync --daemon' deployment (am_server==0 at the
per-module parse_arguments(), so trust_sender_args stays 0) by a remote
unauthenticated client that sends '-r --files-from=-' and a files-from name with
both an interior and a trailing backslash (e.g. 'a\b\').  Count the trailing
backslash so the allocation reserves the doubled slot.

(cherry picked from commit e377e7dc4f3e752723e5fbf9623a8315034e49ab)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 9491aafdac match: clamp peer-supplied s->flength to local len in append mode
In daemon-as-sender mode, send_files() leaves mbuf NULL for an empty local file
(st.st_size == 0). receive_sums() then derives s->flength from the peer's
sum_head (count * blength), so a hostile client that sends a non-empty sum_head
against that empty file drives the append_mode==2 verify loop into
map_ptr(NULL, ...), dereferencing the NULL map. The diminished-file guard
compares against F_LENGTH(file) (the daemon's own flist size, also 0), not the
peer-supplied flength, so it does not catch this.

Clamp s->flength to len before the verify loop -- we cannot checksum bytes we do
not have, and last_match must not run past len for the trailing matched() flush.

(cherry picked from commit 43f43c92d3414f340029ddf7ddc316dc4d4e3e7b)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 8c1d0d3820 match: re-check .len == l before substituting want_i in hash_search
The check_want_i adjacent-match optimisation substitutes i = want_i after
verifying sum1 and sum2, but -- unlike the main chain loop and the aligned_i
path -- did not re-check s->sums[want_i].len == l. A hostile peer acting as the
generator against a daemon-as-sender can set s2length=0 (read_sum_head only
enforces >= 0, so the sum2 memcmp is a no-op) and craft count=2, blength=len+1,
remainder=len with both sum1 set to the file's rolling checksum. The chain head
(.len=remainder=len) passes the l==.len gate; want_i=0 (.len=blength=len+1) is
then substituted unchecked. After matched(), offset advances to len, k =
MIN(blength, len-offset) = 0, map_ptr(...,0) returns NULL, and the rolling-
checksum trim dereferences map[0] -- a remote unauthenticated SEGV of the
per-connection daemon child against any module serving a non-empty regular file.

Add the missing length re-check, matching the chain loop and aligned_i path.

(cherry picked from commit aad82d77aae098c8d8529f69614a0ffcbfbbb5d3)
2026-06-15 17:15:28 +10:00
Andrew Tridgell ada5da09e2 options: clamp snprintf length before err_buf[len] write in parse_size_arg
C99 snprintf returns the would-have-written length on truncation, so when a
daemon client sends an oversized --max-size/--min-size/--bwlimit/--block-size/
--max-alloc value (each arg can be up to BIGPATHBUFLEN-1 = 5119 bytes via
clientserver.c:read_args), len at the failure: label can far exceed
sizeof err_buf (200).  The trailing

    err_buf[len] = '\n';
    err_buf[len+1] = '\0';

then write 0x0A,0x00 at a byte-precise attacker-chosen offset up to ~4.9KB
past err_buf into adjacent .bss.  The same happens via the second snprintf
when the first lands len in [~140,189] and the (min/max: ...) suffix is
truncated.

Clamp len to sizeof err_buf - 2 before the trailing writes, covering both
paths.  Reachable from an unauthenticated client against any daemon module
before chroot/privilege-drop; introduced in 66ca4fc97b (2020-07-10).

(cherry picked from commit 3b81103d7ec98145710f8845dd676ba34458169a)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 578afbb857 log: drop receiver-side assert(!is_utf8) reachable via peer MSG_INFO
A daemon receiver (am_server=1, am_generator=0, send_msgs_to_gen=1) that
receives MSG_INFO/MSG_ERROR/MSG_ERROR_XFER/MSG_WARNING from the wire calls
rwrite() with is_utf8=!am_generator=1 (io.c:read_a_msg).  The
send_msgs_to_gen branch in rwrite() then asserted !is_utf8, giving a
remotely-reachable abort of the per-connection receiver child.

Legitimate senders never put these tags on the wire (they write to their
own terminal), so this never fired on real traffic.  Under NDEBUG the
assert compiles out and the bytes are forwarded raw to the generator,
which logs them with is_utf8=0 through filtered_fwrite -- safe.  Match
that behavior in debug builds: drop the assert and document why is_utf8
may legitimately be set here.

(cherry picked from commit e5a89f3372716ee42fcb145d7a65f9037391e074)
2026-06-15 17:15:28 +10:00
Andrew Tridgell f41a92e76c exclude: cap merge-file include recursion depth
A '. file' (merge) directive inside a filter file makes
parse_filter_str() call parse_filter_file() on the named file, which
in turn feeds each line back through parse_filter_str().  Nothing
bounds the depth, so a file that merges itself recurses until the
stack guard page is hit.

A malicious client of a writable daemon module can trigger this in a
single connection: send '--filter=": .merge"' with --delete-after,
upload a '.merge' file whose only line is '. .merge', and the
generator's per-dir merge handling in push_local_filters() then opens
and re-parses that file forever, crashing the forked daemon child.

Cap the nesting at MAX_MERGE_DEPTH (32).  At the cap, mirror the
existing failed-open handling: abort with RERR_FILEIO under a fatal
(operator-supplied) merge, otherwise log an FERROR and drop the
offending merge so the transfer continues rather than turning a
planted filter file into a guaranteed abort.

(cherry picked from commit c6c585afdf0e2ae88ae57fd46ff9e4a7f4f442fc)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 47addd8609 flist: refuse sub-flist after the final flist has been freed
In incremental-recursion mode the receiver frees each flist as it is
finished with.  When the last one goes, flist_free() sets first_flist
to NULL, flist_cnt to 0 and pool_destroy()s the shared file pool, but
the global dir_flist is left untouched: its files[] array still points
into the now-destroyed pool.

A malicious sender can then send an NDX_FLIST_OFFSET-encoded directory
index.  read_ndx_and_attrs() bounds-checks dir_ndx against the *stale*
dir_flist->used and calls recv_file_list(), which:

  - reads/writes the freed dir_flist->files[dir_ndx] entry
    (FLAG_GOT_DIR_FLIST), and
  - because first_flist is NULL, gets ndx_start == 1 from flist_new(),
    so re-creates dir_flist as a fresh empty FLIST_TEMP list, and then
  - dereferences dir_flist->files[dir_ndx] (uninitialised malloc heap)
    in the dirname check, crashing in f_name().

On a stock glibc the 256 KiB realloc is mmap-zeroed, so the uninit
deref is caught by the existing !d guard and turns into a clean
RERR_UNSUPPORTED exit, but the use-after-free write into the destroyed
pool happens regardless.

A sub-flist marker after every flist has been freed is never valid
protocol, so refuse it up front before touching the stale dir_flist.

(cherry picked from commit 382d7df9a23b976ea6f09598ecea66f269aa213d)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 2db471207e flist: refuse FLAG_HLINKED on directory entries
The F_HL_GNUM/F_HL_PREV optional-extras slots ("non-dirs") and the
F_DIR_DEV_P slots ("receiver dirs") occupy the same OPT_EXTRA offsets;
the macros assume an entry is one or the other.  recv_file_entry()
suppressed FLAG_HLINKED for symlinks and specials/devices on platforms
that cannot hard-link those, but never for directories, so a malicious
sender could mark a directory entry XMIT_HLINKED.

With -H -x and inc_recurse the generator then writes
major/minor(st_dev) into F_DIR_DEV_P(file) (generator.c:1679),
clobbering what the hard-link code reads as F_HL_PREV/F_HL_GNUM.  A
forwarded MSG_NO_SEND for that ndx pushes it onto hlink_list
(got_flist_entry_status() only checks F_IS_HLINKED), and
check_prior() reads F_HL_PREV(file) == major(dev) — typically 0.
flist_for_ndx(0) returns first_flist (ndx_start-1 is the parent
sentinel), and flist->files[0 - ndx_start] underflows the files[]
array by one slot, then dereferences the resulting wild pointer.

Directories cannot be hard-linked on POSIX; do not honour XMIT_HLINKED
on an S_ISDIR entry.

(cherry picked from commit 63be23ed575a24430b058d54724a643c89344b5b)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 2626e20777 log: do not dereference F_SUM() for %C on a deleted-entry log line
log_delete() builds a placeholder file_struct with only
(file_extra_cnt + 2) extras — enough for the REQ_EXTRA fields and the
first two OPT_EXTRA slots, but not for the SUM_EXTRA_CNT slots that
F_SUM() reaches into.  When the active out-format / log-format contains
%C and always_checksum is on, log_formatted() called sum_as_hex() on
F_SUM(file) for the placeholder, reading flist_csum_len bytes that begin
before the new_array0() allocation and hex-encoding them into the log
line / FCLIENT message.

A daemon receiver can be driven onto this path entirely from the wire:
the client args line can include --out-format='%o%C' and -c, and
read_a_msg() accepts a multiplexed MSG_DELETED from the peer; a payload
of MAXPATHLEN or more bytes skips the send_msg() forward and lands in
the FCLIENT log_formatted() call with the attacker-supplied format.

A deleted entry has no checksum to print, so fall through to the
existing blank-fill branch when ITEM_DELETED is set.  This also covers
the legitimate generator-side log_delete() callers when the daemon's
configured "log format" contains %C.

(cherry picked from commit 84728d81ef92268fb549c24f393c18a8e8eadfd9)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 3c49c401cd flist: gate FLAG_HLINKED on preserve_hard_links in recv_file_entry
A malicious sender can set XMIT_HLINKED on a file-list entry even when
the receiver was not invoked with -H.  recv_file_entry() only reserves
the (inc_recurse+1) hard-link extras slots when preserve_hard_links is
set (the gate at create_object), but it set FLAG_HLINKED on the struct
unconditionally from the peer's xflags.  HLINK_BUMP() then offsets
F_SUM() and F_RDEV_P() past the start of the pool_alloc()'d region,
and the subsequent read_buf(f, F_SUM(file), flist_csum_len) writes 16
attacker-controlled checksum bytes 8 bytes below the allocation — into
the adjacent file_struct in the same pool extent.

Vanilla ASAN does not see this because lib/pool_alloc.c bumps inside a
single 32 KiB malloc; it surfaces as use-after-poison once the pool is
instrumented with ASAN_POISON_MEMORY_REGION/ASAN_UNPOISON_MEMORY_REGION.

Mirror the gating already present at the extra_len computation and at
the F_HL_GNUM/F_HL_PREV write site: only set FLAG_HLINKED when
preserve_hard_links is enabled.  This also closes the symmetric
F_RDEV_P() underflow for device entries and the F_SUM(first)/
F_RDEV_P(first) reads in the first_hlink_ndx copy path.

(cherry picked from commit adfae919876c9f3454ce12a1b7becbed47415ff7)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 66b2f31340 recv_files: refuse transfer-phase ndx that targets a cleared flist entry
A peer can send duplicate file-list entries; flist_sort_and_clean() then
clear_file()s one of them (basename and mode zeroed), but its slot stays
indexable.  If the transfer phase later sends an ndx for that slot,
recv_files()/send_files() pick the cleared file_struct and f_name() on it
returns NULL.  In recv_files() that NULL flowed straight into the daemon
filter check and set_file_attrs() → full_fname(), which dereferences its
argument: a remote NULL-pointer crash on any anonymous writable module
with -X negotiated.

Reject an inactive entry at the point both receiver and sender resolve
ndx → file_struct, and harden full_fname() against a NULL argument as a
backstop for the many error-reporting call sites that feed it an f_name()
result.

Found by fuzzing the daemon protocol over stdio.

(cherry picked from commit 8c70bff2daf5ba06a3b42ef58b9860b564c98ce9)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 4d1044156b flist: refuse sub-flist for a cleared dir_flist slot
Under inc_recurse the receiver appends every received directory entry to
dir_flist before flist_sort_and_clean() runs.  If the peer sends entries
that the dedup/prune pass then clear_file()s, the cleared file_struct
(basename and mode zeroed) remains in dir_flist->files[].  A later
sub-flist tagged with that dir_ndx passed the existing >= used bounds
check, and the dirname-validation strcmp() in recv_file_list() then
called f_name() on the cleared entry — which returns NULL — and
dereferenced it: a remote NULL-pointer crash on any anonymous writable
module.

Reject a dir_ndx whose entry is no longer active alongside the existing
range and duplicate-flist checks, and guard the strcmp() against a NULL
f_name() result as defence in depth.

Found by fuzzing the daemon protocol over stdio.

(cherry picked from commit a900b25008151a8a8ec1c34aa7e2914b23416019)
2026-06-15 17:15:28 +10:00
Andrew Tridgell f30368d2f8 hlink: reject undeclared cross-flist gnum instead of assert()
With inc_recurse, a non-first hard-link entry carries first_hlink_ndx as
its gnum.  recv_file_entry() bounds it as [0, ndx_start + used) but does
not require that a value below ndx_start was ever declared via
XMIT_HLINK_FIRST in an earlier flist.  When such a gnum reaches
match_gnums() it is absent from prior_hlinks, and the new-node branch hit
assert(gnum >= hlink_flist->ndx_start) — a peer-controlled abort() on
default builds.

Replace the assert with a clean RERR_PROTOCOL exit.  In NDEBUG builds the
old code silently tolerated the bogus gnum (it is only used as a hash
key, not an index), so there is no memory-safety exposure either way; the
change just turns SIGABRT into a normal protocol error.

Found by fuzzing the daemon protocol over stdio.

(cherry picked from commit 56b40f0fae4c85f46bfba2aef872a7ce10565fbc)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 3fbee7e407 lib/pool_alloc: fence chunks with an ASan redzone to catch pool underflows
The bump allocator hands out chunks from one big malloc, so AddressSanitizer
cannot see a write that underflows one chunk into its neighbour -- e.g. a
miscomputed F_SUM() that reaches before a file_struct's extras (the
FLAG_HLINKED-without-H bug). Under ASan, poison a small redzone just below
each returned chunk (allocations grow downward from the top of an extent) so
such an underflow becomes a hard use-after-poison report; unpoison a whole
extent whenever its space is reused (reset/reclaim) so legitimate later
allocations never trip over an old redzone. Compiled out entirely without
-fsanitize=address.

Validated: a deliberate write 12 bytes below F_SUM is caught as a
use-after-poison; the existing suite (hardlinks/inc-recurse, daemon, xattrs,
merge) is clean under ASan, so the redzone introduces no false positives.

Also add a require_asan() test helper (mirrors require_tcp) for tests that are
only meaningful under an ASan build.

(cherry picked from commit 402962b7cdea8afb27c0e6e198fef62172c751e3)
2026-06-15 17:15:28 +10:00
Andrew Tridgell 2c55da28d9 wildtest: don't read past the buffer when scanning a test line
main()'s line parser stepped through the fgets() buffer with `*++s` in
three places without first checking for the terminating NUL, so a test
line whose last token runs to the end of the buffer (e.g. a final line
with no trailing newline) could advance s past the NUL and read out of
bounds.

Guard the flag-separator check and rewrite the two whitespace-skip loops
so they never step past the NUL. No behaviour change for well-formed
input: the existing wildtest.txt still passes.

Fixes #776
Reported-by: vikk777 (@vikk777)
2026-06-15 15:24:42 +10:00
Andrew Tridgell 6d2be4a328 log: copy forwarded message by length in rwrite(), not strlcpy()
rwrite()'s daemon/logfile branch did strlcpy(msg, buf, MIN(sizeof msg, len+1)),
but strlcpy() scans the whole source with strlen(); buf is the data buffer from
read_a_msg() (io.c) holding exactly len bytes of a forwarded MSG_* payload with
no NUL terminator, so strlen() reads past the message into uninitialised stack.

Copy exactly len (bounded) bytes with memcpy() and NUL-terminate, matching the
(buf, len) contract the rest of rwrite() already honours.  Behaviour is
unchanged for the NUL-terminated callers; the over-read is gone.
2026-06-15 15:24:42 +10:00
Andrew Tridgell eb4f63970b generator: don't read an unstat'd sx.st when creating a device/special
this fixes a valgrind error where we could read an uninitialised sx.st
field when we don't fill the stat data.

Thanks to report from Michael Mess <michael@michaelmess.de>
2026-06-15 15:24:42 +10:00
Andrew Tridgell 0a4e480202 rsync: stat through the held dir fd in set_file_attrs
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.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 893d3f612e acls: route race-safe ACL ops through libacl *_at when available
When configure detects a patched libacl exporting acl_get_file_at /
acl_set_file_at / acl_delete_def_file_at (new HAVE_LIBACL_AT; ACL_1.3), route the
receiver's race-safe ACL get/set/delete through them instead of lib/acl.c's
xacl_* -- held fd via AT_EMPTY_PATH, dirfd+leaf via AT_SYMLINK_NOFOLLOW, reusing
the existing unpack_smb_acl/pack_smb_acl conversion.  libacl's /proc/self/fd
compat makes this race-safe on every Linux kernel.

lib/acl.c stays as the fallback for a stock libacl (macro undefined -> zero change
there); it already closes the same pre-6.13 parent-symlink-race gap via its own
/proc/self/fd compat, so the two paths are equivalent in safety and prefer libacl
only when its newer bindings are present.  -VV reports ACL-libacl-at and keeps
"ACL_at": true.

A stock -lacl lacks these symbols, so HAVE_LIBACL_AT is undefined on real distros
and the build is byte-identical to the lib/acl.c path until libacl ships ACL_1.3;
build against the patched lib via CPPFLAGS/LDFLAGS pointing at its install prefix.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 8fc908ebaf lib/acl: close the pre-6.13 ACL parent-symlink race via /proc/self/fd
On a kernel without the Linux 6.13 *xattrat syscalls, xacl_*_at() returned ENOSYS
so acls.c fell back to the unconfined path-based sys_acl_set_file() -- the
long-standing parent-symlink race for --acls on every pre-6.13 Linux (the
receiver applies a received ACL to a leaf an attacker raced to a symlink ->
arbitrary-ACL LPE; acl-symlink-race only documented it as a residual there).

Add a /proc/self/fd compat to the dirfd dispatchers: when the *xattrat syscall is
absent (runtime ENOSYS or unbuilt), address the leaf as
/proc/self/fd/<dirfd>/<leaf> and use the l*xattr (no-follow-leaf) calls.  The
/proc/self/fd/<dirfd> magic symlink resolves to the pinned parent inode (a raced
parent symlink can't redirect it) and l*xattr does not follow a raced leaf
symlink, so the dirfd+leaf ACL ops are race-safe on every Linux kernel with
procfs -- no libacl dependency and no 6.13 requirement.  xacl_at_available() now
returns true when the syscalls OR the /proc compat are usable, so acls.c takes
the race-safe dirfd path instead of the path-based fallback (now only the BSDs /
a /proc-less namespace).  The held-fd path (fsetxattr) is unchanged.

Verified on ubuntu-2004 (kernel 5.4, HAVE_XATTRAT_SYSCALLS=0): "ACL_at": true and
acl-symlink-race PASSES while asserting (a vacuous pass before); no regression on
a 6.13+ host (native syscalls unchanged) or t_acl.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 144e7902d5 acls: keep --acls functional where the OS lacks a race-safe ACL primitive
When the receiver cannot pin an entry with a held fd and the kernel has no
*xattrat syscalls (pre-6.13 Linux, the BSDs, Solaris, ...), the hardened ACL
path previously skipped the apply with a warning and reported success -- it
silently left the destination ACL stale, so a changed or revoked source ACL
did not propagate yet rsync exited 0.

Per project policy, prefer the documented --acls functionality over refusing it
on platforms that cannot offer the race-safe primitive (Linux 6.13+ takes the
secure xacl_*_at() path, and an operator who needs the guarantee can move
there).  The get / default-ACL-delete / set fallbacks now fall through to the
path-based sys_acl_*file() calls -- the long-standing 3.4.x behaviour -- which
restores correct ACL application at the cost of the parent-symlink race that is
unavoidable on those platforms.

Tests: a new acls-unpinnable test asserts a received ACL updates (and removes a
stale grant from) a no-owner-read (0300) destination directory -- validated on
the path-based fallback via a forced-no-xattrat build and on the xattrat path;
its non-Linux / proto-29 skips are added to the per-platform expect-skip lists.
acl-symlink-race PASSES with a note (rather than skipping) where -VV reports no
race-safe primitive, so its skip set stays kernel-independent -- the same
workflow's RSYNC_EXPECT_SKIPPED is shared by no-xattrat and 6.13+ boxes.
2026-06-15 15:24:42 +10:00
Andrew Tridgell a21e0f7660 xattrs: route copy_xattrs through a held fd, not lsetxattr-by-path
The secure_relpath hardening converted set_xattr()/rsync_xal_set() to
operate on a held, O_NOFOLLOW-opened fd so a parent-symlink race can't
redirect the metadata apply, but copy_xattrs() was missed: it still wrote
each attribute via sys_lsetxattr(dest, ...) on a path.  lsetxattr does
not follow the leaf symlink, but the kernel's path walk follows symlinks
in parent components.

All three callers pass peer-influenced destination paths, and two of them
called copy_xattrs() right after closing the fd the secure walker had
opened on that same path:

  util1.c copy_file()      -- close(ofd) then copy_xattrs(source, dest)
  generator.c back_file    -- close(f_copy) then copy_xattrs(fname, backupptr)
  generator.c alt-dest dir -- copy_xattrs(fnamecmpbuf, fname)

So on a non-chrooted daemon with -X plus --backup/--copy-dest/EXDEV-copy,
an attacker who races a parent-directory swap into the destination or
backup path between the close and the setxattr could set xattrs on a file
outside the module root (security.capabilities if root).

Give copy_xattrs() a dest_fd argument and use sys_fsetxattr() when one is
held, mirroring the fd>=0 ? fsetxattr : lsetxattr pattern already used by
rsync_xal_set/set_xattr.  Reorder the two regular-file callers to set the
xattrs before dropping ofd/f_copy, and add a gen_entry_copy_xattrs()
helper that pins the directory case via held_dfd_for + openat(O_NOFOLLOW)
the same way set_file_attrs() already does.

(cherry picked from commit 2f8142f39d1acef8fa08f394f86d669b6673dc82)
2026-06-15 15:24:42 +10:00
Andrew Tridgell 26783f76fe rsync/acls/xattrs: apply received xattrs and ACLs through the held fd
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.
2026-06-15 15:24:42 +10:00
Andrew Tridgell d7f929a30e lib/acl.c: POSIX ACL get/set via fd/at xattr syscalls + unit test
POSIX ACLs are stored by the kernel as the system.posix_acl_{access,default}
xattrs.  lib/acl.c serializes that wire format and operates on it via
fgetxattr/fsetxattr on a held O_NOFOLLOW fd -- or getxattrat/setxattrat
(AT_SYMLINK_NOFOLLOW) on a dirfd+leaf -- giving a symlink-race-safe ACL
primitive that, unlike libacl's access-only acl_get_fd/acl_set_fd, also covers
the default ACL.

It is self-contained (no libacl, no rsync globals): it speaks a neutral
(tag, perm, id) entry array, so t_acl can compare it directly against the system
libacl as an oracle.  configure gains SUPPORT_ACL_FD (POSIX ACLs + the xattr
header, independent of the -X feature) and an optional HAVE_XATTRAT_SYSCALLS
probe (the *xattrat syscalls, Linux 6.13+); the fd path needs neither.

t_acl exercises every op rsync needs in both directions (set via lib -> read via
libacl and vice versa), round-trips, the default-ACL delete, errno
discrimination, and the NOFOLLOW leaf refusal.  It self-skips (77) without
SUPPORT_ACL_FD or on a filesystem lacking ACL support.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 8bfa8888f1 token: allow uncompressed literal runs larger than CHUNK_SIZE
Backport of the upstream #1002 fix. The simple_recv_token() literal-run
cap added with the GHSA-g37v-g3gj-pmwq hardening rejects peers that use a
64k block size (e.g. the acrosync library / iOS PhotoBackup) with
"invalid uncompressed token length 65536". The residue loop already
reads the run CHUNK_SIZE bytes at a time, so read_buf never overruns the
static buffer; drop the cap to restore interop. The compressed-token
MAX_TOKEN_INDEX overflow cap is unchanged.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 4a83db49e1 generator: route copy_altdest_file failure cleanup through do_unlink_at()
copy_altdest_file() cleaned up after a failed --copy-dest/--link-dest
copy with a bare unlink(copy_to).  copy_to is the in-tree destination
name (or a tmp name in the same directory), whose parent components are
peer-named and can be raced to a symlink between copy_file() opening the
path and this cleanup.  Every other generator-side unlink already goes
through do_unlink_at() so the secure-relpath walker resolves each
component with O_NOFOLLOW; do the same here.

(cherry picked from commit 518f7e1575cfcf6f8deaac6ea8ae1e48134c463d)
2026-06-15 15:24:42 +10:00
Andrew Tridgell 5ed0ab3ff4 generator: confine the alt-dest basis stat for an inner-module chroot
The receiver and change_dir confinement for a /./ inner-module chroot was
backported, but the generator still stat'd a --compare/copy/link-dest basis
with plain link_stat(basis_dir/fname), following a symlinked parent that
points outside the inner module (the kernel chroot confines only the outer
path) -- so an outside file's metadata could suppress (or forge a basis
for) the transfer.  Route the alt-dest stat through basis_link_stat(),
which resolves the parent via secure_relative_open() and stats the leaf
with link_stat_at() for the inner-module-chroot daemon; plain link_stat()
everywhere else (the non-chroot daemon sanitizes basis paths, and a local
receiver must still follow an operator's --link-dest=../backup).

Closes chroot-alt-dest-inner-module (the openbsd per-component-resolver
case that the secure_relative_open/sanitize path masked elsewhere).
2026-06-15 15:24:42 +10:00
Andrew Tridgell ca9f33f231 rrsync: backport the full restricted-dir hardening
The earlier copy-unsafe-links denial left the rrsync wrapper short of the
3.5.0 restricted-dir hardening, so a daemon-side rrsync still followed a
symlinked --log-file, created device/special files, and had a
realpath-vs-exec TOCTOU.  Replace support/rrsync with the fully-hardened
3.5.0 wrapper (version-agnostic; verified functional with this rsync):
 - safe_open_logfile(): open the log file O_NOFOLLOW + S_ISREG + inode
   re-check so a planted symlink/special is refused;
 - force --no-D in a restricted (non-"/") dir so `rsync -a` strips device/
   special semantics instead of creating them;
 - inode-pin each realpath-validated arg via O_PATH + /proc/self/fd/N
   (where available) so the exec'd rsync can't be race-flipped after the
   check; fall through unpinned where /proc/self/fd is unavailable.

Tests: rrsync-logfile-symlink, rrsync-specials-denied, rrsync-symlink,
rrsync-copy-unsafe-links-denied, rrsync-archive-mode.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 5db4667b0d rrsync: deny copy-unsafe-links in restricted dirs; flock fallback
Deny --copy-unsafe-links inside a restricted (non-"/") rrsync directory (it
dereferences a symlink pointing outside the tree and copies the referent
out -- exfiltration), and don't abort the run when flock() is unavailable.

Test: rrsync-copy-unsafe-links-denied.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 42eb7f925e rsync-ssl: validate helper hostnames and require a CA for stunnel mode
Validate the hostname passed to the rsync-ssl helper (reject option-injection
/ shell-metacharacter hostnames) and require a CA certificate in stunnel mode
so the server certificate is actually verified.

Tests: rsync-ssl-hostname-validation, rsync-ssl-stunnel-ca-required.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 846887d5c2 daemon: add an "auth digest" floor to refuse weak negotiated auth digests
The daemon picks the challenge-response auth digest from the client's
negotiation, or -- for a peer that sends no digest list (any rsync before
3.2.0, including the openrsync that ships with macOS) -- falls back to md5
(protocol >= 30) or md4.  The negotiation is unauthenticated, so an on-path
attacker can also rewrite it to force md5/md4 between two modern peers.  A
captured (challenge, response) pair is then far cheaper to brute-force
offline against a weak digest, recovering a guessable shared secret.

Add a per-module (or global) "auth digest = NAME" parameter.  After the
digest is negotiated, auth_server() refuses the connection (before issuing
the challenge) when the negotiated digest is weaker than NAME, comparing by
position in the strongest-first valid_auth_checksums list.  Because the
check is on the resolved digest, it also catches the md5/md4 fallback, so an
old or list-omitting client cannot slip under the floor.  A floor naming a
digest this build lacks is fail-closed.  Default off, preserving
compatibility with older clients.

Documented in rsyncd.conf(5) and SECURITY.md.  The test covers the strong-
client accept, the unknown-floor fail-closed, and -- with a bundled old
static rsync -- the real md5-downgrade refusal (plus a no-floor control).

(cherry picked from commit 1ba7951c; backport drops the SECURITY.md policy
section, which is 3.5.0-specific, and the _test.py, which the stable harness
does not run.)
2026-06-15 15:24:42 +10:00
Andrew Tridgell 6e00a3b867 daemon: bound argument lists + proxy-protocol peer/length hardening
- io: bound daemon argument lists so a malicious daemon client cannot grow
   argv without limit (DoS);
 - socket: bound the PROXY CONNECT request and proxy response header lines;
 - daemon: require a trusted-proxy host list for "proxy protocol = true"
   (reject untrusted proxy peers, fail-closed), and warn at startup when the
   trusted-proxy list is unset so the fail-closed behaviour is not silent.

Tests: daemon-argv-limit, proxy-connect-request-too-long,
proxy-response-header-too-long, proxy-protocol-trusted-peer.
2026-06-15 15:24:42 +10:00
Andrew Tridgell c02380f7d3 log/batch: refuse planted symlinks at --log-file and --write/read-batch
- log: open the --log-file / daemon log file through
   safe_open_no_attacker_symlinks() so a planted symlink can't redirect the
   privileged log write;
 - batch: open the --write-batch/--read-batch files the same way (+ an
   S_ISREG check), and restore O_BINARY / an O_CLOEXEC fallback on the batch
   opens.

Tests: log-file-symlink, batch-file-symlink.
2026-06-15 15:24:42 +10:00
Andrew Tridgell ad49840f8a util1: confine the non-daemon receiver's destination chdir (cross-uid)
A non-daemon receiver named its own destination and reached it with a
plain chdir(), so a parent component an attacker raced from a real
directory to a symlink was followed -- a root-run transfer's CWD escaped
to an attacker-chosen tree before any write.  Resolve the operator-named
dest (absolute and relative) through safe_open_no_attacker_symlinks() +
fchdir: still follow the operator's/root's own symlinked dest (the
/backup -> /mnt/disk admin pattern) but refuse one owned by another uid.

Tests: symlink-race-dest, symlink-race-relative-dest, dest-symlinked-dir.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 8639f647aa daemon: secure inner chroot module paths
A daemon module with a /./ inner boundary (use chroot=yes, path=.../.inner)
relies on the inner module root, not the kernel chroot, as its trust
boundary: the chroot confines the outer path, but a symlink inside the
module can point to a sibling that is inside the chroot yet outside the
inner module.  Extend the receiver finish/rename gate (use_secure_symlinks),
change_dir()'s confined fchdir, and the basis-open gate from
"am_daemon && !am_chrooted" to "am_daemon && (!am_chrooted || module_dirlen)"
so an inner module gets the same confinement as a non-chroot module.
secure_relpath_active() already carries the inner-module clause.

Tests: chroot-basis-forge/receiver-write/special-inner-module.
2026-06-15 15:24:42 +10:00
Andrew Tridgell e624db85f6 exclude: refuse planted symlinks at filter merge / *-from paths
parse_filter_file() opened operator- and (via per-directory merge files
like .cvsignore) sender-controlled paths via plain fopen() with no symlink
defense: an unprivileged source-tree owner could plant a dir-merge file as
a symlink to a root-readable file, leaking its content through the filter
parser, or redirect an --exclude-from/--files-from open via a planted
parent.  Open via safe_open_no_attacker_symlinks(O_RDONLY)+fdopen(), which
refuses symlinks anywhere in the path unless owned by uid 0 or our euid.

Tests: excludefrom-symlink, filter-merge-symlink.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 535a562ccc syscall: resolve the daemon's served-module anchor via the pinned module_dirfd
open_anchor_dirfd() now dups the identity-pinned module_dirfd when the
anchor is the served module root, instead of re-resolving the absolute
module_dir with openat(AT_FDCWD, ...) -- which re-traverses the module's
ancestors as the dropped-privilege module uid and EACCESes when the module
sits under a non-traversable parent (a 0700 home).  Functionally identical
(same inode), but privilege-drop-safe.  syscall.o links into the t_* test
helpers without clientserver.o, so add a module_dirfd stub to t_stub.c.

Test: daemon-module-private-parent.
2026-06-15 15:24:42 +10:00
Andrew Tridgell b33571a1df daemon/authenticate: open operator-supplied paths refusing planted symlinks
Route the daemon's operator-supplied path opens through
safe_open_no_attacker_symlinks() / a pinned parent dir so a local attacker
cannot plant a symlink at one and redirect the daemon's privileged
read/write: motd, --early-input, lock and --config paths
(clientserver/connection/params); the --password-file and daemon secrets
file, fstat'ing the opened fd (authenticate); and the pid-file parent dir.

Tests: daemon-config-symlink, early-input-symlink, daemon-secrets-file-symlink,
password-file-symlink, daemon-module-chdir-symlink.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 9e12cabd8b util1: safe_open_no_attacker_symlinks() + confine daemon module-root chdir
- safe_open_no_attacker_symlinks(): open an operator-supplied path
   refusing to traverse any symlink (parent or leaf) not owned by uid 0 or
   our euid -- a trusted-owned symlink is still followed, an untrusted one
   fails ELOOP.  Used by the daemon's operator-path opens.
 - confine the daemon module-root chdir under use-chroot=no so a planted
   symlink at the module path is not followed.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 45dd14950a sender: read symlink targets through scan dirfds
The directory-scan confinement resolves the opendir and the per-entry stat
through the held scan dirfd, but readlink_stat() still read the symlink target
with a path-based do_readlink(): a raced parent symlink could redirect that read
and leak an out-of-tree symlink target for a same-name entry.

Add do_readlink_atfd() (readlinkat via the held dirfd) and scan_readlink(), which
routes the read through scan_dirfd for an entry directly in the scan dir (the
same gate scan_link_stat uses), else falls back to do_readlink().  Completes the
enumeration confinement's symlink-target leg.
2026-06-15 15:24:42 +10:00
Andrew Tridgell 5aa125d472 flist/clientserver: confine the directory-scan enumeration
The sender/daemon enumerated each source directory with a plain opendir()
on the full accumulated path -- not through the secure resolver -- so the
enumeration itself was unconfined: a parent component raced into a symlink
after the scan let the listing (and per-entry lstat) escape the transfer
root / module.

 - secure_opendir() resolves the directory through the secure resolver and
   turns the held fd into a DIR* via fdopendir(); the daemon anchors it at
   the identity-pinned module_dirfd (captured at module setup while the
   daemon was positioned at the module root, before any privilege drop);
 - the per-entry scan stats through the opendir fd (scan_dirfd) with
   fstatat(), so the listing can't be redirected out of the tree.

Closes daemon-subdir-climb-symlink and sender-scan-dir-escape.
2026-06-15 12:21:57 +10:00
Andrew Tridgell a064a92f82 sender: confine the non-daemon content open against a parent-symlink race
A local/non-daemon sender opened each file's content by path
(do_open_checklinks -> do_open_nofollow), O_NOFOLLOWing only the leaf, so
a parent component raced from a real directory to a symlink after the
file-list scan was followed -- a higher-authority sender (root nightly
backup, or a victim copying an attacker's tree) read and copied a file
from OUTSIDE the source tree.

For default symlink handling (no -L/--copy-unsafe-links/-k follow), open
the content through secure_relative_open(NULL, fname, O_RDONLY|O_NOFOLLOW)
anchored at the transfer root change_pathname() chdir'd into, so a parent
that escaped the tree is refused.  The symlink-following modes keep
following.  The daemon sender was already module_dir-anchored.

Closes symlink-race-source.
2026-06-15 12:21:57 +10:00
Andrew Tridgell 0b7ee36157 build: add t_rename_secure and t_symlink_secure check helpers
The secure-rename and secure-symlink/mknod regression tests drive these
unit helpers (which exercise do_rename_at()/do_symlink_at()/do_mknod_at()
directly), mirroring the existing t_secure_relpath/t_chmod_secure helpers.
Built by `make check-progs`.

Enables: rename-mixed-parent-symlink-race, rename-mixed-parent-escape-poc,
symlink-mknod-fakesuper-symlink-race.
2026-06-15 12:21:57 +10:00
Andrew Tridgell 65d452bb45 syscall: close the chmod leaf race with a held-fd fchmod
set_file_attrs() and the generator chmod a received entry by name relative to
the held parent dirfd.  A plain fchmodat(dfd, name, mode, 0) follows a
final-component symlink an attacker could swap in, escaping the confined tree.

do_fchmodat_nofollow() now stats the leaf NOFOLLOW and, for a regular file,
directory or FIFO, pins it with openat(O_RDONLY|O_NOFOLLOW) and fchmod()s the
held fd: leaf-safe, works on every kernel, and -- unlike the bare fchmodat2()
syscall -- goes through fchmod(), which LD_PRELOAD interceptors such as fakeroot
wrap.  A symlink leaf is refused (ELOOP).  Sockets/devices and any open failure
fall to fchmodat(AT_SYMLINK_NOFOLLOW), then the raw fchmodat2() syscall as a last
resort; if none is available we skip with a warning rather than fall back to a
leaf-following chmod.  Routes do_chmod_at() and do_chmod_atfd() through it.

(Backport of master 463aaa77; the openat2_usable() runtime probe and the
copy_file confinement-gate widening from that commit are omitted -- the former
needs infrastructure not on this branch, the latter is already present.)
2026-06-15 12:21:57 +10:00
Andrew Tridgell 9e0e40b9b9 syscall: hold an ancestor-dirfd stack as the held-directory cache
The held-directory cache kept only the single current leaf directory, so
moving to a sibling re-resolved the whole path from the anchor and re-opened
every ancestor.  The generator and receiver don't recurse -- they iterate a
path-sorted file list (iteration == DFS) -- so there was no call stack holding
the ancestors.

Keep the whole current ancestor chain open as pinned dirfds, keyed on path
components, and on each resolution reuse the longest common prefix, popping
only the divergent tail.  Each directory is then opened once while we are
inside its subtree.  get_dir_fd() (receiver/generator) and held_dir_path_fd()
(sender) share the stack; ds_descend() still resolves each component, so
in-tree dir-symlink following and confinement are unchanged.

change_dir() drops the cwd-relative stack on any real chdir -- the one
invalidation needed now that the cache is content-keyed, not pointer-keyed.
The per-chunk reset_dir_fd_cache() calls in receiver.c/generator.c are gone:
they guarded the old pointer-keyed cache's aliasing bug, which a content-keyed
stack of pinned fds cannot have, and holding the fds across chunks is strictly
more race-safe (a swapped ancestor resolves to the pinned original).

(cherry picked from commit 182bed6cc6b7c33e546132a069b2d5f4203cb91c)
2026-06-15 12:21:57 +10:00
Andrew Tridgell 29ddd45e3d syscall: broaden the symlink-race resolver to every non-chroot receiver
secure_relpath_active() now hardens path resolution for ALL non-chrooted
receivers/generators, not just the non-chrooted daemon module:

    !am_chrooted && (am_daemon || !am_sender)   (+ daemon inner-module clause)

so a root-run or cross-user local / remote-shell transfer resolves
destination paths under the held-dirfd resolver, closing the parent-
component symlink-race / confused-deputy on the receiver side.  A chroot
is its own confinement (excluded); the sender is excluded so it still
follows -L/--copy-links symlinks out of the tree.

Also confine do_rename_at()/do_link_at() each side independently: an
absolute (operator-supplied) source must not disable confinement of a
relative destination, and an absolute operator-trusted path (e.g. an
absolutized --link-dest basis) uses AT_FDCWD with the full path.

Turns green: nondaemon-symlink-race, relative-mkpath-symlink,
relative-mkpath-dir-symlink, rename-mixed-parent-transfer,
symlink-dest-backupdir.  No regression (itemize/xattrs-hlink stay green
via the absolutized alt-dest basis).
2026-06-12 17:15:21 +10:00