mirror of
https://github.com/RsyncProject/rsync.git
synced 2026-05-29 09:17:21 -04:00
The chmod-symlink-race test was previously a no-op on Solaris,
OpenBSD, NetBSD, and Cygwin via a case 'uname -s' skip. The skip
was too broad: of the four scenarios the helper exercises, only
the 'legitimate within-tree dir-symlink' one actually needs
RESOLVE_BENEATH-equivalent kernel support. The other three
(attack rejection, plain relative path, top-level file) behave
identically on the per-component O_NOFOLLOW fallback and would
have caught the t_stub.c max_alloc=0 bug fixed in the previous
commit if the test had been allowed to run.
Make the helper probe the running kernel for either
openat2(RESOLVE_BENEATH) on Linux 5.6+ or openat(O_RESOLVE_BENEATH)
on FreeBSD 13+ / macOS 15+ by opening '.' under the requested
confinement. Honour the result:
- If RESOLVE_BENEATH-equivalent confinement is available, the
within-tree symlink scenario must succeed (status quo).
- If not, the per-component O_NOFOLLOW fallback rejects every
symlink including legitimate ones; expect the within-tree
symlink scenario to be rejected (rc != 0) and the file mode
to remain unchanged.
The attack-rejection, plain-path and top-level scenarios are
unchanged: they expect the same outcome on both code paths.
Drop the case-based skip from chmod-symlink-race.test so the test
runs everywhere and the per-component fallback gets the CI
coverage that the SunOS/OpenBSD/NetBSD/Cygwin runners can
provide. HPE NonStop -- which lacks RESOLVE_BENEATH but isn't in
the existing skip list -- is also covered by this change.
184 lines
5.9 KiB
C
184 lines
5.9 KiB
C
/*
|
|
* Test harness for do_chmod_at(). Confirms the symlink-TOCTOU
|
|
* primitive used by CVE-2026-29518 (and its incomplete-fix follow-up
|
|
* for chmod) is closed by do_chmod_at(): a parent directory component
|
|
* being a symlink that escapes the receiver's confinement must be
|
|
* rejected, while a parent symlink that resolves *within* the tree
|
|
* must still work (so legitimate dir-symlinks are not regressed).
|
|
*
|
|
* Not linked into rsync itself.
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License version 2 as
|
|
* published by the Free Software Foundation.
|
|
*/
|
|
|
|
#include "rsync.h"
|
|
|
|
#include <sys/stat.h>
|
|
|
|
#ifdef __linux__
|
|
#include <sys/syscall.h>
|
|
#include <linux/openat2.h>
|
|
#endif
|
|
|
|
int dry_run = 0;
|
|
int am_root = 0;
|
|
int am_sender = 0;
|
|
int read_only = 0;
|
|
int list_only = 0;
|
|
int copy_links = 0;
|
|
int copy_unsafe_links = 0;
|
|
extern int am_daemon, am_chrooted;
|
|
|
|
short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG];
|
|
|
|
static int errs = 0;
|
|
|
|
/* Probe the running kernel for the RESOLVE_BENEATH-equivalent confinement
|
|
* that secure_relative_open() prefers over the per-component O_NOFOLLOW
|
|
* walk. Returns 1 if either openat2(RESOLVE_BENEATH) on Linux 5.6+ or
|
|
* openat(O_RESOLVE_BENEATH) on FreeBSD 13+ / macOS 15+ is honoured by
|
|
* the running kernel, 0 otherwise. The probe opens "." (a directory
|
|
* the helper has just chdir'd into) so it can't fail for any reason
|
|
* other than the kernel rejecting the requested confinement flag. */
|
|
static int kernel_resolve_beneath_supported(void)
|
|
{
|
|
int fd;
|
|
#ifdef __linux__
|
|
{
|
|
struct open_how how;
|
|
memset(&how, 0, sizeof how);
|
|
how.flags = O_RDONLY | O_DIRECTORY;
|
|
how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS;
|
|
fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how);
|
|
if (fd >= 0) {
|
|
close(fd);
|
|
return 1;
|
|
}
|
|
/* ENOSYS = kernel < 5.6. Fall through to the O_RESOLVE_BENEATH
|
|
* probe in case we're a Linux build running on a kernel that
|
|
* gained O_RESOLVE_BENEATH via some out-of-tree backport. */
|
|
}
|
|
#endif
|
|
#ifdef O_RESOLVE_BENEATH
|
|
fd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY | O_RESOLVE_BENEATH);
|
|
if (fd >= 0) {
|
|
close(fd);
|
|
return 1;
|
|
}
|
|
#endif
|
|
return 0;
|
|
}
|
|
|
|
static void check(const char *label, int actual_rc, int expect_ok,
|
|
const char *path, mode_t expected_mode)
|
|
{
|
|
struct stat st;
|
|
int got_ok = (actual_rc == 0);
|
|
if (got_ok != expect_ok) {
|
|
fprintf(stderr, "FAIL [%s]: rc=%d errno=%d (%s), expected %s\n",
|
|
label, actual_rc, errno, strerror(errno),
|
|
expect_ok ? "success" : "rejection");
|
|
errs++;
|
|
return;
|
|
}
|
|
if (path && stat(path, &st) < 0) {
|
|
fprintf(stderr, "FAIL [%s]: stat(%s) failed: %s\n",
|
|
label, path, strerror(errno));
|
|
errs++;
|
|
return;
|
|
}
|
|
if (path && (st.st_mode & 07777) != expected_mode) {
|
|
fprintf(stderr,
|
|
"FAIL [%s]: %s mode is 0%o, expected 0%o\n",
|
|
label, path, st.st_mode & 07777, expected_mode);
|
|
errs++;
|
|
return;
|
|
}
|
|
fprintf(stderr, "OK [%s]\n", label);
|
|
}
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
if (argc != 2) {
|
|
fprintf(stderr, "usage: %s <module-dir>\n", argv[0]);
|
|
return 2;
|
|
}
|
|
if (chdir(argv[1]) < 0) {
|
|
perror("chdir");
|
|
return 2;
|
|
}
|
|
|
|
/* Simulate the daemon-without-chroot deployment that do_chmod_at()
|
|
* defends. With am_daemon=0 or am_chrooted=1 the wrapper falls
|
|
* through to plain do_chmod() and the symlink-race test would be
|
|
* meaningless. */
|
|
am_daemon = 1;
|
|
am_chrooted = 0;
|
|
|
|
/* Test layout (all inside the directory we just chdir'd to):
|
|
*
|
|
* ./realdir/sentinel -- regular target file
|
|
* ./inside_link -> realdir -- legitimate dir-symlink within the tree
|
|
* ./escape_link -> ../trap -- attacker swap, target outside tree
|
|
* ../trap/sentinel -- the file the attacker wants to alter
|
|
*
|
|
* The shell wrapper that calls this helper has set both sentinel
|
|
* files to mode 0600 so we have a clean baseline to compare.
|
|
*/
|
|
|
|
/* Scenario A: legitimate parent dir-symlink.
|
|
*
|
|
* On platforms whose kernel offers RESOLVE_BENEATH-equivalent
|
|
* confinement (Linux 5.6+ openat2, FreeBSD 13+ / macOS 15+
|
|
* O_RESOLVE_BENEATH), the within-tree symlink is followed and
|
|
* the chmod must succeed.
|
|
*
|
|
* On platforms that fall back to the per-component O_NOFOLLOW
|
|
* walk (OpenBSD, NetBSD, Solaris, older Cygwin, HPE NonStop,
|
|
* and pre-5.6 Linux), every symlink is rejected -- including
|
|
* this legitimate one. That's a real platform limitation (the
|
|
* same one that causes the #715 regression there) and the
|
|
* expected outcome is rejection.
|
|
*
|
|
* Detect at runtime and expect accordingly. The other three
|
|
* scenarios behave identically on both code paths and need no
|
|
* adjustment. */
|
|
int kernel_has_rb = kernel_resolve_beneath_supported();
|
|
fprintf(stderr, "INFO: kernel RESOLVE_BENEATH-equivalent confinement: %s\n",
|
|
kernel_has_rb ? "available" : "not available (per-component fallback)");
|
|
|
|
int rc = do_chmod_at("inside_link/sentinel", 0640);
|
|
if (kernel_has_rb) {
|
|
check("A: legit dir-symlink within tree (kernel confined)",
|
|
rc, 1, "realdir/sentinel", 0640);
|
|
} else {
|
|
check("A: legit dir-symlink within tree (per-component fallback rejects)",
|
|
rc, 0, "realdir/sentinel", 0600);
|
|
}
|
|
|
|
/* Scenario B: parent symlink escapes the tree -- chmod must be
|
|
* rejected and the outside file's mode must be unchanged. */
|
|
rc = do_chmod_at("escape_link/sentinel", 0666);
|
|
check("B: parent symlink escapes tree (the attack)",
|
|
rc, 0, "../trap/sentinel", 0600);
|
|
|
|
/* Scenario C: plain relative path with no symlink components,
|
|
* regression check that the safe wrapper doesn't break the
|
|
* normal case. */
|
|
rc = do_chmod_at("realdir/sentinel", 0644);
|
|
check("C: plain relative path (regression check)",
|
|
rc, 1, "realdir/sentinel", 0644);
|
|
|
|
/* Scenario D: top-level file, no parent directory component.
|
|
* Falls back to do_chmod(); should succeed. */
|
|
rc = do_chmod_at("topfile", 0640);
|
|
check("D: top-level file, no parent component",
|
|
rc, 1, "topfile", 0640);
|
|
|
|
if (errs)
|
|
fprintf(stderr, "%d failure(s)\n", errs);
|
|
return errs ? 1 : 0;
|
|
}
|