Commit Graph
143 Commits
Author SHA1 Message Date
Andrew Tridgell 3113011218 rsync: add --confine-root, bounding operator path resolution
The ownership walk that resolves operator-supplied paths asks who planted a
symlink, not where the path came out, so a symlink owned by uid 0 or the euid
is followed wherever it points.  A daemon already narrows that with the served
module root; nothing else has a root to narrow it with.

That leaves a wrapper serving a restricted directory over a remote shell with
no way to bound the resolution.  rrsync can vet the argv it is handed, but
filter rules travel over the protocol instead: a dir-merge rule can name a
merge file outside the restricted dir and the server reads it in as rules.
Redacting the resulting diagnostics does not close it, because an exclude-only
merge produces none -- every line becomes a pattern, so nothing fails to parse,
and the client reads the file's contents off which of its own names went
missing from the file list.

--confine-root gives that wrapper the root the daemon has.  The existing module
check becomes a root check that takes its root from module_dir when we are a
daemon and from the option otherwise, so daemon behaviour is unchanged; a
daemon ignores the option outright, since it arrives in a peer-supplied argv
and could only widen the module.

The tracker is seeded from getcwd() rather than curr_dir, which is only the
lexical name change_dir() was given: descend into a source argument through a
trusted symlink and the two sit at different depths, so a ".." that really
escapes looks like it landed inside.  When the cwd cannot be read there is
nothing to measure against and the open is refused -- an empty tracker does not
deny by itself, because a leading ".." pops nothing from it and an empty path
reads as an ancestor of the root.

An fd pin (/proc/self/fd/N, which rrsync uses so no later symlink can redirect
a validated option path) is spelled outside the root by construction, so the
walk transits the pin namespace and the pin is judged by what it points at.
Only a bare ".../fd/<digits>" is resolved that way, and one that will not
readlink to an absolute path is refused; rrsync's ".../fd/N/<leaf>" spelling
resolves through the magic link and has its remaining components checked
normally.

--insecure-links is refused alongside it: that opt-out returns the legacy open
before the walk that enforces the root runs, so the pair would have quietly
meant no confinement at all.
2026-08-03 05:35:03 +10:00
Andrew Tridgell c458873481 syscall: guard do_mknod_atfd()'s mknodat() with HAVE_MKNODAT
It used HAVE_MKNOD.  Older Darwin has mknod() but not mknodat(), so the
call was compiled and then failed to link:

    "_mknodat", referenced from:
          _do_mknod_atfd in syscall.o
    ld: symbol(s) not found for architecture x86_64

Reported on macOS 10.13.6 x86_64, where config.h carries
/* #undef HAVE_MKNODAT */ next to #define HAVE_MKNOD 1.  The sibling
do_mknod_at() already keys off HAVE_MKNODAT and its comment names this
exact platform.

Both occurrences change together.  The second guards

    return -1;	/* mknodat()'s errno (regular/device node) */

against an ENOSYS fallback, so leaving it on HAVE_MKNOD would report
"mknodat()'s errno" on a build where the call was never compiled -- a
stale errno from whatever ran last.

Checked by rewriting config.h the way that platform has it and compiling
syscall.c: the parent leaves one unresolved mknodat reference, this
leaves none.  A FIFO still goes to mkfifoat() where that exists, and a
socket still returns EOPNOTSUPP; only the regular/device-node path
becomes ENOSYS, which is what a platform without mknodat() can offer.
2026-08-01 19:19:42 +10:00
Filipe Casal 5f0f8f298e syscall: preserve ordinary mode when setgid is denied
macOS's fchmodat(..., AT_SYMLINK_NOFOLLOW) returns EPERM and applies
NOTHING when the requested setgid bit is ungrantable, so
"rsync -a --chmod=D2750,F0640" exits 23 and leaves the destination at
0755/0700 where 3.4.4 leaves 0750.  fchmod() on an already-open
descriptor does the right thing: it succeeds, drops the setgid bit it
cannot grant, and applies the ordinary bits.

That fd path existed but was fenced behind "#if defined __linux__".
Guard it on O_NOFOLLOW so every platform that can open a leaf without
following a symlink uses it, and leave the fchmodat/fchmodat2 fallbacks
under __linux__.  Measured on macOS with an ungrantable group:

  fchmodat(2750, NOFOLLOW)  EPERM, 0700 -> 0700   (dir and FIFO alike)
  fchmod(fd, 2750)          ok,    0700 -> 0750
  fchmodat(0750, NOFOLLOW)  ok,    0700 -> 0750

A FIFO observed by the lstat takes the pathname call instead, then one
retry without S_ISGID.  Opening a FIFO -- even O_NONBLOCK -- makes this
process a reader for as long as the descriptor lives, which wakes a
writer blocked in open(O_WRONLY) and can cost it a SIGPIPE or the bytes
it writes before we close; the third line above is why that is
avoidable.  This does not make the function FIFO-open-free: the type
comes from the lstat, so a leaf swapped to a FIFO after it is still
opened, and set_file_attrs() opens FIFOs elsewhere for ACL/xattr work.
Closing that needs the open constrained to the observed type, which is
tracked separately.

The retry is a pathname call and does not pin the inode, so a leaf
swapped for another object of the same name is chmod'd instead;
AT_SYMLINK_NOFOLLOW still keeps it off a symlink's target, and the held
parent fd still confines the ancestors, so the out-of-tree boundary is
unaffected.  Only S_ISGID is retried -- clearing S_ISUID too could
discard a bit that was grantable when only setgid caused the failure.
Linux keeps the fd-first order it has always had.

The raced-to-a-symlink refusal after openat() also accepts EMLINK and
EFTYPE.  ELOOP is not universal for O_NOFOLLOW on a symlink -- FreeBSD
documents EMLINK and NetBSD EFTYPE -- so those two silently skipped the
refusal and fell through to the (still symlink-safe) pathname call.

Verified on real macOS: the regression test FAILS on 93a67aa9 with rc=23
and modes 0755/0700, and PASSES here with 0750/0750.
2026-07-29 10:12:24 +10:00
Filipe Casal 4ce54db5ef syscall: preserve fake-super backups as placeholders 2026-07-28 15:14:43 +10:00
Andrew Tridgell c5bc4e3677 syscall: defer a literal ".." to the walk before the leaf fast paths
secure_walk_at() has two fast paths for the final component that call
openat(ds_cur(&ds), part, ...) directly instead of going through
ds_descend().  A final component of ".." therefore never met the anchor
floor, and what happened depended entirely on the caller's flags:

    ".." with O_DIRECTORY              -> ELOOP          (refused)
    ".." with O_DIRECTORY|O_NOFOLLOW   -> fd for the directory ABOVE
                                          the anchor
    ".." without O_DIRECTORY           -> parent opened, then closed,
                                          EISDIR returned

That was harmless while every literal ".." was rejected at the front
door, but secure_relative_open_at_beneath() now admits them and
documents the held-fd stack as refusing every climb above the anchor.
The sender's own call passes O_RDONLY|O_DIRECTORY and so was never
affected -- but the guarantee the new API advertises has to hold for
whatever flags the next caller picks.

Route a literal "." or ".." through ds_descend() before the leaf fast
paths.  All three flag combinations now refuse a bare ".." with ELOOP,
and t_secure_relpath covers the matrix.
2026-07-25 17:28:54 +10:00
Filipe Casal 4d8cbbecac sender: allow confined parent-relative copy-links targets 2026-07-25 17:28:54 +10:00
Andrew Tridgell 0bfcd3b0f2 syscall: honor operator_path_resolve in do_chmod_at/do_lchown_at
Every other mutating do_*_at() wrapper (unlink, symlink, link, mknod,
rmdir, open, mkdir, rename) resolves an operator-supplied path through
owner_walk_parent() when operator_path_resolve is set.  do_chmod_at()
and do_lchown_at() did not look at the flag at all, and both hand an
absolute name straight to the unconfined full-path do_chmod()/do_lchown().

set_file_attrs() is called with operator_path_resolve = 1 precisely so
that "a flipped temp-dir parent then can't redirect the chmod/chown"
(rsync.c).  With no held dirfd -- an absolute --temp-dir or
--partial-dir -- both fell back to these two wrappers, so that promise
did not hold.  Give them the same ownership-walk branch the others use.

S_ISLNK(mode) still takes do_chmod()'s lchmod()/setattrlist() path.

The missing branch was spotted by Omar Elsayed in review on the
partial-dir EACCES recovery PR, together with the fix approach.

Suggested-by: Omar Elsayed <omarelsayed161@gmail.com>
2026-07-25 10:38:42 +10:00
Andrew Tridgell 7b16872eff syscall: silence scan-build dead-store in do_fchmodat_nofollow fallback
When neither AT_FDCWD nor AT_SYMLINK_NOFOLLOW is available, the function body is
a no-op warning that never reads mode or dfd, so the leading 'mode &= CHMOD_BITS'
became a dead store and dfd an unused parameter -- which the pinned clang-18
scan-build gate flags (deadcode.DeadStores).  Move the mask inside the
AT_SYMLINK_NOFOLLOW guard where mode is actually used, and mark dfd/mode used in
the fallback.  No behavior change on any platform that has the symlink-safe
primitive.
2026-07-24 16:01:48 +10:00
Omar ElsayedandAndrew Tridgell 71b01c4b5c syscall: confine operator paths in do_symlink_at and do_rmdir_at
do_symlink_at() and do_rmdir_at() were the only operator-path syscall
wrappers still missing the operator_path_resolve branch that
do_mknod_at()/do_open_at()/do_rename_at()/do_unlink_at() already carry:
an absolute operator path (e.g. an absolute --backup-dir) took the
'*path == "/"' arm straight to the bare do_symlink()/rmdir(), whose
libc path resolution follows a parent-component symlink.  A local
attacker who owns a parent component of the backup tree could thus
redirect a backup symlink creation or a backup-dir rmdir outside the
intended tree.

Route both through owner_walk_parent() + symlinkat()/unlinkat() when
operator_path_resolve is set, mirroring the existing wrappers, so a
foreign-owned parent component is refused while the operator's own is
followed.  symlink_optout_allowed() (--insecure-links / 'insecure
links =') restores the legacy following.

Tests: operator-path-backup-symlink and operator-path-backup-rmdir
drive a live parent-component swap (native C flipper) against a local
--backup-dir push and confirm nothing escapes the backup tree (RED
before, GREEN after).

Co-authored-by: Andrew Tridgell <andrew@tridgell.net>
2026-07-20 14:07:05 +10:00
Andrew Tridgell 11d3090730 syscall: drop the dead name_is_dir arg from abspath_excluded_by_module
abspath_excluded_by_module() is module-ROOT confinement only by design (the
daemon name/exclude filter is name-based visibility, not a physical-path
boundary -- munge symlinks is that defense), so its name_is_dir parameter was
always discarded ((void)name_is_dir).  Remove it, and the now-redundant
absent-leaf second call in owner_walk_parent()'s leaf check that re-tested the
SAME leafabs with name_is_dir=1 for an identical result, plus the
fstatat()/isdir/absent that only fed the ignored logic.  Behavior-preserving
(the refuse decision is unchanged at every call site); drops one incidental
lstat.

(Leonid Bugaev May-2026 re-audit, KI-49 -- dead defense-in-depth arm, no escape.)
2026-07-20 14:05:32 +10:00
Andrew Tridgell a701236c64 confine the remaining cross-tree operator-path syscalls
The backup-dir fix pinned chmod/chown for an operator leaf; extend the same
confinement to every other sink that re-resolves a cross-tree operator path
(an absolute --temp-dir/--partial-dir/--*-dest), each confirmed by a cross-uid
race PoC that is RED on stock 3.2.7 and GREEN here:

- set_file_attrs (rsync.c): route times (do_futimens), and -- by aliasing
  held_fd to the pinned op_leaf_fd -- the xattr/ACL ops through the same
  O_NOFOLLOW leaf fd, not a re-resolvable path.  A raced/refused pin skips them
  (op_refuse) rather than redirecting.  finish_transfer now wraps the pre-rename
  set_file_attrs in operator_path_resolve so an absolute --temp-dir temp file's
  metadata is pinned too (in-tree temps keep their held dirfd, so op_pin is off).

- do_rename_at / do_link_at (syscall.c): an ABSOLUTE side was left at AT_FDCWD
  and followed a flipped parent symlink, letting a name-disclosed --temp-dir /
  predictable --partial-dir rename pull an attacker file into the destination
  (content injection).  Resolve an absolute (operator) side via the ownership
  walk -- with module-exclude enforced -- while a relative (transfer) side stays
  on secure_relative_open.  --insecure-links keeps the legacy path.

- secure_basis_open (receiver.c): an alt-dest basis read
  (--copy-dest/--compare-dest/--link-dest) on a non-daemon receiver used a bare
  do_open; route it through the ownership walk (refuses a foreign-owned basis
  symlink, still allows the "../sibling" basis of #915).  Daemons keep their
  existing confinement branch.

- configure.ac: probe futimens (do_futimens is gated on HAVE_FUTIMENS).

New fd wrappers: do_fchmod, do_fchown, do_futimens.  Documented residuals left
as-is: copy_altdest_file's basis copy (routing it re-opens copy-xattrs-symlink-
race; basis_link_stat already refuses the foreign symlink), crtimes
(do_setattrlist_crtime/do_SetFileTime, path-based on macOS/Cygwin), and
device/socket leaf metadata (pinning a device via open has side effects).
2026-07-20 14:05:31 +10:00
Andrew Tridgell 1624b6daac backup: confine cross-tree operator-path metadata via a pinned fd
A symlink race on a non-daemon --backup-dir let a local attacker redirect
rsync's backup writes and chmods outside the backup tree when rsync runs as
root.  make_backup() sets operator_path_resolve, but set_file_attrs() had no
held parent dir fd for an absolute backup path (held_dfd_for() returns -1), so
its chmod/chown fell through to the path-based wrappers and, for an absolute
path, to raw chmod()/lchown().  copy_valid_path() mirrors the source dir's
attrs onto each backup subdir; when an attacker flips a backup component to a
symlink in that window the raw lchown retags the planted symlink as root-owned
-- laundering it into a "trusted" (uid 0) symlink that the owner-walk then
follows, so the backup rename/chmod escapes the tree.

Pin the leaf inode of a cross-tree operator path with an O_NOFOLLOW open via
the operator owner-walk resolver and drive fchmod/fchown off that fd; a raced
symlink leaf makes the open fail and the op is refused, never redirected.  Gate
on the INTENDED type (new_mode), not the attacker-controlled on-disk type.  As
root any open failure is the race (a real owned leaf never fails); a non-root
operator, which cannot launder a uid-0 symlink, falls back to the legacy path
op on a benign EACCES.  --insecure-links opts back out.

Adds do_fchmod()/do_fchown() fd wrappers.  Residual cross-tree metadata sinks
(times, ACLs, xattrs, and --temp-dir's finish_transfer set_file_attrs) are not
covered here and are tracked for a follow-up.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 502fb584c4 daemon: insecure links opt-out restores legacy symlink following on the receiver
secure_relpath_active() (the gate that routes receiver-side filesystem ops --
get_dir_fd/dpc, do_*_at, open_tmpfile, make_path, link_stat -- through the
symlink-race-safe resolver) checked only am_daemon/am_chrooted/am_sender, not
the symlink_optout_allowed() opt-out.  So `insecure links = yes` (or a non-daemon
--insecure-links) restored the legacy follow only on the SENDER enumeration
(which checks the opt-out directly), while the receiver still confined writes,
mkdirs, renames, unlinks and stats through a pre-existing in-module symlink --
i.e. the admin opt-out did not actually reproduce the pre-3.4.3 behaviour it
documents (rsyncd.conf(5) "munge symlinks"/"insecure links").

Have secure_relpath_active() return 0 when symlink_optout_allowed(), so the
opt-out uniformly disables the secure resolver on both sides.  No effect on the
default (opt-out off): confinement is unchanged.
2026-07-20 14:05:31 +10:00
Andrew Tridgell fe1adcfd73 syscall: do_mknod_at must mkfifoat for an operator-path FIFO on non-Linux
The operator_path_resolve branch of do_mknod_at() called mknodat() and
returned its result directly, without the FIFO/socket fallback that the
bare-path do_mknod() and the secure-relpath branch below it both have.
mknodat() can make a FIFO only on Linux; on the BSDs/macOS/Solaris it fails
with EINVAL, so creating a special file under an operator-supplied path --
e.g. backing up a FIFO into a --backup-dir -- failed there.  Retry race-safely
with mkfifoat() on the held parent dirfd, and fail a nested socket closed
(EOPNOTSUPP) exactly as the secure-relpath path does.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 5d1b15086d syscall: give the dirstack a fixed fd array instead of a malloc'd one
The race-safe resolver's dirstack grew its fd array with realloc() inside the
recursive ds_descend() walk and freed it via a passed pointer.  clang's
unix.Malloc analyzer cannot model that ownership and reported a false "Potential
leak of ds.fds", failing the pinned-clang-18 scan-build gate.

The walk holds one open fd per path component, so its depth is already bounded
by RLIMIT_NOFILE; use a fixed inline array (DS_MAXDEPTH, mirroring DPC_MAXDEPTH)
and drop the malloc/realloc/free entirely.  ds_push() fails with ENOMEM past the
cap.  No behaviour change -- 1024 levels exceeds any reachable depth.
2026-07-20 14:05:31 +10:00
Andrew Tridgell 54965efca9 syscall/util1: race-safe path resolution via a held dirfd-stack resolver
The core symlink-race (TOCTOU) defense for the CVE-2026-29518 class: a portable
secure resolver that walks a path one component at a time holding an O_NOFOLLOW
dir fd per level (the dirfd-stack), plus the do_*_at() filesystem wrappers, the
held-directory fd cache, secure_relative_open[_at](), the operator-path ownership
walk (owner_walk_parent / open_no_attacker_symlinks, follow a uid0/euid symlink,
refuse a foreign one) and its module-root confinement (abspath_excluded_by_module).
util1.c routes change_dir / robust_rename / make_path / handle_partial_dir through
it; the resolver bounds its deep-path fd use against RLIMIT_NOFILE.  Also drops the
obsolete android.c openat2 path and confines delete.c via the held dirfd.
2026-07-20 14:05:30 +10:00
Zen Dodd 3c9a12011e ci: fix no-AT_FDCWD compile check 2026-07-20 10:05:43 +10:00
Zen Dodd 5cb4b8290b syscall: build without AT_SYMLINK_NOFOLLOW 2026-07-20 10:05:43 +10:00
Andrew Tridgell b3107260a2 build: fall back to do_mknod() when mknodat() is unavailable (#896)
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.

Fixes: #896
2026-06-05 06:35:12 +10:00
Andrew Tridgell 4634b0ada7 android: probe openat2 usability behind a SIGSYS handler
Android's seccomp sandbox traps openat2() with SECCOMP_RET_TRAP, which
raises SIGSYS and kills the process instead of returning ENOSYS, so the
secure resolver cannot simply try openat2() and inspect errno.  Add
openat2_usable() in a new android.c: it probes openat2() once behind a
temporary SIGSYS handler and caches the result.

Gate every SYS_openat2 call on openat2_usable(): in the resolver via an
openat2_beneath() wrapper, and in t_chmod_secure's kernel probe directly,
so a blocked openat2 reports ENOSYS and the caller falls back to the
portable O_NOFOLLOW resolver.  Only openat2 is gated -- a plain openat()
(e.g. opening an operator-trusted absolute basedir) is left free.

The probe body compiles only on Android -- __ANDROID__ is a Bionic target
macro, so it is set for NDK cross-builds and native Termux alike and unset
everywhere else, where openat2_usable() collapses to a constant 1.  Link
android.o into the secure-resolver test helpers too so their self-tests
survive on Termux.

Adapted from PR #909.
2026-06-04 13:41:07 +10:00
Andrew Tridgell 5972ebdaf8 syscall/receiver: honour a relative alt-basis dir on a daemon receiver (#915)
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.
2026-06-04 07:41:41 +10:00
Andrew TridgellandClaude Opus 4.7 4f5a5857ce Fix --preallocate --sparse to actually produce sparse files
rsync.1 says combining --preallocate with --sparse yields sparse blocks
wherever the filesystem can punch holes, but since 2019 (commit c2da3809,
"keep file-size 0 when possible") it has silently left the file fully
allocated. Two problems, both rooted in that commit switching --preallocate /
--inplace to fallocate(FALLOC_FL_KEEP_SIZE):

  * do_fallocate() then returned 0 instead of the reserved length, so the
    receiver's preallocated_len was 0 and write_sparse() always lseek'd over
    null runs instead of punching them (and the over-preallocation trim in
    receiver.c never fired either).

  * more fundamentally, KEEP_SIZE leaves the file size at 0 while data is
    written incrementally, so the FALLOC_FL_PUNCH_HOLE call lands on blocks
    beyond EOF and is a silent no-op -- the reserved blocks are never freed.

Fix both: don't request KEEP_SIZE when --sparse is also active, so the file is
preallocated at full size and the punch lands within it; and return the
reserved length from do_fallocate() so preallocated_len drives the punch
decision and the over-allocation trim. --preallocate without --sparse keeps
the KEEP_SIZE (file-size-0) behaviour. t_stub.c gains a sparse_files stub since
do_fallocate now references it and the test helpers link syscall.o.

preallocate_test.py now asserts via st_blocks (where the filesystem can punch
holes) that --preallocate --sparse ends up sparse, guarding the regression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:03:58 +10:00
Andrew TridgellandClaude Opus 4.7 1d5b5ab83a build: add gcov coverage and --disable-openat2 knobs for the test suite
Two test-coverage build knobs (both behaviour-neutral by default):

  --enable-coverage  appends '--coverage -fprofile-update=atomic -O0' and adds
                     a 'make coverage' target (whole suite, run serially, then
                     gcovr HTML with branch + decision coverage). rsync forks
                     and its children exit without running the gcov atexit
                     flush -- the generator via its SIGUSR1 handler
                     (_exit_cleanup) and the receiver via the SIGUSR2 handler
                     -- so under GCOV_COVERAGE we call __gcov_dump() at both, or
                     receiver.c/generator.c record no coverage at all.

  --disable-openat2  gates the Linux openat2(RESOLVE_BENEATH) sites in syscall.c
                     on HAVE_OPENAT2 (defined by default), so disabling it forces
                     the portable per-component O_NOFOLLOW resolver to run as the
                     primary on ordinary Linux -- exercising and
                     coverage-counting that fallback tier without a pre-5.6
                     kernel. NOTE: coordinate with the parallel syscall.c
                     path-resolution restructure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:31:52 +10:00
Andrew TridgellandClaude Opus 4.7 3cc6a9e8cd util1+syscall: secure copy_file source/dest opens; bare-path defence-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>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 30656c5e35 syscall: add symlink-race-safe do_*_at() wrappers and harden secure_relative_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>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 862fe4eeaf syscall+receiver: secure receiver-side do_chmod against symlink-race 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>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 f1c24ab03b syscall+clientserver: am_chrooted and use_secure_symlinks for daemon-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>
2026-05-20 10:01:22 +10:00
Andrew TridgellandClaude Opus 4.7 7f60ec001a syscall: also use O_RESOLVE_BENEATH on FreeBSD and MacOS
FreeBSD and MacOS have O_RESOLVE_BENEATH as an openat() flag with the same
"must not escape dirfd" semantics as Linux's RESOLVE_BENEATH. The
kernel rejects ".." escapes, absolute symlinks, and symlinks whose
target lies outside dirfd, while still following symlinks that
resolve within it -- the same trade-off that fixes issue #715 on
Linux.

Add a parallel BSD path in secure_relative_open(), gated on
declared. Unlike Linux, BSD doesn't have the header/runtime split
where the symbol can exist without kernel support, so no runtime
fallback is needed: if the flag compiles in, the kernel honours it.

OpenBSD and NetBSD have no equivalent kernel primitive and continue
to use the existing per-component O_NOFOLLOW walk; issue #715
remains visible on those platforms (a userland resolver or
unveil(2)-based fence would be follow-up work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:31 +10:00
Andrew TridgellandClaude Opus 4.7 4fa7156ccd syscall: use openat2(RESOLVE_BENEATH) on Linux for secure_relative_open
The CVE fix in commit c35e283 made secure_relative_open() walk every
component of relpath with O_NOFOLLOW. That blocks every symlink in the
path, which is stricter than the threat model required: legitimate
directory symlinks within the destination tree (e.g. when using -K /
--copy-dirlinks) are also rejected, breaking delta transfers with
"failed verification -- update discarded".  See issue #715.

On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives
us exactly what we want: the kernel rejects any resolution that would
escape the starting directory (via "..", absolute paths, or symlinks
pointing outside dirfd) while still following symlinks that resolve
within it. /proc magic-links are blocked too.

Use openat2 first; fall back to the existing per-component O_NOFOLLOW
walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head
of the function are kept as defense in depth. The Linux gate is
plain #ifdef __linux__: the runtime ENOSYS fallback covers the only
case that actually matters (header present + old kernel), and any
Linux build environment without linux/openat2.h will fail with a
clear "no such file" error rather than silently disabling the
protection.

Verified manually that openat2(RESOLVE_BENEATH) blocks all four
escape patterns (absolute symlink, ../ symlink, lexical .., absolute
path) while allowing direct and within-tree symlinks. The new
testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel
Henrique) exercises the issue #715 regression and passes; full
make check passes 47/47.

Test: testsuite/symlink-dirlink-basis.test (8 scenarios)
Fixes: https://github.com/RsyncProject/rsync/issues/715

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:30:31 +10:00
Silent 77be09aaed syscall: fix a Y2038 bug by replacing Int32x32To64 with multiplication
Int32x32To64 macro internally truncates the arguments to int32,
while time_t is 64-bit on most/all modern platforms.
Therefore, usage of this macro creates a Year 2038 bug.
2025-08-23 17:32:11 +10:00
Krzysztof Płocharz 992e10efaf Fix --open-noatime option not working on files
atime of source files could sometimes be overwritten
even though --open-noatime option was used.

To fix that, optional O_NOATIME flag was added
to do_open_nofollow which is also used to open regular
files since fix:
  "fixed symlink race condition in sender"
Previously optional O_NOATIME flag was only in do_open.
2025-08-23 17:13:09 +10:00
Natanael Copa 68e9add76a Fix build on ancient glibc without openat(AT_FDCWD
Fixes: https://github.com/RsyncProject/rsync/issues/701
2025-01-16 06:43:57 +11:00
Andrew Tridgell 0590b09d9a fixed symlink race condition in sender
when we open a file that we don't expect to be a symlink use
O_NOFOLLOW to prevent a race condition where an attacker could change
a file between being a normal file and a symlink
2025-01-15 05:30:32 +11:00
Andrew Tridgell 9f86ddc965 disallow ../ elements in relpath for secure_relative_open 2025-01-15 05:30:32 +11:00
Andrew Tridgell b4a27ca25d added secure_relative_open()
this is an open that enforces no symlink following for all path
components in a relative path
2025-01-15 05:30:32 +11:00
Holger Hoffstätte 07069880a2 Fix warning about conflicting lseek/lseek64 prototypes
Clang rightfully complains about conflicting prototypes, as both lseek() variants
are redefined:

  syscall.c:394:10: warning: a function declaration without a prototype is deprecated
  in all versions of C and is treated as a zero-parameter prototype in C2x, conflicting
  with a previous declaration [-Wdeprecated-non-prototype]
        off64_t lseek64();
                ^
/usr/include/unistd.h:350:18: note: conflicting prototype is here
extern __off64_t lseek64 (int __fd, __off64_t __offset, int __whence)
                 ^
1 warning generated.

The point of the #ifdef is to build for the configured OFF_T; there is
no reason to redefine lseek/lseek64, which should have been found
via configure.

Signed-off-by: Holger Hoffstätte <holger@applied-asynchrony.com>
2024-11-19 21:28:39 -08:00
Wayne Davison b7b387b1f7 Add FALLTHROUGH comment. 2022-03-13 09:31:44 -07:00
Wayne Davison 142aba00d5 Silence some symlink mode-change failures. 2022-01-17 22:23:31 -08:00
Wayne Davison c3b553a93f Preparing for release of 3.2.4pre2 2022-01-15 17:21:01 -08:00
Wayne Davison 3e44bbd313 Preparing for release of 3.2.4pre1 2022-01-02 15:13:19 -08:00
Wayne Davison 296352ecb0 Tweak atime/crtime code a bit more. 2021-10-10 12:43:11 -07:00
Wayne Davison 11a9b62322 Avoid spurious warning about "code" var not being initialized. 2021-10-10 10:05:26 -07:00
Wayne Davison 452ef78517 Unify on "path" vs "fname" arg naming. 2021-10-10 09:53:35 -07:00
Wayne Davison 0d1b48893a Change do_lchmod() back to a swtich with some better ENOTSUP & ENOSYS logic. 2021-10-10 09:32:43 -07:00
Wayne Davison 78b5bc6629 Enable --atimes on macOS. 2021-10-02 15:23:30 -07:00
Wayne Davison f41cdc75a1 Check ro in set_create_time() for Cygwin too. 2021-10-02 11:39:41 -07:00
Wayne Davison 15dd2058fd Change do_chmod to always try lchmod() first (when possible). 2021-10-01 13:28:57 -07:00
Wayne Davison 291a042b3e Support --crtimes on Cygwin. 2021-07-08 18:59:26 -07:00
Wayne Davison 9dd62525f3 Work around glibc's lchmod() issue a better way. 2020-11-29 09:40:03 -08:00
Wayne Davison 5db7e4b1ee Use linkat() if available
Some OSes have a more capable linkat() function that can hard-link
syslinks, so use linkat() when it is available.
2020-07-27 16:36:55 -07:00