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". CI didn't catch it because the openat2
path avoids those allocations, but the secure_relative_open() fallback
hits my_strdup() and aborts. Set max_alloc = (size_t)-1, matching the
v34-stable-testsuite fix. Reported by steadytao on PR #980.
The stable branch keeps the old shell test suite, so the modern Python
suite lives on the v34-stable-testsuite branch. Build rsync here and run
that suite against the built binary (helpers/config.h as tooldir from
this build, test scripts via --srcdir), giving regression coverage for
3.4.x without importing the full master suite.
Runs on ubuntu-latest and ubuntu-22.04 (older-LTS coverage for backports).
Each does a pipe-transport pass (with the same RSYNC_EXPECT_SKIPPED list
the v34-stable-testsuite ubuntu jobs use) and a --use-tcp pass for the
daemon tests the pipe run skips. Addresses review on PR #980.
Add the NEWS entry for rsync 3.4.4 (8 June 2026): the backported
regression fixes, the PORTABILITY note documenting the #915 alt-basis
platform limitation, the openat2 autodetect/mknodat fallback build
notes, the stable-testsuite CI addition, and a CREDITS section for the
contributors, reporters, and the PR #980 review.
The workflows triggered only on 'master', so PRs targeting a release branch
(e.g. v3.4-stable for 3.4.4) got no CI. Add a '*-stable' branch wildcard to
the push and pull_request filters.
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.
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.
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>
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
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
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
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
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.
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.
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).
--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).
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>
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>
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.
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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 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>
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>
The default python3 on AlmaLinux 8 is 3.6, but runtests.py uses
subprocess.run(capture_output=...) and check_output(text=...) which
were introduced in 3.7. Install the python39 module stream and point
/usr/bin/python3 at it via alternatives so the existing shebang
resolves correctly.
Reproduced as: TypeError: __init__() got an unexpected keyword
argument 'capture_output' at runtests.py line 75.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The intent is to validate that future security fixes still build and
test cleanly on the oldest still-supported LTS releases of the two
mainstream Linux families, so backports can be developed against the
same CI surface as the trunk:
- ubuntu-22.04: oldest GitHub Actions runner image still available
(20.04 was retired in April 2025). Mirrors the existing
ubuntu-build.yml step list.
- almalinux-8: RHEL 8 rebuild, full support until 2029. Runs in an
almalinux:8 container on ubuntu-latest because GHA has no native
runner for the Fedora/RHEL family. Pulls libzstd/xxhash/lz4 dev
headers from PowerTools + EPEL; commonmark via pip for the man
page generator.
Both jobs follow the same paths-ignore convention as the other
workflows so a workflow-only change to one file won't fan out across
the whole CI matrix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Use unshare with user namespace UID mapping to run the
protected-regular test without real root privileges. Falls back
to skipping if unshare or uidmap is not available.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test correctly skips on Cygwin (which lacks RESOLVE_BENEATH), but
the workflow's RSYNC_EXPECT_SKIPPED list still treats any change in
the skipped set as a CI failure. Add the new test name so the
skipped/got comparison matches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
secure_relative_open() has a kernel-enforced "stay below dirfd" path
on Linux 5.6+ (openat2 RESOLVE_BENEATH) and FreeBSD 13+ (openat
O_RESOLVE_BENEATH). On Solaris, OpenBSD, NetBSD, and Cygwin the code
falls back to the per-component O_NOFOLLOW walk, which by design
rejects every directory symlink in the path -- the very case this
test exercises. Mark the test skipped there rather than have it
fail with a known regression that's tracked separately.
macOS is intentionally not in the skip list: although it does not
have O_RESOLVE_BENEATH either, the test passes there in practice;
investigation of the underlying reason is left as follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>