combined security fixes for 3.4.3 release#895
Merged
Conversation
…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) <noreply@anthropic.com>
…518) 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 <noreply@anthropic.com>
…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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…elative_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) <noreply@anthropic.com>
…in-depth Three related codex audit findings: Finding 3a: copy_file()'s source open in util1.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) <noreply@anthropic.com>
testsuite/chdir-symlink-race.test runs an actual rsync daemon
(via RSYNC_CONNECT_PROG to avoid the network) configured with
"use chroot = no", plants a symlink at module/subdir -> ../outside,
and runs four flavours of attacker-shaped transfer (single-file
poc_chmod, -r push into the symlinked subdir with --size-only and
without, -r push into the module root). All four must leave the
outside-the-module sentinel file's mode AND content unchanged.
Portability:
- file_mode() helper falls back to BSD stat -f %Lp when GNU
stat -c %a is unavailable (macOS, FreeBSD).
- Pre-saved pristine copy + cmp(1) replaces sha1sum, which
differs across platforms (sha1sum / shasum / sha1).
Tests are kept running as root in the user-namespace re-exec
wrapper used by symlink-race tests so the daemon's setuid path
doesn't drop into the test user's identity (which on Linux
would mean the chmod-escape code path can't trigger because
the test user doesn't have CAP_FOWNER over the outside file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cygwin lacks RESOLVE_BENEATH-equivalent kernel support and the per-component O_NOFOLLOW fallback also can't be exercised meaningfully under the cygwin runner's filesystem semantics, so every test that asserts the secure_relative_open / do_*_at machinery actually blocks the attack would skip. Make those skips expected in the workflow's RSYNC_EXPECT_SKIPPED list: - chdir-symlink-race - chmod-symlink-race - bare-do-open-symlink-race - sender-flist-symlink-leak - daemon-chroot-acl Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Add a daemon-refuse-compress test that builds a module configured with
'refuse options = compress' and asserts that:
1. an attempted -z transfer to that module fails with an error
mentioning --compress, and
2. the same transfer without -z still succeeds.
This pins down the documented way to disable all compression on a
daemon, which previously had no automated coverage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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) <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>
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>
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 bb0a811 and the
fix is cheap. Guard before each subsequent call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…check
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>
…_del_stats
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 f015590 ("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>
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
Set the date to 20 May 2026, add a SECURITY FIXES section listing all six May 2026 CVEs (CVE-2026-29518, -43617, -43618, -43619, -43620, -45232) with reach, root cause, fix and reporter for each, plus a note on the defence-in-depth hardening that goes with them. Also list the new symlink-race regression tests under DEVELOPER RELATED.
Drops the "dev" suffix on RSYNC_VERSION ahead of the 2026-05-20 00:00 UTC public release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR includes patches for 6 CVEs, see the NEWS.md update for details