mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-09-14 22:28:57 -04:00
The base gained 99 commits since the last rebase. Nine files needed a
hand-port because the change lands on code the VFS split moved or
renamed, and syscall.c no longer exists here:
- backup.c: make_path() now runs on a private copy of backup_dir_buf
(c933f622), so vfs_make_path() takes dirbuf and drops the restore.
- clientserver.c: keep both the module-root snapshot and the new
daemon_config_filter_file window.
- exclude.c: the peer-driven merge-file confinement is the
vfs_open_owner_walk() is_operator argument, not a global.
- fileio.c: the coalesced --sparse writer's new helpers use
vfs_lseek()/vfs_punch_hole().
- generator.c/vfs/mknod.c: gen_entry_mknod() falls back through
vfs_mknod(); the atfd path keys its mknodat() off HAVE_MKNODAT.
- receiver.c: secure_recv_open() passes VFS_OPERATOR_PATH instead of
toggling operator_path_resolve; open_readonly_inplace() uses the VFS
stat/chmod/open wrappers.
- sender.c: absolute --relative cleanup anchors at "/" via
vfs_resolve_open(), the copy-links walk uses
vfs_resolve_open_at_beneath(), and the source removal goes through
vfs_unlink().
- vfs/chmod.c, vfs/chown.c: VFS_OPERATOR_PATH now takes the ownership
walk, and the no-follow chmod grows the non-Linux fd path.
- vfs/secure_open.c: the fd-anchored resolver splits into a shared
internal with an allow-dotdot entry point, and secure_walk_at() routes
a literal "."/".." through ds_descend() before the leaf fast paths.
Tree is byte-identical to the merge oracle (tag merge-reference-10).
348 lines
12 KiB
C
348 lines
12 KiB
C
/*
|
|
* vfs/chmod.c - chmod wrappers (path, parent-resolved, held-dirfd).
|
|
*
|
|
* Includes the platform-specific lchmod/setattrlist/SYS_fchmodat2 handling and
|
|
* the leaf-safe do_fchmodat_nofollow helper.
|
|
*
|
|
* Copyright (C) 1998-2022 Andrew Tridgell, Martin Pool, Wayne Davison
|
|
* Copyright (C) 2026 Wayne Davison, Andrew Tridgell
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*/
|
|
|
|
#include "rsync.h"
|
|
#include "ifuncs.h"
|
|
#include "vfs/vfs_internal.h"
|
|
#ifdef HAVE_SYS_ATTR_H
|
|
#include <sys/attr.h> /* for the macOS setattrlist() chmod path */
|
|
#endif
|
|
#ifdef __linux__
|
|
#include <sys/syscall.h> /* SYS_fchmodat2 raw-syscall wrapper */
|
|
#endif
|
|
|
|
#ifdef HAVE_CHMOD
|
|
static int vfs__chmod_plain(const char *path, mode_t mode)
|
|
{
|
|
static int switch_step = 0;
|
|
int code;
|
|
|
|
if (dry_run) return 0;
|
|
RETURN_ERROR_IF_RO_OR_LO;
|
|
RETURN_ERROR_IF_NULL(path);
|
|
|
|
switch (switch_step) {
|
|
#ifdef HAVE_LCHMOD
|
|
case 0:
|
|
if ((code = lchmod(path, mode & CHMOD_BITS)) == 0)
|
|
break;
|
|
if (errno == ENOSYS)
|
|
switch_step++;
|
|
else if (errno != ENOTSUP)
|
|
break;
|
|
#endif
|
|
/* FALLTHROUGH */
|
|
default:
|
|
if (S_ISLNK(mode)) {
|
|
# if defined HAVE_SETATTRLIST
|
|
struct attrlist attrList;
|
|
uint32_t m = mode & CHMOD_BITS; /* manpage is wrong: not mode_t! */
|
|
|
|
memset(&attrList, 0, sizeof attrList);
|
|
attrList.bitmapcount = ATTR_BIT_MAP_COUNT;
|
|
attrList.commonattr = ATTR_CMN_ACCESSMASK;
|
|
if ((code = setattrlist(path, &attrList, &m, sizeof m, FSOPT_NOFOLLOW)) == 0)
|
|
break;
|
|
if (errno == ENOTSUP)
|
|
code = 1;
|
|
# else
|
|
code = 1;
|
|
# endif
|
|
} else
|
|
code = chmod(path, mode & CHMOD_BITS); /* DISCOURAGED FUNCTION */
|
|
break;
|
|
}
|
|
if (code != 0 && (preserve_perms || preserve_executability))
|
|
return code;
|
|
return 0;
|
|
}
|
|
|
|
/* chmod `name` relative to dfd without following a final-component symlink.
|
|
* The held parent fd confines the ancestors; this closes the leaf race (an
|
|
* attacker swapping the leaf to a symlink that fchmodat(...,0) would follow out
|
|
* of the tree).
|
|
*
|
|
* Never follows the leaf: a regular file or dir is pinned via
|
|
* openat(O_NOFOLLOW) and chmod'd with fchmod() (leaf-safe, every kernel, and
|
|
* fakeroot-wrappable unlike the raw fchmodat2() syscall); a symlink leaf is
|
|
* refused (ELOOP, or EMLINK/EFTYPE on the BSDs). Other types or an open
|
|
* failure fall to fchmodat(AT_SYMLINK_NOFOLLOW) (a real no-follow chmod on
|
|
* glibc>=2.32 / Linux>=6.6), then the raw fchmodat2() syscall. If no
|
|
* no-follow primitive exists we skip with a warning rather than follow the
|
|
* leaf.
|
|
*
|
|
* A FIFO takes the fd path on Linux and the pathname path elsewhere -- see the
|
|
* S_ISFIFO arm below for why, and for what that costs. Note the type used to
|
|
* choose between them comes from the lstat above, so a leaf swapped between
|
|
* that and the open is classified by what it WAS: an observed regular file or
|
|
* dir that becomes a FIFO is still opened. O_NOFOLLOW rejects symlinks, not
|
|
* type changes. Constraining the open to the observed type would close that;
|
|
* it is not done here. */
|
|
static int do_fchmodat_nofollow(int dfd, const char *name, mode_t mode)
|
|
{
|
|
#if defined AT_FDCWD && defined AT_SYMLINK_NOFOLLOW
|
|
mode &= CHMOD_BITS;
|
|
# ifdef O_NOFOLLOW
|
|
{
|
|
STRUCT_STAT st;
|
|
int oflags = O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_NOCTTY;
|
|
if (vfs_lstat(dfd, name, &st, 0) < 0)
|
|
return -1;
|
|
if (S_ISLNK(st.st_mode)) {
|
|
errno = ELOOP; /* refuse to chmod through a symlink leaf */
|
|
return -1;
|
|
}
|
|
if (S_ISREG(st.st_mode) || S_ISDIR(st.st_mode) || S_ISFIFO(st.st_mode)) {
|
|
int fd;
|
|
# ifndef __linux__
|
|
/* Never open a FIFO here. Opening one -- 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 pathname call reaches the same end state
|
|
* without that: it succeeds outright when the mode is
|
|
* grantable, and when macOS refuses an ungrantable setgid
|
|
* with EPERM (having applied nothing), asking again without
|
|
* that bit gives exactly what fchmod() would have -- it drops
|
|
* the bit it cannot grant and applies the ordinary ones.
|
|
* Measured on macOS: fchmodat(2750) EPERM leaving 0600,
|
|
* fchmodat(0750) ok giving 0750, for a FIFO and a directory
|
|
* alike.
|
|
*
|
|
* This is a pathname call, so unlike the descriptor path it
|
|
* does not pin the inode; 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. That trade buys
|
|
* away the reader hazard, and only for FIFOs.
|
|
*
|
|
* Only S_ISGID is retried. An ungrantable S_ISUID would
|
|
* still fail where fchmod() would have cleared it, but
|
|
* setuid is meaningless on a FIFO and the behaviour is
|
|
* undemonstrated, so it is not coded for.
|
|
*
|
|
* Linux keeps the fd-first order it has always had. */
|
|
if (S_ISFIFO(st.st_mode)) {
|
|
if (fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW) == 0)
|
|
return 0;
|
|
if (errno == EPERM && (mode & S_ISGID)
|
|
&& fchmodat(dfd, name, mode & ~S_ISGID,
|
|
AT_SYMLINK_NOFOLLOW) == 0)
|
|
return 0;
|
|
return -1;
|
|
}
|
|
# endif
|
|
# ifdef O_CLOEXEC
|
|
oflags |= O_CLOEXEC;
|
|
# endif
|
|
fd = openat(dfd, name, oflags);
|
|
if (fd >= 0) {
|
|
int r = fchmod(fd, mode), e = errno;
|
|
close(fd);
|
|
errno = e;
|
|
return r;
|
|
}
|
|
/* A leaf swapped for a symlink between the lstat above and
|
|
* this open: refuse rather than fall through. The errno is
|
|
* not the same everywhere -- Linux/Solaris ELOOP, FreeBSD
|
|
* EMLINK, NetBSD EFTYPE. */
|
|
if (errno == ELOOP
|
|
# ifdef EMLINK
|
|
|| errno == EMLINK
|
|
# endif
|
|
# ifdef EFTYPE
|
|
|| errno == EFTYPE
|
|
# endif
|
|
)
|
|
return -1; /* raced to a symlink: refuse */
|
|
/* otherwise (e.g. EACCES on an unreadable file) fall through */
|
|
}
|
|
}
|
|
# endif
|
|
# if defined __linux__
|
|
{
|
|
int r = fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW);
|
|
if (r == 0)
|
|
return 0;
|
|
if (errno != ENOTSUP && errno != EOPNOTSUPP && errno != ENOSYS)
|
|
return r; /* a real error (EPERM, ENOENT, ...) */
|
|
}
|
|
# ifdef SYS_fchmodat2
|
|
{
|
|
int r = syscall(SYS_fchmodat2, dfd, name, (unsigned int)mode, AT_SYMLINK_NOFOLLOW);
|
|
if (r == 0)
|
|
return 0;
|
|
if (errno != ENOSYS && errno != EPERM && errno != EOPNOTSUPP)
|
|
return r;
|
|
}
|
|
# endif
|
|
/* No symlink-safe chmod primitive here: skip rather than follow the leaf. */
|
|
rprintf(FWARNING, "vfs_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name);
|
|
return 1;
|
|
# else
|
|
return fchmodat(dfd, name, mode, AT_SYMLINK_NOFOLLOW);
|
|
# endif
|
|
#else
|
|
(void)dfd;
|
|
(void)mode;
|
|
/* No symlink-safe chmod primitive here: skip rather than follow the leaf. */
|
|
rprintf(FWARNING, "vfs_chmod: no symlink-safe chmod for \"%s\"; mode not set\n", name);
|
|
return 1;
|
|
#endif
|
|
}
|
|
|
|
/*
|
|
Symlink-race-safe variant of vfs_chmod() for receiver-side use.
|
|
|
|
Threat model: on a daemon running with "use chroot = no" (the prerequisite
|
|
for CVE-2026-29518), a local attacker can race a symlink swap of one of
|
|
the parent directory components of a path the receiver is about to chmod.
|
|
Because chmod() resolves symlinks at every component, the swap redirects
|
|
the chmod outside the receiver's confinement.
|
|
|
|
Defence: open the *parent* directory of fname under vfs_resolve_open()
|
|
(a portable per-component O_NOFOLLOW walk on held parent dirfds) and do
|
|
fchmodat() against that dirfd. A symlink substituted into one of the parent
|
|
components is then either followed within the tree (legitimate dir-symlinks
|
|
still work) or rejected (escape attempts fail).
|
|
|
|
Final-component handling matches vfs_chmod(): fchmodat() with flag 0
|
|
follows a symlink at the final component, which is the same behaviour as
|
|
chmod() and matches every current call site (the file being chmod'd is
|
|
one the receiver itself just created or transferred). For the rare case
|
|
where the caller wants to chmod a symlink-as-an-object (S_ISLNK in the
|
|
mode bits), we fall through to vfs_chmod() which has portability code for
|
|
that case.
|
|
|
|
Falls back to vfs_chmod() for absolute paths and for paths with no parent
|
|
component, where there is nothing to protect against.
|
|
*/
|
|
static int vfs__chmod_secure(const char *fname, mode_t mode, int flags)
|
|
{
|
|
#ifdef AT_FDCWD
|
|
char dirpath[MAXPATHLEN];
|
|
const char *bname;
|
|
const char *slash;
|
|
int dfd, ret, e;
|
|
size_t dlen;
|
|
|
|
if (dry_run) return 0;
|
|
RETURN_ERROR_IF_RO_OR_LO;
|
|
|
|
#if defined O_NOFOLLOW && defined O_DIRECTORY
|
|
/* Operator-supplied path: resolve the parent via the ownership walk, as
|
|
* the other VFS wrappers do. Without this the caller's VFS_OPERATOR_PATH
|
|
* has no effect here, and an absolute name would fall straight through to
|
|
* the unconfined full-path chmod. S_ISLNK(mode) still needs the plain
|
|
* lchmod()/setattrlist() handling. */
|
|
if ((flags & VFS_OPERATOR_PATH) && fname && *fname && !S_ISLNK(mode)) {
|
|
if (vfs_symlink_optout_allowed())
|
|
return vfs__chmod_plain(fname, mode);
|
|
dfd = vfs_owner_walk_parent(fname, &bname, 1);
|
|
if (dfd < 0)
|
|
return -1;
|
|
ret = do_fchmodat_nofollow(dfd, bname, mode);
|
|
e = errno;
|
|
close(dfd);
|
|
errno = e;
|
|
return ret;
|
|
}
|
|
#endif
|
|
|
|
/* Only the daemon-without-chroot case is exposed to the symlink-
|
|
* race attack: a chroot already confines the receiver, and a
|
|
* non-daemon rsync runs with the user's own authority so a
|
|
* symlink they planted can only redirect to files they could
|
|
* already access. Everywhere else, fall through to plain
|
|
* vfs_chmod() to avoid the dirfd-open overhead on every call. */
|
|
if (!vfs_relpath_active())
|
|
return vfs__chmod_plain(fname, mode);
|
|
|
|
if (!fname || !*fname || *fname == '/' || S_ISLNK(mode))
|
|
return vfs__chmod_plain(fname, mode);
|
|
|
|
slash = strrchr(fname, '/');
|
|
if (!slash)
|
|
return vfs__chmod_plain(fname, mode);
|
|
|
|
dlen = slash - fname;
|
|
if (dlen >= sizeof dirpath) {
|
|
errno = ENAMETOOLONG;
|
|
return -1;
|
|
}
|
|
memcpy(dirpath, fname, dlen);
|
|
dirpath[dlen] = '\0';
|
|
bname = slash + 1;
|
|
|
|
dfd = vfs_resolve_open(NULL, dirpath, O_RDONLY | O_DIRECTORY, 0);
|
|
if (dfd < 0)
|
|
return -1;
|
|
|
|
ret = do_fchmodat_nofollow(dfd, bname, mode);
|
|
e = errno;
|
|
close(dfd);
|
|
errno = e;
|
|
return ret;
|
|
#else
|
|
(void)flags;
|
|
return vfs__chmod_plain(fname, mode);
|
|
#endif
|
|
}
|
|
#endif
|
|
|
|
/* Unified chmod. dirfd == VFS_AT_FDCWD resolves `path`; a real held dirfd makes
|
|
* `path` a single component chmod'd (no-follow leaf, via do_fchmodat_nofollow)
|
|
* under it. flags: VFS_ALLOW_SYMLINK (trusted, plain chmod), default 0 (secure
|
|
* receiver resolve). A symlink-as-object (S_ISLNK(mode)) goes through the plain
|
|
* lchmod/setattrlist path. */
|
|
#ifdef HAVE_CHMOD
|
|
int vfs_chmod(int dirfd, const char *path, mode_t mode, int flags)
|
|
{
|
|
if (dry_run) return 0;
|
|
RETURN_ERROR_IF_RO_OR_LO;
|
|
RETURN_ERROR_IF_NULL(path);
|
|
|
|
if (dirfd != VFS_AT_FDCWD) {
|
|
#ifdef AT_FDCWD
|
|
/* Held-fd: reject empty, multi-component and ".." (writing the
|
|
* parent of the pinned dir); "." (chmod the dir itself) is a
|
|
* legitimate single-component op. */
|
|
if (!*path || strchr(path, '/')
|
|
|| (path[0] == '.' && path[1] == '.' && path[2] == '\0')) {
|
|
errno = EINVAL;
|
|
return -1;
|
|
}
|
|
return do_fchmodat_nofollow(dirfd, path, mode);
|
|
#else
|
|
(void)dirfd; (void)mode;
|
|
errno = ENOSYS;
|
|
return -1;
|
|
#endif
|
|
}
|
|
|
|
if (flags & VFS_ALLOW_SYMLINK)
|
|
return vfs__chmod_plain(path, mode);
|
|
return vfs__chmod_secure(path, mode, flags);
|
|
}
|
|
|
|
/* Mode on an already-open fd (no path, no symlink to follow): the race-free
|
|
* counterpart for a pinned cross-tree operator leaf -- see set_file_attrs(). */
|
|
int vfs_fchmod(int fd, mode_t mode)
|
|
{
|
|
if (dry_run) return 0;
|
|
RETURN_ERROR_IF_RO_OR_LO;
|
|
return fchmod(fd, mode);
|
|
}
|
|
#endif
|