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).
(cherry picked from commit 29ddd45e3d)
Preparation for broadening the symlink-race resolver to non-daemon
receivers: a --link-dest/--copy-dest/--compare-dest dir is operator-
supplied and legitimately outside the destination tree (e.g.
--copy-dest=../to), so the confined resolver must not reject its "..".
- check_alt_basis_dirs() now absolutizes a relative basis dir against the
destination curr_dir for local (!sanitize_paths) transfers too, not just
dry_run>1. An absolute path makes the do_*_at() wrappers and copy_file()
use plain (operator-trusted) resolution.
- copy_file() routes the source open through the secure resolver only for a
*relative* source under the broadened gate; an absolute (absolutized)
basis source uses do_open_nofollow().
(cherry picked from commit 4677288a91)
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.
(cherry picked from commit c7aeb639bf)
set_times_at() is the held-dirfd form of set_times() (utimensat tier;
returns -2 where the active tier has no dfd form so the caller falls back).
make_path() now creates its parent chain through do_mkdir_at(), which
resolves the parent via the secure resolver, closing the --relative
implied-parent symlink escape once the gate covers the receiver.
(cherry picked from commit 5ccb76b08b)
Backport of the held-dirfd leaf hardening:
- held_dfd_for(): return the cached entry-directory fd when a path lives
directly in the entry's own dir (the common held-dirfd traversal case),
else -1 so the full-path do_*_at() wrappers are used;
- secure the mixed-parent do_rename_at() paths;
- secure the bare-path do_symlink_at()/do_mknod_at() in fake-super mode.
Gated like the rest of the resolver (active for the hardened receiver),
so behaviour-neutral until the gate is broadened.
(cherry picked from commit 25341c51e9)
The held-dirfd resolver always applies O_NOFOLLOW to the leaf (leaf-race
safety), which refuses an in-tree symlink the operator asked to follow with
-L / --copy-unsafe-links / --copy-dirlinks. For those modes, resolve the
leaf ourselves: read the link, refuse an absolute or escaping target (a
module escape), and re-resolve the relative target through the secure
resolver (which follows in-tree links and rejects an escape above the
anchor), looping for a symlink chain. The final open stays O_NOFOLLOW so
the module boundary and leaf-race protection both hold.
Closes the daemon-copylinks-intree regression from the resolver rewrite.
(cherry picked from commit 41a2864353)
Backport of the 3.5.0 resolver rewrite (drops openat2/RESOLVE_BENEATH for
a portable held-dirfd-stack walk). secure_relative_open() now walks each
path component from the anchor, opening it O_RDONLY|O_DIRECTORY|O_NOFOLLOW
and keeping a stack of the open dirfds: descending a real subdir pushes
its fd, and an in-tree ".." in a followed symlink target pops back to the
already-pinned parent fd rather than re-resolving ".." -- so an ancestor
renamed mid-walk cannot redirect the climb and the walk can never rise
above the anchor. In-tree symlinks are followed (unlike the per-component
O_NOFOLLOW reject), absolute targets are refused, symlink hops are bounded.
This is the substrate the per-subsystem held-dirfd routing hangs off, and
it follows in-tree symlinks while confining -- which openat2 RESOLVE_BENEATH
could not do without breaking legitimate transfers. NOFOLLOW_HIT_SYMLINK()
(ELOOP/EMLINK/EFTYPE) moves to rsync.h. Behaviour-neutral for the existing
daemon gate; the broadening to all non-chroot receivers is a later commit.
(cherry picked from commit c186a81f6a)
Backport of the 3.5.0 P0 held-dirfd scaffolding: open_dir_secure() opens
an entry's parent directory confined (through secure_relative_open) and
returns a held dirfd, and the do_*_atfd() family operates on a single
leaf name relative to that held fd, so a parent component flipped to a
symlink after resolution cannot redirect the operation.
Additive: callers are routed onto these wrappers in the following
per-subsystem commits. The authority gate still matches the 3.4.4
daemon-only resolver; the next commit broadens it to every non-chroot
receiver.
(cherry picked from commit 28e36bfd6a)
The 3.5.0 directory-scan confinement opens directories with the secure
resolver and turns the held fd into a DIR* via fdopendir(), so detect
fdopendir. dirfd() is a macro or static inline on several systems (the
BSDs), where the default AC_CHECK_FUNCS link probe gives a false
negative; probe it with a real compile+link that includes <dirent.h> and
calls it, so HAVE_DIRFD is defined and the confinement is not silently
compiled out.
(cherry picked from commit ac82ccec8f)
Backport of the 3.5.0 fix bounding peer-supplied xattr wire data so a
malicious peer cannot drive an oversized allocation/read.
Test: xattr-wire-cap.
(cherry picked from commit cd61c1cc62)
Backport of the 3.5.0 wire-input validation:
- reject an out-of-range file index from the wire in read_ndx();
- reject a zero-length checksum block.
Test: checksum-zero-blocklen.
(cherry picked from commit 6365fe750b)
Backport of the 3.5.0 option-parsing hardening:
- bound parse_size_arg() against integer overflow;
- reject a zero --max-alloc (which would disable the per-allocation
sanity cap);
- escape newlines in remote-shell argument expansion.
Tests: daemon-max-alloc-zero, max-alloc-zero-rejected,
remote-shell-newline-escaping.
Reported-by: Azizcan Dastan <azizcan.dastan5@gmail.com>
(cherry picked from commit 0d5e8a0614)
Backport of the 3.5.0 daemon-hook quoting fix: only the shell-executed
hooks expand %RSYNC_*% tokens, and those expansions are shell-escaped so
a crafted module path / environment value cannot inject shell syntax.
Test: daemon-exec-rsync-env-shell-escape.
(cherry picked from commit cf44c1df44)
Backport of two 3.5.0 daemon-filter fixes:
- parse_filter_file(): strip module_dirlen off an absolute merge-file
path before the daemon_filter_list check (parse_merge_name() prepended
module_dir), so an anchored module-relative rule actually matches; a
hidden merge file is treated as non-existent rather than tripping the
fatal-vs-silent oracle (Mitchell Benjamin finding 1);
- downgrade implied-parent directories to non-content so a malicious
sender cannot expand --delete scope via a content-dir entry
(Mitchell Benjamin finding 3).
Tests: daemon-filter-merge-bypass, malicious-sender-delete-scope.
Reported-by: Mitchell Benjamin <mitchell@revampstudio.com.au>
(cherry picked from commit 407fe4ac48)
Backport of the 3.5.0 daemon name-converter hardening:
- reject an empty or non-numeric namecvt response instead of letting it
collapse to uid/gid 0 (Mitchell Benjamin finding 2);
- parse the id with overflow/sign checking;
- reject unsafe (newline/control) tokens in the converter response.
Tests: daemon-namecvt-empty-response, daemon-namecvt-newline-token.
Reported-by: Mitchell Benjamin <mitchell@revampstudio.com.au>
(cherry picked from commit 91b09b6e38)
Build rsync here and run the v34-stable-testsuite Python suite against
the built binary (helpers/config.h as tooldir, test scripts via --srcdir)
on ubuntu-latest and ubuntu-22.04, pipe + --use-tcp passes. Gives
regression coverage without importing the full master suite.
Helpers link util2.o but not options.c, so they used the stub's
max_alloc = 0, which makes every my_alloc()/my_strdup() in util2.c abort
with "exceeded --max-alloc=0". The secure_relative_open() fallback path
hits my_strdup() and aborts. Set max_alloc = (size_t)-1.
Commit d046525d made my_alloc() calloc every fresh allocation and made
expand_item_list() memset the freshly grown tail, to hand out predictably
zeroed memory. But that forces the kernel to back pages callers never
touch: each per-directory file_list pre-allocates a FLIST_START-entry
(32768) pointer array -- 256KB -- and calloc now zeroes the whole array
even for an empty directory. With incremental recursion over many
directories the resident set explodes; 80000 empty dirs went from ~336MB
to ~10.8GB.
Restore the pre-d046525d malloc/calloc split: fresh allocations use
malloc (so untouched tails stay lazy) and only explicit do_calloc
requests (new_array0) are zeroed. Callers that need zeroed memory
already ask for it, and the full test suite passes.
Thanks to @guilherme-puida for the report (#959).
Fixes: #959
do_open_nofollow() -- the O_NOFOLLOW read path the sender uses for local
transfers via do_open_checklinks() -- never injected O_NOATIME, so
--open-noatime was dropped and reading a source file bumped its atime. Mirror
do_open()/do_open_at(); caught by the open-noatime test on strict-atime Linux.
Build the test-helper programs without running the suite, so an external
harness (fleettest.py) can invoke runtests.py with its own options.
(cherry picked from commit a122e5d6b6)
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)
configure now probes for <linux/openat2.h> + SYS_openat2 and defines
HAVE_OPENAT2 only when both are present; syscall.c gates the openat2 include
and the openat2(RESOLVE_BENEATH) tier on HAVE_OPENAT2, so the build no longer
fails on kernels/headers that lack the openat2 header (3.4.3 included it
unconditionally on Linux). android.c probes openat2 usability behind a SIGSYS
handler so the Android/Termux seccomp sandbox falls back to the portable
resolver instead of killing the process.
Backport combining c73e0063, 83a24c21, the syscall.c guards from 1d5b5ab8, and
4634b0ad; the --disable-openat2/gcov coverage knobs and test changes are omitted.
Thanks to @mmayer (#924), @fda77 (#905), @darkshram (#900) and @ketas (#904) for the reports.
(cherry picked from commit 1cff146a1280854866518efaeff44982f5c2304d)
A single-file --mkpath copy whose destination parent does not exist
failed under --dry-run: make_path() only *reports* the directories it
would create in a dry run, so change_dir#3 then tried to chdir into a
parent that isn't there and aborted with "change_dir#3 ... failed".
When the parent is genuinely missing in a dry run, skip the chdir and
mark the destination as not-yet-present (dry_run++), exactly as the
multi-file/dir-creation path already does, so the generator doesn't
probe the missing tree. Gating it on the missing-parent case keeps an
ordinary file-to-file dry run chdir'ing into and itemizing against an
existing destination.
Fixes: #880
Thanks to @pkzc for the report (#880).
Co-authored-by: Stiliyan Tonev (Bark) <stiliyan21@gmail.com>
(cherry picked from commit e096bbd64e)
send_deflated_token() adds a matched block to the compressor history with
deflate(Z_INSERT_ONLY). Our bundled zlib implements Z_INSERT_ONLY (it
produces no output and consumes the input in one call), but a build
against a system zlib lacks it and falls back to Z_SYNC_FLUSH (see the top
of the file), which emits a flush block into obuf. For a large
incompressible matched token that block exceeds AVAIL_OUT_SIZE(CHUNK_SIZE),
so deflate returned with avail_in != 0 and the transfer aborted:
"deflate on token returned 0 (N bytes left)" at token.c
The insert output is never sent -- the receiver rebuilds the matching
history itself in see_deflate_token() -- so loop, resetting the output
buffer, and discard it. Drain with the same condition as the data loop
above: until the input is consumed AND avail_out != 0. Stopping at
avail_in == 0 alone can leave pending output in the deflate stream (a
full output buffer with bytes still buffered), which would then be emitted
by the next real deflate send and corrupt the stream. A bundled-zlib
build still finishes in one iteration.
Thanks to @brabalan for the report (#951).
Fixes: #951
(cherry picked from commit 8504275acd)
Without --secluded-args, the client's safe_arg() backslash-escapes shell
and wildcard chars in option values before sending them to the server, so
--chown's --usermap=*:user is transmitted as --usermap=\*:user. Over ssh a
remote shell removes the backslashes before rsync parses the args, but a
daemon has no shell and read_args() stored option args verbatim -- so the
receiver saw the literal "\*", the usermap/groupmap wildcard never matched,
and the module's configured uid/gid won instead. A regression from the
secluded-args hardening; rsync 3.2.3 (protocol 31) worked.
Un-backslash option args in read_args() on the daemon's first
(non-protected) read, mirroring what the ssh-side shell does. File args
after the dot are already handled by glob_expand(); the protected (NUL,
already-unescaped) re-read and the server's stdin read pass unescape=0 so
their raw args are left untouched.
Thanks to @elcamlost for the report (#829).
Fixes: #829
(cherry picked from commit 8dc5fd1408)
do_mknod_at() (the symlink-race-safe variant used by a non-chrooted
daemon receiver) calls mknodat()/mkfifoat(), but the at-variant was
gated only on AT_FDCWD. Older Darwin declares AT_FDCWD without
mknodat(), so the build failed with "mknodat undeclared".
Probe mknodat()/mkfifoat() in configure and require HAVE_MKNODAT for the
at-variant; without it do_mknod_at() falls back to do_mknod(), exactly
as it already does where AT_FDCWD is missing. Linux keeps the mknodat
path since HAVE_MKNODAT is defined there.
Thanks to @debohman for the report (#896).
Fixes: #896
(cherry picked from commit 24d75c04e4)
sum_sizes_sqroot() capped the strong-sum length at SUM_LENGTH (16), the
legacy MD4/MD5 digest size. Since 0902b52f the sum2 array elements are
xfer_sum_len bytes and the sender rejects a sums header whose s2length
exceeds xfer_sum_len. When the negotiated transfer checksum is shorter
than 16 bytes -- xxh64 (8), used when the build's libxxhash lacks
xxh128/xxh3 (e.g. Ubuntu 20.04) -- the generator still emitted s2length
up to 16, so --append-verify and other full-checksum (redo) transfers
died with "Invalid checksum length 16 [sender]" (protocol incompatibility).
Cap s2length at MIN(SUM_LENGTH, xfer_sum_len): unchanged for any checksum
>= 16 bytes (md5/xxh128/sha1), corrected for short ones. Also closes a
latent over-read of the xfer_sum_len-sized digest buffer.
(cherry picked from commit cb82d6937a)
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 948edffb43)
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 9886a06610)
--delete-missing-args (missing_args==2) sends a missing --files-from arg as a
mode-0 entry (IS_MISSING_FILE), the generator's delete signal. The mode-type
validation in recv_file_entry() rejected mode 0 as an invalid file type,
aborting the transfer with 'invalid file mode 00 ... code 2' before the
generator could act (a regression from 3.4.1). Allow mode 0 through only when
missing_args==2 (the delete mode -- not --ignore-missing-args, which never
sends a mode-0 entry); all other modes are still rejected.
Thanks to @mgkeeley for the report (#910).
(cherry picked from commit 73fe630a70)
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) <noreply@anthropic.com>
(cherry picked from commit eee05c177a)
fixes a one byte stack overflow when using RSYNC_PROXY with a
malicious proxy.
Reach: only when RSYNC_PROXY is set and a malicious or MITM'd
proxy returns the pathological response. The byte written is
always '\0' and the attacker doesn't choose the offset, so impact
is corruption of one adjacent stack byte and possible later
misbehaviour or crash -- no information disclosure beyond the
existing rprintf of buffer contents.
Reported by Aisle Research via Michal Ruprich
read_del_stats() in main.c accumulates 5 wire-supplied counts into
the int32 stats.deleted_files field:
stats.deleted_files = read_varint_bounded(..., MAX_WIRE_DEL_STAT, ...);
stats.deleted_files += stats.deleted_dirs = ...;
stats.deleted_files += stats.deleted_symlinks = ...;
stats.deleted_files += stats.deleted_devices = ...;
stats.deleted_files += stats.deleted_specials = ...;
With the previous MAX_WIRE_DEL_STAT = 2^30 (1.07 GB) the worst-case
sum is 5 * 2^30 = 5.37 GB; three maximal values already exceed
INT32_MAX = 2.15 GB on the third "+=", triggering signed integer
overflow (C99 6.5/5 -- undefined behaviour, the compiler may assume
it cannot happen and elide subsequent checks).
The bound was introduced in f0155902 ("defence-in-depth: bound
wire-supplied counts and lengths") with a commit message claiming
"per-summand cap so the total can't overflow", but 2^30 * 5 does
overflow. Lower the per-summand cap to 2^28 (= 268M) so the worst
case is 5 * 2^28 = 1.34 GB < INT32_MAX with margin. 2^28 deletions
per category is still vastly above any plausible real transfer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The bundled popt 1.18 (rsync 3.2.7) calls strlcpy(dst, argv[i], nb)
inside the per-arg loop in poptDupArgv(), where nb is the TOTAL
allocation size — not the remaining bytes after dst has advanced.
The actual write was always within the malloc'd buffer, so it was
silent on older glibcs, but glibc 2.39+ fortified strlcpy compares
the size argument against __bos(dst) and aborts with "*** buffer
overflow detected ***" once dst passes through any bytes.
That broke ~15 tests on Ubuntu 24.04 / glibc 2.39 in CI (any test
spawning a child rsync via popt's argv duplication path). Pass the
remaining bytes (end_buf - dst) so the size argument matches reality.
Master fixed the same bug differently in popt 1.19 (4c8683c8 "update
to popt 1.19") by switching to stpcpy, but pulling that 1500-line
refresh into a security backport is heavier than warranted.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The macOS / Cygwin expected-skipped lists in 3.4.x master assume
features that are present on master but not on 3.2.7 (e.g.
open-noatime semantics) and don't account for new tests added by
the May 2026 sec-patches that get skipped on these platforms
(daemon-chroot-acl, *-symlink-race, sender-flist-symlink-leak).
Realign each list with what the platform actually skips on this
backport branch so RSYNC_EXPECT_SKIPPED matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Solaris /usr/bin/sleep is POSIX and rejects fractional seconds, which
made the test abort silently under `set -eu` (empty log, FAIL). One
second is more than enough for the daemon to listen.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fortified (-D_FORTIFY_SOURCE=2 for gcc) builds make strlcpy() crash when
its third parameter (size) is larger than the buffer:
$ rsync -FFXHav '--filter=merge global-rsync-filter' Align-37-43/ xxx
sending incremental file list
*** buffer overflow detected ***: terminated
It's in the exclude code in setup_merge_file():
strlcpy(y, save, MAXPATHLEN);
Note the 'y' pointer was incremented, so it no longer points to memory
with MAXPATHLEN "owned" bytes.
Fix it by remembering the number of copied bytes into the 'save' buffer
and use that instead of MAXPATHLEN which is clearly incorrect.
Fixes#511.
Two assorted audit findings:
- receive_data() never bounds-checked the block index returned
by recv_token() against sum.count before computing offset2
and feeding it to map_ptr(). An out-of-bounds index from a
hostile sender produces invalid memory access. Add a
sum.count bounds check.
- read_delay_line()'s strchr() call could return NULL when no
space was found, but the code unconditionally added 1 to the
result before dereferencing. Low impact (just a disconnect on
exit of the client-specific forked process) but the NULL
deref is real. Guard the NULL.
Both reported by Joshua Rogers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cumulative-snprintf patterns in log.c (rsyserr) and main.c
(output_itemized_counts) had the shape
len = snprintf(buf, sizeof buf, ...);
len += snprintf(buf+len, sizeof buf - len, ...);
with no guard between calls. snprintf returns the would-have-been
length on truncation, so a truncated first call leaves
"sizeof buf - len" as a negative-then-promoted-to-size_t value,
underflowing into a huge size_t and writing past buf.
Realistic exposure is small in both cases (log header well under
buffer, only ~5 itemized iterations writing ~25 chars each into a
1024-byte buffer) but the defect class matches bb0a8118 and the
fix is cheap. Guard before each subsequent call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multiple receiver-side fields read from the wire were trusted
without upper-bound checks. A hostile peer could either request
extreme allocations (DoS via --max-alloc) or, on platforms where
read_varint returned a negative value, push ~SIZE_MAX through the
size_t conversion to wrap downstream length checks.
Introduce read_int_bounded(), read_varint_bounded() and
read_varint_size() in io.c so wire-derived integer ranges are
checked at the read site rather than scattered across each
caller, with RERR_PROTOCOL on out-of-range input.
Apply the bounded primitives to:
- sum->count (checksum count -- previously could overflow
(size_t)count * xfer_sum_len on 32-bit with raised max-alloc)
- xattrs: count, name_len, datum_len, plus rel_pos overflow
detect to stop chain wrapping the num accumulator
- acls: ida-entry count
- flist: file mode S_IFMT validation, modtime_nsec range check
- delete-stat counters in main: per-summand cap so the total
can't overflow a signed 32-bit accumulator
Reporters include Joshua Rogers (checksum-count overflow finding).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On an rsync daemon configured with "daemon chroot", the reverse-DNS
lookup of the connecting client was performed *after* the chroot
had been entered. If the chroot did not contain the files glibc
needs for resolution (/etc/resolv.conf, /etc/nsswitch.conf,
/etc/hosts, NSS service modules), the lookup failed and
client_name() returned "UNKNOWN". Hostname-based deny rules
("hosts deny = *.evil.example") therefore could not match, and
an attacker controlling their PTR record could connect from a
hostname the administrator had intended to deny. IP-based ACLs
were unaffected.
Do the reverse DNS lookup before chroot/setuid; client_name()
caches its result, so the post-chroot call uses the cached value
and hostname-based ACLs work even when DNS is unavailable
post-chroot.
Adds testsuite/daemon-chroot-acl.test as end-to-end regression
coverage. The test sets up an empty chroot directory, configures
"hosts deny = <localhost-resolved-name>" with daemon chroot, and
asserts the connection is refused with @ERROR access denied.
Uses unshare --user --map-root-user for non-root CAP_SYS_CHROOT;
skips cleanly on non-Linux or when user namespaces aren't
available.
Reporter: Joshua Rogers (MegaManSec).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>